@openparachute/hub 0.6.5-rc.8 → 0.7.0

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.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +34 -0
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections.test.ts +1154 -0
  6. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  7. package/src/__tests__/admin-module-token.test.ts +311 -0
  8. package/src/__tests__/admin-vaults.test.ts +590 -0
  9. package/src/__tests__/api-modules-ops.test.ts +70 -5
  10. package/src/__tests__/api-modules.test.ts +262 -79
  11. package/src/__tests__/hub-server.test.ts +319 -21
  12. package/src/__tests__/invites.test.ts +27 -0
  13. package/src/__tests__/module-manifest.test.ts +305 -8
  14. package/src/__tests__/serve-boot.test.ts +133 -2
  15. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  16. package/src/__tests__/setup-gate.test.ts +13 -7
  17. package/src/__tests__/setup-wizard.test.ts +228 -1
  18. package/src/__tests__/vault-name.test.ts +20 -5
  19. package/src/__tests__/well-known.test.ts +44 -8
  20. package/src/account-vault-admin-token.ts +43 -14
  21. package/src/admin-channel-token.ts +135 -0
  22. package/src/admin-connections.ts +980 -0
  23. package/src/admin-module-token.ts +197 -0
  24. package/src/admin-vaults.ts +390 -12
  25. package/src/api-hub-upgrade.ts +4 -3
  26. package/src/api-modules-ops.ts +41 -16
  27. package/src/api-modules.ts +238 -116
  28. package/src/api-tokens.ts +8 -5
  29. package/src/commands/serve-boot.ts +80 -3
  30. package/src/commands/setup.ts +4 -4
  31. package/src/connections-store.ts +161 -0
  32. package/src/grants.ts +50 -0
  33. package/src/hub-server.ts +349 -59
  34. package/src/invites.ts +22 -0
  35. package/src/jwt-sign.ts +41 -1
  36. package/src/module-manifest.ts +429 -23
  37. package/src/origin-check.ts +106 -0
  38. package/src/proxy-error-ui.ts +1 -1
  39. package/src/service-spec.ts +132 -41
  40. package/src/setup-wizard.ts +68 -6
  41. package/src/users.ts +11 -0
  42. package/src/vault-name.ts +27 -7
  43. package/src/well-known.ts +41 -33
  44. package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
  45. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  46. package/web/ui/dist/index.html +2 -2
  47. package/src/__tests__/api-modules-config.test.ts +0 -882
  48. package/src/api-modules-config.ts +0 -421
  49. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  50. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -3158,6 +3158,54 @@ describe("typed vault name (hub#267)", () => {
3158
3158
  }
3159
3159
  });
3160
3160
 
