@openparachute/hub 0.6.3 → 0.6.4-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +609 -0
  3. package/src/__tests__/account-usage.test.ts +137 -0
  4. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  5. package/src/__tests__/account-vault-token.test.ts +53 -1
  6. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  7. package/src/__tests__/admin-vaults.test.ts +20 -0
  8. package/src/__tests__/api-account.test.ts +125 -4
  9. package/src/__tests__/api-invites.test.ts +180 -0
  10. package/src/__tests__/api-mint-token.test.ts +259 -10
  11. package/src/__tests__/api-modules-ops.test.ts +187 -1
  12. package/src/__tests__/api-modules.test.ts +40 -4
  13. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  14. package/src/__tests__/auto-wire.test.ts +101 -1
  15. package/src/__tests__/cli.test.ts +188 -2
  16. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  17. package/src/__tests__/expose-cloudflare.test.ts +5 -4
  18. package/src/__tests__/expose.test.ts +10 -5
  19. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  20. package/src/__tests__/hub-server.test.ts +628 -13
  21. package/src/__tests__/hub-unit.test.ts +4 -0
  22. package/src/__tests__/invites.test.ts +220 -0
  23. package/src/__tests__/launchctl-guard.test.ts +185 -0
  24. package/src/__tests__/migrate-cutover.test.ts +32 -0
  25. package/src/__tests__/module-ops-client.test.ts +68 -0
  26. package/src/__tests__/scope-explanations.test.ts +16 -0
  27. package/src/__tests__/serve-boot.test.ts +74 -1
  28. package/src/__tests__/serve.test.ts +121 -7
  29. package/src/__tests__/spawn-path.test.ts +191 -0
  30. package/src/__tests__/status.test.ts +64 -0
  31. package/src/__tests__/supervisor.test.ts +177 -0
  32. package/src/__tests__/users.test.ts +27 -0
  33. package/src/account-home-ui.ts +82 -9
  34. package/src/account-setup.ts +342 -0
  35. package/src/account-usage.ts +118 -0
  36. package/src/account-vault-admin-token.ts +242 -0
  37. package/src/account-vault-token.ts +27 -2
  38. package/src/admin-login-ui.ts +94 -0
  39. package/src/admin-vault-admin-token.ts +8 -2
  40. package/src/admin-vaults.ts +137 -29
  41. package/src/api-account.ts +54 -1
  42. package/src/api-invites.ts +347 -0
  43. package/src/api-mint-token.ts +81 -0
  44. package/src/api-modules-ops.ts +168 -53
  45. package/src/api-modules.ts +36 -0
  46. package/src/auto-wire.ts +87 -0
  47. package/src/cli.ts +122 -32
  48. package/src/commands/expose-2fa-warning.ts +17 -13
  49. package/src/commands/migrate-cutover.ts +12 -5
  50. package/src/commands/serve-boot.ts +33 -3
  51. package/src/commands/serve.ts +158 -37
  52. package/src/commands/status.ts +9 -1
  53. package/src/hub-db.ts +70 -2
  54. package/src/hub-server.ts +399 -41
  55. package/src/hub-unit.ts +4 -9
  56. package/src/invites.ts +291 -0
  57. package/src/launchctl-guard.ts +131 -0
  58. package/src/managed-unit.ts +13 -3
  59. package/src/migrate-offer.ts +15 -6
  60. package/src/module-ops-client.ts +47 -22
  61. package/src/scope-attenuation.ts +19 -0
  62. package/src/scope-explanations.ts +9 -1
  63. package/src/service-spec.ts +8 -3
  64. package/src/spawn-path.ts +148 -0
  65. package/src/supervisor.ts +84 -7
  66. package/src/users.ts +42 -4
  67. package/src/vault-hub-origin-env.ts +28 -0
  68. package/src/vault-name.ts +13 -1
  69. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  70. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Unit tests for the `/account/` per-vault usage fetch + formatting
