@openparachute/hub 0.5.14-rc.15 → 0.5.14-rc.16

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.14-rc.15",
3
+ "version": "0.5.14-rc.16",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -262,4 +262,138 @@ describe("renderAccountHome", () => {
262
262
  // The escaped vault name also flows into the connect command + endpoint.
263
263
  expect(html).toContain("parachute-<vault>");
264
264
  });
265
+
266
+ // --- friend vault-token mint affordance (the new surface) ----------------
267
+
268
+ test("mint affordance — read+write tile offers both verbs, POSTs to the right path", () => {
269
+ const html = renderAccountHome({
270
+ username: "alice",
271
+ assignedVaults: ["work"],
272
+ passwordChanged: true,
273
+ hubOrigin: HUB_ORIGIN,
274
+ isFirstAdmin: false,
275
+ csrfToken: CSRF,
276
+ twoFactorEnabled: false,
277
+ mintableVerbs: { work: ["read", "write"] },
278
+ });
279
+ // The collapsible mint block is present, framed as secondary (headless).
280
+ expect(html).toContain('data-testid="token-mint"');
281
+ expect(html).toContain("Mint an access token");
282
+ expect(html).toContain("for scripts / headless clients");
283
+ // Both verb radios render.
284
+ expect(html).toContain('data-testid="mint-verb-read"');
285
+ expect(html).toContain('data-testid="mint-verb-write"');
286
+ // Form POSTs to the per-vault endpoint with the CSRF token embedded.
287
+ expect(html).toContain('action="/account/vault-token/work"');
288
+ expect(html).toContain('method="POST"');
289
+ expect(html).toContain('data-testid="mint-form"');
290
+ expect(html).toContain(CSRF);
291
+ // Recommends the no-token path as default.
292
+ expect(html).toContain("no-token");
293
+ });
294
+
295
+ test("mint affordance — a read-only role offers ONLY the read verb", () => {
296
+ // Today every assignment is write-role, but the renderer is verb-blind to
297
+ // the role: it shows exactly the verbs it's handed. A read-only cap must
298
+ // never surface a write radio (the server would reject it anyway).
299
+ const html = renderAccountHome({
300
+ username: "alice",
301
+ assignedVaults: ["work"],
302
+ passwordChanged: true,
303
+ hubOrigin: HUB_ORIGIN,
304
+ isFirstAdmin: false,
305
+ csrfToken: CSRF,
306
+ twoFactorEnabled: false,
307
+ mintableVerbs: { work: ["read"] },
308
+ });
309
+ expect(html).toContain('data-testid="mint-verb-read"');
310
+ expect(html).not.toContain('data-testid="mint-verb-write"');
311
+ });
312
+
313
+ test("mint affordance — never offers an admin verb", () => {
314
+ const html = renderAccountHome({
315
+ username: "alice",
316
+ assignedVaults: ["work"],
317
+ passwordChanged: true,
318
+ hubOrigin: HUB_ORIGIN,
319
+ isFirstAdmin: false,
320
+ csrfToken: CSRF,
321
+ twoFactorEnabled: false,
322
+ mintableVerbs: { work: ["read", "write"] },
323
+ });
324
+ expect(html).not.toContain('value="admin"');
325
+ expect(html).not.toContain('data-testid="mint-verb-admin"');
326
+ });
327
+
328
+ test("mint affordance — absent when no mintable verbs (admin / no-vault / unmapped role)", () => {
329
+ // Admin branch: no tiles at all, so no mint block.
330
+ const admin = renderAccountHome({
331
+ username: "admin",
332
+ assignedVaults: [],
333
+ passwordChanged: true,
334
+ hubOrigin: HUB_ORIGIN,
335
+ isFirstAdmin: true,
336
+ csrfToken: CSRF,
337
+ twoFactorEnabled: false,
338
+ });
339
+ expect(admin).not.toContain('data-testid="token-mint"');
340
+ // Assigned vault but empty verb list (fail-closed unknown role) → no block.
341
+ const empty = renderAccountHome({
342
+ username: "alice",
343
+ assignedVaults: ["work"],
344
+ passwordChanged: true,
345
+ hubOrigin: HUB_ORIGIN,
346
+ isFirstAdmin: false,
347
+ csrfToken: CSRF,
348
+ twoFactorEnabled: false,
349
+ mintableVerbs: { work: [] },
350
+ });
351
+ expect(empty).not.toContain('data-testid="token-mint"');
352
+ });
353
+
354
+ test("minted-token banner — shows the token once with a save-it warning, no revoke claim", () => {
355
+ const html = renderAccountHome({
356
+ username: "alice",
357
+ assignedVaults: ["work"],
358
+ passwordChanged: true,
359
+ hubOrigin: HUB_ORIGIN,
360
+ isFirstAdmin: false,
361
+ csrfToken: CSRF,
362
+ twoFactorEnabled: false,
363
+ mintableVerbs: { work: ["read", "write"] },
364
+ mintedToken: {
365
+ vaultName: "work",
366
+ verb: "read",
367
+ token: "eyJhbGciOi.FAKE.TOKEN",
368
+ expiresInDays: 90,
369
+ },
370
+ });
371
+ expect(html).toContain('data-testid="minted-token-banner"');
372
+ expect(html).toContain("eyJhbGciOi.FAKE.TOKEN");
373
+ expect(html).toContain('data-testid="copy-minted-token"');
374
+ // Explicit "won't be shown again" + the scope + the TTL.
375
+ expect(html).toContain("won't be shown again");
376
+ expect(html).toContain("vault:work:read");
377
+ expect(html).toContain("90 days");
378
+ // No false revoke-yourself promise (no friend-facing revoke today).
379
+ expect(html).toContain("ask the hub operator");
380
+ });
381
+
382
+ test("mint error banner — surfaces an inline authorization error", () => {
383
+ const html = renderAccountHome({
384
+ username: "alice",
385
+ assignedVaults: ["work"],
386
+ passwordChanged: true,
387
+ hubOrigin: HUB_ORIGIN,
388
+ isFirstAdmin: false,
389
+ csrfToken: CSRF,
390
+ twoFactorEnabled: false,
391
+ mintableVerbs: { work: ["read", "write"] },
392
+ mintError: 'You\'re not assigned to a vault named "other".',
393
+ });
394
+ expect(html).toContain('data-testid="mint-error-banner"');
395
+ expect(html).toContain("not assigned");
396
+ // Error render must NOT also show a token.
397
+ expect(html).not.toContain('data-testid="minted-token-banner"');
398
+ });
265
399
  });
