@openparachute/hub 0.5.13-rc.37 → 0.5.13-rc.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13-rc.37",
3
+ "version": "0.5.13-rc.39",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -298,4 +298,165 @@ describe("handleApproveClient", () => {
298
298
  const res = await handleApproveClient(req, id, { db: harness.db, issuer: ISSUER });
299
299
  expect(res.status).toBe(405);
300
300
  });
301
+
302
+ // Workstream D — OAuth resume via `return_to`. The SPA approve page
303
+ // can pass a hub-relative authorize URL as JSON body; the response
304
+ // echoes it as `redirect_to` so the SPA can navigate the browser there
305
+ // and resume the parked OAuth flow. The pre-D no-body shape continues
306
+ // to work (no `redirect_to` field, share-link dead-end case).
307
+ describe("workstream D — return_to / redirect_to", () => {
308
+ function jsonApproveReq(clientId: string, bearer: string, body: unknown): Request {
309
+ return new Request(`${ISSUER}/api/oauth/clients/${encodeURIComponent(clientId)}/approve`, {
310
+ method: "POST",
311
+ headers: {
312
+ authorization: `Bearer ${bearer}`,
313
+ "content-type": "application/json",
314
+ },
315
+ body: JSON.stringify(body),
316
+ });
317
+ }
318
+
319
+ test("echoes a same-origin /oauth/authorize?... return_to as redirect_to", async () => {
320
+ const { bearer } = await makeOperatorBearer();
321
+ const id = regPending();
322
+ const returnTo =
323
+ "/oauth/authorize?client_id=" +
324
+ encodeURIComponent(id) +
325
+ "&response_type=code&scope=vault%3Awork%3Aread";
326
+ const res = await handleApproveClient(
327
+ jsonApproveReq(id, bearer, { return_to: returnTo }),
328
+ id,
329
+ { db: harness.db, issuer: ISSUER },
330
+ );
331
+ expect(res.status).toBe(200);
332
+ const body = (await res.json()) as Record<string, unknown>;
333
+ expect(body.redirect_to).toBe(returnTo);
334
+ expect(body.status).toBe("approved");
335
+ expect(getClient(harness.db, id)?.status).toBe("approved");
336
+ });
337
+
338
+ test("omits redirect_to entirely when return_to is missing (share-link case preserved)", async () => {
339
+ const { bearer } = await makeOperatorBearer();
340
+ const id = regPending();
341
+ // No body — the pre-D shape. The endpoint must continue to work.
342
+ const res = await handleApproveClient(approveReq(id, bearer), id, {
343
+ db: harness.db,
344
+ issuer: ISSUER,
345
+ });
346
+ expect(res.status).toBe(200);
347
+ const body = (await res.json()) as Record<string, unknown>;
348
+ expect(body).not.toHaveProperty("redirect_to");
349
+ expect(body.status).toBe("approved");
350
+ });
351
+
352
+ test("drops an off-origin return_to (scheme-relative) silently, still approves", async () => {
353
+ const { bearer } = await makeOperatorBearer();
354
+ const id = regPending();
355
+ const res = await handleApproveClient(
356
+ jsonApproveReq(id, bearer, { return_to: "//evil.example/oauth/authorize?foo=1" }),
357
+ id,
358
+ { db: harness.db, issuer: ISSUER },
359
+ );
360
+ expect(res.status).toBe(200);
361
+ const body = (await res.json()) as Record<string, unknown>;
362
+ // No redirect_to — server refuses to echo a bad value. The client
363
+ // is still approved (we don't fail an otherwise-legitimate approve
364
+ // over a malformed return_to).
365
+ expect(body).not.toHaveProperty("redirect_to");
366
+ expect(getClient(harness.db, id)?.status).toBe("approved");
367
+ });
368
+
369
+ test("drops a non-authorize return_to (off-path) silently", async () => {
370
+ const { bearer } = await makeOperatorBearer();
371
+ const id = regPending();
372
+ const res = await handleApproveClient(
373
+ jsonApproveReq(id, bearer, { return_to: "/admin/vaults" }),
374
+ id,
375
+ { db: harness.db, issuer: ISSUER },
376
+ );
377
+ expect(res.status).toBe(200);
378
+ const body = (await res.json()) as Record<string, unknown>;
379
+ // `/admin/vaults` is same-origin but isn't a `/oauth/authorize?...`
380
+ // URL — the server-side gate is "authorize URL only" so the SPA
381
+ // can't be used as a redirect gadget for arbitrary in-SPA navigation.
382
+ expect(body).not.toHaveProperty("redirect_to");
383
+ });
384
+
385
+ test("drops absolute URL return_to silently", async () => {
386
+ const { bearer } = await makeOperatorBearer();
387
+ const id = regPending();
388
+ const res = await handleApproveClient(
389
+ jsonApproveReq(id, bearer, {
390
+ return_to: "https://evil.example/oauth/authorize?foo=1",
391
+ }),
392
+ id,
393
+ { db: harness.db, issuer: ISSUER },
394
+ );
395
+ expect(res.status).toBe(200);
396
+ const body = (await res.json()) as Record<string, unknown>;
397
+ expect(body).not.toHaveProperty("redirect_to");
398
+ });
399
+
400
+ test("non-JSON body is treated as 'no return_to' (no parser explosion)", async () => {
401
+ const { bearer } = await makeOperatorBearer();
402
+ const id = regPending();
403
+ // text/plain body — pre-D / unknown clients send anything. The
404
+ // endpoint must NOT throw on parse and must NOT echo a redirect_to.
405
+ const req = new Request(`${ISSUER}/api/oauth/clients/${id}/approve`, {
406
+ method: "POST",
407
+ headers: {
408
+ authorization: `Bearer ${bearer}`,
409
+ "content-type": "text/plain",
410
+ },
411
+ body: "garbage",
412
+ });
413
+ const res = await handleApproveClient(req, id, {
414
+ db: harness.db,
415
+ issuer: ISSUER,
416
+ });
417
+ expect(res.status).toBe(200);
418
+ const body = (await res.json()) as Record<string, unknown>;
419
+ expect(body).not.toHaveProperty("redirect_to");
420
+ });
421
+
422
+ test("malformed JSON body is treated as 'no return_to'", async () => {
423
+ const { bearer } = await makeOperatorBearer();
424
+ const id = regPending();
425
+ const req = new Request(`${ISSUER}/api/oauth/clients/${id}/approve`, {
426
+ method: "POST",
427
+ headers: {
428
+ authorization: `Bearer ${bearer}`,
429
+ "content-type": "application/json",
430
+ },
431
+ body: "{not json",
432
+ });
433
+ const res = await handleApproveClient(req, id, {
434
+ db: harness.db,
435
+ issuer: ISSUER,
436
+ });
437
+ expect(res.status).toBe(200);
438
+ const body = (await res.json()) as Record<string, unknown>;
439
+ expect(body).not.toHaveProperty("redirect_to");
440
+ });
441
+
442
+ test("re-approve with return_to echoes redirect_to (idempotent path)", async () => {
443
+ // The OAuth resume flow can legitimately race: operator opens the
444
+ // approve link, an automated path approves the same client, then
445
+ // operator clicks. We still want the redirect to fire so the
446
+ // operator's flow resumes — not dead-end on already_approved.
447
+ const { bearer } = await makeOperatorBearer();
448
+ const id = regPending();
449
+ approveClient(harness.db, id);
450
+ const returnTo = `/oauth/authorize?client_id=${encodeURIComponent(id)}&response_type=code&scope=vault%3Awork%3Aread`;
451
+ const res = await handleApproveClient(
452
+ jsonApproveReq(id, bearer, { return_to: returnTo }),
453
+ id,
454
+ { db: harness.db, issuer: ISSUER },
455
+ );
456
+ expect(res.status).toBe(200);
457
+ const body = (await res.json()) as Record<string, unknown>;
458
+ expect(body.already_approved).toBe(true);
459
+ expect(body.redirect_to).toBe(returnTo);
460
+ });
461
+ });
301
462
  });