3
+ * (`account-usage.ts`). The fetch mints a read token + hits the vault's loopback
4
+ * `/.parachute/usage` endpoint; it must be fault-tolerant (any failure → null)
5
+ * and shape-strict (a malformed body → null, not a render of `undefined`).
6
+ */
7
+ import type { Database } from "bun:sqlite";
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import {
13
+ type VaultUsageStat,
14
+ fetchVaultUsage,
15
+ formatBytes,
16
+ formatUsageStat,
17
+ } from "../account-usage.ts";
18
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
19
+
20
+ interface Harness {
21
+ db: Database;
22
+ cleanup: () => void;
23
+ }
24
+
25
+ function makeHarness(): Harness {
26
+ const dir = mkdtempSync(join(tmpdir(), "phub-account-usage-"));
27
+ const db = openHubDb(hubDbPath(dir));
28
+ return {
29
+ db,
30
+ cleanup: () => {
31
+ db.close();
32
+ rmSync(dir, { recursive: true, force: true });
33
+ },
34
+ };
35
+ }
36
+
37
+ let harness: Harness;
38
+ beforeEach(() => {
39
+ harness = makeHarness();
40
+ });
41
+ afterEach(() => {
42
+ harness.cleanup();
43
+ });
44
+
45
+ /** A stub signer — no real key needed; the fetch only carries the token string. */
46
+ const stubSign = async () => ({
47
+ token: "stub.jwt.token",
48
+ jti: "jti-1",
49
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
50
+ });
51
+
52
+ function baseDeps(fetchImpl: typeof fetch) {
53
+ return {
54
+ db: harness.db,
55
+ hubOrigin: "https://hub.test",
56
+ vaultPort: 1940,
57
+ userId: "user-1",
58
+ fetchImpl,
59
+ signToken: stubSign as never,
60
+ };
61
+ }
62
+
63
+ describe("fetchVaultUsage", () => {
64
+ test("returns the stat on a well-formed usage report", async () => {
65
+ const fetchImpl = (async () =>
66
+ new Response(
67
+ JSON.stringify({
68
+ counts: { notes: 42, attachments: 3, links: 5, tags: 2 },
69
+ bytes: { content: 1000, db: 2048, assets: 4096, total: 6144 },
70
+ computedAt: new Date().toISOString(),
71
+ cached: false,
72
+ }),
73
+ { status: 200, headers: { "content-type": "application/json" } },
74
+ )) as unknown as typeof fetch;
75
+ const stat = await fetchVaultUsage("work", baseDeps(fetchImpl));
76
+ expect(stat).toEqual({ notes: 42, totalBytes: 6144 });
77
+ });
78
+
79
+ test("calls the vault's loopback usage endpoint with a Bearer", async () => {
80
+ let seenUrl = "";
81
+ let seenAuth = "";
82
+ const fetchImpl = (async (url: string, init?: RequestInit) => {
83
+ seenUrl = url;
84
+ seenAuth = (init?.headers as Record<string, string>)?.authorization ?? "";
85
+ return new Response(JSON.stringify({ counts: { notes: 1 }, bytes: { total: 10 } }), {
86
+ status: 200,
87
+ });
88
+ }) as unknown as typeof fetch;
89
+ await fetchVaultUsage("work", baseDeps(fetchImpl));
90
+ expect(seenUrl).toBe("http://127.0.0.1:1940/vault/work/.parachute/usage");
91
+ expect(seenAuth).toBe("Bearer stub.jwt.token");
92
+ });
93
+
94
+ test("returns null on a non-2xx response (vault down / 403 / 404)", async () => {
95
+ for (const status of [403, 404, 500]) {
96
+ const fetchImpl = (async () => new Response("nope", { status })) as unknown as typeof fetch;
97
+ const stat = await fetchVaultUsage("work", baseDeps(fetchImpl));
98
+ expect(stat).toBeNull();
99
+ }
100
+ });
101
+
102
+ test("returns null when the body is malformed (missing counts/bytes)", async () => {
103
+ const fetchImpl = (async () =>
104
+ new Response(JSON.stringify({ counts: {}, bytes: {} }), {
105
+ status: 200,
106
+ })) as unknown as typeof fetch;
107
+ const stat = await fetchVaultUsage("work", baseDeps(fetchImpl));
108
+ expect(stat).toBeNull();
109
+ });
110
+
111
+ test("returns null when fetch throws (network error)", async () => {
112
+ const fetchImpl = (async () => {
113
+ throw new Error("ECONNREFUSED");
114
+ }) as unknown as typeof fetch;
115
+ const stat = await fetchVaultUsage("work", baseDeps(fetchImpl));
116
+ expect(stat).toBeNull();
117
+ });
118
+ });
119
+
120
+ describe("formatUsageStat / formatBytes", () => {
121
+ test("pluralizes notes + renders the byte size", () => {
122
+ expect(formatUsageStat({ notes: 1, totalBytes: 1024 } as VaultUsageStat)).toBe("1 note · 1 KB");
123
+ expect(formatUsageStat({ notes: 42, totalBytes: 6 * 1024 * 1024 } as VaultUsageStat)).toBe(
124
+ "42 notes · 6.0 MB",
125
+ );
126
+ expect(formatUsageStat({ notes: 0, totalBytes: 0 } as VaultUsageStat)).toBe("0 notes · 0 B");
127
+ });
128
+
129
+ test("formatBytes picks the largest sensible unit", () => {
130
+ expect(formatBytes(0)).toBe("0 B");
131
+ expect(formatBytes(512)).toBe("512 B");
132
+ expect(formatBytes(2048)).toBe("2 KB");
133
+ expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
134
+ expect(formatBytes(3 * 1024 * 1024 * 1024)).toBe("3.0 GB");
135
+ expect(formatBytes(-5)).toBe("0 B");
136
+ });
137
+ });
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Security tests for the friend-facing vault-ADMIN deep-link mint —
3
+ * `POST /account/vault-admin-token/<name>` (`handleAccountVaultAdminTokenPost`).
4
+ *
5
+ * The non-admin sibling of `/admin/vault-admin-token/<name>` (which is
6
+ * first-admin-gated). This one is gated on ASSIGNMENT: an assigned user holds
7
+ * `admin` on their vault (2026-05-30) and may bootstrap the vault's own admin
8
+ * SPA — token rotation + Git backup config. Authorization is tested
9
+ * adversarially. The spine:
10
+ * - No session → 401.
11
+ * - Assigned vault → 303 → <vault-url><managementUrl>#token=<jwt>,
12
+ * token carries `vault:<name>:admin`,
13
+ * `aud=vault.<name>`, `iss=<hub>`, sub=user.
14
+ * - UNassigned vault → 403 (cross-vault blocked).
15
+ * - First admin → 403 (no user_vaults rows → uses SPA path).
16
+ * - Unrotated user (item F) → 303 → /account/change-password, NO mint
17
+ * (does not reintroduce the #469 bypass).
18
+ * - CSRF missing/mismatch → 400.
19
+ * - Invalid vault name → 400.
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 { handleAccountVaultAdminTokenPost } from "../account-vault-admin-token.ts";
27
+ import { VAULT_ADMIN_TOKEN_TTL_SECONDS } from "../admin-vault-admin-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 { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
32
+ import { createUser } from "../users.ts";
33
+
34
+ const ISSUER = "https://hub.test";
35
+
36
+ interface Harness {
37
+ db: Database;
38
+ cleanup: () => void;
39
+ }
40
+
41
+ function makeHarness(): Harness {
42
+ const dir = mkdtempSync(join(tmpdir(), "phub-account-vault-admin-token-"));
43
+ const db = openHubDb(hubDbPath(dir));
44
+ return {
45
+ db,
46
+ cleanup: () => {
47
+ db.close();
48
+ rmSync(dir, { recursive: true, force: true });
49
+ },
50
+ };
51
+ }
52
+
53
+ let harness: Harness;
54
+ beforeEach(() => {
55
+ harness = makeHarness();
56
+ });
57
+ afterEach(() => {
58
+ harness.cleanup();
59
+ });
60
+
61
+ const deps = (extra: { managementUrl?: string } = {}) => ({
62
+ db: harness.db,
63
+ hubOrigin: ISSUER,
64
+ ...extra,
65
+ });
66
+
67
+ /** A shared CSRF token + matching cookie value for the double-submit handshake. */
68
+ function csrfPair(): { token: string; cookieFragment: string } {
69
+ const token = generateCsrfToken();
70
+ const cookie = buildCsrfCookie(token, { secure: false }).split(";")[0] ?? "";
71
+ return { token, cookieFragment: cookie };
72
+ }
73
+
74
+ /**
75
+ * First-admin operator + a friend assigned to `vaults`. `passwordChanged`
76
+ * defaults true (the precondition for minting now — item F gates an unrotated
77
+ * friend first). Pass `passwordChanged: false` to exercise the force-change gate.
78
+ */
79
+ async function seedFriend(
80
+ vaults: string[],
81
+ opts: { passwordChanged?: boolean } = {},
82
+ ): Promise<{ friendId: string; cookie: string; csrfToken: string }> {
83
+ await createUser(harness.db, "operator", "operator-password-123");
84
+ const friend = await createUser(harness.db, "friend", "friend-password-123", {
85
+ assignedVaults: vaults,
86
+ allowMulti: true,
87
+ passwordChanged: opts.passwordChanged ?? true,
88
+ });
89
+ const session = createSession(harness.db, { userId: friend.id });
90
+ const { token, cookieFragment } = csrfPair();
91
+ const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
92
+ const cookie = `${sessionCookie}; ${cookieFragment}`;
93
+ return { friendId: friend.id, cookie, csrfToken: token };
94
+ }
95
+
96
+ function mintReq(
97
+ vaultName: string,
98
+ opts: { cookie?: string; csrfToken?: string; omitCsrf?: boolean; method?: string } = {},
99
+ ): Request {
100
+ const body = new URLSearchParams();
101
+ if (!opts.omitCsrf && opts.csrfToken !== undefined) body.set(CSRF_FIELD_NAME, opts.csrfToken);
102
+ const headers: Record<string, string> = {
103
+ "content-type": "application/x-www-form-urlencoded",
104
+ };
105
+ if (opts.cookie) headers.cookie = opts.cookie;
106
+ return new Request(`${ISSUER}/account/vault-admin-token/${encodeURIComponent(vaultName)}`, {
107
+ method: opts.method ?? "POST",
108
+ headers,
109
+ body: body.toString(),
110
+ });
111
+ }
112
+
113
+ /** Pull the `#token=<jwt>` out of a 303 Location fragment. */
114
+ function tokenFromLocation(location: string): string {
115
+ const m = location.match(/[#&]token=([^&]+)$/);
116
+ expect(m).not.toBeNull();
117
+ return m![1] as string;
118
+ }
119
+
120
+ describe("handleAccountVaultAdminTokenPost — happy path (assigned vault)", () => {
121
+ test("303 → vault admin SPA with #token carrying vault:<name>:admin", async () => {
122
+ const { friendId, cookie, csrfToken } = await seedFriend(["work"]);
123
+ const res = await handleAccountVaultAdminTokenPost(
124
+ mintReq("work", { cookie, csrfToken }),
125
+ "work",
126
+ deps(),
127
+ );
128
+ expect(res.status).toBe(303);
129
+ expect(res.headers.get("cache-control")).toBe("no-store");
130
+ const location = res.headers.get("location") ?? "";
131
+ // Default managementUrl is /admin/ → lands on the vault admin SPA home.
132
+ expect(location.startsWith(`${ISSUER}/vault/work/admin/#token=`)).toBe(true);
133
+
134
+ const token = tokenFromLocation(location);
135
+ const validated = await validateAccessToken(harness.db, token, ISSUER);
136
+ expect(validated.payload.sub).toBe(friendId);
137
+ expect(validated.payload.iss).toBe(ISSUER);
138
+ expect(validated.payload.aud).toBe("vault.work");
139
+ const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
140
+ expect(scopeClaim.split(/\s+/)).toEqual(["vault:work:admin"]);
141
+ expect((validated.payload as { vault_scope?: string[] }).vault_scope).toEqual(["work"]);
142
+
143
+ // Short TTL (10 min, deep-link bootstrap token — not the 90-day headless one).
144
+ const expMs = new Date((validated.payload.exp ?? 0) * 1000).getTime();
145
+ const skew = expMs - Date.now();
146
+ expect(skew).toBeGreaterThan((VAULT_ADMIN_TOKEN_TTL_SECONDS - 60) * 1000);
147
+ expect(skew).toBeLessThan((VAULT_ADMIN_TOKEN_TTL_SECONDS + 60) * 1000);
148
+
149
+ // A revocable registry row was written for the friend.
150
+ const rows = harness.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM tokens").get();
151
+ expect(rows?.n).toBe(1);
152
+ });
153
+
154
+ test("honors a vault-declared managementUrl (e.g. a custom admin path)", async () => {
155
+ const { cookie, csrfToken } = await seedFriend(["work"]);
156
+ const res = await handleAccountVaultAdminTokenPost(
157
+ mintReq("work", { cookie, csrfToken }),
158
+ "work",
159
+ deps({ managementUrl: "/manage/" }),
160
+ );
161
+ expect(res.status).toBe(303);
162
+ const location = res.headers.get("location") ?? "";
163
+ expect(location.startsWith(`${ISSUER}/vault/work/manage/#token=`)).toBe(true);
164
+ });
165
+
166
+ test("a friend assigned to multiple vaults can deep-link each, never cross-vault", async () => {
167
+ const { cookie, csrfToken } = await seedFriend(["work", "home"]);
168
+ for (const v of ["work", "home"]) {
169
+ const res = await handleAccountVaultAdminTokenPost(
170
+ mintReq(v, { cookie, csrfToken }),
171
+ v,
172
+ deps(),
173
+ );
174
+ expect(res.status).toBe(303);
175
+ const token = tokenFromLocation(res.headers.get("location") ?? "");
176
+ const validated = await validateAccessToken(harness.db, token, ISSUER);
177
+ expect(validated.payload.aud).toBe(`vault.${v}`);
178
+ }
179
+ const res = await handleAccountVaultAdminTokenPost(
180
+ mintReq("secret", { cookie, csrfToken }),
181
+ "secret",
182
+ deps(),
183
+ );
184
+ expect(res.status).toBe(403);
185
+ });
186
+ });
187
+
188
+ describe("handleAccountVaultAdminTokenPost — authorization gates (adversarial)", () => {
189
+ test("401 when no session cookie is present", async () => {
190
+ const { token, cookieFragment } = csrfPair();
191
+ const res = await handleAccountVaultAdminTokenPost(
192
+ mintReq("work", { cookie: cookieFragment, csrfToken: token }),
193
+ "work",
194
+ deps(),
195
+ );
196
+ expect(res.status).toBe(401);
197
+ });
198
+
199
+ test("403 for a vault the friend is NOT assigned to (cross-vault)", async () => {
200
+ const { cookie, csrfToken } = await seedFriend(["work"]);
201
+ const res = await handleAccountVaultAdminTokenPost(
202
+ mintReq("other", { cookie, csrfToken }),
203
+ "other",
204
+ deps(),
205
+ );
206
+ expect(res.status).toBe(403);
207
+ // No token minted.
208
+ const rows = harness.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM tokens").get();
209
+ expect(rows?.n).toBe(0);
210
+ });
211
+
212
+ test("403 — the first admin cannot deep-link here (no user_vaults rows; uses SPA path)", async () => {
213
+ // Mirrors `/admin/vault-admin-token` being friend-blocked: the inverse, this
214
+ // friend surface refuses the unrestricted admin. Admins use the SPA's
215
+ // first-admin-gated /admin/vault-admin-token instead.
216
+ const admin = await createUser(harness.db, "operator", "operator-password-123");
217
+ const session = createSession(harness.db, { userId: admin.id });
218
+ const { token, cookieFragment } = csrfPair();
219
+ const cookie = `${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}; ${cookieFragment}`;
220
+ const res = await handleAccountVaultAdminTokenPost(
221
+ mintReq("work", { cookie, csrfToken: token }),
222
+ "work",
223
+ deps(),
224
+ );
225
+ expect(res.status).toBe(403);
226
+ });
227
+
228
+ test("an invalid vault name is rejected before any mint", async () => {
229
+ const { cookie, csrfToken } = await seedFriend(["work"]);
230
+ for (const name of ["has..dots", "Work", "WORK"]) {
231
+ const res = await handleAccountVaultAdminTokenPost(
232
+ mintReq(name, { cookie, csrfToken }),
233
+ name,
234
+ deps(),
235
+ );
236
+ expect(res.status).toBe(400);
237
+ }
238
+ });
239
+
240
+ // Item F / hub#469 — force-change gate. An assigned user who has NOT rotated
241
+ // the admin-set temp password is redirected to change-password BEFORE minting,
242
+ // so the temp-password handoff can't be parlayed into a vault-admin deep-link.
243
+ // This is the SAME gate /account/vault-token applies (post-#550) — it does NOT
244
+ // reintroduce the bypass #469 closed. Fires AFTER authority (so an unassigned
245
+ // request still 403s) and BEFORE the mint.
246
+ test("authorized but unrotated user → 303 to /account/change-password, NO mint (item F)", async () => {
247
+ const { cookie, csrfToken } = await seedFriend(["work"], { passwordChanged: false });
248
+ const res = await handleAccountVaultAdminTokenPost(
249
+ mintReq("work", { cookie, csrfToken }),
250
+ "work",
251
+ deps(),
252
+ );
253
+ expect(res.status).toBe(303);
254
+ expect(res.headers.get("location")).toBe("/account/change-password");
255
+ // Critically: no token was minted and no deep-link token leaked.
256
+ const rows = harness.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM tokens").get();
257
+ expect(rows?.n).toBe(0);
258
+ });
259
+
260
+ test("an UNASSIGNED unrotated user still 403s (authority precedes the force-change gate)", async () => {
261
+ const { cookie, csrfToken } = await seedFriend(["work"], { passwordChanged: false });
262
+ const res = await handleAccountVaultAdminTokenPost(
263
+ mintReq("other", { cookie, csrfToken }),
264
+ "other",
265
+ deps(),
266
+ );
267
+ expect(res.status).toBe(403);
268
+ });
269
+ });
270
+
271
+ describe("handleAccountVaultAdminTokenPost — CSRF + method", () => {
272
+ test("405 on non-POST", async () => {
273
+ const { cookie } = await seedFriend(["work"]);
274
+ const res = await handleAccountVaultAdminTokenPost(
275
+ mintReq("work", { cookie, method: "GET" }),
276
+ "work",
277
+ deps(),
278
+ );
279
+ expect(res.status).toBe(405);
280
+ });
281
+
282
+ test("400 when the CSRF token is missing", async () => {
283
+ const { cookie } = await seedFriend(["work"]);
284
+ const res = await handleAccountVaultAdminTokenPost(
285
+ mintReq("work", { cookie, omitCsrf: true }),
286
+ "work",
287
+ deps(),
288
+ );
289
+ expect(res.status).toBe(400);
290
+ });
291
+
292
+ test("400 when the CSRF form token does not match the cookie", async () => {
293
+ const { cookie } = await seedFriend(["work"]);
294
+ const res = await handleAccountVaultAdminTokenPost(
295
+ mintReq("work", { cookie, csrfToken: generateCsrfToken() }),
296
+ "work",
297
+ deps(),
298
+ );
299
+ expect(res.status).toBe(400);
300
+ });
301
+ });
@@ -73,14 +73,23 @@ function csrfPair(): { token: string; cookieFragment: string } {
73
73
  return { token, cookieFragment: cookie };
74
74
  }
75
75
 
76
- /** Build the first-admin operator + a friend assigned to `vaults`. */
76
+ /**
77
+ * Build the first-admin operator + a friend assigned to `vaults`.
78
+ *
79
+ * `passwordChanged` defaults to true: the friend has already rotated the admin-
80
+ * set temp password, which is the precondition for minting a token now (item F
81
+ * / hub#469 — an unrotated friend is force-redirected before any mint). Pass
82
+ * `passwordChanged: false` to exercise the force-change gate.
83
+ */
77
84
  async function seedFriend(
78
85
  vaults: string[],
86
+ opts: { passwordChanged?: boolean } = {},
79
87
  ): Promise<{ friendId: string; cookie: string; csrfToken: string }> {
80
88
  await createUser(harness.db, "operator", "operator-password-123");
81
89
  const friend = await createUser(harness.db, "friend", "friend-password-123", {
82
90
  assignedVaults: vaults,
83
91
  allowMulti: true,
92
+ passwordChanged: opts.passwordChanged ?? true,
84
93
  });
85
94
  const session = createSession(harness.db, { userId: friend.id });
86
95
  const { token, cookieFragment } = csrfPair();
@@ -282,6 +291,49 @@ describe("handleAccountVaultTokenPost — authorization gates (adversarial)", ()
282
291
  );
283
292
  expect(res.status).toBe(400);
284
293
  });
294
+
295
+ // Item I — uppercase vault names are rejected at the hub edge (lowercase-only,
296
+ // matching vault's init; the old `[a-zA-Z0-9_-]` superset drifted from it).
297
+ test("an uppercase vault name is rejected (item I — lowercase-only)", async () => {
298
+ const { cookie, csrfToken } = await seedFriend(["work"]);
299
+ for (const name of ["Work", "WORK", "myVault"]) {
300
+ const res = await handleAccountVaultTokenPost(
301
+ mintReq(name, { cookie, csrfToken, verb: "read" }),
302
+ name,
303
+ deps(),
304
+ );
305
+ expect(res.status).toBe(400);
306
+ }
307
+ });
308
+
309
+ // Item F / hub#469 — force-change gate. An assigned friend who has NOT yet
310
+ // rotated the admin-set temp password is redirected to the change-password
311
+ // rail instead of minting a long-lived token (which would outlive the
312
+ // rotation). The gate fires AFTER the authority checks (so an unassigned
313
+ // request still 403s) and BEFORE the mint.
314
+ test("authorized but unrotated friend → 303 to /account/change-password, no mint (item F)", async () => {
315
+ const { cookie, csrfToken } = await seedFriend(["work"], { passwordChanged: false });
316
+ const res = await handleAccountVaultTokenPost(
317
+ mintReq("work", { cookie, csrfToken, verb: "read" }),
318
+ "work",
319
+ deps(),
320
+ );
321
+ expect(res.status).toBe(303);
322
+ expect(res.headers.get("location")).toBe("/account/change-password");
323
+ // No token row was written for the friend.
324
+ const rows = harness.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM tokens").get();
325
+ expect(rows?.n).toBe(0);
326
+ });
327
+
328
+ test("an UNASSIGNED unrotated friend still 403s (authority precedes the force-change gate)", async () => {
329
+ const { cookie, csrfToken } = await seedFriend(["work"], { passwordChanged: false });
330
+ const res = await handleAccountVaultTokenPost(
331
+ mintReq("other", { cookie, csrfToken, verb: "read" }),
332
+ "other",
333
+ deps(),
334
+ );
335
+ expect(res.status).toBe(403);
336
+ });
285
337
  });
