@openparachute/hub 0.7.3-rc.3 → 0.7.3-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.3-rc.3",
3
+ "version": "0.7.3-rc.4",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -733,6 +733,7 @@ import { findGrant, recordGrant } from "../grants.ts";
733
733
  import { findInviteByHash, issueInvite } from "../invites.ts";
734
734
  import { findTokenRowByJti, listActiveRevocations, recordTokenMint } from "../jwt-sign.ts";
735
735
  import { createUser, setUserVaults } from "../users.ts";
736
+ import { getVaultCapBytes, setVaultCap } from "../vault-caps.ts";
736
737
 
737
738
  const VAULT_ORIGIN = "http://127.0.0.1:19400";
738
739
  const AGENT_ORIGIN = "http://127.0.0.1:19410";
@@ -1026,11 +1027,19 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1026
1027
  const pendingWork = issueInvite(db, { createdBy: alice.id, vaultName: "work" });
1027
1028
  const pendingDefault = issueInvite(db, { createdBy: alice.id, vaultName: "default" });
1028
1029
  const redeemedWork = issueInvite(db, { createdBy: alice.id, vaultName: "work" });
1029
- db.prepare("UPDATE invites SET used_at = ?, redeemed_user_id = ? WHERE token = ?").run(
1030
- new Date().toISOString(),
1031
- alice.id,
1032
- redeemedWork.invite.tokenHash,
1033
- );
1030
+ // Simulate a fully-redeemed single-use invite exactly as the redeem path
1031
+ // leaves it (v15): used_at stamped AND used_count bumped to max_uses, so
1032
+ // the cascade's `used_count < max_uses` exhaustion guard treats it as
1033
+ // terminal (untouched), same as the pre-v15 `used_at IS NULL` guard did.
1034
+ db.prepare(
1035
+ "UPDATE invites SET used_at = ?, used_count = 1, redeemed_user_id = ? WHERE token = ?",
1036
+ ).run(new Date().toISOString(), alice.id, redeemedWork.invite.tokenHash);
1037
+
1038
+ // 4b. vault_caps (v15): one row per vault. The work row is dropped by
1039
+ // the cascade; the default row stays (a re-created same-name vault
1040
+ // must not inherit a stale cap).
1041
+ setVaultCap(db, "work", 1024 * 1024 * 1024);
1042
+ setVaultCap(db, "default", 2 * 1024 * 1024 * 1024);
1034
1043
 
1035
1044
  // 5. Connections: one sourced on work (torn down), one on default (kept).
