@openparachute/hub 0.6.3 → 0.6.4-rc.2

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 (72) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +880 -0
  3. package/src/__tests__/account-usage.test.ts +137 -0
  4. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  5. package/src/__tests__/account-vault-token.test.ts +53 -1
  6. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  7. package/src/__tests__/admin-vaults.test.ts +20 -0
  8. package/src/__tests__/api-account.test.ts +125 -4
  9. package/src/__tests__/api-invites.test.ts +217 -0
  10. package/src/__tests__/api-mint-token.test.ts +259 -10
  11. package/src/__tests__/api-modules-ops.test.ts +187 -1
  12. package/src/__tests__/api-modules.test.ts +40 -4
  13. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  14. package/src/__tests__/auto-wire.test.ts +101 -1
  15. package/src/__tests__/cli.test.ts +188 -2
  16. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  17. package/src/__tests__/expose-cloudflare.test.ts +5 -4
  18. package/src/__tests__/expose.test.ts +10 -5
  19. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  20. package/src/__tests__/hub-server.test.ts +628 -13
  21. package/src/__tests__/hub-unit.test.ts +4 -0
  22. package/src/__tests__/invites.test.ts +220 -0
  23. package/src/__tests__/launchctl-guard.test.ts +185 -0
  24. package/src/__tests__/migrate-cutover.test.ts +32 -0
  25. package/src/__tests__/module-ops-client.test.ts +68 -0
  26. package/src/__tests__/scope-explanations.test.ts +16 -0
  27. package/src/__tests__/serve-boot.test.ts +74 -1
  28. package/src/__tests__/serve.test.ts +121 -7
  29. package/src/__tests__/spawn-path.test.ts +191 -0
  30. package/src/__tests__/status.test.ts +64 -0
  31. package/src/__tests__/supervisor.test.ts +177 -0
  32. package/src/__tests__/users.test.ts +27 -0
  33. package/src/account-home-ui.ts +82 -9
  34. package/src/account-setup.ts +381 -0
  35. package/src/account-usage.ts +118 -0
  36. package/src/account-vault-admin-token.ts +242 -0
  37. package/src/account-vault-token.ts +27 -2
  38. package/src/admin-login-ui.ts +121 -0
  39. package/src/admin-vault-admin-token.ts +8 -2
  40. package/src/admin-vaults.ts +137 -29
  41. package/src/api-account.ts +54 -1
  42. package/src/api-invites.ts +345 -0
  43. package/src/api-mint-token.ts +81 -0
  44. package/src/api-modules-ops.ts +168 -53
  45. package/src/api-modules.ts +36 -0
  46. package/src/auto-wire.ts +87 -0
  47. package/src/cli.ts +122 -32
  48. package/src/commands/expose-2fa-warning.ts +17 -13
  49. package/src/commands/migrate-cutover.ts +12 -5
  50. package/src/commands/serve-boot.ts +33 -3
  51. package/src/commands/serve.ts +158 -37
  52. package/src/commands/status.ts +9 -1
  53. package/src/hub-db.ts +70 -2
  54. package/src/hub-server.ts +399 -41
  55. package/src/hub-unit.ts +4 -9
  56. package/src/invites.ts +291 -0
  57. package/src/launchctl-guard.ts +131 -0
  58. package/src/managed-unit.ts +13 -3
  59. package/src/migrate-offer.ts +15 -6
  60. package/src/module-ops-client.ts +47 -22
  61. package/src/scope-attenuation.ts +19 -0
  62. package/src/scope-explanations.ts +9 -1
  63. package/src/service-spec.ts +8 -3
  64. package/src/spawn-path.ts +148 -0
  65. package/src/supervisor.ts +84 -7
  66. package/src/users.ts +42 -4
  67. package/src/vault-hub-origin-env.ts +28 -0
  68. package/src/vault-name.ts +13 -1
  69. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  70. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -30,6 +30,7 @@ import {
30
30
  } from "../api-account.ts";
31
31
  import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
32
32
  import { hubDbPath, openHubDb } from "../hub-db.ts";
