@openparachute/hub 0.5.13-rc.38 → 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.38",
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).
@@ -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);
package/src/hub.ts CHANGED
@@ -9,15 +9,17 @@ import { CSRF_FIELD_NAME } from "./csrf.ts";
9
9
  * The page is split into two sections, organized by **ownership**:
10
10
  *
11
11
  * - **Services** — surfaces provided by the modules running on this
12
- * hub. Browse notes (the Notes PWA); transcribe audio (Scribe); run
13
- * agents (Agent). Each entry points at the service's own UI; the
14
- * service owns what's behind the link (use, config, admin —
15
- * whatever it chooses to surface). Entries are dynamic, derived
16
- * from `/.well-known/parachute.json`; only installed services show
17
- * up. Vault deliberately doesn't have its own Services entry — its
18
- * content is browsed via Notes, so a separate "Vault" tile would
19
- * just send the operator to the admin SPA, which is exactly the
20
- * friction Aaron flagged ("clicked Vault, took me to hub management").
12
+ * hub. Browse notes (the Notes PWA); transcribe audio (Scribe);
13
+ * administer a vault (Vault admin SPA); run agents (Agent). Each
14
+ * entry points at the service's own UI; the service owns what's
15
+ * behind the link (use, config, admin whatever it chooses to
16
+ * surface). Entries are dynamic, derived from
17
+ * `/.well-known/parachute.json`; only installed services show up.
18
+ * Vault declares `uiUrl` per workstream C (patterns#96, 2026-05-25)
19
+ * the earlier "vault has no tile because content browses via
20
+ * Notes" rule split: Notes covers end-user content browsing; the
21
+ * vault tile covers operator admin (per-vault tokens, config, MCP).
22
+ * Both deserve discovery presence.
21
23
  *
22
24
  * - **Admin** — hub-owned admin surfaces for cross-cutting host
23
25
  * concerns. Always visible: even with zero vaults installed, an
@@ -455,17 +457,25 @@ const HTML_TEMPLATE = `<!doctype html>
455
457
  /**
456
458
  * Render the "Get started" section (hub#342) above the Services grid.
457
459
  *
458
- * Two hardcoded targets, each conditional on its prerequisite being
459
- * installed:
460
+ * One hardcoded target, conditional on its prerequisite being installed:
460
461
  * - "Open Notes" → /app/notes/ (requires parachute-app installed;
461
462
  * App auto-bootstraps Notes-as-UI per parachute-app §17, so the
462
463
  * mere presence of App means /app/notes/ is live)
463
- * - "Browse Vault" → /vault/<first-vault-name>/admin/ (requires
464
- * parachute-vault installed; uses the first vault's name from
465
- * its services.json mount path tail)
466
464
  *
467
- * If neither prerequisite is met (fresh install pre-wizard) the
468
- * section stays hidden. The hardcoded shape mirrors the wizard's
465
+ * The earlier "Browse Vault" tile retired in workstream C (2026-05-25)
466
+ * once vault declared uiUrl in its module.json (per patterns#96). With
467
+ * the multi-instance prefix lifted into buildWellKnown, every vault
468
+ * instance now renders its own tile in the Services grid below — one
469
+ * per instance, with the actual instance name in the discovery doc
470
+ * rather than a hub-side guess at "first vault."
471
+ *
472
+ * Notes stays here because Notes is the end-user CTA (the audience
473
+ * that needs the strongest above-the-fold prompt); the Services grid
474
+ * positions it equally with admin tiles, which underweights the
475
+ * "browse my content" path for a returning operator.
476
+ *
477
+ * If the prerequisite is not met (fresh install pre-wizard, no app)
478
+ * the section stays hidden. The hardcoded shape mirrors the wizard's
469
479
  * own done-screen "Start using your vault" tile — same architectural
470
480
  * shape (single obvious entry point) at a different surface.
471
481
  *
@@ -478,11 +488,6 @@ const HTML_TEMPLATE = `<!doctype html>
478
488
  if (!getStartedGrid || !getStartedSection) return;
479
489
  const tiles = [];
480
490
  const hasApp = services.some((s) => s && s.name === 'parachute-app');
481
- // services[] is fanned out per-vault for parachute-vault rows (see
482
- // well-known.ts emitVaultRows) — "path" is the per-instance mount
483
- // "/vault/<name>". Pick the first vault entry; the order matches
484
- // services.json's paths[] order, so this is deterministic.
485
- const vault = services.find((s) => s && s.name === 'parachute-vault');
486
491
  if (hasApp) {
487
492
  tiles.push({
488
493
  title: 'Open Notes',
@@ -490,25 +495,6 @@ const HTML_TEMPLATE = `<!doctype html>
490
495
  href: '/app/notes/',
491
496
  });
492
497
  }
493
- if (vault) {
494
- // vault.path is the per-instance mount "/vault/<name>". Extract
495
- // the tail as the display name; mirror the wizard's own
496
- // firstVaultName() shape.
497
- let vaultName = 'default';
498
- if (typeof vault.path === 'string' && vault.path.startsWith('/vault/')) {
499
- // Character class for the slash so the template literal can't eat
500
- // the backslash-escape — \/ collapses to / inside backticks, and
501
- // /\/+$/ degenerates to a // line comment that breaks the whole
502
- // IIFE. [/]+$ keeps the same semantics with no escape needed.
503
- const tail = vault.path.slice('/vault/'.length).replace(/[/]+$/, '');
504
- if (tail.length > 0) vaultName = tail;
505
- }
506
- tiles.push({
507
- title: 'Browse Vault',
508
- desc: "Open your vault's admin UI — content, settings, MCP.",
509
- href: '/vault/' + encodeURIComponent(vaultName) + '/admin/',
510
- });
511
- }
512
498
  if (tiles.length === 0) {
513
499
  getStartedSection.setAttribute('hidden', '');
514
500
  return;
@@ -520,11 +506,21 @@ const HTML_TEMPLATE = `<!doctype html>
520
506
 
521
507
  function renderServices(services) {
522
508
  // Render one tile per service that declares a uiUrl. Entries without
523
- // uiUrl are intentionally omitted — vault is the canonical example
524
- // (its content is browsed via Notes, which has its own uiUrl row).
525
- // Multiple entries with the same shortName collapse into one tile;
526
- // operators with two scribe instances pick the first arbitrarily,
527
- // and they'd know which they meant.
509
+ // uiUrl are intentionally omitted — API-only modules with no
510
+ // operator surface. The shortName-collapse below picks the first row
511
+ // per service short-name; for multi-instance services, this matters
512
+ // only when the services.json entry name is shared across instances.
513
+ //
514
+ // Today: vault registers as parachute-vault with multiple paths[] —
515
+ // shortName is "vault", one tile points at the first instance's admin.
516
+ // Same effective behavior the retired hardcoded "Browse Vault" tile
517
+ // had (workstream C, 2026-05-25).
518
+ //
519
+ // If a future multi-vault setup ever uses distinct entry names per
520
+ // instance (e.g. parachute-vault-default, parachute-vault-techne),
521
+ // the shortName collapse will yield "vault-default" and "vault-techne"
522
+ // and the operator gets one tile per named vault automatically — no
523
+ // disambiguation code needed.
528
524
  const byShort = new Map();
529
525
  for (const svc of services) {
530
526
  if (!svc || !svc.uiUrl) continue;
@@ -1251,8 +1251,13 @@ export async function handleApproveClientPost(
1251
1251
  * (no scheme, no double-slash) targeting `/oauth/authorize` with a query
1252
1252
  * string — anything else is either an open-redirect attempt or a misuse of
1253
1253
  * the endpoint. Empty string is rejected (the form always supplies one).
1254
+ *
1255
+ * Exported so the SPA approve-client endpoint (`handleApproveClient` in
1256
+ * admin-clients.ts) can apply the same gate when echoing a `return_to` back
1257
+ * to the caller — workstream D. Single helper = single shape of "what's a
1258
+ * valid OAuth-resume target?" for the whole hub.
1254
1259
  */
1255
- function isSafeAuthorizeReturnTo(value: string): boolean {
1260
+ export function isSafeAuthorizeReturnTo(value: string): boolean {
1256
1261
  if (!value) return false;
1257
1262
  // Reject scheme-relative ("//evil.example/foo") and absolute URLs. Only
1258
1263
  // single-slash root-relative paths are allowed.