@@ -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
+ });
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
6
7
  import { HUB_SVC, hubPortPath } from "../hub-control.ts";
7
8
  import { hubDbPath, openHubDb } from "../hub-db.ts";
8
9
  import {
@@ -16,6 +17,7 @@ import { setNotesRedirectDisabled } from "../hub-settings.ts";
16
17
  import { clearNotesRedirectLogState } from "../notes-redirect.ts";
17
18
  import { pidPath } from "../process-state.ts";
18
19
  import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
20
+ import { buildSessionCookie, createSession } from "../sessions.ts";
19
21
  import { rotateSigningKey } from "../signing-keys.ts";
20
22
  import type { ModuleState, Supervisor } from "../supervisor.ts";
21
23
  import { createUser } from "../users.ts";
@@ -4171,3 +4173,98 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
4171
4173
  }
4172
4174
  });
4173
4175
  });
4176
+
4177
+ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to-end)", () => {
4178
+ // Drive the real dispatch (`hubFetch`) so the route wiring + precedence
4179
+ // (the `/account/vault-token/` prefix must win over `/account/` and the
4180
+ // SPA catch-all) is exercised, not just the handler in isolation.
4181
+ async function seed(h: Harness, assignedVaults: string[]) {
4182
+ const db = openHubDb(hubDbPath(h.dir));
4183
+ rotateSigningKey(db); // mint needs an active signing key
4184
+ await createUser(db, "operator", "operator-password-123");
4185
+ const friend = await createUser(db, "friend", "friend-password-123", {
4186
+ assignedVaults,
4187
+ allowMulti: true,
4188
+ });
4189
+ const session = createSession(db, { userId: friend.id });
4190
+ const csrf = generateCsrfToken();
4191
+ const cookie = `${buildSessionCookie(session.id, 3600, { secure: false })}; ${
4192
+ buildCsrfCookie(csrf, { secure: false }).split(";")[0]
4193
+ }`;
4194
+ return { db, friendId: friend.id, cookie, csrf };
4195
+ }
4196
+
4197
+ function postBody(csrf: string, verb: string): string {
4198
+ const b = new URLSearchParams();
4199
+ b.set("__csrf", csrf);
4200
+ b.set("verb", verb);
4201
+ return b.toString();
4202
+ }
4203
+
4204
+ test("assigned vault → 200 with a token banner, routed through hubFetch", async () => {
4205
+ const h = makeHarness();
4206
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4207
+ const { db, cookie, csrf } = await seed(h, ["work"]);
4208
+ try {
4209
+ const res = await hubFetch(h.dir, {
4210
+ getDb: () => db,
4211
+ manifestPath: h.manifestPath,
4212
+ issuer: "https://hub.test",
4213
+ })(
4214
+ req("/account/vault-token/work", {
4215
+ method: "POST",
4216
+ headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
4217
+ body: postBody(csrf, "read"),
4218
+ }),
4219
+ );
4220
+ expect(res.status).toBe(200);
4221
+ const html = await res.text();
4222
+ expect(html).toContain('data-testid="minted-token-banner"');
4223
+ expect(html).toContain("vault:work:read");
4224
+ } finally {
4225
+ db.close();
4226
+ h.cleanup();
4227
+ }
4228
+ });
4229
+
4230
+ test("unassigned vault → 403, no token, routed through hubFetch", async () => {
4231
+ const h = makeHarness();
4232
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4233
+ const { db, cookie, csrf } = await seed(h, ["work"]);
4234
+ try {
4235
+ const res = await hubFetch(h.dir, {
4236
+ getDb: () => db,
4237
+ manifestPath: h.manifestPath,
4238
+ issuer: "https://hub.test",
4239
+ })(
4240
+ req("/account/vault-token/other", {
4241
+ method: "POST",
4242
+ headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
4243
+ body: postBody(csrf, "read"),
4244
+ }),
4245
+ );
4246
+ expect(res.status).toBe(403);
4247
+ const html = await res.text();
4248
+ expect(html).not.toContain('data-testid="minted-token-banner"');
4249
+ } finally {
4250
+ db.close();
4251
+ h.cleanup();
4252
+ }
4253
+ });
4254
+
4255
+ test("GET on the mint path → 405 (POST-only)", async () => {
4256
+ const h = makeHarness();
4257
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4258
+ const { db } = await seed(h, ["work"]);
4259
+ try {
4260
+ const res = await hubFetch(h.dir, {
4261
+ getDb: () => db,
4262
+ manifestPath: h.manifestPath,
4263
+ })(req("/account/vault-token/work"));
4264
+ expect(res.status).toBe(405);
4265
+ } finally {
4266
+ db.close();
4267
+ h.cleanup();
4268
+ }
4269
+ });
4270
+ });