33
+ import { recordTokenMint } from "../jwt-sign.ts";
33
34
  import {
34
35
  CHANGE_PASSWORD_MAX_ATTEMPTS,
35
36
  CHANGE_PASSWORD_WINDOW_MS,
@@ -257,6 +258,63 @@ describe("POST /account/change-password", () => {
257
258
  }
258
259
  });
259
260
 
261
+ // Item F / hub#469 — a successful self-service password change revokes the
262
+ // user's still-active tokens (so a token minted under the admin's temp
263
+ // password dies with the rotation). Mirrors `resetUserPassword`'s admin-reset
264
+ // revoke. Tokens belonging to OTHER users are untouched.
265
+ test("self-change revokes the user's active tokens, leaves other users' tokens (item F)", async () => {
266
+ const { userId, cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw", {
267
+ passwordChanged: false,
268
+ });
269
+ const other = await createUser(harness.db, "other", "other-strong-passphrase", {
270
+ passwordChanged: true,
271
+ allowMulti: true,
272
+ });
273
+ // Seed one active token for the changing user + one for another user.
274
+ recordTokenMint(harness.db, {
275
+ jti: "tok-self-1",
276
+ createdVia: "cli_mint",
277
+ subject: userId,
278
+ userId,
279
+ clientId: "parachute-account",
280
+ scopes: ["vault:work:read"],
281
+ expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
282
+ });
283
+ recordTokenMint(harness.db, {
284
+ jti: "tok-other-1",
285
+ createdVia: "cli_mint",
286
+ subject: other.id,
287
+ userId: other.id,
288
+ clientId: "parachute-account",
289
+ scopes: ["vault:work:read"],
290
+ expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
291
+ });
292
+
293
+ const { body, headers } = formBody({
294
+ [CSRF_FIELD_NAME]: TEST_CSRF,
295
+ current_password: "old-default-pw",
296
+ new_password: "user-chosen-strong-passphrase",
297
+ new_password_confirm: "user-chosen-strong-passphrase",
298
+ next: "/account/",
299
+ });
300
+ const req = new Request("http://hub.test/account/change-password", {
301
+ method: "POST",
302
+ headers: { ...headers, cookie },
303
+ body,
304
+ });
305
+ const res = await handleAccountChangePasswordPost(req, { db: harness.db });
306
+ expect(res.status).toBe(302);
307
+
308
+ const selfTok = harness.db
309
+ .query<{ revoked_at: string | null }, [string]>("SELECT revoked_at FROM tokens WHERE jti = ?")
310
+ .get("tok-self-1");
311
+ const otherTok = harness.db
312
+ .query<{ revoked_at: string | null }, [string]>("SELECT revoked_at FROM tokens WHERE jti = ?")
313
+ .get("tok-other-1");
314
+ expect(selfTok?.revoked_at).not.toBeNull(); // changing user's token revoked
315
+ expect(otherTok?.revoked_at).toBeNull(); // other user's token untouched
316
+ });
317
+
260
318
  test("non-admin user with no next defaults to /account/ (no admin-shell flash)", async () => {
261
319
  // Without this rewrite, a friend's change-password POST would 302 to
262
320
  // /admin/vaults, the SPA would load, the 403 from
@@ -743,7 +801,7 @@ describe("handleAccountHomeGet", () => {
743
801
 
744
802
  test("302 → /login when no session cookie is present", async () => {
745
803
  const req = new Request(`${HUB_ORIGIN}/account/`);
746
- const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
804
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
747
805
  expect(res.status).toBe(302);
748
806
  const location = res.headers.get("location") ?? "";
749
807
  // Round-trip /account/ as the `next` param so post-login lands back.
@@ -762,7 +820,7 @@ describe("handleAccountHomeGet", () => {
762
820
  const session = createSession(harness.db, { userId: friend.id });
763
821
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
764
822
  const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
765
- const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
823
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
766
824
  expect(res.status).toBe(200);
767
825
  expect(res.headers.get("content-type")).toContain("text/html");
768
826
  const html = await res.text();
@@ -782,7 +840,7 @@ describe("handleAccountHomeGet", () => {
782
840
  const session = createSession(harness.db, { userId: admin.id });
783
841
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
784
842
  const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
785
- const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
843
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
786
844
  expect(res.status).toBe(200);
787
845
  const html = await res.text();
788
846
  expect(html).toContain("Welcome, admin");
@@ -813,8 +871,71 @@ describe("handleAccountHomeGet", () => {
813
871
  harness.db.exec("PRAGMA foreign_keys = ON");
814
872
  }
815
873
  const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
816
- const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
874
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
817
875
  expect(res.status).toBe(302);
818
876
  expect(res.headers.get("location")).toBe("/login");
819
877
  });
878
+
879
+ test("renders the per-vault usage stat when usage resolves", async () => {
880
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
881
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
882
+ allowMulti: true,
883
+ passwordChanged: true,
884
+ assignedVaults: ["alice"],
885
+ });
886
+ const session = createSession(harness.db, { userId: friend.id });
887
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
888
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
889
+ const res = await handleAccountHomeGet(req, {
890
+ db: harness.db,
891
+ hubOrigin: HUB_ORIGIN,
892
+ resolveVaultPort: () => 1940,
893
+ // Stub the fetch: resolves to a known stat.
894
+ fetchUsage: async () => ({ notes: 7, totalBytes: 2 * 1024 * 1024 }),
895
+ });
896
+ expect(res.status).toBe(200);
897
+ const html = await res.text();
898
+ expect(html).toContain('data-testid="vault-usage"');
899
+ expect(html).toContain("7 notes · 2.0 MB");
900
+ });
901
+
902
+ test("omits the usage stat gracefully when the fetch fails (null)", async () => {
903
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
904
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
905
+ allowMulti: true,
906
+ passwordChanged: true,
907
+ assignedVaults: ["alice"],
908
+ });
909
+ const session = createSession(harness.db, { userId: friend.id });
910
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
911
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
912
+ const res = await handleAccountHomeGet(req, {
913
+ db: harness.db,
914
+ hubOrigin: HUB_ORIGIN,
915
+ resolveVaultPort: () => 1940,
916
+ fetchUsage: async () => null,
917
+ });
918
+ expect(res.status).toBe(200);
919
+ const html = await res.text();
920
+ // Tile still renders; just no usage stat.
921
+ expect(html).toContain("<strong>alice</strong>");
922
+ expect(html).not.toContain('data-testid="vault-usage"');
923
+ });
924
+
925
+ test("renders the 'Configure / back up this vault ↗' deep-link button for an assigned vault", async () => {
926
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
927
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
928
+ allowMulti: true,
929
+ passwordChanged: true,
930
+ assignedVaults: ["alice"],
931
+ });
932
+ const session = createSession(harness.db, { userId: friend.id });
933
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
934
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
935
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
936
+ expect(res.status).toBe(200);
937
+ const html = await res.text();
938
+ expect(html).toContain('data-testid="vault-admin-button"');
939
+ expect(html).toContain('action="/account/vault-admin-token/alice"');
940
+ });
820
941
  });
@@ -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
+ });
@@ -317,6 +317,32 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
317
317
  }
