@openparachute/hub 0.6.3 → 0.6.4-rc.10

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 (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -28,8 +28,11 @@ import {
28
28
  handleAccountHomeGet,
29
29
  markPasswordChanged,
30
30
  } from "../api-account.ts";
31
+ import { registerClient } from "../clients.ts";
31
32
  import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
33
+ import { recordGrant } from "../grants.ts";
32
34
  import { hubDbPath, openHubDb } from "../hub-db.ts";
35
+ import { recordTokenMint } from "../jwt-sign.ts";
33
36
  import {
34
37
  CHANGE_PASSWORD_MAX_ATTEMPTS,
35
38
  CHANGE_PASSWORD_WINDOW_MS,
@@ -257,6 +260,63 @@ describe("POST /account/change-password", () => {
257
260
  }
258
261
  });
259
262
 
263
+ // Item F / hub#469 — a successful self-service password change revokes the
264
+ // user's still-active tokens (so a token minted under the admin's temp
265
+ // password dies with the rotation). Mirrors `resetUserPassword`'s admin-reset
266
+ // revoke. Tokens belonging to OTHER users are untouched.
267
+ test("self-change revokes the user's active tokens, leaves other users' tokens (item F)", async () => {
268
+ const { userId, cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw", {
269
+ passwordChanged: false,
270
+ });
271
+ const other = await createUser(harness.db, "other", "other-strong-passphrase", {
272
+ passwordChanged: true,
273
+ allowMulti: true,
274
+ });
275
+ // Seed one active token for the changing user + one for another user.
276
+ recordTokenMint(harness.db, {
277
+ jti: "tok-self-1",
278
+ createdVia: "cli_mint",
279
+ subject: userId,
280
+ userId,
281
+ clientId: "parachute-account",
282
+ scopes: ["vault:work:read"],
283
+ expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
284
+ });
285
+ recordTokenMint(harness.db, {
286
+ jti: "tok-other-1",
287
+ createdVia: "cli_mint",
288
+ subject: other.id,
289
+ userId: other.id,
290
+ clientId: "parachute-account",
291
+ scopes: ["vault:work:read"],
292
+ expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
293
+ });
294
+
295
+ const { body, headers } = formBody({
296
+ [CSRF_FIELD_NAME]: TEST_CSRF,
297
+ current_password: "old-default-pw",
298
+ new_password: "user-chosen-strong-passphrase",
299
+ new_password_confirm: "user-chosen-strong-passphrase",
300
+ next: "/account/",
301
+ });
302
+ const req = new Request("http://hub.test/account/change-password", {
303
+ method: "POST",
304
+ headers: { ...headers, cookie },
305
+ body,
306
+ });
307
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
308
+ expect(res.status).toBe(302);
309
+
310
+ const selfTok = harness.db
311
+ .query<{ revoked_at: string | null }, [string]>("SELECT revoked_at FROM tokens WHERE jti = ?")
312
+ .get("tok-self-1");
313
+ const otherTok = harness.db
314
+ .query<{ revoked_at: string | null }, [string]>("SELECT revoked_at FROM tokens WHERE jti = ?")
315
+ .get("tok-other-1");
316
+ expect(selfTok?.revoked_at).not.toBeNull(); // changing user's token revoked
317
+ expect(otherTok?.revoked_at).toBeNull(); // other user's token untouched
318
+ });
319
+
260
320
  test("non-admin user with no next defaults to /account/ (no admin-shell flash)", async () => {
261
321
  // Without this rewrite, a friend's change-password POST would 302 to
262
322
  // /admin/vaults, the SPA would load, the 403 from
@@ -743,7 +803,7 @@ describe("handleAccountHomeGet", () => {
743
803
 
744
804
  test("302 → /login when no session cookie is present", async () => {
745
805
  const req = new Request(`${HUB_ORIGIN}/account/`);
746
- const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
806
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
747
807
  expect(res.status).toBe(302);
748
808
  const location = res.headers.get("location") ?? "";
749
809
  // Round-trip /account/ as the `next` param so post-login lands back.
@@ -762,7 +822,7 @@ describe("handleAccountHomeGet", () => {
762
822
  const session = createSession(harness.db, { userId: friend.id });
763
823
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
764
824
  const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
765
- const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
825
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
766
826
  expect(res.status).toBe(200);
767
827
  expect(res.headers.get("content-type")).toContain("text/html");
768
828
  const html = await res.text();
@@ -774,6 +834,65 @@ describe("handleAccountHomeGet", () => {
774
834
  expect(html).toContain(`https://notes.parachute.computer/add?url=${encoded}`);
775
835
  });
776
836
 
837
+ test("onboarding NOT condensed when only a first-party browser grant exists (hub#583, GET /account/)", async () => {
838
+ // The exact field report: create account → open Notes (a first-party OAuth
839
+ // client that writes a vault-scoped grant) → later visit /account/ to wire
840
+ // up Claude. The checklist must NOT already be condensed.
841
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
842
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
843
+ allowMulti: true,
844
+ passwordChanged: true,
845
+ assignedVaults: ["alice"],
846
+ });
847
+ // Notes signs in via DCR (generated client_id, client_name "Notes") and
848
+ // records a vault-scoped grant.
849
+ const notes = registerClient(harness.db, {
850
+ redirectUris: ["https://hub.test/notes/cb"],
851
+ clientName: "Notes",
852
+ });
853
+ recordGrant(harness.db, friend.id, notes.client.clientId, ["vault:alice:read"]);
854
+
855
+ const session = createSession(harness.db, { userId: friend.id });
856
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
857
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
858
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
859
+ expect(res.status).toBe(200);
860
+ const html = await res.text();
861
+ // Full checklist (not condensed): the connect step is present, the
862
+ // "you're connected" done-line is NOT.
863
+ expect(html).toContain('data-connected="false"');
864
+ expect(html).toContain('data-testid="onboarding-step-2"');
865
+ expect(html).not.toContain('data-testid="onboarding-done-line"');
866
+ });
867
+
868
+ test("onboarding condensed when an external AI client grant exists (hub#583, GET /account/)", async () => {
869
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
870
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
871
+ allowMulti: true,
872
+ passwordChanged: true,
873
+ assignedVaults: ["alice"],
874
+ });
875
+ // Claude Code: a genuine external MCP client.
876
+ const claude = registerClient(harness.db, {
877
+ redirectUris: ["https://claude.ai/cb"],
878
+ clientName: "Claude",
879
+ });
880
+ recordGrant(harness.db, friend.id, claude.client.clientId, ["vault:alice:read"]);
881
+
882
+ const session = createSession(harness.db, { userId: friend.id });
883
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
884
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
885
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
886
+ expect(res.status).toBe(200);
887
+ const html = await res.text();
888
+ // Condensed done-state.
889
+ expect(html).toContain('data-connected="true"');
890
+ expect(html).toContain('data-testid="onboarding-done-line"');
891
+ expect(html).toContain("You're connected");
892
+ // And it still keeps the "Connect another AI" expander.
893
+ expect(html).toContain('data-testid="onboarding-connect-another"');
894
+ });
895
+
777
896
  test("200 + admin branch when the first-admin signs in (no vault assignments)", async () => {
778
897
  // The first-created user with no vault pin is the admin posture.
779
898
  const admin = await createUser(harness.db, "admin", "admin-passphrase", {
@@ -782,7 +901,7 @@ describe("handleAccountHomeGet", () => {
782
901
  const session = createSession(harness.db, { userId: admin.id });
783
902
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
784
903
  const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
785
- const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
904
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
786
905
  expect(res.status).toBe(200);
787
906
  const html = await res.text();
788
907
  expect(html).toContain("Welcome, admin");
@@ -813,8 +932,121 @@ describe("handleAccountHomeGet", () => {
813
932
  harness.db.exec("PRAGMA foreign_keys = ON");
814
933
  }
815
934
  const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
816
- const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
935
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
817
936
  expect(res.status).toBe(302);
818
937
  expect(res.headers.get("location")).toBe("/login");
819
938
  });
939
+
940
+ test("renders the per-vault usage stat when usage resolves", async () => {
941
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
942
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
943
+ allowMulti: true,
944
+ passwordChanged: true,
945
+ assignedVaults: ["alice"],
946
+ });
947
+ const session = createSession(harness.db, { userId: friend.id });
948
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
949
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
950
+ const res = await handleAccountHomeGet(req, {
951
+ db: harness.db,
952
+ hubOrigin: HUB_ORIGIN,
953
+ resolveVaultPort: () => 1940,
954
+ // Stub the fetch: resolves to a known stat.
955
+ fetchUsage: async () => ({ notes: 7, totalBytes: 2 * 1024 * 1024 }),
956
+ });
957
+ expect(res.status).toBe(200);
958
+ const html = await res.text();
959
+ expect(html).toContain('data-testid="vault-usage"');
960
+ expect(html).toContain("7 notes · 2.0 MB");
961
+ });
962
+
963
+ test("omits the usage stat gracefully when the fetch fails (null)", async () => {
964
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
965
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
966
+ allowMulti: true,
967
+ passwordChanged: true,
968
+ assignedVaults: ["alice"],
969
+ });
970
+ const session = createSession(harness.db, { userId: friend.id });
971
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
972
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
973
+ const res = await handleAccountHomeGet(req, {
974
+ db: harness.db,
975
+ hubOrigin: HUB_ORIGIN,
976
+ resolveVaultPort: () => 1940,
977
+ fetchUsage: async () => null,
978
+ });
979
+ expect(res.status).toBe(200);
980
+ const html = await res.text();
981
+ // Tile still renders; just no usage stat.
982
+ expect(html).toContain("<strong>alice</strong>");
983
+ expect(html).not.toContain('data-testid="vault-usage"');
984
+ });
985
+
986
+ test("renders the 'Advanced vault settings ↗' deep-link button for an assigned vault", async () => {
987
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
988
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
989
+ allowMulti: true,
990
+ passwordChanged: true,
991
+ assignedVaults: ["alice"],
992
+ });
993
+ const session = createSession(harness.db, { userId: friend.id });
994
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
995
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
996
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
997
+ expect(res.status).toBe(200);
998
+ const html = await res.text();
999
+ expect(html).toContain('data-testid="vault-admin-button"');
1000
+ expect(html).toContain("Advanced vault settings");
1001
+ expect(html).toContain('action="/account/vault-admin-token/alice"');
1002
+ });
1003
+
1004
+ test("renders the backup-state line when the mirror status resolves enabled", async () => {
1005
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
1006
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
1007
+ allowMulti: true,
1008
+ passwordChanged: true,
1009
+ assignedVaults: ["alice"],
1010
+ });
1011
+ const session = createSession(harness.db, { userId: friend.id });
1012
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
1013
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
1014
+ const res = await handleAccountHomeGet(req, {
1015
+ db: harness.db,
1016
+ hubOrigin: HUB_ORIGIN,
1017
+ resolveVaultPort: () => 1940,
1018
+ // Stub the mirror fetch: resolves to a backed-up, GitHub-pushing config.
1019
+ fetchMirror: async () => ({ enabled: true, backedUpToRemote: true }),
1020
+ });
1021
+ expect(res.status).toBe(200);
1022
+ const html = await res.text();
1023
+ expect(html).toContain('data-testid="backup-state-line"');
1024
+ // Already pushing → the handler threads mirrorPushing=true, so the
1025
+ // "Back up to GitHub ↗" action is suppressed.
1026
+ expect(html).not.toContain('data-testid="backup-github-button"');
1027
+ expect(html).toContain("version history + GitHub");
1028
+ });
1029
+
1030
+ test("omits the backup line gracefully when the mirror fetch fails (null)", async () => {
1031
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
1032
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
1033
+ allowMulti: true,
1034
+ passwordChanged: true,
1035
+ assignedVaults: ["alice"],
1036
+ });
1037
+ const session = createSession(harness.db, { userId: friend.id });
1038
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
1039
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
1040
+ const res = await handleAccountHomeGet(req, {
1041
+ db: harness.db,
1042
+ hubOrigin: HUB_ORIGIN,
1043
+ resolveVaultPort: () => 1940,
1044
+ fetchMirror: async () => null,
1045
+ });
1046
+ expect(res.status).toBe(200);
1047
+ const html = await res.text();
1048
+ // Tile still renders; just no backup line.
1049
+ expect(html).toContain("<strong>alice</strong>");
1050
+ expect(html).not.toContain('data-testid="backup-state-line"');
1051
+ });
820
1052
  });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Admin API tests for `/api/invites*` (`api-invites.ts`).