@@ -370,14 +370,16 @@ describe("hubFetch routing", () => {
370
370
  }
371
371
  });
372
372
 
373
- test("/.well-known/parachute.json: uiUrl resolver is skipped for vault entries (loadManagementUrls handles vault)", async () => {
373
+ test("/.well-known/parachute.json: vault entry uiUrl is prefixed with the per-instance mount path", async () => {
374
374
  const h = makeHarness();
375
375
  try {
376
376
  const vaultWithDir: ServiceEntry = { ...vaultEntry("default"), installDir: "/fake/vault" };
377
377
  writeManifest({ services: [vaultWithDir] }, h.manifestPath);
378
- // The fake module.json declares uiUrl, but vault is supposed to be
379
- // skipped by loadServiceUiMetadata (it has its own managementUrl
380
- // path). So doc.services[vault] should NOT carry uiUrl.
378
+ // Workstream C (patterns#96 + vault PR 367): vault declares
379
+ // `uiUrl: "/admin/"` as a per-instance path. loadServiceUiMetadata
380
+ // no longer skips vault entries; buildWellKnown prefixes the
381
+ // declared path with the per-instance mount on emission, yielding
382
+ // `/vault/default/admin/` for a vault mounted at `/vault/default`.
381
383
  const res = await hubFetch(h.dir, {
382
384
  manifestPath: h.manifestPath,
383
385
  readModuleManifest: async () => ({
@@ -386,12 +388,48 @@ describe("hubFetch routing", () => {
386
388
  port: 1940,
387
389
  paths: ["/vault/default"],
388
390
  health: "/health",
389
- uiUrl: "/should-be-ignored",
391
+ uiUrl: "/admin/",
390
392
  }),
391
393
  })(req("/.well-known/parachute.json"));
392
394
  const body = (await res.json()) as { services: Array<{ name: string; uiUrl?: string }> };
393
- const vaultSvc = body.services.find((s) => s.name === "parachute-vault");
394
- expect(vaultSvc).not.toHaveProperty("uiUrl");
395
+ const vaultSvc = body.services.find((s) => s.name === "parachute-vault-default");
396
+ expect(vaultSvc?.uiUrl).toMatch(/\/vault\/default\/admin\/$/);
397
+ } finally {
398
+ h.cleanup();
399
+ }
400
+ });
401
+
402
+ test("/.well-known/parachute.json: vault with multiple paths emits one row per instance with its own prefixed uiUrl", async () => {
403
+ const h = makeHarness();
404
+ try {
405
+ const multi: ServiceEntry = {
406
+ name: "parachute-vault",
407
+ port: 1940,
408
+ paths: ["/vault/default", "/vault/techne"],
409
+ health: "/vault/default/health",
410
+ version: "0.4.8",
411
+ installDir: "/fake/vault",
412
+ };
413
+ writeManifest({ services: [multi] }, h.manifestPath);
414
+ const res = await hubFetch(h.dir, {
415
+ manifestPath: h.manifestPath,
416
+ readModuleManifest: async () => ({
417
+ name: "vault",
418
+ manifestName: "parachute-vault",
419
+ port: 1940,
420
+ paths: ["/vault/default", "/vault/techne"],
421
+ health: "/health",
422
+ uiUrl: "/admin/",
423
+ }),
424
+ })(req("/.well-known/parachute.json"));
425
+ const body = (await res.json()) as {
426
+ services: Array<{ name: string; path?: string; uiUrl?: string }>;
427
+ };
428
+ const vaultRows = body.services.filter((s) => s.name === "parachute-vault");
429
+ expect(vaultRows.length).toBe(2);
430
+ const uiUrls = vaultRows.map((r) => r.uiUrl).sort();
431
+ expect(uiUrls[0]).toMatch(/\/vault\/default\/admin\/$/);
432
+ expect(uiUrls[1]).toMatch(/\/vault\/techne\/admin\/$/);
395
433
  } finally {
396
434
  h.cleanup();
397
435
  }
@@ -78,10 +78,11 @@ describe("renderHub", () => {
78
78
  expect(html).not.toContain("['notes', 'scribe', 'agent']");
79
79
  });
80
80
 
81
- test("Services skip rule emerges from data, not name-checks (vault has no uiUrl)", () => {
82
- // The previous `isVaultName` hardcoded skip is gone — vault doesn't
83
- // declare uiUrl, so it naturally doesn't render. Other API-only
84
- // modules (current or future) get the same treatment for free.
81
+ test("Services skip rule emerges from data, not name-checks (any service without uiUrl skipped)", () => {
82
+ // The previous `isVaultName` hardcoded skip is gone — vault now
83
+ // declares uiUrl per workstream C (patterns#96), so it renders too;
84
+ // API-only modules (current or future) without uiUrl get omitted
85
+ // for free under the same data-driven rule.
85
86
  expect(html).toContain("if (!svc || !svc.uiUrl) continue;");
86
87
  // The function definition is gone (the comment may still mention the
87
88
  // name as historical context — we only care about the active code).
@@ -3970,13 +3970,20 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
3970
3970
  });
3971
3971
  }
3972
3972
 
3973
- test("session absent → page renders Sign-in CTA + shareable deep link (no CLI message)", async () => {
3974
- // Approval-UX rc.19: the unauthenticated viewer no longer sees a
3975
- // "Ask the operator to run `parachute auth approve-client <id>`"
3976
- // message. The web approval path (#277) is the canonical recovery
3977
- // now render a primary Sign-in CTA wired to /login?next=/admin/...
3978
- // and a shareable deep link the operator can send to whoever runs
3979
- // the hub.
3973
+ test("session absent → Sign-in CTA preserves the authorize URL through login + shareable deep link", async () => {
3974
+ // Approval-UX rc.19: the unauthenticated viewer sees no CLI hint
3975
+ // the web approval path (#277) is the canonical recovery now. The
3976
+ // primary Sign-in CTA wires `/login?next=<authorize URL>` so post-
3977
+ // login the operator lands BACK on the same `/oauth/authorize?...`
3978
+ // request now authenticated, they see the inline approve form, one
3979
+ // click resumes the OAuth flow through consent → redirect_uri. The
3980
+ // shareable secondary deep link still points at the SPA approve page
3981
+ // (it's for sharing with another admin, not for the in-flight flow).
3982
+ //
3983
+ // Pre-fix the Sign-in CTA also pointed at the SPA approve page —
3984
+ // approving the client but discarding the authorize URL params, so
3985
+ // the calling app (e.g. Claude.ai MCP) was never told and the user
3986
+ // looped on retry. Caught by Aaron on the Render deploy.
3980
3987
  const { db, cleanup } = await makeDb();
3981
3988
  try {
3982
3989
  const reg = registerClient(db, {
@@ -3984,19 +3991,34 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
3984
3991
  clientName: "MyApp",
3985
3992
  status: "pending",
3986
3993
  });
3987
- const req = new Request(pendingAuthorizeUrl(reg.client.clientId));
3994
+ const authorizePath = pendingAuthorizeUrl(reg.client.clientId);
3995
+ const req = new Request(authorizePath);
3988
3996
  const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
3989
3997
  expect(res.status).toBe(403);
3990
3998
  const html = await res.text();
3991
3999
  expect(html).toContain("App not yet approved");
3992
- // Primary CTA: Sign-in link wired to land the admin directly on
3993
- // the approval page after authenticating.
4000
+ // Primary CTA: Sign-in link wired to round-trip the operator back
4001
+ // to the original /oauth/authorize?... URL after login (resumes the
4002
+ // OAuth flow rather than dead-ending at the SPA approve page).
3994
4003
  expect(html).toContain("Sign in as admin to approve");
3995
- const expectedLoginHref = `/login?next=${encodeURIComponent(
4004
+ const requestUrl = new URL(authorizePath);
4005
+ const returnTo = `${requestUrl.pathname}${requestUrl.search}`;
4006
+ const expectedLoginHref = `/login?next=${encodeURIComponent(returnTo)}`;
4007
+ expect(html).toContain(`href="${expectedLoginHref}"`);
4008
+ // Sanity: the next= target carries the authorize path + the
4009
+ // client_id + state so the flow can resume verbatim post-login.
4010
+ expect(returnTo).toContain("/oauth/authorize");
4011
+ expect(returnTo).toContain(encodeURIComponent(reg.client.clientId));
4012
+ expect(returnTo).toContain("state=rt-208");
4013
+ // The legacy SPA approve path is NOT what the Sign-in CTA points
4014
+ // at any more (regression guard for the fix).
4015
+ const legacyHref = `/login?next=${encodeURIComponent(
3996
4016
  `/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
3997
4017
  )}`;
3998
- expect(html).toContain(`href="${expectedLoginHref}"`);
3999
- // Secondary CTA: shareable, fully-qualified deep link + Copy button.
4018
+ expect(html).not.toContain(`href="${legacyHref}"`);
4019
+ // Secondary CTA: shareable, fully-qualified deep link + Copy button
4020
+ // — still points at the SPA approve page (no OAuth flow context to
4021
+ // preserve for the share-with-another-admin case).
4000
4022
  expect(html).toContain(
4001
4023
  `${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
4002
4024
  );
@@ -408,13 +408,43 @@ describe("renderApprovePending unauthenticated CTAs", () => {
408
408
  hubOrigin: "https://hub.example.com",
409
409
  };
410
410
 
411
- test("renders Sign in CTA wired to /login?next=/admin/approve-client/<id>", () => {
411
+ test("without loginNextUrl: Sign in CTA falls back to /admin/approve-client/<id>", () => {
412
+ // Back-compat fallback for callers that don't have an in-flight OAuth
413
+ // URL to resume (e.g. direct share-page entry). The SPA approve path
414
+ // approves the client but discards any OAuth context — fine here
415
+ // because there's no OAuth flow to discard.
412
416
  const html = renderApprovePending(COMMON);
413
417
  expect(html).toContain("Sign in as admin to approve");
414
418
  const expectedHref = `/login?next=${encodeURIComponent("/admin/approve-client/client-xyz")}`;
415
419
  expect(html).toContain(`href="${expectedHref}"`);
416
420
  });
417
421
 
422
+ test("with loginNextUrl set: Sign in CTA preserves the authorize URL through login", () => {
423
+ // The OAuth-flow entry case (the bug fix): the page is rendered for an
424
+ // `/oauth/authorize?...` GET, the operator isn't signed in, the CTA
425
+ // sends them through `/login?next=<authorize URL>` so post-login they
426
+ // land BACK on the OAuth flow — now authenticated, see the inline
427
+ // approve form, click once, OAuth flow resumes through consent →
428
+ // redirect_uri callback. Without this the SPA approves the client but
429
+ // the calling app (Claude MCP) is never told and the user loops on
430
+ // retry. Caught when Aaron tested via Claude.ai's MCP connector
431
+ // against the Render deploy.
432
+ const authorizeUrl =
433
+ "/oauth/authorize?client_id=client-xyz&redirect_uri=https%3A%2F%2Fapp.example%2Fcb" +
434
+ "&response_type=code&code_challenge=abc&code_challenge_method=S256&state=rt";
435
+ const html = renderApprovePending({ ...COMMON, loginNextUrl: authorizeUrl });
436
+ expect(html).toContain("Sign in as admin to approve");
437
+ const expectedHref = `/login?next=${encodeURIComponent(authorizeUrl)}`;
438
+ expect(html).toContain(`href="${expectedHref}"`);
439
+ // Sanity: the legacy SPA approve path is NOT what `next` points at.
440
+ const legacyHref = `/login?next=${encodeURIComponent("/admin/approve-client/client-xyz")}`;
441
+ expect(html).not.toContain(`href="${legacyHref}"`);
442
+ // The shareable deep link (secondary CTA, not Sign-in) still uses
443
+ // the SPA approve path — it's for sharing with another admin who
444
+ // isn't in an OAuth flow.
445
+ expect(html).toContain("https://hub.example.com/admin/approve-client/client-xyz");
446
+ });
447
+
418
448
  test("renders fully-qualified shareable deep link + Copy button + clipboard JS", () => {
419
449
  const html = renderApprovePending(COMMON);
420
450
  expect(html).toContain("https://hub.example.com/admin/approve-client/client-xyz");
@@ -324,7 +324,7 @@ describe("buildWellKnown", () => {
324
324
  expect(svc?.uiUrl).toBe("https://notes.example.com/app");
325
325
  });
326
326
 
327
- test("uiUrl absent when the resolver returns undefined (vault case)", () => {
327
+ test("uiUrl absent when the resolver returns undefined (API-only service)", () => {
328
328
  const doc = buildWellKnown({
329
329
  services: [vault, notes],
330
330
  canonicalOrigin: "https://x.example",
@@ -336,6 +336,44 @@ describe("buildWellKnown", () => {
336
336
  expect(notesSvc?.uiUrl).toBe("https://x.example/notes");
337
337
  });
338
338
 
339
+ // Workstream C (patterns#96): vault declares `uiUrl: "/admin/"` as a
340
+ // per-instance path. buildWellKnown applies the per-instance mount-path
341
+ // prefix on emission, yielding one tile per vault instance pointing at
342
+ // `<origin>/vault/<name>/admin/`. Non-vault uiUrl behavior is unchanged.
343
+ test("vault uiUrl is prefixed with the per-instance mount path (single instance)", () => {
344
+ const doc = buildWellKnown({
345
+ services: [vault],
346
+ canonicalOrigin: "https://x.example",
347
+ uiUrlFor: () => "/admin/",
348
+ });
349
+ const svc = doc.services.find((s) => s.name === "parachute-vault");
350
+ expect(svc?.uiUrl).toBe("https://x.example/vault/default/admin/");
351
+ });
352
+
353
+ test("vault uiUrl is prefixed per-instance for multi-path vault entries", () => {
354
+ const multi: ServiceEntry = { ...vault, paths: ["/vault/default", "/vault/techne"] };
355
+ const doc = buildWellKnown({
356
+ services: [multi],
357
+ canonicalOrigin: "https://x.example",
358
+ uiUrlFor: () => "/admin/",
359
+ });
360
+ const rows = doc.services.filter((s) => s.name === "parachute-vault");
361
+ expect(rows.length).toBe(2);
362
+ const uiUrls = rows.map((r) => r.uiUrl).sort();
363
+ expect(uiUrls[0]).toBe("https://x.example/vault/default/admin/");
364
+ expect(uiUrls[1]).toBe("https://x.example/vault/techne/admin/");
365
+ });
366
+
367
+ test("vault uiUrl absolute URL still passes through verbatim (no prefix)", () => {
368
+ const doc = buildWellKnown({
369
+ services: [vault],
370
+ canonicalOrigin: "https://x.example",
371
+ uiUrlFor: () => "https://vault.example.com/admin",
372
+ });
373
+ const svc = doc.services.find((s) => s.name === "parachute-vault");
374
+ expect(svc?.uiUrl).toBe("https://vault.example.com/admin");
375
+ });
376
+
339
377
  test("displayName resolver overrides services.json displayName", () => {
340
378
  const notesWithName: ServiceEntry = { ...notes, displayName: "FromServicesJson" };
341
379
  const doc = buildWellKnown({
@@ -18,6 +18,33 @@
18
18
  * the API path logs because cross-machine "who approved this" is the
19
19
  * audit-grade signal we'd want when the operator approves from a browser
20
20
  * rather than a terminal they own.
21
+ *
22
+ * ## OAuth resume via `return_to` (workstream D, AUDIT-UI-UX.md §5 row D)
23
+ *
24
+ * The SPA approve page (`web/ui/src/routes/ApproveClient.tsx`) was a
25
+ * documented dead-end pre-D: it flipped the client to approved, then told
26
+ * the operator to "return to the app and retry" — the parked OAuth flow
27
+ * had no way to resume.
28
+ *
29
+ * D adds the affordance, not a behaviour change for existing callers. If
30
+ * the POST body carries a `return_to` JSON field that's a hub-relative
31
+ * `/oauth/authorize?...` URL, the response echoes it back as `redirect_to`
32
+ * and the SPA navigates the browser there to resume the flow. Callers
33
+ * that don't pass `return_to` (the "share this link with another admin"
34
+ * case the unauth pending-client CTA renders) get the unchanged response
35
+ * shape; the SPA renders its dead-end success state and the deep-link
36
+ * UX is preserved.
37
+ *
38
+ * Two cases, one route — `return_to` is the discriminator. The pattern
39
+ * doc is `parachute-patterns/patterns/oauth-dcr-approval.md` §"SPA
40
+ * approve page (two cases, one route)".
41
+ *
42
+ * Validation reuses `isSafeAuthorizeReturnTo` from oauth-handlers.ts so
43
+ * the SPA endpoint and the inline `/oauth/authorize/approve` endpoint
44
+ * apply the same gate — single source of truth for "what's a valid OAuth
45
+ * resume target?" Off-origin or non-authorize values are silently dropped
46
+ * (the response omits `redirect_to`) rather than 4xx'ing — a bad
47
+ * `return_to` shouldn't block an otherwise-legitimate approve.
21
48
  */
22
49
  import type { Database } from "bun:sqlite";
23
50
  import {
@@ -28,6 +55,7 @@ import {
28
55
  } from "./admin-auth.ts";
29
56
  import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
30
57
  import { approveClient, getClient } from "./clients.ts";
58
+ import { isSafeAuthorizeReturnTo } from "./oauth-handlers.ts";
31
59
 
32
60
  export interface AdminClientsDeps {
33
61
  db: Database;
@@ -102,6 +130,11 @@ export async function handleApproveClient(
102
130
  } catch (err) {
103
131
  return adminAuthErrorResponse(err as AdminAuthError);
104
132
  }
133
+ // Parse the body OPTIONALLY — pre-D callers send no body at all, so a
134
+ // missing / empty / non-JSON body is fine. Only fish out `return_to` when
135
+ // the caller actually provided a parseable JSON object; everything else
136
+ // is treated as "no return_to specified," same as pre-D.
137
+ const returnTo = await readReturnTo(req);
105
138
  const before = getClient(deps.db, clientId);
106
139
  if (!before) {
107
140
  return jsonError(404, "not_found", `no client registered with id ${clientId}`);
@@ -123,20 +156,63 @@ export async function handleApproveClient(
123
156
  `client approved: client_id=${clientId} client_name=${before.clientName ?? ""} approver_sub=${ctx.sub}`,
124
157
  );
125
158
  }
126
- return new Response(
127
- JSON.stringify({
128
- client_id: clientId,
129
- status: "approved",
130
- already_approved: !wasPending,
131
- }),
132
- {
133
- status: 200,
134
- headers: {
135
- "content-type": "application/json",
136
- "cache-control": "no-store",
137
- },
159
+ // Only echo `redirect_to` when the caller's `return_to` passed the gate.
160
+ // Bad / missing values just drop off the response — the SPA falls back
161
+ // to its dead-end success state. We don't 4xx an otherwise-legitimate
162
+ // approve over a bad return_to (the client is now approved either way).
163
+ const body: ApproveClientResponse = {
164
+ client_id: clientId,
165
+ status: "approved",
166
+ already_approved: !wasPending,
167
+ };
168
+ if (returnTo !== null && isSafeAuthorizeReturnTo(returnTo)) {
169
+ body.redirect_to = returnTo;
170
+ }
171
+ return new Response(JSON.stringify(body), {
172
+ status: 200,
173
+ headers: {
174
+ "content-type": "application/json",
175
+ "cache-control": "no-store",
138
176
  },
139
- );
177
+ });
178
+ }
179
+
180
+ interface ApproveClientResponse {
181
+ client_id: string;
182
+ status: "approved";
183
+ already_approved: boolean;
184
+ /**
185
+ * Hub-relative `/oauth/authorize?...` URL the SPA should navigate to
186
+ * after approving, to resume a parked OAuth flow. Only present when the
187
+ * POST body's `return_to` passed `isSafeAuthorizeReturnTo`. Absent for
188
+ * the share-link case (no `return_to` provided) so the SPA's dead-end
189
+ * success state still renders.
190
+ */
191
+ redirect_to?: string;
192
+ }
193
+
194
+ /**
195
+ * Pull `return_to` out of the request body if present. Tolerant by design:
196
+ * pre-D callers (and tests, and curl probes) send no body or a non-JSON
197
+ * body, and the endpoint MUST continue to work in those shapes. Any parse
198
+ * failure or missing field returns null; the response omits `redirect_to`
199
+ * accordingly.
200
+ *
201
+ * Only `application/json` bodies are inspected — keeping the format
202
+ * restricted to JSON matches the existing API conventions (the SPA's
203
+ * other admin POSTs use JSON throughout) and avoids parser ambiguity
204
+ * over form-encoded variants on a deliberately optional field.
205
+ */
206
+ async function readReturnTo(req: Request): Promise<string | null> {
207
+ const ct = req.headers.get("content-type") ?? "";
208
+ if (!ct.toLowerCase().includes("application/json")) return null;
209
+ try {
210
+ const body = (await req.json()) as { return_to?: unknown };
211
+ if (typeof body?.return_to !== "string") return null;
212
+ return body.return_to;
213
+ } catch {
214
+ return null;
215
+ }
140
216
  }
141
217
 
142
218
  function jsonError(status: number, error: string, description: string): Response {
package/src/hub-server.ts CHANGED
@@ -719,12 +719,24 @@ async function loadManagementUrls(
719
719
  }
720
720
 
721
721
  /**
722
- * For each NON-vault `ServiceEntry` with a known `installDir`, read its
722
+ * For each `ServiceEntry` with a known `installDir`, read its
723
723
  * `.parachute/module.json` and surface the optional `uiUrl` and
724
724
  * `displayName`. Returns two `name → value` maps keyed by services.json
725
- * entry name. Mirrors `loadManagementUrls` (vault is the analog there;
726
- * non-vault services are the analog here — vaults are user-facing via
727
- * Notes, not their own UI).
725
+ * entry name.
726
+ *
727
+ * Vaults are NOT skipped — as of patterns#96 (workstream C) vault declares
728
+ * its own `uiUrl: "/admin/"` (multi-instance form). `buildWellKnown`
729
+ * applies the per-instance mount-path prefix for vault rows so each
730
+ * instance gets a discovery tile pointing at `/vault/<name>/admin/`. The
731
+ * earlier "vaults browse via Notes — no tile" rule retired with PR 1 of
732
+ * workstream C; operators administer per-vault tokens / config / MCP via
733
+ * the vault admin SPA, which is a different audience from Notes' content
734
+ * browse. See [`module-ui-declaration.md` §"Use vs admin"](https://github.com/ParachuteComputer/parachute-patterns/blob/main/patterns/module-ui-declaration.md#use-vs-admin--both-can-be-true).
735
+ *
736
+ * `loadManagementUrls` continues to handle vault's `managementUrl` for
737
+ * the hub admin SPA's vault-list "Manage" link — a different surface
738
+ * (admin SPA, not discovery), even when the target path happens to
739
+ * collide (`/admin/` for both).
728
740
  *
729
741
  * Why read at request time and not from services.json: services own the
730
742
  * write side of services.json (`upsertService` replaces the whole entry
@@ -746,9 +758,7 @@ async function loadServiceUiMetadata(
746
758
  const displayNames = new Map<string, string>();
747
759
  await Promise.all(
748
760
  services.map(async (s) => {
749
- // Skip vaults — they have their own loadManagementUrls path and no
750
- // operator-facing user UI of their own (content browses via Notes).
751
- if (isVaultEntry(s) || !s.installDir) return;
761
+ if (!s.installDir) return;
752
762
  try {
753
763
  const m = await read(s.installDir);
754
764
  if (m?.uiUrl) uiUrls.set(s.name, m.uiUrl);