From bd3e6c1da94acf09f7f96c774eb022b09a504abd Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Mon, 18 May 2026 15:56:33 +0200 Subject: [PATCH 1/5] feat: add json_body to auth method VALID_TYPES and credential hint Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/services/auth-methods.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/server/services/auth-methods.ts b/src/lib/server/services/auth-methods.ts index c0b8d64..486503f 100644 --- a/src/lib/server/services/auth-methods.ts +++ b/src/lib/server/services/auth-methods.ts @@ -2,7 +2,7 @@ import { and, eq } from "drizzle-orm"; import { db } from "../db"; import { targetAuthMethods } from "../db/schema"; -const VALID_TYPES = ["bearer", "basic", "custom_header", "query_param", "ssh_key", "jwt_es256", "oauth2_refresh_token"]; +const VALID_TYPES = ["bearer", "basic", "custom_header", "query_param", "ssh_key", "jwt_es256", "oauth2_refresh_token", "json_body"]; export function computeCredentialHint(credential: string, type?: string): string { if (type === "custom_header") { @@ -42,6 +42,16 @@ export function computeCredentialHint(credential: string, type?: string): string return "OAuth2 (invalid config)"; } } + if (type === "json_body") { + try { + const parsed = JSON.parse(credential); + const keys = Object.keys(parsed); + if (keys.length === 0) return "JSON Body (empty)"; + return `keys: ${keys.join(", ")}`; + } catch { + return "JSON Body (invalid)"; + } + } if (credential.length < 10) return "••••••••"; return `${credential.slice(0, 3)}••••••••${credential.slice(-4)}`; } From 53cfbdc1503a60f0adb5c86dee4456436ad5ba3f Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Mon, 18 May 2026 15:58:36 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20add=20json=5Fbody=20gateway=20suppo?= =?UTF-8?q?rt=20=E2=80=94=20merge=20stored=20JSON=20into=20request=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/services/gateway.ts | 52 ++++++++++++++++++++ tests/integration/gateway.test.ts | 76 ++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/src/lib/server/services/gateway.ts b/src/lib/server/services/gateway.ts index 82760ad..29c5816 100644 --- a/src/lib/server/services/gateway.ts +++ b/src/lib/server/services/gateway.ts @@ -164,6 +164,58 @@ export async function proxyToTarget( { status: 500 }, ); } + } else if (authMethod.type === "json_body") { + try { + const storedFields = JSON.parse(authMethod.credential); + let agentBody: Record = {}; + if (request.method !== "GET" && request.method !== "HEAD") { + try { + const cloned = request.clone(); + const text = await cloned.text(); + if (text) agentBody = JSON.parse(text); + } catch { /* non-JSON or empty body — use empty object */ } + } + const mergedBody = JSON.stringify({ ...agentBody, ...storedFields }); + headers.set("Content-Type", "application/json"); + + console.log("[gateway] →", request.method, url.toString()); + console.log("[gateway] → headers:", Object.fromEntries(headers.entries())); + + let upstreamResponse: Response; + try { + upstreamResponse = await fetch(url.toString(), { + method: request.method, + headers, + body: mergedBody, + // @ts-expect-error duplex needed for streaming body + duplex: "half", + }); + } catch (err) { + console.error("[gateway] ✗ upstream request failed:", err); + return Response.json({ error: "upstream request failed" }, { status: 502 }); + } + + console.log("[gateway] ←", upstreamResponse.status, url.toString()); + console.log("[gateway] ← headers:", Object.fromEntries(upstreamResponse.headers.entries())); + + const responseHeaders = new Headers(); + for (const [key, value] of upstreamResponse.headers.entries()) { + const lower = key.toLowerCase(); + if (lower === "transfer-encoding" || lower === "content-encoding") continue; + responseHeaders.set(key, value); + } + + const body = await upstreamResponse.arrayBuffer(); + responseHeaders.set("Content-Length", String(body.byteLength)); + + return new Response(body, { + status: upstreamResponse.status, + headers: responseHeaders, + }); + } catch (err) { + console.error("[gateway] ✗ json_body merge failed:", err); + return Response.json({ error: "json_body merge failed" }, { status: 500 }); + } } } diff --git a/tests/integration/gateway.test.ts b/tests/integration/gateway.test.ts index b3bf88f..2d8f7d6 100644 --- a/tests/integration/gateway.test.ts +++ b/tests/integration/gateway.test.ts @@ -340,4 +340,80 @@ describe("gateway proxy", () => { const [, init] = fetchSpy.mock.calls[0]; expect((init!.headers as Headers).get("X-API-Key")).toBe("legacy-key"); }); + + it("proxies request with json_body credential merged into request body", async () => { + const { token: tokenRow } = await createTestToken(); + const target = await createTestTarget("GoCardlessAPI", "https://bankaccountdata.gocardless.com"); + const storedBody = JSON.stringify({ secret_id: "my-secret-id", secret_key: "my-secret-key" }); + await createTestAuthMethod(target.id, { type: "json_body", credential: storedBody }); + await grantPermission(tokenRow.id, target.id); + + const fullToken = await getFullToken(tokenRow.id); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ access: "token123" })); + + const request = new Request(`http://localhost/gateway/${target.slug}/api/v2/token/new/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + const response = await proxyRequest(fullToken, target.slug, "api/v2/token/new/", request); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledOnce(); + const [, init] = fetchSpy.mock.calls[0]; + + const sentBody = await new Response(init!.body).json(); + expect(sentBody).toEqual({ secret_id: "my-secret-id", secret_key: "my-secret-key" }); + expect((init!.headers as Headers).get("Content-Type")).toBe("application/json"); + }); + + it("json_body merges with agent-supplied body fields (stored wins)", async () => { + const { token: tokenRow } = await createTestToken(); + const target = await createTestTarget("MergeAPI", "https://api.merge.com"); + const storedBody = JSON.stringify({ api_key: "secret-123" }); + await createTestAuthMethod(target.id, { type: "json_body", credential: storedBody }); + await grantPermission(tokenRow.id, target.id); + + const fullToken = await getFullToken(tokenRow.id); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ok: true })); + + const request = new Request(`http://localhost/gateway/${target.slug}/data`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: "test", api_key: "agent-tried-to-override" }), + }); + + const response = await proxyRequest(fullToken, target.slug, "data", request); + + expect(response.status).toBe(200); + const [, init] = fetchSpy.mock.calls[0]; + const sentBody = await new Response(init!.body).json(); + expect(sentBody).toEqual({ query: "test", api_key: "secret-123" }); + }); + + it("json_body works with empty/no agent body", async () => { + const { token: tokenRow } = await createTestToken(); + const target = await createTestTarget("EmptyBodyAPI", "https://api.emptybody.com"); + const storedBody = JSON.stringify({ token: "abc" }); + await createTestAuthMethod(target.id, { type: "json_body", credential: storedBody }); + await grantPermission(tokenRow.id, target.id); + + const fullToken = await getFullToken(tokenRow.id); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ok: true })); + + const request = new Request(`http://localhost/gateway/${target.slug}/action`, { + method: "POST", + }); + + const response = await proxyRequest(fullToken, target.slug, "action", request); + + expect(response.status).toBe(200); + const [, init] = fetchSpy.mock.calls[0]; + const sentBody = await new Response(init!.body).json(); + expect(sentBody).toEqual({ token: "abc" }); + }); }); From 280396362a007f4ec30834e253fb2322d323cc39 Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Mon, 18 May 2026 15:59:36 +0200 Subject: [PATCH 3/5] feat: add json_body UI fields in auth method form --- src/lib/components/auth-method-fields.svelte | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/lib/components/auth-method-fields.svelte b/src/lib/components/auth-method-fields.svelte index 8fd5c35..abcc1f5 100644 --- a/src/lib/components/auth-method-fields.svelte +++ b/src/lib/components/auth-method-fields.svelte @@ -48,6 +48,7 @@ const optionalHint = mode === 'edit' ? ' (leave blank to keep existing)' : ''; + {/if} @@ -248,6 +249,19 @@ const optionalHint = mode === 'edit' ? ' (leave blank to keep existing)' : ''; +{:else if authType === 'json_body'} +
+ + +

Raw JSON object. These fields will be merged into the request body.

+
{:else}
From 10a3e5ce4685f0dde317f0501cd01f84c12a626b Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Mon, 18 May 2026 16:00:36 +0200 Subject: [PATCH 4/5] feat: add json_body form parsing with JSON validation Co-Authored-By: Claude Sonnet 4.6 --- .../(app)/targets/[slug]/+page.server.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/routes/(app)/targets/[slug]/+page.server.ts b/src/routes/(app)/targets/[slug]/+page.server.ts index e0dda77..6567ca6 100644 --- a/src/routes/(app)/targets/[slug]/+page.server.ts +++ b/src/routes/(app)/targets/[slug]/+page.server.ts @@ -168,6 +168,17 @@ export const actions = { if (tokenUrl) config.tokenUrl = tokenUrl; credential = JSON.stringify(config); + } else if (type === "json_body") { + credential = data.get("credential")?.toString() ?? ""; + if (!credential) return fail(400, { error: "JSON body is required" }); + try { + const parsed = JSON.parse(credential); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return fail(400, { error: "JSON body must be a JSON object" }); + } + } catch { + return fail(400, { error: "Invalid JSON" }); + } } else { credential = data.get("credential")?.toString() ?? ""; if (!credential) return fail(400, { error: "Credential is required" }); @@ -276,6 +287,19 @@ export const actions = { if (tokenUrl) config.tokenUrl = tokenUrl; credential = JSON.stringify(config); } + } else if (type === "json_body") { + const raw = data.get("credential")?.toString() ?? ""; + if (raw) { + try { + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return fail(400, { error: "JSON body must be a JSON object" }); + } + credential = raw; + } catch { + return fail(400, { error: "Invalid JSON" }); + } + } } else { const raw = data.get("credential")?.toString() ?? ""; if (raw) credential = raw; From 446b54a3218a3414245aabf3e8ad33484348d7e3 Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Mon, 18 May 2026 16:02:04 +0200 Subject: [PATCH 5/5] docs: add json_body spec, plan, and update AGENTS.md auth types list Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 2 +- .../plans/2026-05-18-json-body-auth.md | 357 ++++++++++++++++++ .../specs/2026-05-18-json-body-auth-design.md | 42 +++ 3 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-05-18-json-body-auth.md create mode 100644 docs/superpowers/specs/2026-05-18-json-body-auth-design.md diff --git a/AGENTS.md b/AGENTS.md index 27ed1ec..cf844af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ audit_logs (every gateway + SSH request) - Targets have `type: "api" | "ssh"`. API targets have `baseUrl`, SSH targets have `config` (JSONB: host, port, username). - Cascade deletes: deleting a target removes its auth methods and permissions. -- Auth method types: `bearer`, `basic`, `custom_header`, `ssh_key`. +- Auth method types: `bearer`, `basic`, `custom_header`, `query_param`, `ssh_key`, `jwt_es256`, `oauth2_refresh_token`, `json_body`. - Webhook endpoints are linked to tokens (agents), not targets. Each endpoint has a unique slug for its public URL. - Webhook events expire after 7 days. Agents poll and ACK events. - Webhook endpoints have optional `handlingInstructions` (plain text) that agents receive in the poll response. diff --git a/docs/superpowers/plans/2026-05-18-json-body-auth.md b/docs/superpowers/plans/2026-05-18-json-body-auth.md new file mode 100644 index 0000000..eba0cd3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-json-body-auth.md @@ -0,0 +1,357 @@ +# json_body Auth Method Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `json_body` auth method type that merges stored JSON credentials into the request body at the gateway. + +**Architecture:** New auth type `json_body` — stored as raw JSON string in `credential` column. Gateway parses it and merges into the request body (stored fields override agent fields). UI uses a raw JSON textarea. + +**Tech Stack:** SvelteKit, Drizzle ORM, Vitest + Testcontainers + +--- + +### Task 1: Add `json_body` to auth-methods service + +**Files:** +- Modify: `src/lib/server/services/auth-methods.ts:5` (VALID_TYPES) +- Modify: `src/lib/server/services/auth-methods.ts:6-47` (computeCredentialHint) + +- [ ] **Step 1: Add `json_body` to VALID_TYPES** + +In `src/lib/server/services/auth-methods.ts`, change line 5: + +```typescript +const VALID_TYPES = ["bearer", "basic", "custom_header", "query_param", "ssh_key", "jwt_es256", "oauth2_refresh_token", "json_body"]; +``` + +- [ ] **Step 2: Add credential hint for `json_body`** + +In `src/lib/server/services/auth-methods.ts`, add a new block after the `oauth2_refresh_token` hint block (after line 43), before the generic fallback: + +```typescript +if (type === "json_body") { + try { + const parsed = JSON.parse(credential); + const keys = Object.keys(parsed); + if (keys.length === 0) return "JSON Body (empty)"; + return `keys: ${keys.join(", ")}`; + } catch { + return "JSON Body (invalid)"; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/server/services/auth-methods.ts +git commit -m "feat: add json_body to auth method VALID_TYPES and credential hint" +``` + +--- + +### Task 2: Add gateway body merge logic + +**Files:** +- Modify: `src/lib/server/services/gateway.ts:61-212` (proxyToTarget function) + +- [ ] **Step 1: Write the failing integration test** + +In `tests/integration/gateway.test.ts`, add after the last test (before the closing `});`): + +```typescript +it("proxies request with json_body credential merged into request body", async () => { + const { token: tokenRow } = await createTestToken(); + const target = await createTestTarget("GoCardlessAPI", "https://bankaccountdata.gocardless.com"); + const storedBody = JSON.stringify({ secret_id: "my-secret-id", secret_key: "my-secret-key" }); + await createTestAuthMethod(target.id, { type: "json_body", credential: storedBody }); + await grantPermission(tokenRow.id, target.id); + + const fullToken = await getFullToken(tokenRow.id); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ access: "token123" })); + + const request = new Request(`http://localhost/gateway/${target.slug}/api/v2/token/new/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + const response = await proxyRequest(fullToken, target.slug, "api/v2/token/new/", request); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledOnce(); + const [, init] = fetchSpy.mock.calls[0]; + + // Read the body that was sent upstream + const sentBody = await new Response(init!.body).json(); + expect(sentBody).toEqual({ secret_id: "my-secret-id", secret_key: "my-secret-key" }); + + // Content-Type should be set to application/json + expect((init!.headers as Headers).get("Content-Type")).toBe("application/json"); +}); + +it("json_body merges with agent-supplied body fields (stored wins)", async () => { + const { token: tokenRow } = await createTestToken(); + const target = await createTestTarget("MergeAPI", "https://api.merge.com"); + const storedBody = JSON.stringify({ api_key: "secret-123" }); + await createTestAuthMethod(target.id, { type: "json_body", credential: storedBody }); + await grantPermission(tokenRow.id, target.id); + + const fullToken = await getFullToken(tokenRow.id); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ok: true })); + + const request = new Request(`http://localhost/gateway/${target.slug}/data`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: "test", api_key: "agent-tried-to-override" }), + }); + + const response = await proxyRequest(fullToken, target.slug, "data", request); + + expect(response.status).toBe(200); + const [, init] = fetchSpy.mock.calls[0]; + const sentBody = await new Response(init!.body).json(); + expect(sentBody).toEqual({ query: "test", api_key: "secret-123" }); +}); + +it("json_body works with empty/no agent body", async () => { + const { token: tokenRow } = await createTestToken(); + const target = await createTestTarget("EmptyBodyAPI", "https://api.emptybody.com"); + const storedBody = JSON.stringify({ token: "abc" }); + await createTestAuthMethod(target.id, { type: "json_body", credential: storedBody }); + await grantPermission(tokenRow.id, target.id); + + const fullToken = await getFullToken(tokenRow.id); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ok: true })); + + const request = new Request(`http://localhost/gateway/${target.slug}/action`, { + method: "POST", + }); + + const response = await proxyRequest(fullToken, target.slug, "action", request); + + expect(response.status).toBe(200); + const [, init] = fetchSpy.mock.calls[0]; + const sentBody = await new Response(init!.body).json(); + expect(sentBody).toEqual({ token: "abc" }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run tests/integration/gateway.test.ts` +Expected: 3 new tests FAIL (json_body not handled in gateway) + +- [ ] **Step 3: Implement body merge in gateway** + +In `src/lib/server/services/gateway.ts`, add a new `else if` block after the `oauth2_refresh_token` block (after line 167), inside the `if (authMethod)` block: + +```typescript +} else if (authMethod.type === "json_body") { + try { + const storedFields = JSON.parse(authMethod.credential); + let agentBody: Record = {}; + if (request.method !== "GET" && request.method !== "HEAD") { + try { + const cloned = request.clone(); + const text = await cloned.text(); + if (text) agentBody = JSON.parse(text); + } catch { /* non-JSON or empty body — use empty object */ } + } + const mergedBody = JSON.stringify({ ...agentBody, ...storedFields }); + headers.set("Content-Type", "application/json"); + + // Override the fetch call to use merged body + console.log("[gateway] →", request.method, url.toString()); + console.log("[gateway] → headers:", Object.fromEntries(headers.entries())); + + let upstreamResponse: Response; + try { + upstreamResponse = await fetch(url.toString(), { + method: request.method, + headers, + body: mergedBody, + // @ts-expect-error duplex needed for streaming body + duplex: "half", + }); + } catch (err) { + console.error("[gateway] ✗ upstream request failed:", err); + return Response.json({ error: "upstream request failed" }, { status: 502 }); + } + + console.log("[gateway] ←", upstreamResponse.status, url.toString()); + console.log("[gateway] ← headers:", Object.fromEntries(upstreamResponse.headers.entries())); + + const responseHeaders = new Headers(); + for (const [key, value] of upstreamResponse.headers.entries()) { + const lower = key.toLowerCase(); + if (lower === "transfer-encoding" || lower === "content-encoding") continue; + responseHeaders.set(key, value); + } + + const body = await upstreamResponse.arrayBuffer(); + responseHeaders.set("Content-Length", String(body.byteLength)); + + return new Response(body, { + status: upstreamResponse.status, + headers: responseHeaders, + }); + } catch (err) { + console.error("[gateway] ✗ json_body merge failed:", err); + return Response.json({ error: "json_body merge failed" }, { status: 500 }); + } +} +``` + +**Important:** The `json_body` block needs to return early (before the normal fetch at line 176) because it replaces the body. The block includes its own fetch + response handling, returning directly. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/integration/gateway.test.ts` +Expected: All tests PASS including the 3 new ones + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/services/gateway.ts tests/integration/gateway.test.ts +git commit -m "feat: add json_body gateway support — merge stored JSON into request body" +``` + +--- + +### Task 3: Add UI form fields for json_body + +**Files:** +- Modify: `src/lib/components/auth-method-fields.svelte` + +- [ ] **Step 1: Add `json_body` option to the type dropdown** + +In `src/lib/components/auth-method-fields.svelte`, add after the `oauth2_refresh_token` option (line 50): + +```svelte + +``` + +- [ ] **Step 2: Add textarea field for json_body** + +Add a new `{:else if}` block before the final `{:else}` block (before line 251): + +```svelte +{:else if authType === 'json_body'} +
+ + +

Raw JSON object. These fields will be merged into the request body.

+
+``` + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/components/auth-method-fields.svelte +git commit -m "feat: add json_body UI fields in auth method form" +``` + +--- + +### Task 4: Add form parsing for json_body + +**Files:** +- Modify: `src/routes/(app)/targets/[slug]/+page.server.ts` + +- [ ] **Step 1: Add json_body parsing in addAuthMethod action** + +In `+page.server.ts`, in the `addAuthMethod` action, add a new `else if` block before the final `else` block (before line 171): + +```typescript +} else if (type === "json_body") { + credential = data.get("credential")?.toString() ?? ""; + if (!credential) return fail(400, { error: "JSON body is required" }); + try { + const parsed = JSON.parse(credential); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return fail(400, { error: "JSON body must be a JSON object" }); + } + } catch { + return fail(400, { error: "Invalid JSON" }); + } +``` + +- [ ] **Step 2: Add json_body parsing in editAuthMethod action** + +In `+page.server.ts`, in the `editAuthMethod` action, add a new `else if` block before the final `else` block (before line 279): + +```typescript +} else if (type === "json_body") { + const raw = data.get("credential")?.toString() ?? ""; + if (raw) { + try { + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return fail(400, { error: "JSON body must be a JSON object" }); + } + credential = raw; + } catch { + return fail(400, { error: "Invalid JSON" }); + } + } +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/(app)/targets/[slug]/+page.server.ts +git commit -m "feat: add json_body form parsing with JSON validation" +``` + +--- + +### Task 5: Update AGENTS.md documentation + +**Files:** +- Modify: `AGENTS.md` + +- [ ] **Step 1: Add json_body to auth method types list** + +In `AGENTS.md`, find the line that says: + +``` +- Auth method types: `bearer`, `basic`, `custom_header`, `ssh_key`. +``` + +Replace with: + +``` +- Auth method types: `bearer`, `basic`, `custom_header`, `query_param`, `ssh_key`, `jwt_es256`, `oauth2_refresh_token`, `json_body`. +``` + +- [ ] **Step 2: Commit** + +```bash +git add AGENTS.md +git commit -m "docs: add json_body to auth method types in AGENTS.md" +``` + +--- + +### Task 6: Final verification + +- [ ] **Step 1: Run full test suite** + +Run: `npx vitest run` +Expected: All tests PASS + +- [ ] **Step 2: Start dev server and verify UI** + +Run: `npm run dev` +Navigate to a target's detail page, click "Add auth method", verify `JSON Body` appears in the type dropdown and shows the textarea when selected. diff --git a/docs/superpowers/specs/2026-05-18-json-body-auth-design.md b/docs/superpowers/specs/2026-05-18-json-body-auth-design.md new file mode 100644 index 0000000..c76e688 --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-json-body-auth-design.md @@ -0,0 +1,42 @@ +# json_body Auth Method Type + +## Problem + +Some APIs require credentials in the request body (e.g., GoCardless Bank Account Data API sends `secret_id` and `secret_key` as JSON POST body). Current auth methods only inject into headers or query params. + +## Design + +New auth method type `json_body` that merges stored JSON into the request body at the gateway. + +### Flow + +1. Agent sends request through gateway (e.g., `POST /gateway/gocardless/api/v2/token/new/` with body `{}`) +2. Gateway detects default auth method type `json_body` +3. Gateway parses stored credential as JSON object +4. Gateway parses agent request body as JSON (falls back to `{}` if empty) +5. Gateway merges: `{ ...agentBody, ...storedCredentials }` (stored fields win) +6. Forwards to upstream with `Content-Type: application/json` + +### Credential Storage + +- Raw JSON string stored in `credential` field (same as other types) +- Must be a valid JSON object (validated on create/update) +- Example: `{"secret_id": "abc123", "secret_key": "xyz789"}` + +### Credential Hint + +Shows the top-level key names: `keys: secret_id, secret_key` + +### UI + +Raw JSON textarea (like `ssh_key`), with placeholder showing example format. + +## Changes + +| File | Change | +|------|--------| +| `src/lib/server/services/auth-methods.ts` | Add `json_body` to `VALID_TYPES`, update `computeCredentialHint` | +| `src/lib/server/services/gateway.ts` | Add body merge logic in proxy flow | +| `src/lib/components/auth-method-fields.svelte` | Add textarea for `json_body` type | +| `src/routes/(app)/targets/[slug]/+page.server.ts` | Form parsing for `json_body` | +| `tests/integration/gateway.test.ts` | Test gateway with `json_body` auth |