286
338
 
287
339
  describe("handleAccountVaultTokenPost — CSRF + method + rate limit", () => {
@@ -121,6 +121,23 @@ describe("handleVaultAdminToken", () => {
121
121
  expect(res.status).toBe(400);
122
122
  });
123
123
 
124
+ // Item I — uppercase names are rejected at the hub edge (lowercase-only,
125
+ // matching vault's init). Rejected on shape (400) before the known-vault check.
126
+ test("400 when the vault name contains uppercase letters (item I)", async () => {
127
+ const { cookie } = await withSession();
128
+ for (const name of ["Work", "WORK", "myVault"]) {
129
+ const req = new Request(`${ISSUER}/admin/vault-admin-token/${name}`, {
130
+ headers: { cookie },
131
+ });
132
+ const res = await handleVaultAdminToken(req, name, {
133
+ db: harness.db,
134
+ issuer: ISSUER,
135
+ knownVaultNames: known(name, "work"),
136
+ });
137
+ expect(res.status).toBe(400);
138
+ }
139
+ });
140
+
124
141
  test("200 mints a JWT carrying vault:<name>:admin", async () => {
125
142
  const { cookie, userId } = await withSession();
126
143
  const req = new Request(`${ISSUER}/admin/vault-admin-token/work`, { headers: { cookie } });
@@ -225,6 +225,26 @@ describe("POST /vaults — body validation", () => {
225
225
  }
226
226
  });
227
227
 
228
+ // Item I — uppercase is rejected at the hub edge (vault's init is
229
+ // lowercase-only `[a-z0-9_-]`; a hub `[a-zA-Z0-9_-]` superset drifted from it).
230
+ test("400 when name contains uppercase letters (item I — lowercase-only)", async () => {
231
+ const h = makeHarness();
232
+ try {
233
+ const db = openHubDb(hubDbPath(h.dir));
234
+ try {
235
+ rotateSigningKey(db);
236
+ for (const name of ["Work", "MyVault", "FOO", "camelCase"]) {
237
+ const res = await call({ db, manifestPath: h.manifestPath, body: { name } });
238
+ expect(res.status).toBe(400);
239
+ }
240
+ } finally {
241
+ db.close();
242
+ }
243
+ } finally {
244
+ h.cleanup();
245
+ }
246
+ });
247
+
228
248
  test('400 when name is the reserved "list"', async () => {
229
249
  const h = makeHarness();
230
250
  try {