1036
1045
  putConnection(store, {
@@ -1092,6 +1101,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1092
1101
  grants_dropped: number;
1093
1102
  user_vaults_removed: number;
1094
1103
  invites_invalidated: number;
1104
+ vault_cap_removed: boolean;
1095
1105
  connections_torn_down: number;
1096
1106
  orphaned_channels: string[];
1097
1107
  vault_removed: boolean;
@@ -1134,6 +1144,11 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1134
1144
  expect(findInviteByHash(db, redeemedWork.invite.tokenHash)?.usedAt).not.toBeNull();
1135
1145
  expect(findInviteByHash(db, redeemedWork.invite.tokenHash)?.revokedAt).toBeNull();
1136
1146
 
1147
+ // 4b. vault_caps: the work cap row dropped; the default cap row stays.
1148
+ expect(out.cascade.vault_cap_removed).toBe(true);
1149
+ expect(getVaultCapBytes(db, "work")).toBeNull();
1150
+ expect(getVaultCapBytes(db, "default")).toBe(2 * 1024 * 1024 * 1024);
1151
+
1137
1152
  // 5. Connections: work connection torn down (trigger deregistered +
1138
1153
  // channel entry deleted + record removed); default connection kept.
1139
1154
  expect(out.cascade.connections_torn_down).toBe(1);
@@ -0,0 +1,619 @@
1
+ /**
2
+ * Multi-use public-signup links + email-as-username + per-vault caps
3
+ * (DEMO-PREP-2026-06-25 Workstream B: B1 multi-use invite, B2 email capture,
4
+ * B4 public signup page; the cap value persisted for B3's Phase-2 enforcement).
5
+ *
6
+ * Coverage required by the brief:
7
+ * - multi-use exhaustion (used_count == max_uses → refused)
8
+ * - expiry refusal (multi-use link past expires_at → 410)
9
+ * - email capture (stored on users + on the invite, validated)
10
+ * - signup → vault provision with the cap PERSISTED to vault_caps
11
+ * - backwards-compat with legacy single-use invites
12
+ *
13
+ * Plus: the migration is backwards-compatible (legacy rows redeem unchanged),
14
+ * the invite primitive's seat accounting, and the API mint gates.
15
+ */
16
+ import type { Database } from "bun:sqlite";
17
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
18
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { handleAccountSetupGet, handleAccountSetupPost } from "../account-setup.ts";
22
+ import type { RunResult } from "../admin-vaults.ts";
23
+ import { handleCreateInvite } from "../api-invites.ts";
24
+ import { CSRF_FIELD_NAME, buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
25
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
26
+ import {
27
+ InviteExhaustedError,
28
+ InviteUsedError,
29
+ assertInviteRedeemable,
30
+ findInviteByRawToken,
31
+ inviteStatus,
32
+ issueInvite,
33
+ recordInviteRedemption,
34
+ } from "../invites.ts";
35
+ import { signAccessToken } from "../jwt-sign.ts";
36
+ import { __resetForTests } from "../rate-limit.ts";
37
+ import { createUser, getUserByUsernameCI } from "../users.ts";
38
+ import { getVaultCapBytes } from "../vault-caps.ts";
39
+
40
+ const ISSUER = "https://hub.test";
41
+
42
+ interface Harness {
43
+ db: Database;
44
+ manifestPath: string;
45
+ cleanup: () => void;
46
+ }
47
+
48
+ function makeHarness(): Harness {
49
+ const dir = mkdtempSync(join(tmpdir(), "phub-public-signup-"));
50
+ const db = openHubDb(hubDbPath(dir));
51
+ const manifestPath = join(dir, "services.json");
52
+ writeFileSync(
53
+ manifestPath,
54
+ JSON.stringify({
55
+ services: [
56
+ {
57
+ name: "parachute-vault",
58
+ port: 4101,
59
+ paths: ["/vault/seed"],
60
+ health: "/health",
61
+ version: "0.0.0-test",
62
+ },
63
+ ],
64
+ }),
65
+ );
66
+ return {
67
+ db,
68
+ manifestPath,
69
+ cleanup: () => {
70
+ db.close();
71
+ rmSync(dir, { recursive: true, force: true });
72
+ },
73
+ };
74
+ }
75
+
76
+ let harness: Harness;
77
+ beforeEach(() => {
78
+ harness = makeHarness();
79
+ __resetForTests();
80
+ });
81
+ afterEach(() => {
82
+ harness.cleanup();
83
+ __resetForTests();
84
+ });
85
+
86
+ /** Stub vault create: append the named vault path to services.json. */
87
+ function makeStubRunCommand() {
88
+ const calls: string[][] = [];
89
+ const run = async (cmd: readonly string[]): Promise<RunResult> => {
90
+ calls.push([...cmd]);
91
+ const name = cmd[2] ?? "";
92
+ const manifest = JSON.parse(readFileSync(harness.manifestPath, "utf8")) as {
93
+ services: { name: string; paths: string[] }[];
94
+ };
95
+ const vaultSvc = manifest.services.find((s) => s.name === "parachute-vault");
96
+ if (vaultSvc && !vaultSvc.paths.includes(`/vault/${name}`)) {
97
+ vaultSvc.paths.push(`/vault/${name}`);
98
+ writeFileSync(harness.manifestPath, JSON.stringify(manifest));
99
+ }
100
+ const createJson = {
101
+ name,
102
+ token: "",
103
+ paths: {
104
+ vault_dir: `/d/${name}`,
105
+ vault_db: `/d/${name}/v.db`,
106
+ vault_config: `/d/${name}/v.yaml`,
107
+ },
108
+ set_as_default: false,
109
+ };
110
+ return { exitCode: 0, stdout: JSON.stringify(createJson), stderr: "" };
111
+ };
112
+ return { run, calls };
113
+ }
114
+
115
+ function csrfPair(): { token: string; cookieFragment: string } {
116
+ const token = generateCsrfToken();
117
+ const cookie = buildCsrfCookie(token, { secure: false }).split(";")[0] ?? "";
118
+ return { token, cookieFragment: cookie };
119
+ }
120
+
121
+ function postReq(token: string, fields: Record<string, string>, csrfCookie: string): Request {
122
+ const form = new URLSearchParams(fields);
123
+ return new Request(`${ISSUER}/account/setup/${token}`, {
124
+ method: "POST",
125
+ headers: { "content-type": "application/x-www-form-urlencoded", cookie: csrfCookie },
126
+ body: form.toString(),
127
+ });
128
+ }
129
+
130
+ function deps(runCommand?: (cmd: readonly string[]) => Promise<RunResult>) {
131
+ return {
132
+ db: harness.db,
133
+ hubOrigin: ISSUER,
134
+ manifestPath: harness.manifestPath,
135
+ ...(runCommand !== undefined ? { runCommand } : {}),
136
+ };
137
+ }
138
+
139
+ /** Drive one full public-signup redemption (multi-use shape) for a new account. */
140
+ async function signup(
141
+ rawToken: string,
142
+ username: string,
143
+ email: string,
144
+ run: (cmd: readonly string[]) => Promise<RunResult>,
145
+ ): Promise<Response> {
146
+ const { token: csrfToken, cookieFragment } = csrfPair();
147
+ __resetForTests(); // each signup is a distinct IP-bucket attempt window
148
+ return handleAccountSetupPost(
149
+ postReq(
150
+ rawToken,
151
+ {
152
+ [CSRF_FIELD_NAME]: csrfToken,
153
+ username,
154
+ email,
155
+ password: `${username}-strong-password-1`,
156
+ password_confirm: `${username}-strong-password-1`,
157
+ vault_name: username,
158
+ },
159
+ cookieFragment,
160
+ ),
161
+ rawToken,
162
+ deps(run),
163
+ );
164
+ }
165
+
166
+ // ===========================================================================
167
+ // B1 — multi-use seat accounting (primitive level)
168
+ // ===========================================================================
169
+
170
+ describe("invite primitive — multi-use seats (v15)", () => {
171
+ test("legacy default is single-use: max_uses=1, used_count=0", async () => {
172
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
173
+ const { invite } = issueInvite(harness.db, { createdBy: admin.id });
174
+ expect(invite.maxUses).toBe(1);
175
+ expect(invite.usedCount).toBe(0);
176
+ expect(invite.email).toBeNull();
177
+ expect(invite.vaultCapBytes).toBeNull();
178
+ });
179
+
180
+ // recordInviteRedemption's redeemed_user_id FKs users(id), so tests pass
181
+ // real committed user ids (as the production redeem path does).
182
+ async function makeUsers(...names: string[]): Promise<string[]> {
183
+ const ids: string[] = [];
184
+ for (const n of names) {
185
+ const u = await createUser(harness.db, n, `${n}-password-1`, { allowMulti: true });
186
+ ids.push(u.id);
187
+ }
188
+ return ids;
189
+ }
190
+
191
+ test("recordInviteRedemption bumps used_count, stamps used_at on first only", async () => {
192
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
193
+ const [u1, u2] = await makeUsers("ra", "rb");
194
+ const { rawToken, invite } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 3 });
195
+ const t0 = new Date("2026-06-25T00:00:00Z");
196
+ const t1 = new Date("2026-06-25T01:00:00Z");
197
+ expect(recordInviteRedemption(harness.db, invite.tokenHash, u1!, "a@x.io", t0)).toBe(true);
198
+ let row = findInviteByRawToken(harness.db, rawToken);
199
+ expect(row?.usedCount).toBe(1);
200
+ expect(row?.usedAt).toBe(t0.toISOString());
201
+ expect(row?.email).toBe("a@x.io");
202
+ // Second redeem: used_count bumps, used_at preserved (COALESCE), email updates.
203
+ expect(recordInviteRedemption(harness.db, invite.tokenHash, u2!, "b@x.io", t1)).toBe(true);
204
+ row = findInviteByRawToken(harness.db, rawToken);
205
+ expect(row?.usedCount).toBe(2);
206
+ expect(row?.usedAt).toBe(t0.toISOString()); // unchanged
207
+ expect(row?.email).toBe("b@x.io"); // latest redeemer
208
+ });
209
+
210
+ test("exhaustion: the (max_uses+1)th record is refused (zero rows)", async () => {
211
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
212
+ const [u1, u2, u3] = await makeUsers("ea", "eb", "ec");
213
+ const { invite } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 2 });
214
+ expect(recordInviteRedemption(harness.db, invite.tokenHash, u1!)).toBe(true);
215
+ expect(recordInviteRedemption(harness.db, invite.tokenHash, u2!)).toBe(true);
216
+ // Third — over cap.
217
+ expect(recordInviteRedemption(harness.db, invite.tokenHash, u3!)).toBe(false);
218
+ });
219
+
220
+ test("status: partially-used multi-use link stays 'pending'; exhausted → 'redeemed'", async () => {
221
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
222
+ const [u1, u2] = await makeUsers("sa", "sb");
223
+ const { rawToken, invite } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 2 });
224
+ recordInviteRedemption(harness.db, invite.tokenHash, u1!);
225
+ expect(inviteStatus(findInviteByRawToken(harness.db, rawToken)!)).toBe("pending");
226
+ recordInviteRedemption(harness.db, invite.tokenHash, u2!);
227
+ expect(inviteStatus(findInviteByRawToken(harness.db, rawToken)!)).toBe("redeemed");
228
+ });
229
+
230
+ test("assertInviteRedeemable: exhausted single-use throws InviteUsedError, multi-use throws InviteExhaustedError", async () => {
231
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
232
+ const [u1, u2, u3] = await makeUsers("aa", "ab", "ac");
233
+ const single = issueInvite(harness.db, { createdBy: admin.id, maxUses: 1 });
234
+ recordInviteRedemption(harness.db, single.invite.tokenHash, u1!);
235
+ expect(() => assertInviteRedeemable(harness.db, single.rawToken)).toThrow(InviteUsedError);
236
+
237
+ const multi = issueInvite(harness.db, { createdBy: admin.id, maxUses: 2 });
238
+ recordInviteRedemption(harness.db, multi.invite.tokenHash, u2!);
239
+ recordInviteRedemption(harness.db, multi.invite.tokenHash, u3!);
240
+ expect(() => assertInviteRedeemable(harness.db, multi.rawToken)).toThrow(InviteExhaustedError);
241
+ });
242
+ });
243
+
244
+ // ===========================================================================
245
+ // B4 — public signup page end-to-end (multi-use redemption)
246
+ // ===========================================================================
247
+
248
+ describe("public signup page — multi-use redemption + caps", () => {
249
+ test("two strangers redeem ONE link → two accounts, two vaults, cap persisted, used_count=2", async () => {
250
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
251
+ const cap = 1024 * 1024 * 1024; // 1 GiB
252
+ const { rawToken } = issueInvite(harness.db, {
253
+ createdBy: admin.id,
254
+ maxUses: 5,
255
+ vaultCapBytes: cap,
256
+ });
257
+ const stub = makeStubRunCommand();
258
+
259
+ const r1 = await signup(rawToken, "alice", "alice@example.com", stub.run);
260
+ expect(r1.status).toBe(302);
261
+ const r2 = await signup(rawToken, "bob", "bob@example.com", stub.run);
262
+ expect(r2.status).toBe(302);
263
+
264
+ // Two distinct accounts with their emails stored.
265
+ const alice = getUserByUsernameCI(harness.db, "alice");
266
+ const bob = getUserByUsernameCI(harness.db, "bob");
267
+ expect(alice?.email).toBe("alice@example.com");
268
+ expect(bob?.email).toBe("bob@example.com");
269
+ expect(alice?.assignedVaults).toEqual(["alice"]);
270
+ expect(bob?.assignedVaults).toEqual(["bob"]);
271
+
272
+ // Each provisioned vault carries the cap (B4 persistence for the Phase-2 reader).
273
+ expect(getVaultCapBytes(harness.db, "alice")).toBe(cap);
274
+ expect(getVaultCapBytes(harness.db, "bob")).toBe(cap);
275
+
276
+ // The link recorded two redemptions, still has seats, stays pending.
277
+ const after = findInviteByRawToken(harness.db, rawToken);
278
+ expect(after?.usedCount).toBe(2);
279
+ expect(after?.maxUses).toBe(5);
280
+ expect(inviteStatus(after!)).toBe("pending");
281
+ });
282
+
283
+ test("multi-use link with NO cap on the invite → provisioned vault has NO vault_caps row (uncapped contract)", async () => {
284
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
285
+ // issueInvite directly (bypasses the API's auto-default) with no cap.
286
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 5 });
287
+ const stub = makeStubRunCommand();
288
+ expect((await signup(rawToken, "nina", "nina@example.com", stub.run)).status).toBe(302);
289
+ // No cap row → the Phase-2 reader treats the vault as uncapped.
290
+ expect(getVaultCapBytes(harness.db, "nina")).toBeNull();
291
+ });
292
+
293
+ test("CONCURRENT redeem of a max_uses=1 link: exactly one 302, one 410 (atomic exhaustion guard)", async () => {
294
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
295
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 1 });
296
+ const stub = makeStubRunCommand();
297
+
298
+ // Build two complete POST requests up front, then fire them together with
299
+ // NO reset between them — a genuine race for the single seat. (The `signup`
300
+ // helper resets the rate-limiter per call, which would serialize the
301
+ // window; here both share one generous signup bucket, so neither 429s.)
302
+ const mkPost = (username: string): Request => {
303
+ const { token: csrfToken, cookieFragment } = csrfPair();
304
+ return postReq(
305
+ rawToken,
306
+ {
307
+ [CSRF_FIELD_NAME]: csrfToken,
308
+ username,
309
+ email: `${username}@example.com`,
310
+ password: `${username}-strong-password-1`,
311
+ password_confirm: `${username}-strong-password-1`,
312
+ vault_name: username,
313
+ },
314
+ cookieFragment,
315
+ );
316
+ };
317
+ const [r1, r2] = await Promise.all([
318
+ handleAccountSetupPost(mkPost("racea"), rawToken, deps(stub.run)),
319
+ handleAccountSetupPost(mkPost("raceb"), rawToken, deps(stub.run)),
320
+ ]);
321
+
322
+ // Exactly one winner (302) and one loser (410) — never two 302s.
323
+ expect([r1.status, r2.status].sort()).toEqual([302, 410]);
324
+
325
+ // The link recorded exactly ONE redemption; exactly one account exists.
326
+ const after = findInviteByRawToken(harness.db, rawToken);
327
+ expect(after?.usedCount).toBe(1);
328
+ const accounts = [
329
+ getUserByUsernameCI(harness.db, "racea"),
330
+ getUserByUsernameCI(harness.db, "raceb"),
331
+ ].filter((u) => u !== null);
332
+ expect(accounts.length).toBe(1);
333
+ });
334
+
335
+ test("exhaustion refusal: the (N+1)th signup on a maxed link → 410", async () => {
336
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
337
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 1 + 1 });
338
+ const stub = makeStubRunCommand();
339
+ expect((await signup(rawToken, "alice", "alice@example.com", stub.run)).status).toBe(302);
340
+ expect((await signup(rawToken, "bob", "bob@example.com", stub.run)).status).toBe(302);
341
+ // Third — link is exhausted.
342
+ const r3 = await signup(rawToken, "carol", "carol@example.com", stub.run);
343
+ expect(r3.status).toBe(410);
344
+ expect(await r3.text()).toContain("Signups closed");
345
+ expect(getUserByUsernameCI(harness.db, "carol")).toBeNull();
346
+ });
347
+
348
+ test("expiry refusal: a multi-use link past expires_at → 410, no account created", async () => {
349
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
350
+ const t0 = new Date("2026-06-25T00:00:00Z");
351
+ const { rawToken } = issueInvite(harness.db, {
352
+ createdBy: admin.id,
353
+ maxUses: 10,
354
+ expiresInSeconds: 60,
355
+ now: () => t0,
356
+ });
357
+ const stub = makeStubRunCommand();
358
+ const later = new Date(t0.getTime() + 120_000);
359
+ const { token: csrfToken, cookieFragment } = csrfPair();
360
+ const res = await handleAccountSetupPost(
361
+ postReq(
362
+ rawToken,
363
+ {
364
+ [CSRF_FIELD_NAME]: csrfToken,
365
+ username: "dave",
366
+ email: "dave@example.com",
367
+ password: "dave-strong-password-1",
368
+ password_confirm: "dave-strong-password-1",
369
+ },
370
+ cookieFragment,
371
+ ),
372
+ rawToken,
373
+ { ...deps(stub.run), now: () => later },
374
+ );
375
+ expect(res.status).toBe(410);
376
+ expect(getUserByUsernameCI(harness.db, "dave")).toBeNull();
377
+ });
378
+
379
+ test("email capture: GET renders the email field for a multi-use link", async () => {
380
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
381
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 5 });
382
+ const html = await handleAccountSetupGet(
383
+ new Request(`${ISSUER}/account/setup/${rawToken}`),
384
+ rawToken,
385
+ deps(),
386
+ ).text();
387
+ expect(html).toContain('name="email"');
388
+ expect(html).toContain('type="email"');
389
+ });
390
+
391
+ test("email validation: malformed email → 400 re-render, no account", async () => {
392
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
393
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 5 });
394
+ const stub = makeStubRunCommand();
395
+ const { token: csrfToken, cookieFragment } = csrfPair();
396
+ const res = await handleAccountSetupPost(
397
+ postReq(
398
+ rawToken,
399
+ {
400
+ [CSRF_FIELD_NAME]: csrfToken,
401
+ username: "eve",
402
+ email: "not-an-email",
403
+ password: "eve-strong-password-1",
404
+ password_confirm: "eve-strong-password-1",
405
+ },
406
+ cookieFragment,
407
+ ),
408
+ rawToken,
409
+ deps(stub.run),
410
+ );
411
+ expect(res.status).toBe(400);
412
+ expect(await res.text()).toContain("valid email");
413
+ expect(getUserByUsernameCI(harness.db, "eve")).toBeNull();
414
+ });
415
+
416
+ test("missing email on a multi-use link → 400, no account", async () => {
417
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
418
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 5 });
419
+ const stub = makeStubRunCommand();
420
+ const { token: csrfToken, cookieFragment } = csrfPair();
421
+ const res = await handleAccountSetupPost(
422
+ postReq(
423
+ rawToken,
424
+ {
425
+ [CSRF_FIELD_NAME]: csrfToken,
426
+ username: "frank",
427
+ password: "frank-strong-password-1",
428
+ password_confirm: "frank-strong-password-1",
429
+ },
430
+ cookieFragment,
431
+ ),
432
+ rawToken,
433
+ deps(stub.run),
434
+ );
435
+ expect(res.status).toBe(400);
436
+ expect(getUserByUsernameCI(harness.db, "frank")).toBeNull();
437
+ });
438
+ });
439
+
440
+ // ===========================================================================
441
+ // Backwards-compat — legacy single-use invites redeem unchanged
442
+ // ===========================================================================
443
+
444
+ describe("backwards-compat — legacy single-use invites", () => {
445
+ test("a legacy invite row (no v15 columns set) redeems with NO email field and single-use", async () => {
446
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
447
+ // Simulate a legacy invite: a default issueInvite is max_uses=1 / no email
448
+ // / no cap — exactly what a pre-v15 row looks like after the migration's
449
+ // column defaults backfill it.
450
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "maya" });
451
+
452
+ // GET form has NO email field (single-use → friends flow, no email).
453
+ const html = await handleAccountSetupGet(
454
+ new Request(`${ISSUER}/account/setup/${rawToken}`),
455
+ rawToken,
456
+ deps(),
457
+ ).text();
458
+ expect(html).not.toContain('name="email"');
459
+
460
+ const stub = makeStubRunCommand();
461
+ const { token: csrfToken, cookieFragment } = csrfPair();
462
+ const res = await handleAccountSetupPost(
463
+ postReq(
464
+ rawToken,
465
+ {
466
+ [CSRF_FIELD_NAME]: csrfToken,
467
+ username: "maya",
468
+ // No email submitted — single-use doesn't require it.
469
+ password: "maya-strong-password-1",
470
+ password_confirm: "maya-strong-password-1",
471
+ },
472
+ cookieFragment,
473
+ ),
474
+ rawToken,
475
+ deps(stub.run),
476
+ );
477
+ expect(res.status).toBe(302);
478
+
479
+ const user = getUserByUsernameCI(harness.db, "maya");
480
+ expect(user).not.toBeNull();
481
+ expect(user?.email).toBeNull(); // no email captured on the friends flow
482
+ expect(user?.assignedVaults).toEqual(["maya"]);
483
+
484
+ // Vault provisioned WITHOUT a cap (legacy invites carry no cap).
485
+ expect(getVaultCapBytes(harness.db, "maya")).toBeNull();
486
+
487
+ // Single-use: a replay is refused (used → 410).
488
+ __resetForTests();
489
+ const replay = await handleAccountSetupPost(
490
+ postReq(
491
+ rawToken,
492
+ {
493
+ [CSRF_FIELD_NAME]: csrfToken,
494
+ username: "maya2",
495
+ password: "maya2-strong-password-1",
496
+ password_confirm: "maya2-strong-password-1",
497
+ },
498
+ cookieFragment,
499
+ ),
500
+ rawToken,
501
+ deps(stub.run),
502
+ );
503
+ expect(replay.status).toBe(410);
504
+ expect(await replay.text()).toContain("already used");
505
+ });
506
+ });
507
+
508
+ // ===========================================================================
509
+ // API mint gates (POST /api/invites) — admin creates a multi-use capped link
510
+ // ===========================================================================
511
+
512
+ describe("POST /api/invites — multi-use + cap mint gates", () => {
513
+ async function adminBearer(adminId: string): Promise<string> {
514
+ const minted = await signAccessToken(harness.db, {
515
+ sub: adminId,
516
+ scopes: ["parachute:host:admin"],
517
+ audience: "hub",
518
+ clientId: "parachute-hub-spa",
519
+ issuer: ISSUER,
520
+ ttlSeconds: 300,
521
+ vaultScope: [],
522
+ });
523
+ return minted.token;
524
+ }
525
+
526
+ function createReq(bearer: string, body: Record<string, unknown>): Request {
527
+ return new Request(`${ISSUER}/api/invites`, {
528
+ method: "POST",
529
+ headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
530
+ body: JSON.stringify(body),
531
+ });
532
+ }
533
+
534
+ test("mints a multi-use capped link; wire shape carries the new fields", async () => {
535
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
536
+ const bearer = await adminBearer(admin.id);
537
+ const cap = 1024 * 1024 * 1024;
538
+ const res = await handleCreateInvite(
539
+ createReq(bearer, { max_uses: 25, vault_cap_bytes: cap }),
540
+ { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath },
541
+ );
542
+ expect(res.status).toBe(201);
543
+ const body = (await res.json()) as {
544
+ invite: { max_uses: number; used_count: number; vault_cap_bytes: number; email: null };
545
+ url: string;
546
+ };
547
+ expect(body.invite.max_uses).toBe(25);
548
+ expect(body.invite.used_count).toBe(0);
549
+ expect(body.invite.vault_cap_bytes).toBe(cap);
550
+ expect(body.url).toContain("/account/setup/");
551
+ });
552
+
553
+ test("auto-applies the ~1 GiB default cap to a multi-use provisioning link with no explicit cap", async () => {
554
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
555
+ const bearer = await adminBearer(admin.id);
556
+ const res = await handleCreateInvite(createReq(bearer, { max_uses: 10 }), {
557
+ db: harness.db,
558
+ issuer: ISSUER,
559
+ manifestPath: harness.manifestPath,
560
+ });
561
+ expect(res.status).toBe(201);
562
+ const body = (await res.json()) as { invite: { vault_cap_bytes: number } };
563
+ expect(body.invite.vault_cap_bytes).toBe(1024 * 1024 * 1024);
564
+ });
565
+
566
+ test("single-use link with no explicit cap stays uncapped (no auto-default)", async () => {
567
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
568
+ const bearer = await adminBearer(admin.id);
569
+ const res = await handleCreateInvite(createReq(bearer, {}), {
570
+ db: harness.db,
571
+ issuer: ISSUER,
572
+ manifestPath: harness.manifestPath,
573
+ });
574
+ expect(res.status).toBe(201);
575
+ const body = (await res.json()) as { invite: { vault_cap_bytes: number | null } };
576
+ expect(body.invite.vault_cap_bytes).toBeNull();
577
+ });
578
+
579
+ test("rejects max_uses > 1 with a pre-named username", async () => {
580
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
581
+ const bearer = await adminBearer(admin.id);
582
+ const res = await handleCreateInvite(
583
+ createReq(bearer, { max_uses: 10, username: "jonathan" }),
584
+ { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath },
585
+ );
586
+ expect(res.status).toBe(400);
587
+ });
588
+
589
+ test("rejects max_uses > 1 with a pinned NEW vault name", async () => {
590
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
591
+ const bearer = await adminBearer(admin.id);
592
+ const res = await handleCreateInvite(
593
+ createReq(bearer, { max_uses: 10, vault_name: "shared", provision_vault: true }),
594
+ { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath },
595
+ );
596
+ expect(res.status).toBe(400);
597
+ });
598
+
599
+ test("rejects vault_cap_bytes on a non-provisioning invite", async () => {
600
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
601
+ const bearer = await adminBearer(admin.id);
602
+ const res = await handleCreateInvite(
603
+ createReq(bearer, { provision_vault: false, vault_cap_bytes: 1000 }),
604
+ { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath },
605
+ );
606
+ expect(res.status).toBe(400);
607
+ });
608
+
609
+ test("rejects max_uses out of range", async () => {
610
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
611
+ const bearer = await adminBearer(admin.id);
612
+ const res = await handleCreateInvite(createReq(bearer, { max_uses: 0 }), {
613
+ db: harness.db,
614
+ issuer: ISSUER,
615
+ manifestPath: harness.manifestPath,
616
+ });
617
+ expect(res.status).toBe(400);
618
+ });
619
+ });