@openparachute/hub 0.6.5-rc.8 → 0.7.1

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 (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +310 -6
  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-credentials.test.ts +1320 -0
  6. package/src/__tests__/admin-connections.test.ts +1154 -0
  7. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  8. package/src/__tests__/admin-module-token.test.ts +311 -0
  9. package/src/__tests__/admin-vaults.test.ts +590 -0
  10. package/src/__tests__/api-invites.test.ts +166 -6
  11. package/src/__tests__/api-modules-ops.test.ts +70 -5
  12. package/src/__tests__/api-modules.test.ts +262 -79
  13. package/src/__tests__/audience-gate.test.ts +752 -0
  14. package/src/__tests__/hub-db.test.ts +36 -0
  15. package/src/__tests__/hub-server.test.ts +585 -21
  16. package/src/__tests__/invites.test.ts +91 -1
  17. package/src/__tests__/lifecycle.test.ts +238 -3
  18. package/src/__tests__/module-manifest.test.ts +305 -8
  19. package/src/__tests__/serve-boot.test.ts +133 -2
  20. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  21. package/src/__tests__/setup-gate.test.ts +13 -7
  22. package/src/__tests__/setup-wizard.test.ts +228 -1
  23. package/src/__tests__/vault-name.test.ts +20 -5
  24. package/src/__tests__/well-known.test.ts +44 -8
  25. package/src/__tests__/ws-bridge.test.ts +573 -0
  26. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  27. package/src/account-setup.ts +94 -23
  28. package/src/account-vault-admin-token.ts +43 -14
  29. package/src/admin-channel-token.ts +135 -0
  30. package/src/admin-connections.ts +1882 -0
  31. package/src/admin-login-ui.ts +64 -15
  32. package/src/admin-module-token.ts +197 -0
  33. package/src/admin-vaults.ts +399 -12
  34. package/src/api-hub-upgrade.ts +4 -3
  35. package/src/api-invites.ts +92 -12
  36. package/src/api-modules-ops.ts +41 -16
  37. package/src/api-modules.ts +238 -116
  38. package/src/api-tokens.ts +8 -5
  39. package/src/audience-gate.ts +268 -0
  40. package/src/chrome-strip.ts +8 -1
  41. package/src/commands/lifecycle.ts +187 -47
  42. package/src/commands/serve-boot.ts +80 -3
  43. package/src/commands/setup.ts +4 -4
  44. package/src/connections-store.ts +191 -0
  45. package/src/grants.ts +50 -0
  46. package/src/help.ts +13 -6
  47. package/src/host-admin-token-validation.ts +6 -2
  48. package/src/hub-db.ts +26 -1
  49. package/src/hub-server.ts +849 -70
  50. package/src/invites.ts +91 -2
  51. package/src/jwt-sign.ts +47 -1
  52. package/src/module-manifest.ts +536 -23
  53. package/src/origin-check.ts +109 -0
  54. package/src/proxy-error-ui.ts +1 -1
  55. package/src/service-spec.ts +132 -41
  56. package/src/services-manifest.ts +97 -0
  57. package/src/setup-wizard.ts +68 -6
  58. package/src/users.ts +11 -0
  59. package/src/vault-name.ts +27 -7
  60. package/src/well-known.ts +41 -33
  61. package/src/ws-bridge.ts +256 -0
  62. package/src/ws-connection-caps.ts +170 -0
  63. package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
  64. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/src/__tests__/api-modules-config.test.ts +0 -882
  67. package/src/api-modules-config.ts +0 -421
  68. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  69. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.5-rc.8",
3
+ "version": "0.7.1",
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": {
@@ -21,11 +21,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
21
21
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
22
  import { tmpdir } from "node:os";
23
23
  import { join } from "node:path";
24
+ import { hasScope } from "../../packages/scope-guard/src/scope.ts";
24
25
  import { handleAccountSetupGet, handleAccountSetupPost } from "../account-setup.ts";
26
+ import { handleAccountVaultTokenPost } from "../account-vault-token.ts";
25
27
  import type { RunResult } from "../admin-vaults.ts";
26
28
  import { CSRF_FIELD_NAME, buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
27
29
  import { hubDbPath, openHubDb } from "../hub-db.ts";
28
30
  import { consumeInvite, findInviteByRawToken, issueInvite, revokeInvite } from "../invites.ts";
31
+ import { validateAccessToken } from "../jwt-sign.ts";
29
32
  import { __resetForTests } from "../rate-limit.ts";
30
33
  import { findActiveSession } from "../sessions.ts";
31
34
  import { createUser, getUserByUsernameCI, userCount, vaultVerbsForUserVault } from "../users.ts";
@@ -296,11 +299,16 @@ describe("POST /account/setup/<token> — security invariants", () => {
296
299
  expect(firstId).not.toBe(id);
297
300
  });
298
301
 
299
- test("a 'read' invite lands a read-only assignment", async () => {
302
+ test("a 'read' invite (shared-vault shape) lands a read-only assignment", async () => {
303
+ // read+provision is no longer a redeemable shape (a fresh vault's sole
304
+ // user must hold write — see the hand-edited-row guard test below), so
305
+ // the read role rides the shared-vault shape: assign an EXISTING vault.
300
306
  const admin = await createUser(harness.db, "operator", "operator-password-1");
307
+ seedExistingVault("shared");
301
308
  const { rawToken } = issueInvite(harness.db, {
302
309
  createdBy: admin.id,
303
310
  vaultName: "shared",
311
+ provisionVault: false,
304
312
  role: "read",
305
313
  });
306
314
  const { token: csrfToken, cookieFragment } = csrfPair();
@@ -322,6 +330,45 @@ describe("POST /account/setup/<token> — security invariants", () => {
322
330
  const user = getUserByUsernameCI(harness.db, "guest");
323
331
  expect(vaultVerbsForUserVault(harness.db, user?.id ?? "", "shared")).toEqual(["read"]);
324
332
  });
333
+
334
+ test("hand-edited provision_vault=1 + role='read' row → refused at redeem (no un-writable owner), invite unconsumed", async () => {
335
+ // The API refuses to MINT this shape (400 invalid_request); this row can
336
+ // only exist via a hand edit. The redeem-side guard must refuse it too —
337
+ // honoring it would provision a vault whose ONLY user can never write.
338
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
339
+ const { rawToken } = issueInvite(harness.db, {
340
+ createdBy: admin.id,
341
+ vaultName: "deadend",
342
+ provisionVault: true,
343
+ role: "read",
344
+ });
345
+ const before = userCount(harness.db);
346
+ const { token: csrfToken, cookieFragment } = csrfPair();
347
+ const stub = makeStubRunCommand();
348
+ const res = await handleAccountSetupPost(
349
+ postReq(
350
+ rawToken,
351
+ {
352
+ [CSRF_FIELD_NAME]: csrfToken,
353
+ username: "guest",
354
+ password: "guest-strong-password-1",
355
+ password_confirm: "guest-strong-password-1",
356
+ },
357
+ cookieFragment,
358
+ ),
359
+ rawToken,
360
+ deps(stub.run),
361
+ );
362
+ expect(res.status).toBe(400);
363
+ const html = await res.text();
364
+ expect(html).toContain("must have write access");
365
+ // No account, no vault shell-out, invite re-usable for the operator to
366
+ // revoke + re-mint correctly.
367
+ expect(getUserByUsernameCI(harness.db, "guest")).toBeNull();
368
+ expect(userCount(harness.db) - before).toBe(0);
369
+ expect(stub.calls.length).toBe(0);
370
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
371
+ });
325
372
  });
326
373
 
327
374
  describe("POST /account/setup/<token> — rejection paths", () => {
@@ -527,6 +574,40 @@ describe("POST /account/setup/<token> — vault-name validation (N1)", () => {
527
574
  // No account created.
528
575
  expect(getUserByUsernameCI(harness.db, "sam")).toBeNull();
529
576
  });
577
+
578
+ test("an invitee-chosen RESERVED vault name (list/new/assets/admin) → 400, never provisioned (B2h)", async () => {
579
+ // Pre-consolidation, the invite path's validator reserved only "list" —
580
+ // a non-admin invite redeemer could squat "admin" and capture the
581
+ // daemon-level /vault/admin mount. One consolidated set closes that.
582
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
583
+ for (const name of ["list", "new", "assets", "admin"]) {
584
+ // vault_name null → the redeemer names their own vault.
585
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id });
586
+ const { token: csrfToken, cookieFragment } = csrfPair();
587
+ const stub = makeStubRunCommand();
588
+ const res = await handleAccountSetupPost(
589
+ postReq(
590
+ rawToken,
591
+ {
592
+ [CSRF_FIELD_NAME]: csrfToken,
593
+ username: "sam",
594
+ password: "sam-strong-password-12",
595
+ password_confirm: "sam-strong-password-12",
596
+ vault_name: name,
597
+ },
598
+ cookieFragment,
599
+ ),
600
+ rawToken,
601
+ deps(stub.run),
602
+ );
603
+ expect(res.status).toBe(400);
604
+ const html = await res.text();
605
+ expect(html).toContain("reserved");
606
+ // The vault CLI is never reached; no account created.
607
+ expect(stub.calls.length).toBe(0);
608
+ expect(getUserByUsernameCI(harness.db, "sam")).toBeNull();
609
+ }
610
+ });
530
611
  });
531
612
 
532
613
  describe("POST /account/setup/<token> — concurrent redeem (N2)", () => {
@@ -749,9 +830,10 @@ describe("POST /account/setup/<token> — cross-tenant: existing-vault rejection
749
830
  expect(userCount(harness.db) - before).toBe(1);
750
831
  });
751
832
 
752
- test("shared-vault invite that slipped through (provision_vault=false + pinned name) → rejected at redeem", async () => {
753
- // Defense in depth: the admin API rejects creating this shape, but if one
754
- // exists in the DB the redeem must still refuse to assign the existing vault.
833
+ test("shared-vault invite (provision_vault=false + pinned EXISTING name) → assigns at the invite's role, NO provisioning", async () => {
834
+ // The supported shared-vault shape: host:admin minted this invite, the
835
+ // same authority that can assign any user to any vault via POST
836
+ // /api/users — the invite just packages that assignment as a link.
755
837
  await createUser(harness.db, "owner", "owner-strong-password-1", {
756
838
  assignedVaults: ["legacy"],
757
839
  role: "write",
@@ -764,6 +846,46 @@ describe("POST /account/setup/<token> — cross-tenant: existing-vault rejection
764
846
  createdBy: admin.id,
765
847
  vaultName: "legacy",
766
848
  provisionVault: false,
849
+ role: "write",
850
+ });
851
+ const { token: csrfToken, cookieFragment } = csrfPair();
852
+ const stub = makeStubRunCommand();
853
+ const res = await handleAccountSetupPost(
854
+ postReq(
855
+ rawToken,
856
+ {
857
+ [CSRF_FIELD_NAME]: csrfToken,
858
+ username: "guest",
859
+ password: "guest-strong-password-11",
860
+ password_confirm: "guest-strong-password-11",
861
+ },
862
+ cookieFragment,
863
+ ),
864
+ rawToken,
865
+ deps(stub.run),
866
+ );
867
+ expect(res.status).toBe(302);
868
+ // NO provisioning shell-out — the vault already exists.
869
+ expect(stub.calls.length).toBe(0);
870
+ const user = getUserByUsernameCI(harness.db, "guest");
871
+ expect(user?.assignedVaults).toEqual(["legacy"]);
872
+ expect(vaultVerbsForUserVault(harness.db, user?.id ?? "", "legacy")).toEqual([
873
+ "read",
874
+ "write",
875
+ "admin",
876
+ ]);
877
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).not.toBeNull();
878
+ });
879
+
880
+ test("shared-vault invite whose vault VANISHED from services.json → 400, invite unconsumed", async () => {
881
+ // The vault-delete cascade revokes pending invites, so this is the
882
+ // defense-in-depth path (manual manifest edits / restored DB).
883
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
884
+ const { rawToken } = issueInvite(harness.db, {
885
+ createdBy: admin.id,
886
+ vaultName: "ghost",
887
+ provisionVault: false,
888
+ role: "read",
767
889
  });
768
890
  const before = userCount(harness.db);
769
891
  const { token: csrfToken, cookieFragment } = csrfPair();
@@ -785,13 +907,195 @@ describe("POST /account/setup/<token> — cross-tenant: existing-vault rejection
785
907
  expect(res.status).toBe(400);
786
908
  expect(getUserByUsernameCI(harness.db, "wouldbe")).toBeNull();
787
909
  expect(userCount(harness.db) - before).toBe(0);
788
- // No vault shell-out — rejected before provisioning.
789
910
  expect(stub.calls.length).toBe(0);
790
- // Invite NOT consumed.
791
911
  expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
792
912
  });
793
913
  });
794
914
 
915
+ describe("POST /account/setup/<token> — the Adam/Jonathan read-only shared-vault e2e", () => {
916
+ test("redeem read-role shared invite → read mints, write/admin/cross-vault REFUSED, vault-side scope check refuses writes", async () => {
917
+ // End-to-end pin of the read-role enforcement chain:
918
+ // invite(role=read, existing vault) → redeem → user_vaults row role=read
919
+ // → /account/vault-token mint caps to vaultVerbsForRole ('read' only)
920
+ // → the minted JWT carries vault:<name>:read + the vault_scope pin
921
+ // → scope-guard (what the vault runs per request) refuses write/admin.
922
+ await createUser(harness.db, "adam", "adams-strong-password-1", {
923
+ assignedVaults: ["jonathan-vault", "adams-vault"],
924
+ role: "write",
925
+ });
926
+ seedExistingVault("jonathan-vault");
927
+ seedExistingVault("adams-vault");
928
+ const admin = getUserByUsernameCI(harness.db, "adam");
929
+ const { rawToken } = issueInvite(harness.db, {
930
+ createdBy: admin?.id ?? "",
931
+ vaultName: "jonathan-vault",
932
+ username: "jonathan",
933
+ provisionVault: false,
934
+ role: "read",
935
+ });
936
+
937
+ // Redeem (the form's username field is absent — pre-named).
938
+ const { token: csrfToken, cookieFragment } = csrfPair();
939
+ const stub = makeStubRunCommand();
940
+ const res = await handleAccountSetupPost(
941
+ postReq(
942
+ rawToken,
943
+ {
944
+ [CSRF_FIELD_NAME]: csrfToken,
945
+ password: "jonathans-strong-pass-1",
946
+ password_confirm: "jonathans-strong-pass-1",
947
+ },
948
+ cookieFragment,
949
+ ),
950
+ rawToken,
951
+ deps(stub.run),
952
+ );
953
+ expect(res.status).toBe(302);
954
+ const jonathan = getUserByUsernameCI(harness.db, "jonathan");
955
+ expect(jonathan).not.toBeNull();
956
+ // (1) The user_vaults row is read.
957
+ const row = harness.db
958
+ .query<{ role: string }, [string]>(
959
+ "SELECT role FROM user_vaults WHERE user_id = ? AND vault_name = 'jonathan-vault'",
960
+ )
961
+ .get(jonathan?.id ?? "");
962
+ expect(row?.role).toBe("read");
963
+ expect(vaultVerbsForUserVault(harness.db, jonathan?.id ?? "", "jonathan-vault")).toEqual([
964
+ "read",
965
+ ]);
966
+
967
+ // (2) Enforcement at the mint surface — drive the REAL /account mint
968
+ // handler with the session the redeem just created.
969
+ const setCookie = res.headers.get("set-cookie") ?? "";
970
+ const sessionFragment = setCookie.split(";")[0] ?? "";
971
+ const { token: mintCsrf, cookieFragment: mintCsrfCookie } = csrfPair();
972
+ const cookie = `${sessionFragment}; ${mintCsrfCookie}`;
973
+ const mint = (vault: string, verb: string) =>
974
+ handleAccountVaultTokenPost(
975
+ new Request(`${ISSUER}/account/vault-token/${vault}`, {
976
+ method: "POST",
977
+ headers: { "content-type": "application/x-www-form-urlencoded", cookie },
978
+ body: new URLSearchParams({ [CSRF_FIELD_NAME]: mintCsrf, verb }).toString(),
979
+ }),
980
+ vault,
981
+ { db: harness.db, hubOrigin: ISSUER },
982
+ );
983
+ expect((await mint("jonathan-vault", "write")).status).toBe(403);
984
+ expect((await mint("jonathan-vault", "admin")).status).toBe(403);
985
+ // (3) Cannot touch the OTHER vault at all.
986
+ expect((await mint("adams-vault", "read")).status).toBe(403);
987
+
988
+ const readRes = await mint("jonathan-vault", "read");
989
+ expect(readRes.status).toBe(200);
990
+ const html = await readRes.text();
991
+ const m = html.match(/data-testid="minted-token-value">([^<]+)</);
992
+ expect(m).not.toBeNull();
993
+ const validated = await validateAccessToken(harness.db, m?.[1] ?? "", ISSUER);
994
+ const scopes = ((validated.payload as { scope?: string }).scope ?? "").split(/\s+/);
995
+ expect(scopes).toEqual(["vault:jonathan-vault:read"]);
996
+ expect((validated.payload as { vault_scope?: string[] }).vault_scope).toEqual([
997
+ "jonathan-vault",
998
+ ]);
999
+
1000
+ // (4) The resource-server side: scope-guard (what the vault runs) refuses
1001
+ // writes for this token and refuses the other vault entirely. Hub never
1002
+ // imports scope-guard at runtime (issuer vs validator boundary); the test
1003
+ // crosses it deliberately to pin the cross-system contract.
1004
+ expect(hasScope(scopes, "vault:jonathan-vault:write")).toBe(false);
1005
+ expect(hasScope(scopes, "vault:jonathan-vault:admin")).toBe(false);
1006
+ expect(hasScope(scopes, "vault:jonathan-vault:read")).toBe(true);
1007
+ expect(hasScope(scopes, "vault:adams-vault:read")).toBe(false);
1008
+ });
1009
+ });
1010
+
1011
+ describe("POST /account/setup/<token> — pre-named username (ENFORCED)", () => {
1012
+ test("the invite's username wins — a different submitted username is ignored", async () => {
1013
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
1014
+ const { rawToken } = issueInvite(harness.db, {
1015
+ createdBy: admin.id,
1016
+ username: "jonathan",
1017
+ vaultName: "maya",
1018
+ });
1019
+ const { token: csrfToken, cookieFragment } = csrfPair();
1020
+ const stub = makeStubRunCommand();
1021
+ const res = await handleAccountSetupPost(
1022
+ postReq(
1023
+ rawToken,
1024
+ {
1025
+ [CSRF_FIELD_NAME]: csrfToken,
1026
+ // Submitted name must NOT be honored — the invite is a named deliverable.
1027
+ username: "imposter",
1028
+ password: "jonathans-strong-pass-1",
1029
+ password_confirm: "jonathans-strong-pass-1",
1030
+ },
1031
+ cookieFragment,
1032
+ ),
1033
+ rawToken,
1034
+ deps(stub.run),
1035
+ );
1036
+ expect(res.status).toBe(302);
1037
+ expect(getUserByUsernameCI(harness.db, "imposter")).toBeNull();
1038
+ expect(getUserByUsernameCI(harness.db, "jonathan")).not.toBeNull();
1039
+ });
1040
+
1041
+ test("pre-named username TAKEN at redeem time → 409 'ask your operator', invite stays re-usable", async () => {
1042
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
1043
+ const { rawToken } = issueInvite(harness.db, {
1044
+ createdBy: admin.id,
1045
+ username: "jonathan",
1046
+ vaultName: "maya",
1047
+ });
1048
+ // Someone takes the name between mint and redeem.
1049
+ await createUser(harness.db, "jonathan", "squatter-strong-pass-1", { allowMulti: true });
1050
+ const { token: csrfToken, cookieFragment } = csrfPair();
1051
+ const stub = makeStubRunCommand();
1052
+ const res = await handleAccountSetupPost(
1053
+ postReq(
1054
+ rawToken,
1055
+ {
1056
+ [CSRF_FIELD_NAME]: csrfToken,
1057
+ password: "jonathans-strong-pass-1",
1058
+ password_confirm: "jonathans-strong-pass-1",
1059
+ },
1060
+ cookieFragment,
1061
+ ),
1062
+ rawToken,
1063
+ deps(stub.run),
1064
+ );
1065
+ expect(res.status).toBe(409);
1066
+ const html = await res.text();
1067
+ expect(html).toContain("Ask your hub operator");
1068
+ // No vault provisioned, invite NOT consumed — operator can revoke + re-mint.
1069
+ expect(stub.calls.length).toBe(0);
1070
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
1071
+ });
1072
+
1073
+ test("GET renders the pre-named username read-only (no editable username field)", async () => {
1074
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
1075
+ const { rawToken } = issueInvite(harness.db, {
1076
+ createdBy: admin.id,
1077
+ username: "jonathan",
1078
+ vaultName: "shared",
1079
+ provisionVault: false,
1080
+ role: "read",
1081
+ });
1082
+ seedExistingVault("shared");
1083
+ const res = handleAccountSetupGet(
1084
+ new Request(`${ISSUER}/account/setup/${rawToken}`),
1085
+ rawToken,
1086
+ deps(),
1087
+ );
1088
+ expect(res.status).toBe(200);
1089
+ const html = await res.text();
1090
+ expect(html).toContain("jonathan");
1091
+ expect(html).toContain("Your hub operator chose this username for you.");
1092
+ // No editable username input on a pre-named form.
1093
+ expect(html).not.toContain('name="username"');
1094
+ // Shared-vault invites surface the role.
1095
+ expect(html).toContain("read-only");
1096
+ });
1097
+ });
1098
+
795
1099
  describe("POST /account/setup/<token> — vault name defaults to username (FIX-2)", () => {
796
1100
  test("blank vault_name → vault created NAMED AFTER the username", async () => {
797
1101
  const admin = await createUser(harness.db, "operator", "operator-password-1");
@@ -128,7 +128,8 @@ describe("handleAccountVaultAdminTokenPost — happy path (assigned vault)", ()
128
128
  expect(res.status).toBe(303);
129
129
  expect(res.headers.get("cache-control")).toBe("no-store");
130
130
  const location = res.headers.get("location") ?? "";
131
- // Default managementUrl is /admin/ → lands on the vault admin SPA home.
131
+ // Default managementUrl is the relative "admin/" (B4 per-instance form)
132
+ // → lands on the vault admin SPA home under the vault's mount.
132
133
  expect(location.startsWith(`${ISSUER}/vault/work/admin/#token=`)).toBe(true);
133
134
 
134
135
  const token = tokenFromLocation(location);
@@ -151,18 +152,49 @@ describe("handleAccountVaultAdminTokenPost — happy path (assigned vault)", ()
151
152
  expect(rows?.n).toBe(1);
152
153
  });
153
154
 
154
- test("honors a vault-declared managementUrl (e.g. a custom admin path)", async () => {
155
+ test("honors a vault-declared RELATIVE managementUrl (B4 per-instance form)", async () => {
155
156
  const { cookie, csrfToken } = await seedFriend(["work"]);
156
157
  const res = await handleAccountVaultAdminTokenPost(
157
158
  mintReq("work", { cookie, csrfToken }),
158
159
  "work",
159
- deps({ managementUrl: "/manage/" }),
160
+ deps({ managementUrl: "manage/" }),
160
161
  );
161
162
  expect(res.status).toBe(303);
162
163
  const location = res.headers.get("location") ?? "";
163
164
  expect(location.startsWith(`${ISSUER}/vault/work/manage/#token=`)).toBe(true);
164
165
  });
165
166
 
167
+ test("a LEADING-SLASH managementUrl resolves origin-absolute (B4 inverted pin)", async () => {
168
+ // Pre-B4 "/manage/" joined under the vault mount (/vault/work/manage/).
169
+ // Under the unified semantics a leading-"/" is origin-absolute.
170
+ const { cookie, csrfToken } = await seedFriend(["work"]);
171
+ const res = await handleAccountVaultAdminTokenPost(
172
+ mintReq("work", { cookie, csrfToken }),
173
+ "work",
174
+ deps({ managementUrl: "/manage/" }),
175
+ );
176
+ expect(res.status).toBe(303);
177
+ const location = res.headers.get("location") ?? "";
178
+ expect(location.startsWith(`${ISSUER}/manage/#token=`)).toBe(true);
179
+ });
180
+
181
+ test('COMPAT SHIM: the literal legacy "/admin/" managementUrl still joins under the vault (one release)', async () => {
182
+ // Deployed vaults declare managementUrl "/admin/" — the OLD per-instance
183
+ // form. Origin-absolute resolution would deep-link the daemon-level
184
+ // /vault/admin mount instead of the instance SPA, so the literal
185
+ // "/admin"/"/admin/" keeps the old vault-join for one release with a
186
+ // deprecation log.
187
+ const { cookie, csrfToken } = await seedFriend(["work"]);
188
+ const res = await handleAccountVaultAdminTokenPost(
189
+ mintReq("work", { cookie, csrfToken }),
190
+ "work",
191
+ deps({ managementUrl: "/admin/" }),
192
+ );
193
+ expect(res.status).toBe(303);
194
+ const location = res.headers.get("location") ?? "";
195
+ expect(location.startsWith(`${ISSUER}/vault/work/admin/#token=`)).toBe(true);
196
+ });
197
+
166
198
  test("a friend assigned to multiple vaults can deep-link each, never cross-vault", async () => {
167
199
  const { cookie, csrfToken } = await seedFriend(["work", "home"]);
168
200
  for (const v of ["work", "home"]) {
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Tests for the channel UI session→bearer mint endpoint. Mirrors
3
+ * `admin-host-admin-token.test.ts` shape (channel has a single bare audience,
4
+ * no per-vault name). Covers:
5
+ * - 401 when no admin session cookie is present.
6
+ * - 401 when the cookie names a deleted session.
7
+ * - 405 on POST.
8
+ * - 200 + JWT carrying `aud: "channel"` and `channel:read channel:send channel:admin`.
9
+ * - First-admin gate: 403 for a signed-in non-first-admin (friend); the
10
+ * admin's happy path still mints when a friend exists alongside.
11
+ */
12
+ import type { Database } from "bun:sqlite";
13
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
14
+ import { mkdtempSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { CHANNEL_TOKEN_TTL_SECONDS, handleChannelToken } from "../admin-channel-token.ts";
18
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
19
+ import { validateAccessToken } from "../jwt-sign.ts";
20
+ import { SESSION_TTL_MS, buildSessionCookie, createSession, deleteSession } from "../sessions.ts";
21
+ import { rotateSigningKey } from "../signing-keys.ts";
22
+ import { createUser } from "../users.ts";
23
+
24
+ const ISSUER = "https://hub.test";
25
+
26
+ interface Harness {
27
+ db: Database;
28
+ cleanup: () => void;
29
+ }
30
+
31
+ function makeHarness(): Harness {
32
+ const dir = mkdtempSync(join(tmpdir(), "phub-channel-token-"));
33
+ const db = openHubDb(hubDbPath(dir));
34
+ return {
35
+ db,
36
+ cleanup: () => {
37
+ db.close();
38
+ rmSync(dir, { recursive: true, force: true });
39
+ },
40
+ };
41
+ }
42
+
43
+ let harness: Harness;
44
+ beforeEach(() => {
45
+ harness = makeHarness();
46
+ });
47
+ afterEach(() => {
48
+ harness.cleanup();
49
+ });
50
+
51
+ async function withSession(): Promise<{ cookie: string; userId: string }> {
52
+ const user = await createUser(harness.db, "operator", "hunter2");
53
+ const session = createSession(harness.db, { userId: user.id });
54
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
55
+ return { cookie, userId: user.id };
56
+ }
57
+
58
+ /**
59
+ * Seed an admin (first-created user) + a second non-admin "friend" account,
60
+ * return cookies + ids for both. Used by the first-admin-gate tests.
61
+ */
62
+ async function withAdminAndFriend(): Promise<{
63
+ adminCookie: string;
64
+ adminId: string;
65
+ friendCookie: string;
66
+ friendId: string;
67
+ }> {
68
+ const admin = await createUser(harness.db, "admin", "admin-passphrase");
69
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
70
+ allowMulti: true,
71
+ });
72
+ const adminSession = createSession(harness.db, { userId: admin.id });
73
+ const friendSession = createSession(harness.db, { userId: friend.id });
74
+ return {
75
+ adminCookie: buildSessionCookie(adminSession.id, Math.floor(SESSION_TTL_MS / 1000)),
76
+ adminId: admin.id,
77
+ friendCookie: buildSessionCookie(friendSession.id, Math.floor(SESSION_TTL_MS / 1000)),
78
+ friendId: friend.id,
79
+ };
80
+ }
81
+
82
+ describe("handleChannelToken", () => {
83
+ test("401 when no session cookie is present", async () => {
84
+ const req = new Request(`${ISSUER}/admin/channel-token`);
85
+ const res = await handleChannelToken(req, { db: harness.db, issuer: ISSUER });
86
+ expect(res.status).toBe(401);
87
+ const body = (await res.json()) as { error: string };
88
+ expect(body.error).toBe("unauthenticated");
89
+ });
90
+
91
+ test("401 when the cookie names a deleted session", async () => {
92
+ const { cookie } = await withSession();
93
+ const sid = cookie.match(/parachute_hub_session=([^;]+)/)?.[1] ?? "";
94
+ deleteSession(harness.db, sid);
95
+ const req = new Request(`${ISSUER}/admin/channel-token`, { headers: { cookie } });
96
+ const res = await handleChannelToken(req, { db: harness.db, issuer: ISSUER });
97
+ expect(res.status).toBe(401);
98
+ });
99
+
100
+ test("405 on POST", async () => {
101
+ const { cookie } = await withSession();
102
+ const req = new Request(`${ISSUER}/admin/channel-token`, {
103
+ method: "POST",
104
+ headers: { cookie },
105
+ });
106
+ const res = await handleChannelToken(req, { db: harness.db, issuer: ISSUER });
107
+ expect(res.status).toBe(405);
108
+ });
109
+
110
+ test("200 mints a JWT carrying aud:channel + channel:read channel:send channel:admin", async () => {
111
+ const { cookie, userId } = await withSession();
112
+ rotateSigningKey(harness.db);
113
+ const req = new Request(`${ISSUER}/admin/channel-token`, { headers: { cookie } });
114
+ const res = await handleChannelToken(req, { db: harness.db, issuer: ISSUER });
115
+ expect(res.status).toBe(200);
116
+ expect(res.headers.get("cache-control")).toBe("no-store");
117
+
118
+ const body = (await res.json()) as { token: string; expires_at: string; scopes: string[] };
119
+ // `channel:send` (post) + `channel:read` (SSE replies) + `channel:admin`
120
+ // (config UI list/edit — 2026-06-09 modular-UI architecture P3). Deliberately
121
+ // NOT `channel:write` — that's the session-reply scope a UI token must not hold.
122
+ expect(body.scopes).toEqual(["channel:read", "channel:send", "channel:admin"]);
123
+ expect(body.scopes).not.toContain("channel:write");
124
+ expect(body.token.length).toBeGreaterThan(20);
125
+
126
+ const expMs = new Date(body.expires_at).getTime();
127
+ const skew = expMs - Date.now();
128
+ expect(skew).toBeGreaterThan((CHANNEL_TOKEN_TTL_SECONDS - 30) * 1000);
129
+ expect(skew).toBeLessThan((CHANNEL_TOKEN_TTL_SECONDS + 30) * 1000);
130
+
131
+ const validated = await validateAccessToken(harness.db, body.token, ISSUER);
132
+ expect(validated.payload.sub).toBe(userId);
133
+ expect(validated.payload.iss).toBe(ISSUER);
134
+ // Bare service audience — channel validates `aud === "channel"`
135
+ // (parachute-channel src/hub-jwt.ts CHANNEL_AUDIENCE).
136
+ expect(validated.payload.aud).toBe("channel");
137
+ const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
138
+ const scopes = scopeClaim.split(/\s+/);
139
+ expect(scopes).toContain("channel:read");
140
+ expect(scopes).toContain("channel:send");
141
+ expect(scopes).toContain("channel:admin");
142
+ expect(scopes).not.toContain("channel:write");
143
+ });
144
+
145
+ test("403 not_admin when a signed-in non-first-admin (friend) hits the endpoint", async () => {
146
+ // Privesc closure (mirrors host/vault-admin-token). The friend's session
147
+ // is valid; the endpoint must refuse because session.userId isn't the
148
+ // first-admin row.
149
+ const { friendCookie } = await withAdminAndFriend();
150
+ rotateSigningKey(harness.db);
151
+ const req = new Request(`${ISSUER}/admin/channel-token`, {
152
+ headers: { cookie: friendCookie },
153
+ });
154
+ const res = await handleChannelToken(req, { db: harness.db, issuer: ISSUER });
155
+ expect(res.status).toBe(403);
156
+ const body = (await res.json()) as { error: string; error_description: string };
157
+ expect(body.error).toBe("not_admin");
158
+ expect(body.error_description).toContain("/account/");
159
+ });
160
+
161
+ test("first-admin path still succeeds when a friend exists alongside", async () => {
162
+ const { adminCookie, adminId } = await withAdminAndFriend();
163
+ rotateSigningKey(harness.db);
164
+ const req = new Request(`${ISSUER}/admin/channel-token`, {
165
+ headers: { cookie: adminCookie },
166
+ });
167
+ const res = await handleChannelToken(req, { db: harness.db, issuer: ISSUER });
168
+ expect(res.status).toBe(200);
169
+ const body = (await res.json()) as { token: string };
170
+ const validated = await validateAccessToken(harness.db, body.token, ISSUER);
171
+ expect(validated.payload.sub).toBe(adminId);
172
+ });
173
+ });