318
318
  });
319
319
 
320
+ // Item C — case-insensitive non-requestable guard. An uppercase casing of a
321
+ // host-level scope must NOT bypass the non-requestable membership check (which
322
+ // was exact-string before). `PARACHUTE:HOST:AUTH` is now correctly rejected.
323
+ test("400 invalid_scope when minting an uppercase host scope (item C)", async () => {
324
+ const h = makeHarness();
325
+ try {
326
+ const { db, userId } = await bootstrap(h.dir);
327
+ try {
328
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
329
+ for (const variant of ["PARACHUTE:HOST:AUTH", "Parachute:Host:Admin"]) {
330
+ const resp = await handleApiMintToken(
331
+ jsonRequest({ scope: variant }, { authorization: `Bearer ${op.token}` }),
332
+ { db, issuer: ISSUER },
333
+ );
334
+ expect(resp.status).toBe(400);
335
+ const body = (await resp.json()) as { error: string };
336
+ expect(body.error).toBe("invalid_scope");
337
+ }
338
+ } finally {
339
+ db.close();
340
+ }
341
+ } finally {
342
+ h.cleanup();
343
+ }
344
+ });
345
+
320
346
  test("400 invalid_scope when multi-scope includes a non-requestable", async () => {
321
347
  const h = makeHarness();
322
348
  try {
@@ -416,13 +442,13 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
416
442
  }
417
443
  });
