@openparachute/hub 0.5.14-rc.14 → 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 +1 -1
- package/src/__tests__/account-home-ui.test.ts +157 -1
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/hub.test.ts +74 -6
- package/src/__tests__/users.test.ts +68 -0
- package/src/account-home-ui.ts +356 -49
- package/src/account-vault-token.ts +282 -0
- package/src/api-account.ts +12 -0
- package/src/hub-server.ts +22 -0
- package/src/hub.ts +72 -11
- package/src/rate-limit.ts +29 -0
- package/src/users.ts +58 -0
package/package.json
CHANGED
|
@@ -57,6 +57,23 @@ describe("renderAccountHome", () => {
|
|
|
57
57
|
expect(html).not.toContain("Authorization: Bearer");
|
|
58
58
|
// Copy-button progressive-enhancement script is present.
|
|
59
59
|
expect(html).toContain("navigator.clipboard");
|
|
60
|
+
// Friendlier framing: the block leads with "connect your AI assistant"
|
|
61
|
+
// rather than MCP jargon up top.
|
|
62
|
+
expect(html).toContain('data-testid="connect-ai-heading"');
|
|
63
|
+
expect(html).toContain("Connect your AI");
|
|
64
|
+
// BOTH connect methods render as distinct, labelled blocks.
|
|
65
|
+
expect(html).toContain('data-testid="connect-method-claude-code"');
|
|
66
|
+
expect(html).toContain("Claude Code");
|
|
67
|
+
expect(html).toContain('data-testid="connect-method-claude-ai"');
|
|
68
|
+
expect(html).toContain("Claude.ai");
|
|
69
|
+
// The Claude.ai path mirrors the install.njk canonical phrasing
|
|
70
|
+
// (Settings → Connectors → Add custom connector, paste the endpoint).
|
|
71
|
+
expect(html).toContain("Connectors");
|
|
72
|
+
expect(html).toContain("Add custom connector");
|
|
73
|
+
// A brief "any other MCP client" line is present (no bloat — just one).
|
|
74
|
+
expect(html).toContain('data-testid="connect-any-client-hint"');
|
|
75
|
+
// Notes CTA still present, now framed as the browser-UI option.
|
|
76
|
+
expect(html).toContain('data-testid="open-notes-cta"');
|
|
60
77
|
});
|
|
61
78
|
|
|
62
79
|
test("assigned-vault branch — trailing slash on hubOrigin is normalized", () => {
|
|
@@ -112,11 +129,16 @@ describe("renderAccountHome", () => {
|
|
|
112
129
|
twoFactorEnabled: false,
|
|
113
130
|
});
|
|
114
131
|
expect(html).toContain("Welcome, ghost");
|
|
115
|
-
|
|
132
|
+
// The message explains WHY there's nothing to connect (no vault yet) and
|
|
133
|
+
// gives a clear next step — not just a bare "ask your admin".
|
|
134
|
+
expect(html).toContain("Ask the hub operator to assign you a vault");
|
|
135
|
+
expect(html).toContain("don't have a vault yet");
|
|
116
136
|
// No /admin/ link in this branch — they have no admin role.
|
|
117
137
|
expect(html).not.toContain('href="/admin/"');
|
|
118
138
|
// No Notes CTA.
|
|
119
139
|
expect(html).not.toContain("notes.parachute.computer/add");
|
|
140
|
+
// No connect block — you can't connect a vault you don't have.
|
|
141
|
+
expect(html).not.toContain('data-testid="mcp-connect"');
|
|
120
142
|
});
|
|
121
143
|
|
|
122
144
|
test("account card — change-password link and sign-out form are present", () => {
|
|
@@ -240,4 +262,138 @@ describe("renderAccountHome", () => {
|
|
|
240
262
|
// The escaped vault name also flows into the connect command + endpoint.
|
|
241
263
|
expect(html).toContain("parachute-<vault>");
|
|
242
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
|
+
});
|
|
243
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
|
+
});
|