3
+ *
4
+ * - host:admin gate (403 without the scope)
5
+ * - POST create → 201 with single-emit token + URL; defaults applied
6
+ * - GET list → status-annotated, raw token NEVER present
7
+ * - DELETE /:id → revoke; 409 when already terminal; 404 unknown
8
+ */
9
+ import type { Database } from "bun:sqlite";
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { handleCreateInvite, handleListInvites, handleRevokeInvite } from "../api-invites.ts";
15
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
16
+ import { signAccessToken } from "../jwt-sign.ts";
17
+ import { createUser } from "../users.ts";
18
+
19
+ const ISSUER = "https://hub.test";
20
+ const HOST_ADMIN_SCOPE = "parachute:host:admin";
21
+
22
+ interface Harness {
23
+ db: Database;
24
+ manifestPath: string;
25
+ cleanup: () => void;
26
+ }
27
+
28
+ function makeHarness(): Harness {
29
+ const dir = mkdtempSync(join(tmpdir(), "phub-api-invites-"));
30
+ const db = openHubDb(hubDbPath(dir));
31
+ const manifestPath = join(dir, "services.json");
32
+ writeFileSync(manifestPath, JSON.stringify({ services: [] }));
33
+ return {
34
+ db,
35
+ manifestPath,
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 makeAdminBearer(scopes = [HOST_ADMIN_SCOPE]): Promise<string> {
52
+ const user = await createUser(harness.db, "operator", "operator-password-1", {
53
+ allowMulti: true,
54
+ passwordChanged: true,
55
+ });
56
+ const minted = await signAccessToken(harness.db, {
57
+ sub: user.id,
58
+ scopes,
59
+ audience: "hub",
60
+ clientId: "parachute-hub-spa",
61
+ issuer: ISSUER,
62
+ ttlSeconds: 600,
63
+ });
64
+ return minted.token;
65
+ }
66
+
67
+ function deps() {
68
+ return { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath };
69
+ }
70
+
71
+ function withBearer(path: string, bearer: string, init: RequestInit = {}): Request {
72
+ const headers = new Headers(init.headers ?? {});
73
+ headers.set("authorization", `Bearer ${bearer}`);
74
+ return new Request(`${ISSUER}${path}`, { ...init, headers });
75
+ }
76
+
77
+ describe("/api/invites auth", () => {
78
+ test("403 without host:admin scope", async () => {
79
+ const bearer = await makeAdminBearer(["other:scope"]);
80
+ const res = await handleListInvites(withBearer("/api/invites", bearer), deps());
81
+ expect(res.status).toBe(403);
82
+ });
83
+ });
84
+
85
+ describe("POST /api/invites", () => {
86
+ test("201 with single-emit token + URL; defaults (write/provision/7d)", async () => {
87
+ const bearer = await makeAdminBearer();
88
+ const res = await handleCreateInvite(
89
+ withBearer("/api/invites", bearer, {
90
+ method: "POST",
91
+ headers: { "content-type": "application/json" },
92
+ body: JSON.stringify({}),
93
+ }),
94
+ deps(),
95
+ );
96
+ expect(res.status).toBe(201);
97
+ const body = (await res.json()) as {
98
+ invite: { id: string; status: string; role: string; provision_vault: boolean };
99
+ token: string;
100
+ url: string;
101
+ };
102
+ expect(body.token.length).toBeGreaterThan(40);
103
+ expect(body.url).toBe(`${ISSUER}/account/setup/${body.token}`);
104
+ expect(body.invite.role).toBe("write");
105
+ expect(body.invite.provision_vault).toBe(true);
106
+ expect(body.invite.status).toBe("pending");
107
+ // The id is the sha256 hash — never the raw token.
108
+ expect(body.invite.id).not.toBe(body.token);
109
+ });
110
+
111
+ test("400 on a bad role", async () => {
112
+ const bearer = await makeAdminBearer();
113
+ const res = await handleCreateInvite(
114
+ withBearer("/api/invites", bearer, {
115
+ method: "POST",
116
+ headers: { "content-type": "application/json" },
117
+ body: JSON.stringify({ role: "admin" }),
118
+ }),
119
+ deps(),
120
+ );
121
+ expect(res.status).toBe(400);
122
+ });
123
+
124
+ test("400 rejects a shared-vault invite (provision_vault=false + vault_name)", async () => {
125
+ // Defense in depth (FIX-1): assigning a redeemer to a PRE-EXISTING vault
126
+ // as owner-admin is a cross-tenant breach; shared-vault invites aren't
127
+ // supported yet, so the create handler refuses this combination outright.
128
+ const bearer = await makeAdminBearer();
129
+ const res = await handleCreateInvite(
130
+ withBearer("/api/invites", bearer, {
131
+ method: "POST",
132
+ headers: { "content-type": "application/json" },
133
+ body: JSON.stringify({ provision_vault: false, vault_name: "someoneelse" }),
134
+ }),
135
+ deps(),
136
+ );
137
+ expect(res.status).toBe(400);
138
+ const body = (await res.json()) as { error: string; error_description: string };
139
+ expect(body.error).toBe("invalid_request");
140
+ expect(body.error_description).toContain("shared-vault");
141
+ });
142
+
143
+ test("account-only invite (provision_vault=false, NO vault_name) is still allowed", async () => {
144
+ const bearer = await makeAdminBearer();
145
+ const res = await handleCreateInvite(
146
+ withBearer("/api/invites", bearer, {
147
+ method: "POST",
148
+ headers: { "content-type": "application/json" },
149
+ body: JSON.stringify({ provision_vault: false }),
150
+ }),
151
+ deps(),
152
+ );
153
+ expect(res.status).toBe(201);
154
+ const body = (await res.json()) as {
155
+ invite: { provision_vault: boolean; vault_name: string | null };
156
+ };
157
+ expect(body.invite.provision_vault).toBe(false);
158
+ expect(body.invite.vault_name).toBeNull();
159
+ });
160
+ });
161
+
162
+ describe("GET /api/invites", () => {
163
+ test("lists invites; raw token NEVER present in the wire shape", async () => {
164
+ const bearer = await makeAdminBearer();
165
+ const created = await handleCreateInvite(
166
+ withBearer("/api/invites", bearer, {
167
+ method: "POST",
168
+ headers: { "content-type": "application/json" },
169
+ body: JSON.stringify({ vault_name: "maya" }),
170
+ }),
171
+ deps(),
172
+ );
173
+ const createdBody = (await created.json()) as { token: string };
174
+ const list = await handleListInvites(withBearer("/api/invites", bearer), deps());
175
+ const body = (await list.json()) as { invites: { id: string }[] };
176
+ expect(body.invites.length).toBe(1);
177
+ // The raw token must not be recoverable from the list.
178
+ const json = JSON.stringify(body);
179
+ expect(json).not.toContain(createdBody.token);
180
+ });
181
+ });
182
+
183
+ describe("DELETE /api/invites/:id", () => {
184
+ test("revokes a pending invite; 409 if already revoked; 404 unknown", async () => {
185
+ const bearer = await makeAdminBearer();
186
+ const created = await handleCreateInvite(
187
+ withBearer("/api/invites", bearer, {
188
+ method: "POST",
189
+ headers: { "content-type": "application/json" },
190
+ body: JSON.stringify({}),
191
+ }),
192
+ deps(),
193
+ );
194
+ const { invite } = (await created.json()) as { invite: { id: string } };
195
+
196
+ const ok = await handleRevokeInvite(
197
+ withBearer(`/api/invites/${invite.id}`, bearer, { method: "DELETE" }),
198
+ invite.id,
199
+ deps(),
200
+ );
201
+ expect(ok.status).toBe(200);
202
+
203
+ const again = await handleRevokeInvite(
204
+ withBearer(`/api/invites/${invite.id}`, bearer, { method: "DELETE" }),
205
+ invite.id,
206
+ deps(),
207
+ );
208
+ expect(again.status).toBe(409);
209
+
210
+ const unknown = await handleRevokeInvite(
211
+ withBearer("/api/invites/deadbeef", bearer, { method: "DELETE" }),
212
+ "deadbeef",
213
+ deps(),
214
+ );
215
+ expect(unknown.status).toBe(404);
216
+ });
217
+ });