418
444
 
419
- // A bare `vault:admin` (no vault name) is NOT a per-vault admin scope —
420
- // the de-escalation exception only covers `vault:<name>:admin`. It isn't
421
- // in the non-requestable set either, so it's treated as an ordinary
422
- // (unnamed) scope and mints but with the `vault` fallback audience, not
423
- // a per-vault one. Pinned so a future regex loosening can't silently let
424
- // an unnamed admin through the named-vault exemption.
425
- test("bare vault:admin (no name) is not caught by the de-escalation exemption", async () => {
445
+ // Item B / hub#451 — bare `vault:admin` (no vault name) is NOT mintable on
446
+ // the headless path. The unnamed broad-admin form has no resource pin; the
447
+ // mint endpoint refuses it with 400 `invalid_scope` (even for a full host:admin
448
+ // operator). The legitimate path for a vault admin token is a resource-narrowed
449
+ // `vault:<name>:admin`. The OAuth flow still accepts bare `vault:admin` and
450
+ // narrows it via the picker — that path is unaffected (see oauth-handlers).
451
+ test("bare vault:admin (no name) 400 (non-requestable headlessly, item B / #451)", async () => {
426
452
  const h = makeHarness();
427
453
  try {
428
454
  const { db, userId } = await bootstrap(h.dir);
@@ -432,9 +458,31 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
432
458
  jsonRequest({ scope: "vault:admin" }, { authorization: `Bearer ${op.token}` }),
433
459
  { db, issuer: ISSUER },
434
460
  );
435
- // `vault:admin` isn't a per-vault admin scope and isn't in the
436
- // non-requestable set, so it mints as an ordinary scope. The point
437
- // of this test is that it does NOT get a per-vault audience/pin.
461
+ expect(resp.status).toBe(400);
462
+ const body = (await resp.json()) as { error: string; error_description: string };
463
+ expect(body.error).toBe("invalid_scope");
464
+ expect(body.error_description).toContain("vault:admin");
465
+ } finally {
466
+ db.close();
467
+ }
468
+ } finally {
469
+ h.cleanup();
470
+ }
471
+ });
472
+
473
+ // Item B does NOT touch unnamed vault:read / vault:write — those carry no
474
+ // admin authority and remain mintable (regression guard for the narrow scope
475
+ // of the bare-admin block).
476
+ test("bare vault:read still mints headlessly (item B is admin-only)", async () => {
477
+ const h = makeHarness();
478
+ try {
479
+ const { db, userId } = await bootstrap(h.dir);
480
+ try {
481
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
482
+ const resp = await handleApiMintToken(
483
+ jsonRequest({ scope: "vault:read" }, { authorization: `Bearer ${op.token}` }),
484
+ { db, issuer: ISSUER },
485
+ );
438
486
  expect(resp.status).toBe(200);
439
487
  const body = (await resp.json()) as { token: string };
440
488
  const validated = await validateAccessToken(db, body.token, ISSUER);
@@ -745,6 +793,122 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
745
793
  h.cleanup();
746
794
  }
747
795
  });
796
+
797
+ // Item A (subject-pin) — audit-attribution forgery. A non-operator
798
+ // (vault-admin-only) bearer may NOT override the minted token's `sub`:
799
+ // forging a foreign subject would mis-attribute the registry + revocation
800
+ // rows. It may still mint under its OWN sub (subject omitted / equal).
801
+ test("subject override by non-operator bearer → 403 (forgery blocked)", async () => {
802
+ const h = makeHarness();
803
+ try {
804
+ const { db, userId } = await bootstrap(h.dir);
805
+ try {
806
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
807
+ const resp = await handleApiMintToken(
808
+ jsonRequest(
809
+ { scope: "vault:work:read", subject: "someone-else" },
810
+ { authorization: `Bearer ${bearer}` },
811
+ ),
812
+ { db, issuer: ISSUER },
813
+ );
814
+ expect(resp.status).toBe(403);
815
+ const body = (await resp.json()) as { error: string; error_description: string };
816
+ expect(body.error).toBe("insufficient_scope");
817
+ expect(body.error_description).toContain("non-operator");
818
+ } finally {
819
+ db.close();
820
+ }
821
+ } finally {
822
+ h.cleanup();
823
+ }
824
+ });
825
+
826
+ test("subject equal to own sub by non-operator bearer → 200 (no forgery)", async () => {
827
+ const h = makeHarness();
828
+ try {
829
+ const { db, userId } = await bootstrap(h.dir);
830
+ try {
831
+ const bearer = await mintVaultAdminBearer(db, userId, "work");
832
+ const resp = await handleApiMintToken(
833
+ jsonRequest(
834
+ { scope: "vault:work:read", subject: userId },
835
+ { authorization: `Bearer ${bearer}` },
836
+ ),
837
+ { db, issuer: ISSUER },
838
+ );
839
+ expect(resp.status).toBe(200);
840
+ const body = (await resp.json()) as { jti: string };
841
+ const row = db
842
+ .query<{ subject: string }, [string]>("SELECT subject FROM tokens WHERE jti = ?")
843
+ .get(body.jti);
844
+ expect(row?.subject).toBe(userId);
845
+ } finally {
846
+ db.close();
847
+ }
848
+ } finally {
849
+ h.cleanup();
850
+ }
851
+ });
852
+ });
853
+
854
+ // Item A (subject-pin) — the operator carve-out: a host operator
855
+ // (parachute:host:auth / parachute:host:admin) MAY override `sub` to stamp a
856
+ // service-account subject. This is the documented service-account override
857
+ // that the non-operator pin above must NOT break.
858
+ describe("subject override — operator carve-out (item A)", () => {
859
+ test("host:auth operator overrides subject → 200, registry row carries override", async () => {
860
+ const h = makeHarness();
861
+ try {
862
+ const { db, userId } = await bootstrap(h.dir);
863
+ try {
864
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
865
+ const resp = await handleApiMintToken(
866
+ jsonRequest(
867
+ { scope: "vault:work:read", subject: "svc-account" },
868
+ { authorization: `Bearer ${op.token}` },
869
+ ),
870
+ { db, issuer: ISSUER },
871
+ );
872
+ expect(resp.status).toBe(200);
873
+ const body = (await resp.json()) as { jti: string; token: string };
874
+ const validated = await validateAccessToken(db, body.token, ISSUER);
875
+ expect(validated.payload.sub).toBe("svc-account");
876
+ const row = db
877
+ .query<{ subject: string }, [string]>("SELECT subject FROM tokens WHERE jti = ?")
878
+ .get(body.jti);
879
+ expect(row?.subject).toBe("svc-account");
880
+ } finally {
881
+ db.close();
882
+ }
883
+ } finally {
884
+ h.cleanup();
885
+ }
886
+ });
887
+
888
+ test("host:admin operator overrides subject → 200", async () => {
889
+ const h = makeHarness();
890
+ try {
891
+ const { db, userId } = await bootstrap(h.dir);
892
+ try {
893
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
894
+ const resp = await handleApiMintToken(
895
+ jsonRequest(
896
+ { scope: "vault:work:admin", subject: "svc-account" },
897
+ { authorization: `Bearer ${op.token}` },
898
+ ),
899
+ { db, issuer: ISSUER },
900
+ );
901
+ expect(resp.status).toBe(200);
902
+ const body = (await resp.json()) as { token: string };
903
+ const validated = await validateAccessToken(db, body.token, ISSUER);
904
+ expect(validated.payload.sub).toBe("svc-account");
905
+ } finally {
906
+ db.close();
907
+ }
908
+ } finally {
909
+ h.cleanup();
910
+ }
911
+ });
748
912
  });
