@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  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 +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. 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 {