@openparachute/hub 0.6.3 → 0.6.4-rc.2

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 +880 -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 +217 -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 +381 -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 +121 -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 +345 -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,880 @@
1
+ /**
2
+ * Redemption-flow tests for one-time invite links —
3
+ * `GET|POST /account/setup/<token>` (`account-setup.ts`).
4
+ *
5
+ * Adversarial coverage of the redeem path + the security invariants:
6
+ * - happy path: creates user + vault + session, invite marked used
7
+ * - replay rejected (used_at set → 410)
8
+ * - expired rejected (410)
9
+ * - revoked rejected (410)
10
+ * - tampered/unknown token → 404
11
+ * - createUser fails → invite STILL re-usable (the ordering guarantee)
12
+ * - INVARIANT: the redeemed user holds ONLY their one vault at the
13
+ * invite's role — never host:admin, never another vault
14
+ *
15
+ * Vault provisioning is stubbed via `runCommand`: the stub appends the
16
+ * named vault to services.json (what `parachute-vault create` does) so
17
+ * `provisionVault`'s post-orchestrate re-read finds it — no real shell-out.
18
+ */
19
+ import type { Database } from "bun:sqlite";
20
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
21
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { handleAccountSetupGet, handleAccountSetupPost } from "../account-setup.ts";
25
+ import type { RunResult } from "../admin-vaults.ts";
26
+ import { CSRF_FIELD_NAME, buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
27
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
28
+ import { consumeInvite, findInviteByRawToken, issueInvite, revokeInvite } from "../invites.ts";
29
+ import { __resetForTests } from "../rate-limit.ts";
30
+ import { findActiveSession } from "../sessions.ts";
31
+ import { createUser, getUserByUsernameCI, userCount, vaultVerbsForUserVault } from "../users.ts";
32
+
33
+ const ISSUER = "https://hub.test";
34
+
35
+ interface Harness {
36
+ db: Database;
37
+ manifestPath: string;
38
+ /** Names of vaults the stubbed `runCommand` has "created". */
39
+ cleanup: () => void;
40
+ }
41
+
42
+ function makeHarness(): Harness {
43
+ const dir = mkdtempSync(join(tmpdir(), "phub-account-setup-"));
44
+ const db = openHubDb(hubDbPath(dir));
45
+ const manifestPath = join(dir, "services.json");
46
+ // Seed with vault registered (one path) so the create branch runs
47
+ // `parachute-vault create <name>` rather than the bootstrap install.
48
+ writeFileSync(
49
+ manifestPath,
50
+ JSON.stringify({
51
+ services: [
52
+ {
53
+ name: "parachute-vault",
54
+ port: 4101,
55
+ paths: ["/vault/seed"],
56
+ health: "/health",
57
+ version: "0.0.0-test",
58
+ },
59
+ ],
60
+ }),
61
+ );
62
+ return {
63
+ db,
64
+ manifestPath,
65
+ cleanup: () => {
66
+ db.close();
67
+ rmSync(dir, { recursive: true, force: true });
68
+ },
69
+ };
70
+ }
71
+
72
+ let harness: Harness;
73
+ beforeEach(() => {
74
+ harness = makeHarness();
75
+ __resetForTests();
76
+ });
77
+ afterEach(() => {
78
+ harness.cleanup();
79
+ __resetForTests();
80
+ });
81
+
82
+ /** Stub vault create: append the named vault path to services.json, emit create JSON. */
83
+ function makeStubRunCommand(opts: { fail?: boolean } = {}) {
84
+ const calls: string[][] = [];
85
+ const run = async (cmd: readonly string[]): Promise<RunResult> => {
86
+ calls.push([...cmd]);
87
+ if (opts.fail) return { exitCode: 1, stdout: "", stderr: "boom" };
88
+ // cmd = ["parachute-vault", "create", <name>, "--json", ...]
89
+ const name = cmd[2] ?? "";
90
+ const manifest = JSON.parse(readFileSync(harness.manifestPath, "utf8")) as {
91
+ services: { name: string; paths: string[] }[];
92
+ };
93
+ const vaultSvc = manifest.services.find((s) => s.name === "parachute-vault");
94
+ if (vaultSvc && !vaultSvc.paths.includes(`/vault/${name}`)) {
95
+ vaultSvc.paths.push(`/vault/${name}`);
96
+ writeFileSync(harness.manifestPath, JSON.stringify(manifest));
97
+ }
98
+ const createJson = {
99
+ name,
100
+ token: "",
101
+ paths: {
102
+ vault_dir: `/d/${name}`,
103
+ vault_db: `/d/${name}/v.db`,
104
+ vault_config: `/d/${name}/v.yaml`,
105
+ },
106
+ set_as_default: false,
107
+ };
108
+ return { exitCode: 0, stdout: JSON.stringify(createJson), stderr: "" };
109
+ };
110
+ return { run, calls };
111
+ }
112
+
113
+ function csrfPair(): { token: string; cookieFragment: string } {
114
+ const token = generateCsrfToken();
115
+ const cookie = buildCsrfCookie(token, { secure: false }).split(";")[0] ?? "";
116
+ return { token, cookieFragment: cookie };
117
+ }
118
+
119
+ function deps(runCommand?: (cmd: readonly string[]) => Promise<RunResult>) {
120
+ return {
121
+ db: harness.db,
122
+ hubOrigin: ISSUER,
123
+ manifestPath: harness.manifestPath,
124
+ ...(runCommand !== undefined ? { runCommand } : {}),
125
+ };
126
+ }
127
+
128
+ /** Build a POST request with CSRF cookie + form body. */
129
+ function postReq(token: string, fields: Record<string, string>, csrfCookie: string): Request {
130
+ const form = new URLSearchParams(fields);
131
+ return new Request(`${ISSUER}/account/setup/${token}`, {
132
+ method: "POST",
133
+ headers: {
134
+ "content-type": "application/x-www-form-urlencoded",
135
+ cookie: csrfCookie,
136
+ },
137
+ body: form.toString(),
138
+ });
139
+ }
140
+
141
+ describe("GET /account/setup/<token>", () => {
142
+ test("renders the claim form for a valid invite", async () => {
143
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
144
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "maya" });
145
+ const res = handleAccountSetupGet(
146
+ new Request(`${ISSUER}/account/setup/${rawToken}`),
147
+ rawToken,
148
+ deps(),
149
+ );
150
+ expect(res.status).toBe(200);
151
+ const html = await res.text();
152
+ expect(html).toContain("Claim your invite");
153
+ // Pinned vault → shown read-only, not a name-it field.
154
+ expect(html).toContain("maya");
155
+ });
156
+
157
+ test("unknown token → 404", async () => {
158
+ const res = handleAccountSetupGet(new Request(`${ISSUER}/account/setup/nope`), "nope", deps());
159
+ expect(res.status).toBe(404);
160
+ });
161
+
162
+ test("expired invite → 410", async () => {
163
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
164
+ const now = new Date("2026-06-04T00:00:00Z");
165
+ const { rawToken } = issueInvite(harness.db, {
166
+ createdBy: admin.id,
167
+ expiresInSeconds: 60,
168
+ now: () => now,
169
+ });
170
+ const later = new Date(now.getTime() + 120_000);
171
+ const res = handleAccountSetupGet(
172
+ new Request(`${ISSUER}/account/setup/${rawToken}`),
173
+ rawToken,
174
+ { ...deps(), now: () => later },
175
+ );
176
+ expect(res.status).toBe(410);
177
+ });
178
+ });
179
+
180
+ describe("POST /account/setup/<token> — happy path", () => {
181
+ test("creates user + vault + session, marks invite used", async () => {
182
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
183
+ const { rawToken, invite } = issueInvite(harness.db, {
184
+ createdBy: admin.id,
185
+ vaultName: "maya",
186
+ });
187
+ const { token: csrfToken, cookieFragment } = csrfPair();
188
+ const stub = makeStubRunCommand();
189
+ const res = await handleAccountSetupPost(
190
+ postReq(
191
+ rawToken,
192
+ {
193
+ [CSRF_FIELD_NAME]: csrfToken,
194
+ username: "maya",
195
+ password: "maya-strong-password-1",
196
+ password_confirm: "maya-strong-password-1",
197
+ },
198
+ cookieFragment,
199
+ ),
200
+ rawToken,
201
+ deps(stub.run),
202
+ );
203
+ // 302 → /account/ with a session cookie.
204
+ expect(res.status).toBe(302);
205
+ expect(res.headers.get("location")).toBe("/account/");
206
+ const setCookie = res.headers.get("set-cookie") ?? "";
207
+ expect(setCookie.length).toBeGreaterThan(0);
208
+
209
+ // User created, password_changed=true (chose their own → no force-change).
210
+ const user = getUserByUsernameCI(harness.db, "maya");
211
+ expect(user).not.toBeNull();
212
+ expect(user?.passwordChanged).toBe(true);
213
+ expect(user?.assignedVaults).toEqual(["maya"]);
214
+
215
+ // Vault provisioned via the create branch.
216
+ expect(stub.calls.some((c) => c[0] === "parachute-vault" && c[1] === "create")).toBe(true);
217
+
218
+ // Invite consumed.
219
+ const after = findInviteByRawToken(harness.db, rawToken);
220
+ expect(after?.usedAt).not.toBeNull();
221
+ expect(after?.redeemedUserId).toBe(user?.id ?? "");
222
+
223
+ // Session is live for that user.
224
+ const sid = setCookie.split(";")[0]?.split("=")[1] ?? "";
225
+ const sessionReq = new Request(`${ISSUER}/account/`, {
226
+ headers: { cookie: `parachute_hub_session=${sid}` },
227
+ });
228
+ const session = findActiveSession(harness.db, sessionReq);
229
+ expect(session?.userId).toBe(user?.id ?? "");
230
+
231
+ void invite;
232
+ });
233
+
234
+ test("redeemer names their own vault when the invite doesn't pin one", async () => {
235
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
236
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id }); // vault_name null
237
+ const { token: csrfToken, cookieFragment } = csrfPair();
238
+ const stub = makeStubRunCommand();
239
+ const res = await handleAccountSetupPost(
240
+ postReq(
241
+ rawToken,
242
+ {
243
+ [CSRF_FIELD_NAME]: csrfToken,
244
+ username: "sam",
245
+ password: "sam-strong-password-12",
246
+ password_confirm: "sam-strong-password-12",
247
+ vault_name: "sams-vault",
248
+ },
249
+ cookieFragment,
250
+ ),
251
+ rawToken,
252
+ deps(stub.run),
253
+ );
254
+ expect(res.status).toBe(302);
255
+ const user = getUserByUsernameCI(harness.db, "sam");
256
+ expect(user?.assignedVaults).toEqual(["sams-vault"]);
257
+ });
258
+ });
259
+
260
+ describe("POST /account/setup/<token> — security invariants", () => {
261
+ test("redeemed user holds ONLY their vault at the invite role — NOT admin, NOT another vault", async () => {
262
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
263
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "maya" });
264
+ const { token: csrfToken, cookieFragment } = csrfPair();
265
+ const stub = makeStubRunCommand();
266
+ await handleAccountSetupPost(
267
+ postReq(
268
+ rawToken,
269
+ {
270
+ [CSRF_FIELD_NAME]: csrfToken,
271
+ username: "maya",
272
+ password: "maya-strong-password-1",
273
+ password_confirm: "maya-strong-password-1",
274
+ },
275
+ cookieFragment,
276
+ ),
277
+ rawToken,
278
+ deps(stub.run),
279
+ );
280
+ const user = getUserByUsernameCI(harness.db, "maya");
281
+ expect(user).not.toBeNull();
282
+ const id = user?.id ?? "";
283
+
284
+ // Exactly one vault assignment.
285
+ expect(user?.assignedVaults).toEqual(["maya"]);
286
+ // Role is 'write' (owner) → read/write/admin on THEIR vault only.
287
+ expect(vaultVerbsForUserVault(harness.db, id, "maya")).toEqual(["read", "write", "admin"]);
288
+ // No authority over any other vault.
289
+ expect(vaultVerbsForUserVault(harness.db, id, "seed")).toBeNull();
290
+ expect(vaultVerbsForUserVault(harness.db, id, "other")).toBeNull();
291
+ // The invited user is NOT the first admin (admin is the earliest row).
292
+ const firstId = harness.db
293
+ .query<{ id: string }, []>("SELECT id FROM users ORDER BY created_at ASC LIMIT 1")
294
+ .get()?.id;
295
+ expect(firstId).toBe(admin.id);
296
+ expect(firstId).not.toBe(id);
297
+ });
298
+
299
+ test("a 'read' invite lands a read-only assignment", async () => {
300
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
301
+ const { rawToken } = issueInvite(harness.db, {
302
+ createdBy: admin.id,
303
+ vaultName: "shared",
304
+ role: "read",
305
+ });
306
+ const { token: csrfToken, cookieFragment } = csrfPair();
307
+ const stub = makeStubRunCommand();
308
+ await handleAccountSetupPost(
309
+ postReq(
310
+ rawToken,
311
+ {
312
+ [CSRF_FIELD_NAME]: csrfToken,
313
+ username: "guest",
314
+ password: "guest-strong-password-1",
315
+ password_confirm: "guest-strong-password-1",
316
+ },
317
+ cookieFragment,
318
+ ),
319
+ rawToken,
320
+ deps(stub.run),
321
+ );
322
+ const user = getUserByUsernameCI(harness.db, "guest");
323
+ expect(vaultVerbsForUserVault(harness.db, user?.id ?? "", "shared")).toEqual(["read"]);
324
+ });
325
+ });
326
+
327
+ describe("POST /account/setup/<token> — rejection paths", () => {
328
+ test("replay (used invite) → 410", async () => {
329
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
330
+ const { rawToken, invite } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "x" });
331
+ consumeInvite(harness.db, invite.tokenHash, admin.id);
332
+ const { token: csrfToken, cookieFragment } = csrfPair();
333
+ const res = await handleAccountSetupPost(
334
+ postReq(
335
+ rawToken,
336
+ {
337
+ [CSRF_FIELD_NAME]: csrfToken,
338
+ username: "late",
339
+ password: "late-strong-password-1",
340
+ password_confirm: "late-strong-password-1",
341
+ },
342
+ cookieFragment,
343
+ ),
344
+ rawToken,
345
+ deps(makeStubRunCommand().run),
346
+ );
347
+ expect(res.status).toBe(410);
348
+ // No new user created.
349
+ expect(getUserByUsernameCI(harness.db, "late")).toBeNull();
350
+ });
351
+
352
+ test("expired invite → 410", async () => {
353
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
354
+ const now = new Date("2026-06-04T00:00:00Z");
355
+ const { rawToken } = issueInvite(harness.db, {
356
+ createdBy: admin.id,
357
+ vaultName: "x",
358
+ expiresInSeconds: 60,
359
+ now: () => now,
360
+ });
361
+ const { token: csrfToken, cookieFragment } = csrfPair();
362
+ const later = new Date(now.getTime() + 120_000);
363
+ const res = await handleAccountSetupPost(
364
+ postReq(
365
+ rawToken,
366
+ {
367
+ [CSRF_FIELD_NAME]: csrfToken,
368
+ username: "late",
369
+ password: "late-strong-password-1",
370
+ password_confirm: "late-strong-password-1",
371
+ },
372
+ cookieFragment,
373
+ ),
374
+ rawToken,
375
+ { ...deps(makeStubRunCommand().run), now: () => later },
376
+ );
377
+ expect(res.status).toBe(410);
378
+ });
379
+
380
+ test("revoked invite → 410", async () => {
381
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
382
+ const { rawToken, invite } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "x" });
383
+ revokeInvite(harness.db, invite.tokenHash);
384
+ const { token: csrfToken, cookieFragment } = csrfPair();
385
+ const res = await handleAccountSetupPost(
386
+ postReq(
387
+ rawToken,
388
+ {
389
+ [CSRF_FIELD_NAME]: csrfToken,
390
+ username: "late",
391
+ password: "late-strong-password-1",
392
+ password_confirm: "late-strong-password-1",
393
+ },
394
+ cookieFragment,
395
+ ),
396
+ rawToken,
397
+ deps(makeStubRunCommand().run),
398
+ );
399
+ expect(res.status).toBe(410);
400
+ });
401
+
402
+ test("tampered/unknown token → 404", async () => {
403
+ const { token: csrfToken, cookieFragment } = csrfPair();
404
+ const res = await handleAccountSetupPost(
405
+ postReq(
406
+ "unknown-token",
407
+ {
408
+ [CSRF_FIELD_NAME]: csrfToken,
409
+ username: "x",
410
+ password: "xxxxxxxxxxxx",
411
+ password_confirm: "xxxxxxxxxxxx",
412
+ },
413
+ cookieFragment,
414
+ ),
415
+ "unknown-token",
416
+ deps(makeStubRunCommand().run),
417
+ );
418
+ expect(res.status).toBe(404);
419
+ });
420
+
421
+ test("missing CSRF → 400, invite untouched", async () => {
422
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
423
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "x" });
424
+ const res = await handleAccountSetupPost(
425
+ // No CSRF cookie/field.
426
+ new Request(`${ISSUER}/account/setup/${rawToken}`, {
427
+ method: "POST",
428
+ headers: { "content-type": "application/x-www-form-urlencoded" },
429
+ body: new URLSearchParams({
430
+ username: "x",
431
+ password: "xxxxxxxxxxxx",
432
+ password_confirm: "xxxxxxxxxxxx",
433
+ }).toString(),
434
+ }),
435
+ rawToken,
436
+ deps(makeStubRunCommand().run),
437
+ );
438
+ expect(res.status).toBe(400);
439
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
440
+ });
441
+ });
442
+
443
+ describe("POST /account/setup/<token> — re-usability on createUser failure (ordering)", () => {
444
+ test("createUser collision leaves the invite UNCONSUMED (re-usable)", async () => {
445
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
446
+ // Pre-create a user with the username the invitee will pick → createUser
447
+ // raises UsernameTakenError, AFTER provisionVault has run.
448
+ await createUser(harness.db, "taken", "taken-password-12", { allowMulti: true });
449
+
450
+ const { rawToken, invite } = issueInvite(harness.db, {
451
+ createdBy: admin.id,
452
+ vaultName: "maya",
453
+ });
454
+ const { token: csrfToken, cookieFragment } = csrfPair();
455
+ const res = await handleAccountSetupPost(
456
+ postReq(
457
+ rawToken,
458
+ {
459
+ [CSRF_FIELD_NAME]: csrfToken,
460
+ username: "taken",
461
+ password: "another-strong-password-1",
462
+ password_confirm: "another-strong-password-1",
463
+ },
464
+ cookieFragment,
465
+ ),
466
+ rawToken,
467
+ deps(makeStubRunCommand().run),
468
+ );
469
+ expect(res.status).toBe(409);
470
+ // The invite must NOT be consumed — the ordering guarantee.
471
+ const after = findInviteByRawToken(harness.db, rawToken);
472
+ expect(after?.usedAt).toBeNull();
473
+ expect(after?.redeemedUserId).toBeNull();
474
+
475
+ // And a SECOND attempt with a free username succeeds + consumes it.
476
+ __resetForTests();
477
+ const second = csrfPair();
478
+ const res2 = await handleAccountSetupPost(
479
+ postReq(
480
+ rawToken,
481
+ {
482
+ [CSRF_FIELD_NAME]: second.token,
483
+ username: "maya",
484
+ password: "maya-strong-password-12",
485
+ password_confirm: "maya-strong-password-12",
486
+ },
487
+ second.cookieFragment,
488
+ ),
489
+ rawToken,
490
+ deps(makeStubRunCommand().run),
491
+ );
492
+ expect(res2.status).toBe(302);
493
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).not.toBeNull();
494
+ void invite;
495
+ });
496
+ });
497
+
498
+ describe("POST /account/setup/<token> — vault-name validation (N1)", () => {
499
+ test("a 33-char invitee-chosen vault name → clear validation error, NOT a generic provision failure", async () => {
500
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
501
+ // vault_name null → the redeemer names their own vault.
502
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id });
503
+ const { token: csrfToken, cookieFragment } = csrfPair();
504
+ const tooLong = "a".repeat(33); // passes the bare charset regex, exceeds the 32 cap
505
+ const stub = makeStubRunCommand();
506
+ const res = await handleAccountSetupPost(
507
+ postReq(
508
+ rawToken,
509
+ {
510
+ [CSRF_FIELD_NAME]: csrfToken,
511
+ username: "sam",
512
+ password: "sam-strong-password-12",
513
+ password_confirm: "sam-strong-password-12",
514
+ vault_name: tooLong,
515
+ },
516
+ cookieFragment,
517
+ ),
518
+ rawToken,
519
+ deps(stub.run),
520
+ );
521
+ // 400 with the validator's specific message — the vault CLI is never reached.
522
+ expect(res.status).toBe(400);
523
+ const html = await res.text();
524
+ expect(html).toContain("2–32 characters");
525
+ expect(html).not.toContain("Could not provision your vault");
526
+ expect(stub.calls.length).toBe(0);
527
+ // No account created.
528
+ expect(getUserByUsernameCI(harness.db, "sam")).toBeNull();
529
+ });
530
+ });
531
+
532
+ describe("POST /account/setup/<token> — concurrent redeem (N2)", () => {
533
+ test("two concurrent redeems of one invite create EXACTLY one account (no orphan)", async () => {
534
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
535
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "maya" });
536
+ const before = userCount(harness.db); // 1 (the admin)
537
+
538
+ // Two POSTs with DIFFERENT usernames, fired together. Each has its own
539
+ // CSRF pair. createUser awaits argon2 before its (synchronous) commit
540
+ // transaction; the consume-inside-tx guard is what serializes them.
541
+ const a = csrfPair();
542
+ const b = csrfPair();
543
+ const mk = (uname: string, csrf: { token: string; cookieFragment: string }) =>
544
+ handleAccountSetupPost(
545
+ postReq(
546
+ rawToken,
547
+ {
548
+ [CSRF_FIELD_NAME]: csrf.token,
549
+ username: uname,
550
+ password: `${uname}-strong-password-1`,
551
+ password_confirm: `${uname}-strong-password-1`,
552
+ },
553
+ csrf.cookieFragment,
554
+ ),
555
+ rawToken,
556
+ deps(makeStubRunCommand().run),
557
+ );
558
+ const [r1, r2] = await Promise.all([mk("alice", a), mk("bob", b)]);
559
+
560
+ const statuses = [r1.status, r2.status].sort();
561
+ // Exactly one 302 (success). The loser is rejected — either at the
562
+ // FIX-1 existing-vault gate (409, it saw the name already created) or at
563
+ // the consume-inside-tx race (410, both raced past the existence check
564
+ // then one lost the invite-consume). Both are correct single-account
565
+ // outcomes; the only invariant is "exactly one success, one rejection".
566
+ expect(statuses[0]).toBe(302);
567
+ expect(statuses[1] === 409 || statuses[1] === 410).toBe(true);
568
+ // EXACTLY one account was created from the invite — no orphan row.
569
+ expect(userCount(harness.db) - before).toBe(1);
570
+ const aliceExists = getUserByUsernameCI(harness.db, "alice") !== null;
571
+ const bobExists = getUserByUsernameCI(harness.db, "bob") !== null;
572
+ // Exactly one of the two usernames landed.
573
+ expect(aliceExists !== bobExists).toBe(true);
574
+ // The invite is consumed, pinned to whichever user won.
575
+ const after = findInviteByRawToken(harness.db, rawToken);
576
+ expect(after?.usedAt).not.toBeNull();
577
+ const winner = getUserByUsernameCI(harness.db, aliceExists ? "alice" : "bob");
578
+ expect(after?.redeemedUserId).toBe(winner?.id ?? "");
579
+ });
580
+ });
581
+
582
+ describe("POST /account/setup/<token> — account-only invite (N3)", () => {
583
+ test("provision_vault=false, no vault_name → user with empty assignedVaults, no vault shell-out", async () => {
584
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
585
+ const { rawToken } = issueInvite(harness.db, {
586
+ createdBy: admin.id,
587
+ provisionVault: false,
588
+ });
589
+ const { token: csrfToken, cookieFragment } = csrfPair();
590
+ const stub = makeStubRunCommand();
591
+ const res = await handleAccountSetupPost(
592
+ postReq(
593
+ rawToken,
594
+ {
595
+ [CSRF_FIELD_NAME]: csrfToken,
596
+ username: "accountonly",
597
+ password: "accountonly-password-1",
598
+ password_confirm: "accountonly-password-1",
599
+ },
600
+ cookieFragment,
601
+ ),
602
+ rawToken,
603
+ deps(stub.run),
604
+ );
605
+ expect(res.status).toBe(302);
606
+ const user = getUserByUsernameCI(harness.db, "accountonly");
607
+ expect(user).not.toBeNull();
608
+ expect(user?.assignedVaults).toEqual([]);
609
+ // No vault provisioning shell-out for an account-only invite.
610
+ expect(stub.calls.length).toBe(0);
611
+ // Invite consumed.
612
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).not.toBeNull();
613
+ });
614
+ });
615
+
616
+ /**
617
+ * Add a pre-existing vault (someone else's) directly to services.json so
618
+ * `provisionVault`'s existence check finds it WITHOUT a shell-out. Mirrors the
619
+ * shape the stub writes.
620
+ */
621
+ function seedExistingVault(name: string): void {
622
+ const manifest = JSON.parse(readFileSync(harness.manifestPath, "utf8")) as {
623
+ services: { name: string; paths: string[] }[];
624
+ };
625
+ const vaultSvc = manifest.services.find((s) => s.name === "parachute-vault");
626
+ if (vaultSvc && !vaultSvc.paths.includes(`/vault/${name}`)) {
627
+ vaultSvc.paths.push(`/vault/${name}`);
628
+ writeFileSync(harness.manifestPath, JSON.stringify(manifest));
629
+ }
630
+ }
631
+
632
+ describe("POST /account/setup/<token> — cross-tenant: existing-vault rejection (FIX-1)", () => {
633
+ test("HEADLINE: invitee picks an EXISTING vault name → rejected, no account, owner unchanged", async () => {
634
+ // An owner already holds "shared-vault".
635
+ const owner = await createUser(harness.db, "owner", "owner-strong-password-1", {
636
+ assignedVaults: ["shared-vault"],
637
+ role: "write",
638
+ });
639
+ seedExistingVault("shared-vault");
640
+
641
+ // Unpinned invite: the invitee gets to type a vault name.
642
+ const admin = await createUser(harness.db, "operator", "operator-password-1", {
643
+ allowMulti: true,
644
+ });
645
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id }); // vault_name null
646
+ const before = userCount(harness.db);
647
+ const { token: csrfToken, cookieFragment } = csrfPair();
648
+ const stub = makeStubRunCommand();
649
+ const res = await handleAccountSetupPost(
650
+ postReq(
651
+ rawToken,
652
+ {
653
+ [CSRF_FIELD_NAME]: csrfToken,
654
+ username: "intruder",
655
+ password: "intruder-strong-password-1",
656
+ password_confirm: "intruder-strong-password-1",
657
+ vault_name: "shared-vault", // collides with the owner's vault
658
+ },
659
+ cookieFragment,
660
+ ),
661
+ rawToken,
662
+ deps(stub.run),
663
+ );
664
+
665
+ // Rejected with a re-rendered form carrying the "already exists" error.
666
+ expect(res.status).toBe(409);
667
+ const html = await res.text();
668
+ expect(html).toContain("already exists");
669
+ expect(html).toContain("Choose a different name");
670
+
671
+ // NO account created.
672
+ expect(getUserByUsernameCI(harness.db, "intruder")).toBeNull();
673
+ expect(userCount(harness.db) - before).toBe(0);
674
+ // The invite is NOT consumed — the invitee can retry with a new name.
675
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
676
+ // The pre-existing vault's owner/assignment is UNCHANGED.
677
+ expect(vaultVerbsForUserVault(harness.db, owner.id, "shared-vault")).toEqual([
678
+ "read",
679
+ "write",
680
+ "admin",
681
+ ]);
682
+ // No NEW user got authority over it.
683
+ const intruder = getUserByUsernameCI(harness.db, "intruder");
684
+ expect(intruder).toBeNull();
685
+ });
686
+
687
+ test("pinned EXISTING vault name (provision_vault=true) → rejected, no account", async () => {
688
+ await createUser(harness.db, "owner", "owner-strong-password-1", {
689
+ assignedVaults: ["taken"],
690
+ role: "write",
691
+ });
692
+ seedExistingVault("taken");
693
+ const admin = await createUser(harness.db, "operator", "operator-password-1", {
694
+ allowMulti: true,
695
+ });
696
+ // Admin pins an existing name with provision_vault=true (the redeem must
697
+ // still freshly CREATE — a pre-existing pinned name is rejected too).
698
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "taken" });
699
+ const before = userCount(harness.db);
700
+ const { token: csrfToken, cookieFragment } = csrfPair();
701
+ const res = await handleAccountSetupPost(
702
+ postReq(
703
+ rawToken,
704
+ {
705
+ [CSRF_FIELD_NAME]: csrfToken,
706
+ username: "newbie",
707
+ password: "newbie-strong-password-1",
708
+ password_confirm: "newbie-strong-password-1",
709
+ },
710
+ cookieFragment,
711
+ ),
712
+ rawToken,
713
+ deps(makeStubRunCommand().run),
714
+ );
715
+ expect(res.status).toBe(409);
716
+ expect(getUserByUsernameCI(harness.db, "newbie")).toBeNull();
717
+ expect(userCount(harness.db) - before).toBe(0);
718
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
719
+ });
720
+
721
+ test("concurrent redeem on a FRESH name → exactly one account, the other rejected", async () => {
722
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
723
+ // Pinned to a fresh name so both redeems target the SAME new vault.
724
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "freshvault" });
725
+ const before = userCount(harness.db);
726
+ const a = csrfPair();
727
+ const b = csrfPair();
728
+ const mk = (uname: string, csrf: { token: string; cookieFragment: string }) =>
729
+ handleAccountSetupPost(
730
+ postReq(
731
+ rawToken,
732
+ {
733
+ [CSRF_FIELD_NAME]: csrf.token,
734
+ username: uname,
735
+ password: `${uname}-strong-password-1`,
736
+ password_confirm: `${uname}-strong-password-1`,
737
+ },
738
+ csrf.cookieFragment,
739
+ ),
740
+ rawToken,
741
+ deps(makeStubRunCommand().run),
742
+ );
743
+ const [r1, r2] = await Promise.all([mk("ann", a), mk("ben", b)]);
744
+ const statuses = [r1.status, r2.status].sort();
745
+ // Exactly one success; the other rejected (409 existing-vault gate or 410
746
+ // consume race — see the N2 test for why both are correct).
747
+ expect(statuses[0]).toBe(302);
748
+ expect(statuses[1] === 409 || statuses[1] === 410).toBe(true);
749
+ expect(userCount(harness.db) - before).toBe(1);
750
+ });
751
+
752
+ test("shared-vault invite that slipped through (provision_vault=false + pinned name) → rejected at redeem", async () => {
753
+ // Defense in depth: the admin API rejects creating this shape, but if one
754
+ // exists in the DB the redeem must still refuse to assign the existing vault.
755
+ await createUser(harness.db, "owner", "owner-strong-password-1", {
756
+ assignedVaults: ["legacy"],
757
+ role: "write",
758
+ });
759
+ seedExistingVault("legacy");
760
+ const admin = await createUser(harness.db, "operator", "operator-password-1", {
761
+ allowMulti: true,
762
+ });
763
+ const { rawToken } = issueInvite(harness.db, {
764
+ createdBy: admin.id,
765
+ vaultName: "legacy",
766
+ provisionVault: false,
767
+ });
768
+ const before = userCount(harness.db);
769
+ const { token: csrfToken, cookieFragment } = csrfPair();
770
+ const stub = makeStubRunCommand();
771
+ const res = await handleAccountSetupPost(
772
+ postReq(
773
+ rawToken,
774
+ {
775
+ [CSRF_FIELD_NAME]: csrfToken,
776
+ username: "wouldbe",
777
+ password: "wouldbe-strong-password-1",
778
+ password_confirm: "wouldbe-strong-password-1",
779
+ },
780
+ cookieFragment,
781
+ ),
782
+ rawToken,
783
+ deps(stub.run),
784
+ );
785
+ expect(res.status).toBe(400);
786
+ expect(getUserByUsernameCI(harness.db, "wouldbe")).toBeNull();
787
+ expect(userCount(harness.db) - before).toBe(0);
788
+ // No vault shell-out — rejected before provisioning.
789
+ expect(stub.calls.length).toBe(0);
790
+ // Invite NOT consumed.
791
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
792
+ });
793
+ });
794
+
795
+ describe("POST /account/setup/<token> — vault name defaults to username (FIX-2)", () => {
796
+ test("blank vault_name → vault created NAMED AFTER the username", async () => {
797
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
798
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id }); // unpinned
799
+ const { token: csrfToken, cookieFragment } = csrfPair();
800
+ const stub = makeStubRunCommand();
801
+ const res = await handleAccountSetupPost(
802
+ postReq(
803
+ rawToken,
804
+ {
805
+ [CSRF_FIELD_NAME]: csrfToken,
806
+ username: "dana",
807
+ password: "dana-strong-password-12",
808
+ password_confirm: "dana-strong-password-12",
809
+ vault_name: "", // blank → defaults to username
810
+ },
811
+ cookieFragment,
812
+ ),
813
+ rawToken,
814
+ deps(stub.run),
815
+ );
816
+ expect(res.status).toBe(302);
817
+ const user = getUserByUsernameCI(harness.db, "dana");
818
+ expect(user?.assignedVaults).toEqual(["dana"]);
819
+ // The shell-out created a vault named "dana".
820
+ expect(stub.calls.some((c) => c[1] === "create" && c[2] === "dana")).toBe(true);
821
+ });
822
+
823
+ test("vault_name field OMITTED entirely → still defaults to username", async () => {
824
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
825
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id });
826
+ const { token: csrfToken, cookieFragment } = csrfPair();
827
+ const stub = makeStubRunCommand();
828
+ const res = await handleAccountSetupPost(
829
+ postReq(
830
+ rawToken,
831
+ {
832
+ [CSRF_FIELD_NAME]: csrfToken,
833
+ username: "ezra",
834
+ password: "ezra-strong-password-12",
835
+ password_confirm: "ezra-strong-password-12",
836
+ },
837
+ cookieFragment,
838
+ ),
839
+ rawToken,
840
+ deps(stub.run),
841
+ );
842
+ expect(res.status).toBe(302);
843
+ expect(getUserByUsernameCI(harness.db, "ezra")?.assignedVaults).toEqual(["ezra"]);
844
+ });
845
+
846
+ test("blank vault_name but the username collides with an EXISTING vault → rejected (FIX-1 still applies)", async () => {
847
+ await createUser(harness.db, "owner", "owner-strong-password-1", {
848
+ assignedVaults: ["fiona"],
849
+ role: "write",
850
+ });
851
+ seedExistingVault("fiona");
852
+ const admin = await createUser(harness.db, "operator", "operator-password-1", {
853
+ allowMulti: true,
854
+ });
855
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id });
856
+ const before = userCount(harness.db);
857
+ const { token: csrfToken, cookieFragment } = csrfPair();
858
+ const res = await handleAccountSetupPost(
859
+ postReq(
860
+ rawToken,
861
+ {
862
+ [CSRF_FIELD_NAME]: csrfToken,
863
+ username: "fiona", // username derives a vault that already exists
864
+ password: "fiona-strong-password-1",
865
+ password_confirm: "fiona-strong-password-1",
866
+ vault_name: "",
867
+ },
868
+ cookieFragment,
869
+ ),
870
+ rawToken,
871
+ deps(makeStubRunCommand().run),
872
+ );
873
+ expect(res.status).toBe(409);
874
+ const html = await res.text();
875
+ expect(html).toContain("already exists");
876
+ expect(getUserByUsernameCI(harness.db, "fiona")).toBeNull();
877
+ expect(userCount(harness.db) - before).toBe(0);
878
+ expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
879
+ });
880
+ });