749
913
 
750
914
  describe("capability attenuation — entry gate + regression", () => {
@@ -1051,6 +1215,91 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
1051
1215
  });
1052
1216
  });
1053
1217
 
1218
+ // Item D / hub#450 — vault-existence check on vault:<name>:admin mints.
1219
+ describe("vault-existence check on vault:<name>:admin (item D / #450)", () => {
1220
+ test("vault:typo:admin for an unknown vault → 400 when knownVaultNames is wired", async () => {
1221
+ const h = makeHarness();
1222
+ try {
1223
+ const { db, userId } = await bootstrap(h.dir);
1224
+ try {
1225
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
1226
+ const resp = await handleApiMintToken(
1227
+ jsonRequest({ scope: "vault:typo:admin" }, { authorization: `Bearer ${op.token}` }),
1228
+ { db, issuer: ISSUER, knownVaultNames: new Set(["work", "default"]) },
1229
+ );
1230
+ expect(resp.status).toBe(400);
1231
+ const body = (await resp.json()) as { error: string; error_description: string };
1232
+ expect(body.error).toBe("invalid_scope");
1233
+ expect(body.error_description).toContain("typo");
1234
+ } finally {
1235
+ db.close();
1236
+ }
1237
+ } finally {
1238
+ h.cleanup();
1239
+ }
1240
+ });
1241
+
1242
+ test("vault:work:admin for a KNOWN vault → 200", async () => {
1243
+ const h = makeHarness();
1244
+ try {
1245
+ const { db, userId } = await bootstrap(h.dir);
1246
+ try {
1247
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
1248
+ const resp = await handleApiMintToken(
1249
+ jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
1250
+ { db, issuer: ISSUER, knownVaultNames: new Set(["work", "default"]) },
1251
+ );
1252
+ expect(resp.status).toBe(200);
1253
+ const body = (await resp.json()) as { scope: string };
1254
+ expect(body.scope).toBe("vault:work:admin");
1255
+ } finally {
1256
+ db.close();
1257
+ }
1258
+ } finally {
1259
+ h.cleanup();
1260
+ }
1261
+ });
1262
+
1263
+ test("read/write for an unknown vault are NOT existence-checked (admin-only gate)", async () => {
1264
+ const h = makeHarness();
1265
+ try {
1266
+ const { db, userId } = await bootstrap(h.dir);
1267
+ try {
1268
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
1269
+ const resp = await handleApiMintToken(
1270
+ jsonRequest({ scope: "vault:typo:read" }, { authorization: `Bearer ${op.token}` }),
1271
+ { db, issuer: ISSUER, knownVaultNames: new Set(["work"]) },
1272
+ );
1273
+ expect(resp.status).toBe(200);
1274
+ } finally {
1275
+ db.close();
1276
+ }
1277
+ } finally {
1278
+ h.cleanup();
1279
+ }
1280
+ });
1281
+
1282
+ test("knownVaultNames omitted → existence check skipped (back-compat)", async () => {
1283
+ const h = makeHarness();
1284
+ try {
1285
+ const { db, userId } = await bootstrap(h.dir);
1286
+ try {
1287
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
1288
+ const resp = await handleApiMintToken(
1289
+ jsonRequest({ scope: "vault:typo:admin" }, { authorization: `Bearer ${op.token}` }),
1290
+ { db, issuer: ISSUER },
1291
+ );
1292
+ // No knownVaultNames → the documented "caller responsible" fallback.
1293
+ expect(resp.status).toBe(200);
1294
+ } finally {
1295
+ db.close();
1296
+ }
1297
+ } finally {
1298
+ h.cleanup();
1299
+ }
1300
+ });
1301
+ });
1302
+
1054
1303
  test("405 on non-POST", async () => {
1055
1304
  const h = makeHarness();
1056
1305
  try {