@openparachute/hub 0.5.14-rc.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +23 -0
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +30 -21
  7. package/src/__tests__/api-modules-ops.test.ts +45 -0
  8. package/src/__tests__/api-users.test.ts +7 -2
  9. package/src/__tests__/auth.test.ts +157 -30
  10. package/src/__tests__/cli.test.ts +44 -5
  11. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  12. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  13. package/src/__tests__/expose-cloudflare.test.ts +482 -14
  14. package/src/__tests__/expose.test.ts +52 -2
  15. package/src/__tests__/hub-server.test.ts +97 -0
  16. package/src/__tests__/hub.test.ts +85 -6
  17. package/src/__tests__/init.test.ts +102 -1
  18. package/src/__tests__/lifecycle.test.ts +464 -2
  19. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  20. package/src/__tests__/oauth-ui.test.ts +12 -1
  21. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  22. package/src/__tests__/resource-binding.test.ts +97 -0
  23. package/src/__tests__/scope-explanations.test.ts +41 -12
  24. package/src/__tests__/services-manifest.test.ts +122 -4
  25. package/src/__tests__/setup-wizard.test.ts +335 -15
  26. package/src/__tests__/status.test.ts +36 -0
  27. package/src/__tests__/two-factor-flow.test.ts +602 -0
  28. package/src/__tests__/two-factor.test.ts +183 -0
  29. package/src/__tests__/upgrade.test.ts +78 -1
  30. package/src/__tests__/users.test.ts +68 -0
  31. package/src/__tests__/vault-auth-status.test.ts +47 -6
  32. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  33. package/src/account-home-ui.ts +488 -38
  34. package/src/account-vault-token.ts +282 -0
  35. package/src/admin-handlers.ts +159 -4
  36. package/src/admin-login-ui.ts +49 -5
  37. package/src/admin-vaults.ts +48 -15
  38. package/src/api-account.ts +14 -0
  39. package/src/api-modules-ops.ts +49 -11
  40. package/src/api-users.ts +29 -3
  41. package/src/cli.ts +26 -21
  42. package/src/clients.ts +18 -6
  43. package/src/cloudflare/config.ts +10 -4
  44. package/src/cloudflare/detect.ts +39 -44
  45. package/src/commands/auth.ts +165 -24
  46. package/src/commands/expose-2fa-warning.ts +34 -32
  47. package/src/commands/expose-auth-preflight.ts +89 -78
  48. package/src/commands/expose-cloudflare.ts +370 -12
  49. package/src/commands/expose.ts +8 -0
  50. package/src/commands/init.ts +33 -2
  51. package/src/commands/lifecycle.ts +386 -17
  52. package/src/commands/status.ts +22 -0
  53. package/src/commands/upgrade.ts +55 -11
  54. package/src/commands/wizard.ts +8 -4
  55. package/src/env-file.ts +10 -0
  56. package/src/help.ts +3 -1
  57. package/src/hub-db.ts +39 -1
  58. package/src/hub-server.ts +52 -0
  59. package/src/hub.ts +82 -14
  60. package/src/oauth-handlers.ts +298 -21
  61. package/src/oauth-ui.ts +10 -0
  62. package/src/operator-token.ts +151 -0
  63. package/src/pending-login.ts +116 -0
  64. package/src/rate-limit.ts +51 -0
  65. package/src/resource-binding.ts +134 -0
  66. package/src/scope-explanations.ts +46 -18
  67. package/src/services-manifest.ts +112 -0
  68. package/src/setup-wizard.ts +77 -7
  69. package/src/tailscale/run.ts +28 -11
  70. package/src/totp.ts +201 -0
  71. package/src/two-factor-handlers.ts +287 -0
  72. package/src/two-factor-store.ts +181 -0
  73. package/src/two-factor-ui.ts +462 -0
  74. package/src/users.ts +58 -0
  75. package/src/vault/auth-status.ts +71 -19
  76. package/src/vault-hub-origin-env.ts +163 -0
  77. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  78. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  79. package/web/ui/dist/index.html +2 -2
  80. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  81. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  82. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  83. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Security tests for the friend-facing scoped vault token mint —
