paperclipai/paperclip · error

must be a JSON object

Error message

${name} must be a JSON object

What it means

HTTP 404 with body {"error":"Provider vault not found"} from PATCH /api/secret-provider-configs/:id, second guard (secrets.ts:463). Earlier in the same handler getAccessibleResource (authz.ts:182-195) already verified the secret provider config exists and is company-accessible; this 404 fires only because svc.updateProviderConfig(id, ...) then returned null - i.e. no row matched the update. That is a check-then-act race: the config was deleted (or concurrently soft-deleted) between the two calls. This route is board-only (assertBoard).

Solutions

  1. Re-fetch GET /api/secret-provider-configs/:id after the 404 - if it is also 404, the vault was genuinely deleted; update your local state.
  2. Serialize vault mutations (one writer at a time, or an admin lock) to avoid delete/update races.
  3. If the vault must exist, recreate it via POST /api/secret-provider-configs and reapply the intended changes.
  4. Treat this 404 on a previously verified ID as a concurrency signal, not a bad-ID bug - do not simply retry the same PATCH forever.

Example fix

// before
const existing = await api.get(`/api/secret-provider-configs/${id}`);
await api.patch(`/api/secret-provider-configs/${id}`, changes); // 404 despite existing==200

// after
const existing = await api.get(`/api/secret-provider-configs/${id}`);
if (!existing) throw new Error('vault already deleted');
try {
  await api.patch(`/api/secret-provider-configs/${id}`, changes);
} catch (e) {
  if (e.status === 404) {
    // lost a race with a concurrent delete: re-sync and decide
    await syncVaults();
    return;
  }
  throw e;
}
Defensive patterns

Strategy: validation

Validate before calling

async function patchVaultConfig(api: ApiClient, id: string, changes: unknown) {
  const existing = await api.fetch(`/api/secret-provider-configs/${id}`);
  if (existing.status === 404) throw new Error(`vault ${id} not found`);
  const res = await api.fetch(`/api/secret-provider-configs/${id}`, {
    method: 'PATCH',
    body: JSON.stringify(changes),
  });
  if (res.status === 404) {
    // lost a race with a concurrent delete
    await syncVaultList();
    return null;
  }
  return res.json();
}

Type guard

function isApiErrorBody(body: unknown): body is { error: string } {
  return typeof body === 'object' && body !== null &&
    typeof (body as Record<string, unknown>).error === 'string';
}
const isVaultNotFound = (b: unknown): boolean => isApiErrorBody(b) && b.error === 'Provider vault not found';

Try / catch

try {
  await api.patch(`/api/secret-provider-configs/${id}`, changes);
} catch (err) {
  if (err instanceof ApiError && err.status === 404 && isVaultNotFound(err.body)) {
    const again = await api.get(`/api/secret-provider-configs/${id}`);
    if (!again) { await resyncVaults(); return; } // deleted concurrently
    throw err; // transient inconsistency - retry once
  }
  throw err;
}

Prevention

When it happens

Trigger: Concurrent DELETE /api/secret-provider-configs/:id winning the race against a PATCH; two board users editing the same vault config where one removes it mid-request; a soft-deleted status change racing the update so the UPDATE statement matches zero rows.

Common situations: Config-management tooling doing read-modify-write on vault configs while operators clean up test vaults; double-pane admin UIs with stale forms submitting after another admin removed the vault.

Related errors


AI-assisted analysis of paperclipai/paperclip@120ae5428f (2026-08-18). Data as JSON: /api/errors/a71cf6f2ee5960b9. Report an issue: GitHub.

Appendix: source

Thrown at cli/src/commands/client/approval.ts:254

          printOutput(created, { json: ctx.json });
        } catch (err) {
          handleCommandError(err);
        }
      }),
  );
}

function parseCsv(value: string | undefined): string[] | undefined {
  if (!value) return undefined;
  const rows = value.split(",").map((v) => v.trim()).filter(Boolean);
  return rows.length > 0 ? rows : undefined;
}

function parseJsonObject(value: string, name: string): Record<string, unknown> {
  try {
    const parsed = JSON.parse(value) as unknown;
    if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
      throw new Error(`${name} must be a JSON object`);
    }
    return parsed as Record<string, unknown>;
  } catch (err) {
    throw new Error(`Invalid ${name} JSON: ${err instanceof Error ? err.message : String(err)}`);
  }
}

View on GitHub (pinned to 120ae5428f)