3161
+ test("vault POST rejects every consolidated reserved name (B2h: list, new, assets, admin)", async () => {
3162
+ const db = openHubDb(hubDbPath(h.dir));
3163
+ try {
3164
+ const user = await createUser(db, "owner", "pw");
3165
+ const { createSession } = await import("../sessions.ts");
3166
+ const session = createSession(db, { userId: user.id });
3167
+ const get = handleSetupGet(req("/admin/setup"), {
3168
+ db,
3169
+ manifestPath: h.manifestPath,
3170
+ configDir: h.dir,
3171
+ readExposeStateFn: h.readExposeStateFn,
3172
+ issuer: "https://hub.example",
3173
+ registry: getDefaultOperationsRegistry(),
3174
+ });
3175
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
3176
+ for (const name of ["list", "new", "assets", "admin"]) {
3177
+ const post = await handleSetupVaultPost(
3178
+ req("/admin/setup/vault", {
3179
+ method: "POST",
3180
+ body: new URLSearchParams({
3181
+ [CSRF_FIELD_NAME]: csrf,
3182
+ vault_name: name,
3183
+ }).toString(),
3184
+ headers: {
3185
+ "content-type": "application/x-www-form-urlencoded",
3186
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
3187
+ },
3188
+ }),
3189
+ {
3190
+ db,
3191
+ manifestPath: h.manifestPath,
3192
+ configDir: h.dir,
3193
+ readExposeStateFn: h.readExposeStateFn,
3194
+ issuer: "https://hub.example",
3195
+ supervisor: makeSupervisor(),
3196
+ registry: getDefaultOperationsRegistry(),
3197
+ },
3198
+ );
3199
+ expect(post.status).toBe(400);
3200
+ const html = await post.text();
3201
+ expect(html).toContain("reserved");
3202
+ expect(getSetting(db, "setup_vault_name")).toBeUndefined();
3203
+ }
3204
+ } finally {
3205
+ db.close();
3206
+ }
3207
+ });
3208
+
3161
3209
  test("vault POST with empty name falls back to 'default' + omits the env override", async () => {
3162
3210
  const db = openHubDb(hubDbPath(h.dir));
3163
3211
  try {
@@ -4350,19 +4398,198 @@ describe("setup-wizard JSON surface (hub#168 Cuts 2/3)", () => {
4350
4398
  expect(body.step).toBe("expose");
4351
4399
  // The skip flag is persisted.
4352
4400
  expect(getSetting(db, "setup_vault_skipped")).toBe("true");
4353
- // deriveWizardState advances past the vault step.
4401
+ // deriveWizardState advances past the vault step — but hasRealVault
4402
+ // stays false (no instance exists; only the skip marker is set). The
4403
+ // distinction is what the re-enterable vault step (B5) keys on.
4354
4404
  const s = deriveWizardState({
4355
4405
  db,
4356
4406
  manifestPath: h.manifestPath,
4357
4407
  readExposeStateFn: h.readExposeStateFn,
4358
4408
  });
4359
4409
  expect(s.hasVault).toBe(true);
4410
+ expect(s.hasRealVault).toBe(false);
4360
4411
  expect(s.step).toBe("expose");
4361
4412
  } finally {
4362
4413
  db.close();
4363
4414
  }
4364
4415
  });
4365
4416
 
4417
+ // --- B5 (2026-06-09 hub-module-boundary): re-enterable vault step --------
4418
+ //
4419
+ // A wizard-skip leaves the vault module installed with zero instances and
4420
+ // `setup_vault_skipped` satisfying hasVault — pre-B5 the create form was
4421
+ // unreachable forever after. The hub-side "create your first vault"
4422
+ // affordances (Home's vault card, the legacy /admin/vaults empty state)
4423
+ // deep-link `/admin/setup?step=vault`, which must re-enter the form.
4424
+
4425
+ test("GET ?step=vault re-enters the create form after a wizard-skip (session-gated)", async () => {
4426
+ const db = openHubDb(hubDbPath(h.dir));
4427
+ try {
4428
+ await createUser(db, "owner", "pw");
4429
+ setSetting(db, "setup_vault_skipped", "true");
4430
+ const { createSession } = await import("../sessions.ts");
4431
+ const user = getUserByUsername(db, "owner");
4432
+ if (!user) throw new Error("user missing");
4433
+ const session = createSession(db, { userId: user.id });
4434
+ const res = handleSetupGet(
4435
+ req("/admin/setup?step=vault", {
4436
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
4437
+ }),
4438
+ {
4439
+ db,
4440
+ manifestPath: h.manifestPath,
4441
+ configDir: h.dir,
4442
+ readExposeStateFn: h.readExposeStateFn,
4443
+ issuer: "http://127.0.0.1:1939",
4444
+ registry: getDefaultOperationsRegistry(),
4445
+ },
4446
+ );
4447
+ expect(res.status).toBe(200);
4448
+ const html = await res.text();
4449
+ // The vault create/import/skip form — not the expose step the plain
4450
+ // GET would resume at post-skip.
4451
+ expect(html).toContain('action="/admin/setup/vault"');
4452
+ } finally {
4453
+ db.close();
4454
+ }
4455
+ });
4456
+
4457
+ test("GET ?step=vault without a session 302s to /login (post-skip)", async () => {
4458
+ const db = openHubDb(hubDbPath(h.dir));
4459
+ try {
4460
+ await createUser(db, "owner", "pw");
4461
+ setSetting(db, "setup_vault_skipped", "true");
4462
+ const res = handleSetupGet(req("/admin/setup?step=vault"), {
4463
+ db,
4464
+ manifestPath: h.manifestPath,
4465
+ configDir: h.dir,
4466
+ readExposeStateFn: h.readExposeStateFn,
4467
+ issuer: "http://127.0.0.1:1939",
4468
+ registry: getDefaultOperationsRegistry(),
4469
+ });
4470
+ expect(res.status).toBe(302);
4471
+ expect(res.headers.get("location")).toBe(
4472
+ `/login?next=${encodeURIComponent("/admin/setup?step=vault")}`,
4473
+ );
4474
+ } finally {
4475
+ db.close();
4476
+ }
4477
+ });
4478
+
4479
+ test("GET ?step=vault with NO admin falls through to the welcome step (hasAdmin guard)", async () => {
4480
+ // Fresh box, no user rows: the re-entry branch is gated on
4481
+ // `state.hasAdmin` — the param must not jump a brand-new operator past
4482
+ // account creation into a provisioning form (the POST would reject the
4483
+ // session-less submit anyway, but the GET shouldn't render out of order
4484
+ // either).
4485
+ const db = openHubDb(hubDbPath(h.dir));
4486
+ try {
4487
+ const res = handleSetupGet(req("/admin/setup?step=vault"), {
4488
+ db,
4489
+ manifestPath: h.manifestPath,
4490
+ configDir: h.dir,
4491
+ readExposeStateFn: h.readExposeStateFn,
4492
+ issuer: "http://127.0.0.1:1939",
4493
+ registry: getDefaultOperationsRegistry(),
4494
+ });
4495
+ expect(res.status).toBe(200);
4496
+ // The welcome/account form — not the vault create form, not a redirect.
4497
+ const html = await res.text();
4498
+ expect(html).toContain('action="/admin/setup/account"');
4499
+ expect(html).not.toContain('action="/admin/setup/vault"');
4500
+ } finally {
4501
+ db.close();
4502
+ }
4503
+ });
4504
+
4505
+ test("GET ?step=vault is ignored when a real vault instance exists", async () => {
4506
+ const db = openHubDb(hubDbPath(h.dir));
4507
+ try {
4508
+ await createUser(db, "owner", "pw");
4509
+ setSetting(db, "setup_expose_mode", "localhost");
4510
+ writeManifest(
4511
+ {
4512
+ services: [
4513
+ {
4514
+ name: "parachute-vault",
4515
+ version: "0.1.0",
4516
+ port: 1940,
4517
+ paths: ["/vault/default"],
4518
+ health: "/health",
4519
+ },
4520
+ ],
4521
+ },
4522
+ h.manifestPath,
4523
+ );
4524
+ const res = handleSetupGet(req("/admin/setup?step=vault"), {
4525
+ db,
4526
+ manifestPath: h.manifestPath,
4527
+ configDir: h.dir,
4528
+ readExposeStateFn: h.readExposeStateFn,
4529
+ issuer: "http://127.0.0.1:1939",
4530
+ registry: getDefaultOperationsRegistry(),
4531
+ });
4532
+ // Setup is fully complete — the param must not reopen a provisioning
4533
+ // form; the normal completed flow (301 → /login) runs instead.
4534
+ expect(res.status).toBe(301);
4535
+ expect(res.headers.get("location")).toBe("/login");
4536
+ } finally {
4537
+ db.close();
4538
+ }
4539
+ });
4540
+
4541
+ test("vault POST mode=create proceeds after a skip (short-circuit keys on hasRealVault)", async () => {
4542
+ const db = openHubDb(hubDbPath(h.dir));
4543
+ try {
4544
+ await createUser(db, "owner", "pw");
4545
+ setSetting(db, "setup_vault_skipped", "true");
4546
+ // No supervisor in deps: a create that gets PAST the short-circuit hits
4547
+ // the supervisor gate and 503s. Pre-B5 this returned 200
4548
+ // `{ step: "expose", message: "vault already provisioned" }` because the
4549
+ // skip marker satisfied hasVault — the form was a dead end.
4550
+ const baseDeps = {
4551
+ db,
4552
+ manifestPath: h.manifestPath,
4553
+ configDir: h.dir,
4554
+ readExposeStateFn: h.readExposeStateFn,
4555
+ issuer: "http://127.0.0.1:1939",
4556
+ registry: getDefaultOperationsRegistry(),
4557
+ };
4558
+ const getRes = handleSetupGet(
4559
+ req("/admin/setup", { headers: { accept: "application/json" } }),
4560
+ baseDeps,
4561
+ );
4562
+ const csrf = setCookie(getRes, CSRF_COOKIE_NAME) ?? "";
4563
+ const envelope = (await getRes.json()) as { csrfToken: string };
4564
+ const { createSession } = await import("../sessions.ts");
4565
+ const user = getUserByUsername(db, "owner");
4566
+ if (!user) throw new Error("user missing");
4567
+ const session = createSession(db, { userId: user.id });
4568
+ const cookieHeader = `${SESSION_COOKIE_NAME}=${session.id}; ${CSRF_COOKIE_NAME}=${csrf}`;
4569
+ const postRes = await handleSetupVaultPost(
4570
+ req("/admin/setup/vault", {
4571
+ method: "POST",
4572
+ headers: {
4573
+ accept: "application/json",
4574
+ "content-type": "application/json",
4575
+ cookie: cookieHeader,
4576
+ },
4577
+ body: JSON.stringify({
4578
+ [CSRF_FIELD_NAME]: envelope.csrfToken,
4579
+ mode: "create",
4580
+ vault_name: "second-chance",
4581
+ }),
4582
+ }),
4583
+ baseDeps,
4584
+ );
4585
+ expect(postRes.status).toBe(503);
4586
+ const body = (await postRes.json()) as { error: string };
4587
+ expect(body.error).toContain("supervisor");
4588
+ } finally {
4589
+ db.close();
4590
+ }
4591
+ });
4592
+
4366
4593
  test("vault step import mode requires remote_url (400 on empty)", async () => {
4367
4594
  const db = openHubDb(hubDbPath(h.dir));
4368
4595
  try {
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
- import { DEFAULT_VAULT_NAME, validateVaultName } from "../vault-name.ts";
9
+ import { DEFAULT_VAULT_NAME, RESERVED_VAULT_NAMES, validateVaultName } from "../vault-name.ts";
10
10
 
11
11
  describe("validateVaultName", () => {
12
12
  test("accepts lowercase alphanumeric + hyphens/underscores", () => {
@@ -64,10 +64,25 @@ describe("validateVaultName", () => {
64
64
  expect(validateVaultName(" ").ok).toBe(false);
65
65
  });
66
66
 
67
- test("rejects the reserved name 'list' (matches vault's reservation)", () => {
68
- const result = validateVaultName("list");
69
- expect(result.ok).toBe(false);
70
- if (!result.ok) expect(result.error).toContain("reserved");
67
+ test("rejects every consolidated reserved name (B2h: list, new, assets, admin)", () => {
68
+ // One set for every hub edge — wizard, invite redemption, POST /vaults.
69
+ // `list` mirrors vault's CLI reservation; `new`/`assets` shadow SPA
70
+ // routes; `admin` shadows the daemon-level /vault/admin mount (B-route).
71
+ for (const name of ["list", "new", "assets", "admin"]) {
72
+ const result = validateVaultName(name);
73
+ expect(result.ok).toBe(false);
74
+ if (!result.ok) expect(result.error).toContain("reserved");
75
+ }
76
+ });
77
+
78
+ test("near-miss names of the reserved set still pass (admins, listing, my-admin)", () => {
79
+ for (const name of ["admins", "listing", "my-admin", "assets2", "newer"]) {
80
+ expect(validateVaultName(name).ok).toBe(true);
81
+ }
82
+ });
83
+
84
+ test("RESERVED_VAULT_NAMES is the consolidated four-name set", () => {
85
+ expect([...RESERVED_VAULT_NAMES].sort()).toEqual(["admin", "assets", "list", "new"]);
71
86
  });
72
87
 
73
88
  test("DEFAULT_VAULT_NAME is 'default'", () => {
@@ -361,26 +361,27 @@ describe("buildWellKnown", () => {
361
361
  expect(notesSvc?.uiUrl).toBe("https://x.example/notes");
362
362
  });
363
363
 
364
- // Workstream C (patterns#96): vault declares `uiUrl: "/admin/"` as a
365
- // per-instance path. buildWellKnown applies the per-instance mount-path
366
- // prefix on emission, yielding one tile per vault instance pointing at
367
- // `<origin>/vault/<name>/admin/`. Non-vault uiUrl behavior is unchanged.
368
- test("vault uiUrl is prefixed with the per-instance mount path (single instance)", () => {
364
+ // B4 unified semantics (2026-06-09 hub-module-boundary): relative
365
+ // (no leading slash) = the per-instance form, mount-joined per vault
366
+ // instance; leading-"/" = origin-absolute pass-through. The literal legacy
367
+ // `"/admin/"` on a vault entry rides the one-release COMPAT SHIM
368
+ // (mount-join + deprecation log) because deployed vaults still declare it.
369
+ test("vault RELATIVE uiUrl mount-joins per instance (B4 per-instance form)", () => {
369
370
  const doc = buildWellKnown({
370
371
  services: [vault],
371
372
  canonicalOrigin: "https://x.example",
372
- uiUrlFor: () => "/admin/",
373
+ uiUrlFor: () => "admin/",
373
374
  });
374
375
  const svc = doc.services.find((s) => s.name === "parachute-vault");
375
376
  expect(svc?.uiUrl).toBe("https://x.example/vault/default/admin/");
376
377
  });
377
378
 
378
- test("vault uiUrl is prefixed per-instance for multi-path vault entries", () => {
379
+ test("vault RELATIVE uiUrl mount-joins per instance for multi-path vault entries", () => {
379
380
  const multi: ServiceEntry = { ...vault, paths: ["/vault/default", "/vault/techne"] };
380
381
  const doc = buildWellKnown({
381
382
  services: [multi],
382
383
  canonicalOrigin: "https://x.example",
383
- uiUrlFor: () => "/admin/",
384
+ uiUrlFor: () => "admin/",
384
385
  });
385
386
  const rows = doc.services.filter((s) => s.name === "parachute-vault");
386
387
  expect(rows.length).toBe(2);
@@ -389,6 +390,41 @@ describe("buildWellKnown", () => {
389
390
  expect(uiUrls[1]).toBe("https://x.example/vault/techne/admin/");
390
391
  });
391
392
 
393
+ test('COMPAT SHIM: vault legacy "/admin/" uiUrl still mount-joins per instance (one release)', () => {
394
+ // Deployed vaults declare `uiUrl: "/admin/"` (the OLD per-instance form).
395
+ // Origin-absolute resolution would point every tile at the daemon-level
396
+ // /vault/admin mount — so the literal "/admin"/"/admin/" keeps the old
397
+ // mount-join for one release, with a deprecation log. Remove the shim
398
+ // once vault's new manifest ("admin/") reaches @latest.
399
+ const multi: ServiceEntry = { ...vault, paths: ["/vault/default", "/vault/techne"] };
400
+ const doc = buildWellKnown({
401
+ services: [multi],
402
+ canonicalOrigin: "https://x.example",
403
+ uiUrlFor: () => "/admin/",
404
+ });
405
+ const rows = doc.services.filter((s) => s.name === "parachute-vault");
406
+ const uiUrls = rows.map((r) => r.uiUrl).sort();
407
+ expect(uiUrls[0]).toBe("https://x.example/vault/default/admin/");
408
+ expect(uiUrls[1]).toBe("https://x.example/vault/techne/admin/");
409
+ });
410
+
411
+ test("vault LEADING-SLASH uiUrl (non-shim) is origin-absolute pass-through (B4)", () => {
412
+ // The daemon-level surface form: `/vault/admin/` resolves against the
413
+ // origin — NOT per-instance — so a multi-path vault emits the same
414
+ // daemon-level URL on each row.
415
+ const multi: ServiceEntry = { ...vault, paths: ["/vault/default", "/vault/techne"] };
416
+ const doc = buildWellKnown({
417
+ services: [multi],
418
+ canonicalOrigin: "https://x.example",
419
+ uiUrlFor: () => "/vault/admin/",
420
+ });
421
+ const rows = doc.services.filter((s) => s.name === "parachute-vault");
422
+ expect(rows.length).toBe(2);
423
+ for (const r of rows) {
424
+ expect(r.uiUrl).toBe("https://x.example/vault/admin/");
425
+ }
426
+ });
427
+
392
428
  test("vault uiUrl absolute URL still passes through verbatim (no prefix)", () => {
393
429
  const doc = buildWellKnown({
394
430
  services: [vault],
@@ -77,10 +77,12 @@ export interface AccountVaultAdminTokenDeps {
77
77
  hubOrigin: string;
78
78
  /**
79
79
  * The vault's declared `managementUrl` (from its `.parachute/module.json`),
80
- * resolved by the route handler at request time. Either an absolute URL or a
81
- * path relative to the vault's mounted URL. Defaults to `/admin/` (vault's
82
- * canonical value) when the handler can't resolve one that's where the
83
- * admin sibling's deep-link lands too.
80
+ * resolved by the route handler at request time. Resolved per the B4
81
+ * unified semantics (http(s):// verbatim · leading-`/` origin-absolute ·
82
+ * relative joined under the vault's mounted URL; the literal legacy
83
+ * `"/admin/"` mount-joins via the one-release compat shim). Defaults to
84
+ * `"admin/"` (vault's canonical per-instance value) when the handler can't
85
+ * resolve one — that's where the admin sibling's deep-link lands too.
84
86
  */
85
87
  managementUrl?: string;
86
88
  /** Test seam for the clock (mint). */
@@ -94,17 +96,43 @@ function htmlResponse(body: string, status = 200, extra: Record<string, string>
94
96
  });
95
97
  }
96
98
 
99
+ /** One-time deprecation log for the legacy `"/admin/"` managementUrl (B4 compat shim). */
100
+ let warnedLegacyManagementUrl = false;
101
+
97
102
  /**
98
103
  * Resolve a vault's `managementUrl` against the vault's hub-mounted URL.
99
- * Absolute URL returned verbatim; path → joined onto the vault URL after
100
- * trimming a trailing slash. Mirrors `resolveManagementUrl` in the SPA's
101
- * `web/ui/src/lib/api.ts` so hub-server and SPA deep-links agree.
104
+ * Unified URL-resolution semantics (B4 of the 2026-06-09 hub-module-boundary
105
+ * migration) mirrors `resolveManagementUrl` in the SPA's
106
+ * `web/ui/src/lib/api.ts` so hub-server and SPA deep-links agree:
107
+ *
108
+ * - Absolute http(s) URL → verbatim.
109
+ * - Leading-`/` path → ORIGIN-ABSOLUTE: resolved against the vault URL's
110
+ * origin (not joined under the vault mount).
111
+ * - Relative path (no leading slash, e.g. `"admin/"`) → the PER-INSTANCE
112
+ * form: joined under the vault's mounted URL
113
+ * (`<origin>/vault/<name>/admin/`).
114
+ *
115
+ * COMPAT SHIM (one release — remove once vault's new manifest reaches
116
+ * @latest): the literal legacy `"/admin"`/`"/admin/"` is the OLD per-instance
117
+ * relative declaration deployed vaults still ship; it joins under the vault
118
+ * URL (the pre-B4 behavior) with a one-time deprecation log.
102
119
  */
103
120
  function resolveManagementUrl(vaultUrl: string, managementUrl: string): string {
104
121
  if (/^https?:\/\//i.test(managementUrl)) return managementUrl;
105
122
  const base = vaultUrl.replace(/\/+$/, "");
106
- const tail = managementUrl.startsWith("/") ? managementUrl : `/${managementUrl}`;
107
- return `${base}${tail}`;
123
+ if (managementUrl === "/admin" || managementUrl === "/admin/") {
124
+ if (!warnedLegacyManagementUrl) {
125
+ warnedLegacyManagementUrl = true;
126
+ console.warn(
127
+ `account-vault-admin-token: vault declares the legacy per-instance managementUrl ${JSON.stringify(managementUrl)}; joining under the vault URL for one release. New semantics: relative ("admin/") = per-instance join, leading-"/" = origin-absolute. Upgrade the vault module to clear this.`,
128
+ );
129
+ }
130
+ return `${base}${managementUrl}`;
131
+ }
132
+ if (managementUrl.startsWith("/")) {
133
+ return new URL(managementUrl, `${base}/`).toString();
134
+ }
135
+ return `${base}/${managementUrl}`;
108
136
  }
109
137
 
110
138
  export async function handleAccountVaultAdminTokenPost(
@@ -224,14 +252,15 @@ export async function handleAccountVaultAdminTokenPost(
224
252
  ...(deps.now !== undefined ? { now: deps.now } : {}),
225
253
  });
226
254
 
227
- // Build the redirect target: <vault-url><managementUrl>#token=<jwt>. The
255
+ // Build the redirect target: <vault-url>/<managementUrl>#token=<jwt>. The
228
256
  // vault URL is the hub-mounted path (`<hubOrigin>/vault/<name>`); the
229
- // managementUrl (default `/admin/`) is the vault admin SPA entry point. The
230
- // JWT rides the URL fragment never sent to the server exactly as the hub
231
- // SPA's "Manage" button does (vault PR #219).
257
+ // managementUrl (default `"admin/"` the per-instance relative form under
258
+ // the B4 semantics) is the vault admin SPA entry point. The JWT rides the
259
+ // URL fragment never sent to the server — exactly as the hub SPA's
260
+ // "Manage" button does (vault PR #219).
232
261
  const trimmedOrigin = deps.hubOrigin.replace(/\/+$/, "");
233
262
  const vaultUrl = `${trimmedOrigin}/vault/${vaultName}`;
234
- const target = resolveManagementUrl(vaultUrl, deps.managementUrl ?? "/admin/");
263
+ const target = resolveManagementUrl(vaultUrl, deps.managementUrl ?? "admin/");
235
264
  const sep = target.includes("#") ? "&" : "#";
236
265
  const location = `${target}${sep}token=${minted.token}`;
237
266
 
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `GET /admin/channel-token` — exchange a valid admin session cookie for a
3
+ * short-lived JWT carrying `channel:read channel:send channel:admin`.
4
+ *
5
+ * Why this exists: two channel-owned UIs, both served behind hub's proxy to a
6
+ * logged-in portal operator, need a Bearer to talk to channel's API the same
7
+ * way the vault-management and scribe-config SPAs do, without running the
8
+ * public `/oauth/authorize` flow:
9
+ * - The **chat UI** (`/channel/ui`) receives replies over SSE
10
+ * (`channel:read`) and posts a message (`channel:send`).
11
+ * - The **config/admin UI** (`/channel/admin`, the 2026-06-09 modular-UI
12
+ * architecture P3/P4 config surface) lists + edits configured channels via
13
+ * `channel:admin`-gated endpoints (`requireScope(SCOPE_ADMIN)` in channel's
14
+ * daemon).
15
+ *
16
+ * Both UIs fetch this single endpoint (`fetchToken()` against
17
+ * `/admin/channel-token`), so the minted token carries the union of the scopes
18
+ * either UI needs. The chat UI simply ignores the extra `channel:admin` scope;
19
+ * `requireScope` checks for the *presence* of a specific scope, so extra
20
+ * scopes never break a read/send call. This is what makes the channel config
21
+ * UI work without re-touching the channel repo — the hub endpoint the config
22
+ * UI already calls now mints the admin scope it needs (2026-06-09 modular-UI
23
+ * architecture, P3).
24
+ *
25
+ * Scope choice — `channel:read channel:send channel:admin`, deliberately NOT
26
+ * `channel:write`:
27
+ * - `channel:read` — receive replies over SSE.
28
+ * - `channel:send` — post a message into the channel.
29
+ * - `channel:admin` — list + edit channel config (the config UI).
30
+ * - `channel:write` is the *session-reply* scope (a connected Claude Code
31
+ * session replying on a channel). A UI token must not be able to
32
+ * impersonate a session, so we never mint `channel:write` here.
33
+ *
34
+ * Audience: `channel` (the bare service prefix). Channel validates the JWT's
35
+ * `aud` claim against the literal string `"channel"` (parachute-channel
36
+ * `src/hub-jwt.ts` `CHANNEL_AUDIENCE`), the same shape `inferAudience` in
37
+ * oauth-handlers.ts stamps for the public OAuth flow — so hub-minted and
38
+ * OAuth-minted channel tokens are indistinguishable to channel. Unlike the
39
+ * per-vault admin token (`vault.<name>`), channel has a single bare audience.
40
+ *
41
+ * Multi-user Phase 1 gate: the session must belong to the first admin (the
42
+ * single hub admin under the Phase 1 model — see `users.ts:isFirstAdmin`),
43
+ * mirroring host-admin-token and vault-admin-token. Friends pinned to a vault
44
+ * use the OAuth flow for their assigned scopes; they don't get a channel
45
+ * Bearer via this endpoint.
46
+ *
47
+ * Tokens minted here are short-lived (10 min — matches host/vault admin
48
+ * tokens); the UI re-fetches on near-expiry.
49
+ */
50
+ import type { Database } from "bun:sqlite";
51
+ import { signAccessToken } from "./jwt-sign.ts";
52
+ import { findSession, parseSessionCookie } from "./sessions.ts";
53
+ import { isFirstAdmin } from "./users.ts";
54
+
55
+ /** Short TTL — matches host/vault admin-token. UI re-fetches on near-expiry. */
56
+ export const CHANNEL_TOKEN_TTL_SECONDS = 10 * 60;
57
+ const CHANNEL_AUDIENCE = "channel";
58
+ const CHANNEL_CLIENT_ID = "parachute-hub-spa";
59
+ /**
60
+ * `channel:read` (SSE replies) + `channel:send` (post a message) +
61
+ * `channel:admin` (list + edit channel config — the config UI). Deliberately
62
+ * NOT `channel:write` — that's the session-reply scope, and a UI token must
63
+ * not be able to impersonate a connected session. The chat UI ignores the
64
+ * extra `channel:admin`; the config UI needs it (2026-06-09 modular-UI
65
+ * architecture, P3 — the hub endpoint the channel config UI already calls
66
+ * mints the admin scope so the channel repo doesn't have to change).
67
+ */
68
+ export const CHANNEL_TOKEN_SCOPES = ["channel:read", "channel:send", "channel:admin"] as const;
69
+
70
+ export interface MintChannelTokenDeps {
71
+ db: Database;
72
+ /** Hub origin — written into JWT `iss`. */
73
+ issuer: string;
74
+ }
75
+
76
+ export async function handleChannelToken(
77
+ req: Request,
78
+ deps: MintChannelTokenDeps,
79
+ ): Promise<Response> {
80
+ if (req.method !== "GET") {
81
+ return jsonError(405, "method_not_allowed", "use GET");
82
+ }
83
+ const sid = parseSessionCookie(req.headers.get("cookie"));
84
+ const session = sid ? findSession(deps.db, sid) : null;
85
+ if (!session) {
86
+ return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
87
+ }
88
+ // First-admin gate (mirrors host/vault-admin-token). A friend account
89
+ // (non-first-admin user created via `/api/users`) holds a valid session but
90
+ // must not mint a channel Bearer. Without this check, any signed-in friend
91
+ // hitting `GET /admin/channel-token` would walk away with a token carrying
92
+ // `channel:read channel:send`.
93
+ if (!isFirstAdmin(deps.db, session.userId)) {
94
+ return jsonError(
95
+ 403,
96
+ "not_admin",
97
+ "channel token mint is restricted to the hub admin — your account home is at /account/",
98
+ );
99
+ }
100
+ const minted = await signAccessToken(deps.db, {
101
+ sub: session.userId,
102
+ scopes: [...CHANNEL_TOKEN_SCOPES],
103
+ audience: CHANNEL_AUDIENCE,
104
+ clientId: CHANNEL_CLIENT_ID,
105
+ issuer: deps.issuer,
106
+ ttlSeconds: CHANNEL_TOKEN_TTL_SECONDS,
107
+ // Channel tokens carry no per-user vault pin — the UI Bearer talks to a
108
+ // channel-scoped endpoint, not to a single vault. Empty `vault_scope` is
109
+ // the "no per-user restriction" sentinel matching host-admin tokens.
110
+ vaultScope: [],
111
+ });
112
+ return new Response(
113
+ JSON.stringify({
114
+ token: minted.token,
115
+ expires_at: minted.expiresAt,
116
+ scopes: CHANNEL_TOKEN_SCOPES,
117
+ }),
118
+ {
119
+ status: 200,
120
+ headers: {
121
+ "content-type": "application/json",
122
+ // No browser cache — token rotates per-fetch, and a stale 200 from a
123
+ // back/forward navigation could hand the UI a long-expired JWT.
124
+ "cache-control": "no-store",
125
+ },
126
+ },
127
+ );
128
+ }
129
+
130
+ function jsonError(status: number, error: string, description: string): Response {
131
+ return new Response(JSON.stringify({ error, error_description: description }), {
132
+ status,
133
+ headers: { "content-type": "application/json" },
134
+ });
135
+ }