3
+ * `POST /account/vault-token/<name>` (`handleAccountVaultTokenPost`).
4
+ *
5
+ * This is a new auth-mint surface, so the authorization is tested
6
+ * adversarially. The spine:
7
+ * - No session → 401 (no mint).
8
+ * - Assigned vault → 200, token carries `vault:<name>:<verb>`,
9
+ * `aud=vault.<name>`, `iss=<hub>`, sub=user.
10
+ * - UNassigned vault → 403 (cannot mint for a vault not in the
11
+ * user's `user_vaults` assignment — blocks
12
+ * cross-vault).
13
+ * - `admin` verb → rejected (not in the form vocabulary).
14
+ * - Broader/garbage verb → rejected.
15
+ * - First admin → 403 (no `user_vaults` rows → unrestricted
16
+ * admins use the SPA path, not this one).
17
+ * - CSRF missing/mismatch → 400.
18
+ * - Rate limit → 429 after the bucket fills.
19
+ * - The minted token is a valid hub JWT the vault would accept.
20
+ */
21
+ import type { Database } from "bun:sqlite";
22
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
23
+ import { mkdtempSync, rmSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { ACCOUNT_VAULT_TOKEN_TTL_SECONDS } from "../account-home-ui.ts";
27
+ import { handleAccountVaultTokenPost } from "../account-vault-token.ts";
28
+ import { CSRF_FIELD_NAME, buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
29
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
30
+ import { validateAccessToken } from "../jwt-sign.ts";
31
+ import { __resetForTests } from "../rate-limit.ts";
32
+ import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
33
+ import { createUser } from "../users.ts";
34
+
35
+ const ISSUER = "https://hub.test";
36
+
37
+ interface Harness {
38
+ db: Database;
39
+ cleanup: () => void;
40
+ }
41
+
42
+ function makeHarness(): Harness {
43
+ const dir = mkdtempSync(join(tmpdir(), "phub-account-vault-token-"));
44
+ const db = openHubDb(hubDbPath(dir));
45
+ return {
46
+ db,
47
+ cleanup: () => {
48
+ db.close();
49
+ rmSync(dir, { recursive: true, force: true });
50
+ },
51
+ };
52
+ }
53
+
54
+ let harness: Harness;
55
+ beforeEach(() => {
56
+ harness = makeHarness();
57
+ __resetForTests();
58
+ });
59
+ afterEach(() => {
60
+ harness.cleanup();
61
+ __resetForTests();
62
+ });
63
+
64
+ const deps = () => ({ db: harness.db, hubOrigin: ISSUER });
65
+
66
+ /** A shared CSRF token + matching cookie value for the double-submit handshake. */
67
+ function csrfPair(): { token: string; cookieFragment: string } {
68
+ const token = generateCsrfToken();
69
+ // buildCsrfCookie(...) → "parachute_hub_csrf=<token>; HttpOnly; ...". We only
70
+ // need the name=value fragment to join with the session cookie.
71
+ const cookie = buildCsrfCookie(token, { secure: false }).split(";")[0] ?? "";
72
+ return { token, cookieFragment: cookie };
73
+ }
74
+
75
+ /** Build the first-admin operator + a friend assigned to `vaults`. */
76
+ async function seedFriend(
77
+ vaults: string[],
78
+ ): Promise<{ friendId: string; cookie: string; csrfToken: string }> {
79
+ await createUser(harness.db, "operator", "operator-password-123");
80
+ const friend = await createUser(harness.db, "friend", "friend-password-123", {
81
+ assignedVaults: vaults,
82
+ allowMulti: true,
83
+ });
84
+ const session = createSession(harness.db, { userId: friend.id });
85
+ const { token, cookieFragment } = csrfPair();
86
+ const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
87
+ const cookie = `${sessionCookie}; ${cookieFragment}`;
88
+ return { friendId: friend.id, cookie, csrfToken: token };
89
+ }
90
+
91
+ function mintReq(
92
+ vaultName: string,
93
+ opts: { cookie?: string; csrfToken?: string; verb?: string; omitCsrf?: boolean } = {},
94
+ ): Request {
95
+ const body = new URLSearchParams();
96
+ if (!opts.omitCsrf && opts.csrfToken !== undefined) body.set(CSRF_FIELD_NAME, opts.csrfToken);
97
+ if (opts.verb !== undefined) body.set("verb", opts.verb);
98
+ const headers: Record<string, string> = {
99
+ "content-type": "application/x-www-form-urlencoded",
100
+ };
101
+ if (opts.cookie) headers.cookie = opts.cookie;
102
+ return new Request(`${ISSUER}/account/vault-token/${encodeURIComponent(vaultName)}`, {
103
+ method: "POST",
104
+ headers,
105
+ body: body.toString(),
106
+ });
107
+ }
108
+
109
+ describe("handleAccountVaultTokenPost — happy path (assigned vault)", () => {
110
+ test("200 mints vault:<name>:read for an assigned vault, valid hub JWT", async () => {
111
+ const { friendId, cookie, csrfToken } = await seedFriend(["work"]);
112
+ const res = await handleAccountVaultTokenPost(
113
+ mintReq("work", { cookie, csrfToken, verb: "read" }),
114
+ "work",
115
+ deps(),
116
+ );
117
+ expect(res.status).toBe(200);
118
+ expect(res.headers.get("cache-control")).toBe("no-store");
119
+ const html = await res.text();
120
+ expect(html).toContain('data-testid="minted-token-banner"');
121
+
122
+ // Pull the token out of the show-once banner and validate it as a hub JWT.
123
+ const m = html.match(/data-testid="minted-token-value">([^<]+)</);
124
+ expect(m).not.toBeNull();
125
+ const token = m![1] as string;
126
+ const validated = await validateAccessToken(harness.db, token, ISSUER);
127
+ expect(validated.payload.sub).toBe(friendId);
128
+ expect(validated.payload.iss).toBe(ISSUER);
129
+ expect(validated.payload.aud).toBe("vault.work");
130
+ const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
131
+ expect(scopeClaim.split(/\s+/)).toEqual(["vault:work:read"]);
132
+ // vault_scope pin — token can only ever be used against `work`.
133
+ expect((validated.payload as { vault_scope?: string[] }).vault_scope).toEqual(["work"]);
134
+
135
+ // TTL ≈ 90 days.
136
+ const expMs = new Date((validated.payload.exp ?? 0) * 1000).getTime();
137
+ const skew = expMs - Date.now();
138
+ expect(skew).toBeGreaterThan((ACCOUNT_VAULT_TOKEN_TTL_SECONDS - 60) * 1000);
139
+ expect(skew).toBeLessThan((ACCOUNT_VAULT_TOKEN_TTL_SECONDS + 60) * 1000);
140
+ });
141
+
142
+ test("200 mints vault:<name>:write when verb=write (default-role assignment)", async () => {
143
+ const { cookie, csrfToken } = await seedFriend(["work"]);
144
+ const res = await handleAccountVaultTokenPost(
145
+ mintReq("work", { cookie, csrfToken, verb: "write" }),
146
+ "work",
147
+ deps(),
148
+ );
149
+ expect(res.status).toBe(200);
150
+ const html = await res.text();
151
+ const token = html.match(/data-testid="minted-token-value">([^<]+)</)?.[1] as string;
152
+ const validated = await validateAccessToken(harness.db, token, ISSUER);
153
+ const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
154
+ expect(scopeClaim.split(/\s+/)).toEqual(["vault:work:write"]);
155
+ });
156
+
157
+ test("a friend assigned to multiple vaults can mint for each, never cross-vault", async () => {
158
+ const { cookie, csrfToken } = await seedFriend(["work", "home"]);
159
+ for (const v of ["work", "home"]) {
160
+ const res = await handleAccountVaultTokenPost(
161
+ mintReq(v, { cookie, csrfToken, verb: "read" }),
162
+ v,
163
+ deps(),
164
+ );
165
+ expect(res.status).toBe(200);
166
+ const html = await res.text();
167
+ const token = html.match(/data-testid="minted-token-value">([^<]+)</)?.[1] as string;
168
+ const validated = await validateAccessToken(harness.db, token, ISSUER);
169
+ expect(validated.payload.aud).toBe(`vault.${v}`);
170
+ }
171
+ // ...but a vault NOT in {work, home} is refused.
172
+ const res = await handleAccountVaultTokenPost(
173
+ mintReq("secret", { cookie, csrfToken, verb: "read" }),
174
+ "secret",
175
+ deps(),
176
+ );
177
+ expect(res.status).toBe(403);
178
+ });
179
+ });
180
+
181
+ describe("handleAccountVaultTokenPost — authorization gates (adversarial)", () => {
182
+ test("401 when no session cookie is present", async () => {
183
+ // Even with a CSRF token, no session = no identity = no mint.
184
+ const { token, cookieFragment } = csrfPair();
185
+ const res = await handleAccountVaultTokenPost(
186
+ mintReq("work", { cookie: cookieFragment, csrfToken: token, verb: "read" }),
187
+ "work",
188
+ deps(),
189
+ );
190
+ expect(res.status).toBe(401);
191
+ });
192
+
193
+ test("403 when minting for a vault the friend is NOT assigned to (cross-vault)", async () => {
194
+ // Friend is assigned to `work` only; attempts `other`.
195
+ const { cookie, csrfToken } = await seedFriend(["work"]);
196
+ const res = await handleAccountVaultTokenPost(
197
+ mintReq("other", { cookie, csrfToken, verb: "read" }),
198
+ "other",
199
+ deps(),
200
+ );
201
+ expect(res.status).toBe(403);
202
+ const html = await res.text();
203
+ expect(html).toContain('data-testid="mint-error-banner"');
204
+ expect(html).toContain("not assigned");
205
+ // Critically: no token was minted.
206
+ expect(html).not.toContain('data-testid="minted-token-banner"');
207
+ });
208
+
209
+ test("a non-assigned friend cannot mint even for a vault that EXISTS for another user", async () => {
210
+ // Two friends; friend B is assigned to `shared`, friend A is not.
211
+ await createUser(harness.db, "operator", "operator-password-123");
212
+ const friendB = await createUser(harness.db, "bee", "bee-password-12345", {
213
+ assignedVaults: ["shared"],
214
+ allowMulti: true,
215
+ });
216
+ expect(friendB.id).toBeTruthy();
217
+ const friendA = await createUser(harness.db, "aay", "aay-password-12345", {
218
+ assignedVaults: ["mine"],
219
+ allowMulti: true,
220
+ });
221
+ const sessionA = createSession(harness.db, { userId: friendA.id });
222
+ const { token, cookieFragment } = csrfPair();
223
+ const cookie = `${buildSessionCookie(sessionA.id, Math.floor(SESSION_TTL_MS / 1000))}; ${cookieFragment}`;
224
+ const res = await handleAccountVaultTokenPost(
225
+ mintReq("shared", { cookie, csrfToken: token, verb: "read" }),
226
+ "shared",
227
+ deps(),
228
+ );
229
+ expect(res.status).toBe(403);
230
+ });
231
+
232
+ test("403 — the first admin cannot mint here (no user_vaults rows; uses SPA path)", async () => {
233
+ // The first-created user is the unrestricted admin: empty assignedVaults,
234
+ // so vaultVerbsForUserVault returns null for every vault → 403. Admins
235
+ // mint via /admin/vault-admin-token, not this friend surface.
236
+ const admin = await createUser(harness.db, "operator", "operator-password-123");
237
+ const session = createSession(harness.db, { userId: admin.id });
238
+ const { token, cookieFragment } = csrfPair();
239
+ const cookie = `${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}; ${cookieFragment}`;
240
+ const res = await handleAccountVaultTokenPost(
241
+ mintReq("work", { cookie, csrfToken: token, verb: "read" }),
242
+ "work",
243
+ deps(),
244
+ );
245
+ expect(res.status).toBe(403);
246
+ });
247
+
248
+ test("admin verb is rejected — never mints vault:<name>:admin", async () => {
249
+ const { cookie, csrfToken } = await seedFriend(["work"]);
250
+ const res = await handleAccountVaultTokenPost(
251
+ mintReq("work", { cookie, csrfToken, verb: "admin" }),
252
+ "work",
253
+ deps(),
254
+ );
255
+ expect(res.status).toBe(400);
256
+ const html = await res.text();
257
+ expect(html).not.toContain('data-testid="minted-token-banner"');
258
+ expect(html).not.toContain("vault:work:admin");
259
+ });
260
+
261
+ test("a garbage / broader verb is rejected", async () => {
262
+ const { cookie, csrfToken } = await seedFriend(["work"]);
263
+ for (const verb of ["host", "delete", "read write", "*", ""]) {
264
+ const res = await handleAccountVaultTokenPost(
265
+ mintReq("work", { cookie, csrfToken, verb }),
266
+ "work",
267
+ deps(),
268
+ );
269
+ expect(res.status).toBe(400);
270
+ }
271
+ });
272
+
273
+ test("a syntactically invalid vault name is rejected before any mint", async () => {
274
+ const { cookie, csrfToken } = await seedFriend(["work"]);
275
+ const res = await handleAccountVaultTokenPost(
276
+ mintReq("has..dots", { cookie, csrfToken, verb: "read" }),
277
+ "has..dots",
278
+ deps(),
279
+ );
280
+ expect(res.status).toBe(400);
281
+ });
282
+ });
283
+
284
+ describe("handleAccountVaultTokenPost — CSRF + method + rate limit", () => {
285
+ test("405 on non-POST", async () => {
286
+ const { cookie } = await seedFriend(["work"]);
287
+ const req = new Request(`${ISSUER}/account/vault-token/work`, {
288
+ method: "GET",
289
+ headers: { cookie },
290
+ });
291
+ const res = await handleAccountVaultTokenPost(req, "work", deps());
292
+ expect(res.status).toBe(405);
293
+ });
294
+
295
+ test("400 when the CSRF token is missing", async () => {
296
+ const { cookie } = await seedFriend(["work"]);
297
+ const res = await handleAccountVaultTokenPost(
298
+ mintReq("work", { cookie, omitCsrf: true, verb: "read" }),
299
+ "work",
300
+ deps(),
301
+ );
302
+ expect(res.status).toBe(400);
303
+ });
304
+
305
+ test("400 when the CSRF form token does not match the cookie", async () => {
306
+ const { cookie } = await seedFriend(["work"]);
307
+ // Send a different (non-matching) CSRF token in the form than the cookie.
308
+ const res = await handleAccountVaultTokenPost(
309
+ mintReq("work", { cookie, csrfToken: generateCsrfToken(), verb: "read" }),
310
+ "work",
311
+ deps(),
312
+ );
313
+ expect(res.status).toBe(400);
314
+ });
315
+
316
+ test("429 once the per-user mint bucket fills (10 / 10 min)", async () => {
317
+ const { cookie, csrfToken } = await seedFriend(["work"]);
318
+ // 10 admitted, 11th denied.
319
+ for (let i = 0; i < 10; i++) {
320
+ const res = await handleAccountVaultTokenPost(
321
+ mintReq("work", { cookie, csrfToken, verb: "read" }),
322
+ "work",
323
+ deps(),
324
+ );
325
+ expect(res.status).toBe(200);
326
+ }
327
+ const denied = await handleAccountVaultTokenPost(
328
+ mintReq("work", { cookie, csrfToken, verb: "read" }),
329
+ "work",
330
+ deps(),
331
+ );
332
+ expect(denied.status).toBe(429);
333
+ });
334
+
335
+ test("CSRF failure does NOT burn a rate-limit slot", async () => {
336
+ // A cross-site POST with a bad CSRF token should 400 before the bucket is
337
+ // touched — otherwise an attacker could exhaust the victim's mint bucket.
338
+ const { cookie, csrfToken } = await seedFriend(["work"]);
339
+ for (let i = 0; i < 15; i++) {
340
+ const res = await handleAccountVaultTokenPost(
341
+ mintReq("work", { cookie, csrfToken: generateCsrfToken(), verb: "read" }),
342
+ "work",
343
+ deps(),
344
+ );
345
+ expect(res.status).toBe(400);
346
+ }
347
+ // The legitimate mint still succeeds — the bucket was never touched.
348
+ const ok = await handleAccountVaultTokenPost(
349
+ mintReq("work", { cookie, csrfToken, verb: "read" }),
350
+ "work",
351
+ deps(),
352
+ );
353
+ expect(ok.status).toBe(200);
354
+ });
355
+ });
@@ -8,11 +8,21 @@ import { signAccessToken } from "../jwt-sign.ts";
8
8
  import { upsertService, writeManifest } from "../services-manifest.ts";
9
9
  import { rotateSigningKey } from "../signing-keys.ts";
10
10
 
11
- /** Build the JSON shape parachute-vault create --json emits (PR #184). */
12
- function vaultCreateJson(name: string, token = `pvt_${name}_token`): string {
11
+ /**
12
+ * Build the JSON shape `parachute-vault create --json` emits (PR #184).
13
+ * Post the pvt_* DROP the `token` is a hub-issued access JWT (scoped
14
+ * `vault:<name>:admin`), and may be the empty string when the vault
15
+ * couldn't mint — in which case `token_guidance` carries the reason.
16
+ */
17
+ function vaultCreateJson(
18
+ name: string,
19
+ token = `hubjwt.${name}.access`,
20
+ tokenGuidance?: string,
21
+ ): string {
13
22
  return JSON.stringify({
14
23
  name,
15
24
  token,
25
+ ...(tokenGuidance ? { token_guidance: tokenGuidance } : {}),
16
26
  paths: {
17
27
  vault_dir: `/home/test/.parachute/vault/${name}`,
18
28
  vault_db: `/home/test/.parachute/vault/${name}/vault.db`,
@@ -404,7 +414,7 @@ describe("POST /vaults — orchestration", () => {
404
414
  );
405
415
  return {
406
416
  exitCode: 0,
407
- stdout: vaultCreateJson("work", "pvt_supersecret"),
417
+ stdout: vaultCreateJson("work", "hubjwt.work.access"),
408
418
  stderr: "",
409
419
  };
410
420
  };
@@ -420,7 +430,7 @@ describe("POST /vaults — orchestration", () => {
420
430
  token?: string;
421
431
  paths?: { vault_dir: string; vault_db: string; vault_config: string };
422
432
  };
423
- expect(body.token).toBe("pvt_supersecret");
433
+ expect(body.token).toBe("hubjwt.work.access");
424
434
  expect(body.paths).toEqual({
425
435
  vault_dir: "/home/test/.parachute/vault/work",
426
436
  vault_db: "/home/test/.parachute/vault/work/vault.db",
@@ -434,6 +444,62 @@ describe("POST /vaults — orchestration", () => {
434
444
  }
435
445
  });
436
446
 
447
+ test("201 forwards an empty token + token_guidance when the vault couldn't mint (post-DROP)", async () => {
448
+ // The vault emits `token: ""` + a `token_guidance` reason when no hub
449
+ // origin was reachable to mint against (e.g. loopback create). The hub
450
+ // must forward both verbatim so the SPA can render the
451
+ // created-but-no-token state instead of confusing it with a re-POST.
452
+ const h = makeHarness();
453
+ try {
454
+ const db = openHubDb(hubDbPath(h.dir));
455
+ try {
456
+ rotateSigningKey(db);
457
+ upsertService(
458
+ {
459
+ name: "parachute-vault",
460
+ port: 1940,
461
+ paths: ["/vault/default"],
462
+ health: "/health",
463
+ version: "0.3.5",
464
+ },
465
+ h.manifestPath,
466
+ );
467
+ const runCommand = async (_cmd: readonly string[]): Promise<RunResult> => {
468
+ upsertService(
469
+ {
470
+ name: "parachute-vault",
471
+ port: 1940,
472
+ paths: ["/vault/default", "/vault/work"],
473
+ health: "/health",
474
+ version: "0.3.5",
475
+ },
476
+ h.manifestPath,
477
+ );
478
+ return {
479
+ exitCode: 0,
480
+ stdout: vaultCreateJson("work", "", "no hub origin reachable to mint against"),
481
+ stderr: "",
482
+ };
483
+ };
484
+ const res = await call({
485
+ db,
486
+ manifestPath: h.manifestPath,
487
+ body: { name: "work" },
488
+ runCommand,
489
+ });
490
+ // Still a fresh create — HTTP 201, NOT 200.
491
+ expect(res.status).toBe(201);
492
+ const body = (await res.json()) as { token?: string; token_guidance?: string };
493
+ expect(body.token).toBe("");
494
+ expect(body.token_guidance).toBe("no hub origin reachable to mint against");
495
+ } finally {
496
+ db.close();
497
+ }
498
+ } finally {
499
+ h.cleanup();
500
+ }
501
+ });
502
+
437
503
  test("500 when `parachute-vault create --json` exits 0 but stdout is unparseable", async () => {
438
504
  const h = makeHarness();
439
505
  try {
@@ -382,10 +382,14 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
382
382
  }
383
383
  });
384
384
 
385
- // The de-escalation exception is gated on host:admin specifically. A
386
- // bearer that only holds `parachute:host:auth` (the narrow auth scope-set)
387
- // can mint verb scopes but NOT vault-admin that would be an escalation.
388
- test("400 invalid_scope when auth-only bearer mints vault:<name>:admin", async () => {
385
+ // Single-consent change (2026-05-29) INTENTIONAL canGrant widening. Once
386
+ // `vault:<name>:admin` became requestable (`isNonRequestableScope` dropped
387
+ // the per-vault-admin clause), canGrant rule 1 (`!isNonRequestableScope` +
388
+ // bearer holds `parachute:host:auth`) now ADMITS it. A `parachute:host:auth`
389
+ // bearer is an on-box operator credential, so minting a vault-pinned admin
390
+ // from it is a de-escalation, not an escalation. Pinned here so the widening
391
+ // is deliberate, not an accidental regression.
392
+ test("200 when auth-only bearer mints vault:<name>:admin (intentional canGrant widening)", async () => {
389
393
  const h = makeHarness();
390
394
  try {
391
395
  const { db, userId } = await bootstrap(h.dir);
@@ -398,10 +402,12 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
398
402
  jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
399
403
  { db, issuer: ISSUER },
400
404
  );
401
- expect(resp.status).toBe(400);
402
- const body = (await resp.json()) as { error: string; error_description: string };
403
- expect(body.error).toBe("invalid_scope");
404
- expect(body.error_description).toContain("vault:work:admin");
405
+ expect(resp.status).toBe(200);
406
+ const body = (await resp.json()) as { scope: string; token: string };
407
+ expect(body.scope).toBe("vault:work:admin");
408
+ const validated = await validateAccessToken(db, body.token, ISSUER);
409
+ expect(validated.payload.aud).toBe("vault.work");
410
+ expect(validated.payload.scope).toBe("vault:work:admin");
405
411
  } finally {
406
412
  db.close();
407
413
  }
@@ -765,7 +771,10 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
765
771
  }
766
772
  });
767
773
 
768
- test("host:auth-only bearer mints vault:work:admin → 400 (rule 1 doesn't cover admin)", async () => {
774
+ test("host:auth-only bearer mints vault:work:admin → 200 (single-consent: rule 1 now covers admin)", async () => {
775
+ // Single-consent change (2026-05-29): vault:<name>:admin is requestable
776
+ // now, so canGrant rule 1 admits it for a host:auth bearer. De-escalation
777
+ // from an on-box operator credential — intentional widening.
769
778
  const h = makeHarness();
770
779
  try {
771
780
  const { db, userId } = await bootstrap(h.dir);
@@ -775,9 +784,9 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
775
784
  jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
776
785
  { db, issuer: ISSUER },
777
786
  );
778
- expect(resp.status).toBe(400);
779
- const body = (await resp.json()) as { error: string };
780
- expect(body.error).toBe("invalid_scope");
787
+ expect(resp.status).toBe(200);
788
+ const body = (await resp.json()) as { scope: string };
789
+ expect(body.scope).toBe("vault:work:admin");
781
790
  } finally {
782
791
  db.close();
783
792
  }
@@ -993,10 +1002,12 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
993
1002
  }
994
1003
  });
995
1004
 
996
- // The existing non-requestable behaviour is unchanged: a host:auth-only
997
- // bearer minting the well-formed `vault:work:admin` is still 400 (rule 1
998
- // doesn't cover admin) this path is reached AFTER the shape guard passes.
999
- test("host:auth-only bearer minting vault:work:admin 400 (non-requestable, unchanged)", async () => {
1005
+ // Contrast with the malformed forms above: a WELL-FORMED `vault:work:admin`
1006
+ // clears the shape guard, and (single-consent change, 2026-05-29) now mints
1007
+ // 200 via canGrant rule 1 for a host:auth bearer. The malformed forms are
1008
+ // rejected by the shape guard BEFORE canGrant; this one passes the guard
1009
+ // and is admitted.
1010
+ test("host:auth-only bearer minting well-formed vault:work:admin → 200 (clears shape guard, mints)", async () => {
1000
1011
  const h = makeHarness();
1001
1012
  try {
1002
1013
  const { db, userId } = await bootstrap(h.dir);
@@ -1006,11 +1017,9 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
1006
1017
  jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
1007
1018
  { db, issuer: ISSUER },
1008
1019
  );
1009
- expect(resp.status).toBe(400);
1010
- const body = (await resp.json()) as { error: string; error_description: string };
1011
- expect(body.error).toBe("invalid_scope");
1012
- // Not the malformed-shape message — it cleared the shape guard.
1013
- expect(body.error_description).toContain("not grantable");
1020
+ expect(resp.status).toBe(200);
1021
+ const body = (await resp.json()) as { scope: string };
1022
+ expect(body.scope).toBe("vault:work:admin");
1014
1023
  } finally {
1015
1024
  db.close();
1016
1025
  }
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { MissingDependencyError, lookupDep } from "@openparachute/depcheck";
5
6
  import {
6
7
  API_MODULES_OPS_REQUIRED_SCOPE,
7
8
  _resetOperationsRegistryForTests,
@@ -629,6 +630,50 @@ describe("POST /api/modules/:short/install", () => {
629
630
  expect(op.error).toMatch(/bun add -g exited 1/);
630
631
  });
631
632
 
633
+ test("a MissingDependencyError during install attaches the structured error_detail wire", async () => {
634
+ const { supervisor } = makeIdleSupervisor();
635
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
636
+ const deps = {
637
+ db: h.db,
638
+ issuer: ISSUER,
639
+ manifestPath: h.manifestPath,
640
+ configDir: h.dir,
641
+ supervisor,
642
+ // Simulate `bun` not being on PATH: the install runner's shell-out
643
+ // throws the typed missing-dependency error.
644
+ run: async () => {
645
+ throw new MissingDependencyError("bun", lookupDep("bun"), {
646
+ platform: "linux",
647
+ arch: "x64",
648
+ });
649
+ },
650
+ findGlobalInstall: () => null,
651
+ isLinked: TEST_DEFAULT_NOT_LINKED,
652
+ };
653
+ const res = await handleInstall(
654
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
655
+ "vault",
656
+ deps,
657
+ );
658
+ const body = (await res.json()) as { operation_id: string };
659
+ await new Promise((r) => setTimeout(r, 10));
660
+ const opRes = await handleOperationGet(
661
+ getReq(`/api/modules/operations/${body.operation_id}`, {
662
+ authorization: `Bearer ${bearer}`,
663
+ }),
664
+ body.operation_id,
665
+ deps,
666
+ );
667
+ const op = (await opRes.json()) as {
668
+ status: string;
669
+ error?: string;
670
+ error_detail?: { error_type: string; binary: string };
671
+ };
672
+ expect(op.status).toBe("failed");
673
+ expect(op.error_detail?.error_type).toBe("missing_dependency");
674
+ expect(op.error_detail?.binary).toBe("bun");
675
+ });
676
+
632
677
  test("skips bun add -g when package is already bun-linked (smoke 2026-05-27 finding 1)", async () => {
633
678
  // Smoke finding 1: the wizard's parallel install path was unconditionally
634
679
  // invoking `bun add -g <pkg>` even when the package was already linked
@@ -462,7 +462,7 @@ describe("handleDeleteUser", () => {
462
462
  expect(list.users.map((u) => u.id)).toContain(userId);
463
463
  });
464
464
 
465
- test("204 deletes a non-first user and revokes their tokens", async () => {
465
+ test("200 + revocation_lag_seconds deletes a non-first user and revokes their tokens", async () => {
466
466
  const { bearer } = await makeAdminBearer();
467
467
  // Create a second user (non-first) + mint a token on their behalf.
468
468
  const second = await createUser(harness.db, "alice", "alice-strong-passphrase", {
@@ -497,7 +497,12 @@ describe("handleDeleteUser", () => {
497
497
  second.id,
498
498
  deps(),
499
499
  );
500
- expect(res.status).toBe(204);
500
+ // 200 + body (was a bare 204) so the SPA can warn about revocation lag —
501
+ // consistency with the reset-password path.
502
+ expect(res.status).toBe(200);
503
+ const body = (await res.json()) as { ok: boolean; revocation_lag_seconds: number };
504
+ expect(body.ok).toBe(true);
505
+ expect(body.revocation_lag_seconds).toBe(60);
501
506
 
502
507
  // User row is gone.
503
508
  const listRes = await handleListUsers(withBearer("/api/users", bearer), deps());