@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.
@@ -15,6 +15,18 @@
15
15
  *
16
16
  * Wire shape is snake_case (matches `/api/users`). An invite's raw token
17
17
  * NEVER appears in a GET/list response — only in the POST-create `url`.
18
+ *
19
+ * v15 (DEMO-PREP Workstream B) extends POST /api/invites with `max_uses`
20
+ * (multi-use public-signup links, default 1 = single-use) and
21
+ * `vault_cap_bytes` (per-vault storage cap stamped at provision, B4). A
22
+ * multi-use PROVISIONING link with no explicit `vault_cap_bytes` auto-defaults
23
+ * to ~1 GB (DEFAULT_VAULT_CAP_BYTES) so a public link never provisions
24
+ * uncapped by omission; single-use friends links stay uncapped unless opted
25
+ * in. The list/create wire shape gains `max_uses`, `used_count`, `email`
26
+ * (latest redeemer, B2), and `vault_cap_bytes`. `expires_in` already bounded
27
+ * the link lifetime (B1's expiry). A multi-use link can't pre-name a username
28
+ * or pin a single new vault name (every seat past the first would collide —
29
+ * rejected at mint).
18
30
  */
19
31
  import type { Database } from "bun:sqlite";
20
32
  import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
@@ -32,6 +44,7 @@ import {
32
44
  usernameReservedByPendingInvite,
33
45
  } from "./invites.ts";
34
46
  import { getUserByUsernameCI, validateUsername } from "./users.ts";
47
+ import { DEFAULT_VAULT_CAP_BYTES } from "./vault-caps.ts";
35
48
  import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
36
49
  import { listVaultNamesFromPath } from "./vault-names.ts";
37
50
 
@@ -47,6 +60,18 @@ export interface ApiInvitesDeps {
47
60
  const ALLOWED_ROLES = new Set(["read", "write"]);
48
61
  /** Cap an invite TTL so a typo can't mint a ~forever link. 90 days. */
49
62
  const MAX_INVITE_TTL_SECONDS = 90 * 24 * 60 * 60;
63
+ /**
64
+ * Cap `max_uses` (v15) so a typo can't mint a runaway public-signup link on a
65
+ * shared box. 1000 is comfortably above any demo cohort while still bounding
66
+ * the abuse surface. The DEMO-PREP shared box uses small values (tens of seats).
67
+ */
68
+ const MAX_INVITE_USES = 1000;
69
+ /**
70
+ * Ceiling on a per-vault cap an invite may carry (v15, B4). 100 GiB — far
71
+ * above the ~1 GB default, but bounds a fat-finger that would make the cap
72
+ * meaningless on a shared box. The minimum is 1 byte (a positive integer).
73
+ */
74
+ const MAX_VAULT_CAP_BYTES = 100 * 1024 * 1024 * 1024;
50
75
 
51
76
  interface InviteWireShape {
52
77
  /** sha256 hash — the stable id for list/revoke. NOT the raw token. */
@@ -57,6 +82,14 @@ interface InviteWireShape {
57
82
  role: string;
58
83
  provision_vault: boolean;
59
84
  default_mirror: string | null;
85
+ /** v15 — how many accounts this link may create (1 = single-use). */
86
+ max_uses: number;
87
+ /** v15 — how many it has created so far. */
88
+ used_count: number;
89
+ /** v15 — most-recent redeemer's email (B2), or null. */
90
+ email: string | null;
91
+ /** v15 — per-vault storage cap to stamp at provision (B4), or null (uncapped). */
92
+ vault_cap_bytes: number | null;
60
93
  expires_at: string;
61
94
  used_at: string | null;
62
95
  redeemed_user_id: string | null;
@@ -73,6 +106,10 @@ function toWire(invite: Invite, status: InviteStatus): InviteWireShape {
73
106
  role: invite.role,
74
107
  provision_vault: invite.provisionVault,
75
108
  default_mirror: invite.defaultMirror,
109
+ max_uses: invite.maxUses,
110
+ used_count: invite.usedCount,
111
+ email: invite.email,
112
+ vault_cap_bytes: invite.vaultCapBytes,
76
113
  expires_at: invite.expiresAt,
77
114
  used_at: invite.usedAt,
78
115
  redeemed_user_id: invite.redeemedUserId,
@@ -100,6 +137,10 @@ interface CreateInviteBody {
100
137
  provisionVault: boolean;
101
138
  defaultMirror: string | null;
102
139
  expiresInSeconds: number;
140
+ /** v15 — how many accounts the link may create (default 1 = single-use). */
141
+ maxUses: number;
142
+ /** v15 — per-vault storage cap (bytes) to stamp at provision, or null (uncapped). */
143
+ vaultCapBytes: number | null;
103
144
  }
104
145
 
105
146
  interface ParseErr {
@@ -283,9 +324,71 @@ async function parseCreateBody(
283
324
  expiresInSeconds = Math.floor(rawExpiry);
284
325
  }
285
326
 
327
+ // max_uses — v15. Default 1 (single-use, the legacy shape). A positive
328
+ // integer ≤ MAX_INVITE_USES. >1 = a multi-use public-signup link.
329
+ let maxUses = 1;
330
+ const rawMaxUses =
331
+ Object.hasOwn(obj, "max_uses") && obj.max_uses !== undefined
332
+ ? obj.max_uses
333
+ : Object.hasOwn(obj, "maxUses") && obj.maxUses !== undefined
334
+ ? obj.maxUses
335
+ : undefined;
336
+ if (rawMaxUses !== undefined && rawMaxUses !== null) {
337
+ if (
338
+ typeof rawMaxUses !== "number" ||
339
+ !Number.isInteger(rawMaxUses) ||
340
+ rawMaxUses < 1 ||
341
+ rawMaxUses > MAX_INVITE_USES
342
+ ) {
343
+ return {
344
+ ok: false,
345
+ status: 400,
346
+ error: "invalid_request",
347
+ description: `"max_uses" must be an integer between 1 and ${MAX_INVITE_USES}`,
348
+ };
349
+ }
350
+ maxUses = rawMaxUses;
351
+ }
352
+
353
+ // vault_cap_bytes — v15, B4. Optional per-vault storage cap stamped at
354
+ // provision. null/omitted = uncapped (legacy). A positive integer
355
+ // ≤ MAX_VAULT_CAP_BYTES.
356
+ let vaultCapBytes: number | null = null;
357
+ const rawCap =
358
+ Object.hasOwn(obj, "vault_cap_bytes") && obj.vault_cap_bytes !== undefined
359
+ ? obj.vault_cap_bytes
360
+ : Object.hasOwn(obj, "vaultCapBytes") && obj.vaultCapBytes !== undefined
361
+ ? obj.vaultCapBytes
362
+ : undefined;
363
+ if (rawCap !== undefined && rawCap !== null) {
364
+ if (
365
+ typeof rawCap !== "number" ||
366
+ !Number.isInteger(rawCap) ||
367
+ rawCap < 1 ||
368
+ rawCap > MAX_VAULT_CAP_BYTES
369
+ ) {
370
+ return {
371
+ ok: false,
372
+ status: 400,
373
+ error: "invalid_request",
374
+ description: `"vault_cap_bytes" must be a positive integer ≤ ${MAX_VAULT_CAP_BYTES} (100 GiB)`,
375
+ };
376
+ }
377
+ vaultCapBytes = rawCap;
378
+ }
379
+
286
380
  return {
287
381
  ok: true,
288
- body: { vaultName, username, role, provisionVault, defaultMirror, expiresInSeconds },
382
+ body: {
383
+ vaultName,
384
+ username,
385
+ role,
386
+ provisionVault,
387
+ defaultMirror,
388
+ expiresInSeconds,
389
+ maxUses,
390
+ vaultCapBytes,
391
+ },
289
392
  };
290
393
  }
291
394
 
@@ -303,8 +406,16 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
303
406
  }
304
407
  const parsed = await parseCreateBody(req);
305
408
  if (!parsed.ok) return jsonError(parsed.status, parsed.error, parsed.description);
306
- const { vaultName, username, role, provisionVault, defaultMirror, expiresInSeconds } =
307
- parsed.body;
409
+ const {
410
+ vaultName,
411
+ username,
412
+ role,
413
+ provisionVault,
414
+ defaultMirror,
415
+ expiresInSeconds,
416
+ maxUses,
417
+ vaultCapBytes,
418
+ } = parsed.body;
308
419
  const now = (deps.now ?? (() => new Date()))();
309
420
 
310
421
  // Shape gates over the three supported invite shapes:
@@ -330,6 +441,42 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
330
441
  'a provisioned vault\'s sole user must hold write — use role "write", or share an existing vault (provision_vault=false + vault_name) for read-only access',
331
442
  );
332
443
  }
444
+
445
+ // Multi-use coherence gates (v15). A link that creates many accounts can't:
446
+ // - pre-name a username (one name can't be reused across redeemers), or
447
+ // - pin a single NEW vault name (every redeemer would collide on it —
448
+ // the redeem path's freshly-created invariant rejects the 2nd onward).
449
+ // Either combination would make every seat past the first dead-on-arrival,
450
+ // so reject at mint. (A multi-use SHARED-vault invite — provision_vault=false
451
+ // + an existing vault_name — IS coherent: many users joining one vault; it's
452
+ // not blocked here.)
453
+ if (maxUses > 1) {
454
+ if (username !== null) {
455
+ return jsonError(
456
+ 400,
457
+ "invalid_request",
458
+ "a multi-use link (max_uses > 1) can't pre-name a username — one username can't be shared across signups. Omit \"username\".",
459
+ );
460
+ }
461
+ if (provisionVault && vaultName !== null) {
462
+ return jsonError(
463
+ 400,
464
+ "invalid_request",
465
+ 'a multi-use provisioning link (max_uses > 1) can\'t pin a single vault name — every signup would collide on it. Omit "vault_name" so each signup names their own vault.',
466
+ );
467
+ }
468
+ }
469
+
470
+ // A per-vault cap only applies to a vault THIS link provisions. Pinning a
471
+ // cap on a non-provisioning invite (account-only or shared-existing-vault)
472
+ // has nothing to stamp — reject the contradiction at mint.
473
+ if (vaultCapBytes !== null && !provisionVault) {
474
+ return jsonError(
475
+ 400,
476
+ "invalid_request",
477
+ '"vault_cap_bytes" only applies to a provisioning invite (provision_vault=true) — a non-provisioning link has no vault to cap',
478
+ );
479
+ }
333
480
  if (vaultName !== null && !provisionVault) {
334
481
  const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
335
482
  const known = new Set(listVaultNamesFromPath(manifestPath));
@@ -358,6 +505,17 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
358
505
  }
359
506
  }
360
507
 
508
+ // Auto-apply the ~1 GB default cap to a PUBLIC-SIGNUP (multi-use,
509
+ // provisioning) link when the admin didn't set one explicitly — the
510
+ // DEMO-PREP decision is "1 GB per vault, configurable," so a multi-use
511
+ // signup link on a shared box should never provision UNcapped by omission.
512
+ // An explicit cap (any value, validated above) is honored as-is; a
513
+ // single-use friends link stays uncapped unless the admin opts in.
514
+ const effectiveVaultCapBytes =
515
+ vaultCapBytes === null && maxUses > 1 && provisionVault
516
+ ? DEFAULT_VAULT_CAP_BYTES
517
+ : vaultCapBytes;
518
+
361
519
  const issued = issueInvite(deps.db, {
362
520
  createdBy: authUserId,
363
521
  vaultName,
@@ -366,6 +524,8 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
366
524
  provisionVault,
367
525
  defaultMirror,
368
526
  expiresInSeconds,
527
+ maxUses,
528
+ vaultCapBytes: effectiveVaultCapBytes,
369
529
  ...(deps.now !== undefined ? { now: deps.now } : {}),
370
530
  });
371
531
  const status = inviteStatus(issued.invite, now);
package/src/api-users.ts CHANGED
@@ -88,6 +88,8 @@ export interface UserWireShape {
88
88
  id: string;
89
89
  username: string;
90
90
  password_changed: boolean;
91
+ /** Contactable email captured at signup (v15, B2), or null. Visible to admin. */
92
+ email: string | null;
91
93
  assigned_vaults: string[];
92
94
  created_at: string;
93
95
  }
@@ -97,6 +99,7 @@ function toWire(u: User): UserWireShape {
97
99
  id: u.id,
98
100
  username: u.username,
99
101
  password_changed: u.passwordChanged,
102
+ email: u.email,
100
103
  assigned_vaults: [...u.assignedVaults],
101
104
  created_at: u.createdAt,
102
105
  };
package/src/hub-db.ts CHANGED
@@ -4,7 +4,10 @@
4
4
  * issuer — signing keys (v1), users + opaque refresh tokens (v2), OAuth
5
5
  * clients + auth-codes + grants + browser sessions (v3), TOTP 2FA
6
6
  * enrollment on the users row (v11, hub#473), and one-time invite links
7
- * (v12, the `invites` table; v13 adds the pre-named `username` column).
7
+ * (v12, the `invites` table; v13 adds the pre-named `username` column;
8
+ * v14 adds refresh-token rotation grace; v15 generalizes invites into
9
+ * multi-use public-signup links + email-as-username + the `vault_caps`
10
+ * per-vault storage-cap table).
8
11
  *
9
12
  * Each open() runs `migrate()` to bring the schema up to date. A
10
13
  * `schema_version` table records every applied migration so re-opens are
@@ -474,6 +477,87 @@ const MIGRATIONS: readonly Migration[] = [
474
477
  ALTER TABLE tokens ADD COLUMN rotated_to TEXT;
475
478
  `,
476
479
  },
480
+ {
481
+ version: 15,
482
+ sql: `
483
+ -- Multi-use public signup links + email-as-username + per-vault caps
484
+ -- (DEMO-PREP-2026-06-25 Workstream B: B1 multi-use invite, B2 email
485
+ -- capture, B4 public signup page; the cap value is persisted now so
486
+ -- B3's vault-side enforcement — a separate Phase-2 PR — can read it).
487
+ --
488
+ -- (1) FOUR columns on \`invites\` generalize the single-use link into a
489
+ -- multi-use, capped one. Backwards compatible: every pre-v15 invite
490
+ -- redeems exactly as before because the defaults reproduce the old
491
+ -- single-use semantics.
492
+ --
493
+ -- * max_uses (INTEGER NOT NULL DEFAULT 1) — how many accounts ONE link
494
+ -- may create. DEFAULT 1 = the historical single-use invite, so every
495
+ -- pre-v15 row and every default-shaped new row stays single-use. A
496
+ -- public signup link mints with a higher value (e.g. 25 demo seats).
497
+ -- * used_count (INTEGER NOT NULL DEFAULT 0) — how many accounts the link
498
+ -- HAS created. Redeem refuses once used_count >= max_uses. Replaces
499
+ -- the boolean used_at as the exhaustion signal for multi-use links;
500
+ -- used_at is RETAINED (stamped on the FIRST redeem) so legacy
501
+ -- single-use lookups + the admin list's "redeemed" status keep
502
+ -- working unchanged.
503
+ -- * email (TEXT, nullable) — the redeemer's email captured at signup
504
+ -- as the contactable identity (B2). NULL on every pre-v15 invite and
505
+ -- on admin-issued links that don't collect email; set per-redemption
506
+ -- for public-signup links. Stored so the operator can reach signups.
507
+ -- (One link → many signups means one invite row can't hold every
508
+ -- redeemer's email; this column holds the MOST-RECENT redeemer's
509
+ -- email for a single-use link, and the per-account email lives on the
510
+ -- users row — see (3). For multi-use the canonical per-user email is
511
+ -- users.email.)
512
+ -- * vault_cap_bytes (INTEGER, nullable) — the per-vault storage cap to
513
+ -- STAMP onto each vault this link provisions (B4). NULL on every
514
+ -- pre-v15 invite + admin-issued links that don't set a cap (those
515
+ -- provision uncapped, the historical behavior); set to ~1 GB on a
516
+ -- public-signup link so each provisioned vault gets a vault_caps row
517
+ -- for the Phase-2 enforcement PR to read. The cap travels on the
518
+ -- invite (not a server-wide default) so different links can carry
519
+ -- different caps.
520
+ --
521
+ -- (2) ONE column on \`users\`: email-as-contactable-identity (B2). The
522
+ -- username stays the login + URL identity ([a-z0-9_-]); email is the
523
+ -- separate contact field the operator sees + uses to reach a signup.
524
+ -- Nullable: every pre-v15 account (wizard admin, env-seeded admin,
525
+ -- pre-named friend invites) predates email capture and keeps NULL.
526
+ -- NOT UNIQUE — two people behind one shared mailbox, or an operator
527
+ -- re-using their address across test accounts, shouldn't be blocked
528
+ -- at the schema. Validation (format) happens at the API/redeem edge.
529
+ --
530
+ -- (3) NEW \`vault_caps\` table — the per-vault storage cap, persisted at
531
+ -- provision time (B4: "persist a per-vault cap value at provision
532
+ -- time EVEN THOUGH vault-side enforcement lands in a separate
533
+ -- Phase-2 PR — at minimum store the cap so the later PR can read +
534
+ -- enforce it"). Keyed by vault_name (the same instance-name space
535
+ -- used across services.json / user_vaults / invites.vault_name; no
536
+ -- FK — vault names resolve through services.json, not a DB row, the
537
+ -- established pattern). cap_bytes is the byte ceiling (default
538
+ -- ~1 GB stamped by the public-signup flow), guarded by a
539
+ -- CHECK (cap_bytes > 0) so a direct DB write can't seed a 0/negative
540
+ -- cap the Phase-2 reader would misinterpret. No backfill — existing
541
+ -- vaults have no cap row, which the Phase-2 enforcement reads as
542
+ -- "uncapped" (only vaults provisioned through a capped signup get a
543
+ -- row).
544
+ ALTER TABLE invites ADD COLUMN max_uses INTEGER NOT NULL DEFAULT 1;
545
+ ALTER TABLE invites ADD COLUMN used_count INTEGER NOT NULL DEFAULT 0;
546
+ ALTER TABLE invites ADD COLUMN email TEXT;
547
+ ALTER TABLE invites ADD COLUMN vault_cap_bytes INTEGER;
548
+ ALTER TABLE users ADD COLUMN email TEXT;
549
+ CREATE TABLE vault_caps (
550
+ vault_name TEXT PRIMARY KEY,
551
+ -- CHECK (cap_bytes > 0): guard against a 0/negative cap from a direct
552
+ -- DB write making the Phase-2 enforcement reader treat the vault as
553
+ -- "0-byte ceiling" (everything over cap) or nonsense. The mint + redeem
554
+ -- paths already validate positivity; this is the at-rest backstop.
555
+ cap_bytes INTEGER NOT NULL CHECK (cap_bytes > 0),
556
+ created_at TEXT NOT NULL,
557
+ updated_at TEXT NOT NULL
558
+ );
559
+ `,
560
+ },
477
561
  ];
478
562
 
479
563
  export function openHubDb(path: string = hubDbPath()): Database {
package/src/invites.ts CHANGED
@@ -37,10 +37,23 @@
37
37
  * v13): the redemption form shows it read-only and the redeem handler
38
38
  * enforces it. NULL = redeemer picks their own.
39
39
  *
40
- * Single-use is enforced by stamping `used_at` on redemption — a replay
41
- * attempt sees the row with `used_at` set and `redeemInvite` throws
42
- * `InviteUsedError`. Revocation is a separate `revoked_at` stamp the admin
43
- * sets before redemption. Expiry is enforced at redeem-time.
40
+ * MULTI-USE (migration v15, DEMO-PREP-2026-06-25 Workstream B): an invite
41
+ * carries `max_uses` (how many accounts ONE link may create) + `used_count`
42
+ * (how many it has). A redeem is refused once `used_count >= max_uses`. A
43
+ * LEGACY single-use invite is exactly `max_uses = 1` (the column default), so
44
+ * pre-v15 invites and default-shaped new ones behave identically to before.
45
+ * `used_at` is RETAINED — stamped on the FIRST redeem — so the admin list's
46
+ * "redeemed" status and any single-use lookups keep reading the same signal.
47
+ * Exhaustion is enforced atomically in `recordInviteRedemption` (the
48
+ * `used_count < max_uses` guard in the UPDATE), so two concurrent redeems of
49
+ * the last remaining seat can't both succeed.
50
+ *
51
+ * Revocation is a separate `revoked_at` stamp the admin sets before
52
+ * redemption (terminal regardless of remaining uses). Expiry is enforced at
53
+ * redeem-time. A public-signup link also captures the redeemer's `email`
54
+ * (B2 — the contactable identity, also stored per-account on `users.email`)
55
+ * and may carry a `vault_cap_bytes` to stamp onto each provisioned vault (B4
56
+ * — persisted to `vault_caps` for the Phase-2 enforcement PR to read).
44
57
  */
45
58
  import type { Database } from "bun:sqlite";
46
59
  import { createHash, randomBytes } from "node:crypto";
@@ -70,6 +83,24 @@ export interface Invite {
70
83
  provisionVault: boolean;
71
84
  /** `'internal' | 'off'` mirror knob for the provisioned vault, or null. */
72
85
  defaultMirror: string | null;
86
+ /**
87
+ * How many accounts this link may create (v15). 1 = legacy single-use
88
+ * (the column default); >1 = a multi-use public-signup link.
89
+ */
90
+ maxUses: number;
91
+ /** How many accounts this link HAS created (v15). Redeem refused once == maxUses. */
92
+ usedCount: number;
93
+ /**
94
+ * Most-recent redeemer's email (v15, B2), or null. For a multi-use link
95
+ * the canonical per-account email lives on `users.email`; this column
96
+ * holds the latest redeemer's for the admin's at-a-glance audit.
97
+ */
98
+ email: string | null;
99
+ /**
100
+ * Per-vault storage cap (bytes) to stamp onto each provisioned vault
101
+ * (v15, B4), or null to provision uncapped (legacy behavior).
102
+ */
103
+ vaultCapBytes: number | null;
73
104
  expiresAt: string;
74
105
  usedAt: string | null;
75
106
  redeemedUserId: string | null;
@@ -98,6 +129,20 @@ export class InviteUsedError extends Error {
98
129
  }
99
130
  }
100
131
 
132
+ /**
133
+ * A multi-use link whose seats are all taken (`used_count >= max_uses`). For
134
+ * a single-use (max_uses=1) link this is the same condition the legacy
135
+ * `InviteUsedError` named, but the redeem path throws `InviteUsedError` for a
136
+ * single-use link (familiar "already used" copy) and `InviteExhaustedError`
137
+ * for a multi-use one ("all signups taken") so the message can differ.
138
+ */
139
+ export class InviteExhaustedError extends Error {
140
+ constructor() {
141
+ super("invite has reached its maximum number of signups");
142
+ this.name = "InviteExhaustedError";
143
+ }
144
+ }
145
+
101
146
  export class InviteRevokedError extends Error {
102
147
  constructor() {
103
148
  super("invite has been revoked");
@@ -113,6 +158,10 @@ interface Row {
113
158
  role: string;
114
159
  provision_vault: number;
115
160
  default_mirror: string | null;
161
+ max_uses: number;
162
+ used_count: number;
163
+ email: string | null;
164
+ vault_cap_bytes: number | null;
116
165
  expires_at: string;
117
166
  used_at: string | null;
118
167
  redeemed_user_id: string | null;
@@ -129,6 +178,10 @@ function rowToInvite(r: Row): Invite {
129
178
  role: r.role,
130
179
  provisionVault: r.provision_vault === 1,
131
180
  defaultMirror: r.default_mirror,
181
+ maxUses: r.max_uses,
182
+ usedCount: r.used_count,
183
+ email: r.email,
184
+ vaultCapBytes: r.vault_cap_bytes,
132
185
  expiresAt: r.expires_at,
133
186
  usedAt: r.used_at,
134
187
  redeemedUserId: r.redeemed_user_id,
@@ -142,10 +195,19 @@ export function hashInviteToken(rawToken: string): string {
142
195
  return createHash("sha256").update(rawToken).digest("hex");
143
196
  }
144
197
 
145
- /** Derive an invite's status from its stamps + the current time. */
198
+ /**
199
+ * Derive an invite's status from its stamps + the current time.
200
+ *
201
+ * Multi-use (v15): an invite is "redeemed" only once every seat is taken
202
+ * (`usedCount >= maxUses`). A partially-used multi-use link (some seats left,
203
+ * not expired/revoked) is still "pending" — it can take more signups. A
204
+ * legacy single-use link (maxUses=1) flips to "redeemed" on its first
205
+ * redeem, exactly as before, because 1 >= 1. Expired wins over a not-yet-
206
+ * exhausted link; revoked + exhausted are checked first (terminal).
207
+ */
146
208
  export function inviteStatus(invite: Invite, now: Date = new Date()): InviteStatus {
147
209
  if (invite.revokedAt) return "revoked";
148
- if (invite.usedAt) return "redeemed";
210
+ if (invite.usedCount >= invite.maxUses) return "redeemed";
149
211
  if (now.getTime() > new Date(invite.expiresAt).getTime()) return "expired";
150
212
  return "pending";
151
213
  }
@@ -166,6 +228,16 @@ export interface IssueInviteOpts {
166
228
  provisionVault?: boolean;
167
229
  /** `'internal' | 'off'` mirror knob for the provisioned vault. */
168
230
  defaultMirror?: string | null;
231
+ /**
232
+ * How many accounts this link may create (v15). Default 1 (single-use,
233
+ * the legacy shape). Caller validates the upper bound.
234
+ */
235
+ maxUses?: number;
236
+ /**
237
+ * Per-vault storage cap (bytes) to stamp onto each provisioned vault
238
+ * (v15, B4). Omit/null = provision uncapped (legacy behavior).
239
+ */
240
+ vaultCapBytes?: number | null;
169
241
  /** Lifetime in seconds. Default {@link DEFAULT_INVITE_TTL_SECONDS} (7 days). */
170
242
  expiresInSeconds?: number;
171
243
  now?: () => Date;
@@ -198,12 +270,15 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
198
270
  const username = opts.username ?? null;
199
271
  const provisionVault = opts.provisionVault ?? true;
200
272
  const defaultMirror = opts.defaultMirror ?? null;
273
+ const maxUses = opts.maxUses ?? 1;
274
+ const vaultCapBytes = opts.vaultCapBytes ?? null;
201
275
 
202
276
  db.prepare(
203
277
  `INSERT INTO invites
204
278
  (token, created_by, vault_name, username, role, provision_vault, default_mirror,
279
+ max_uses, used_count, email, vault_cap_bytes,
205
280
  expires_at, used_at, redeemed_user_id, revoked_at, created_at)
206
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?)`,
281
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, NULL, NULL, NULL, ?)`,
207
282
  ).run(
208
283
  tokenHash,
209
284
  opts.createdBy,
@@ -212,6 +287,8 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
212
287
  role,
213
288
  provisionVault ? 1 : 0,
214
289
  defaultMirror,
290
+ maxUses,
291
+ vaultCapBytes,
215
292
  expiresAt,
216
293
  createdAt,
217
294
  );
@@ -226,6 +303,10 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
226
303
  role,
227
304
  provisionVault,
228
305
  defaultMirror,
306
+ maxUses,
307
+ usedCount: 0,
308
+ email: null,
309
+ vaultCapBytes,
229
310
  expiresAt,
230
311
  usedAt: null,
231
312
  redeemedUserId: null,
@@ -275,7 +356,7 @@ export function usernameReservedByPendingInvite(
275
356
  const row = db
276
357
  .query<{ token: string }, [string, string]>(
277
358
  `SELECT token FROM invites
278
- WHERE username = ? AND used_at IS NULL AND revoked_at IS NULL AND expires_at > ?
359
+ WHERE username = ? AND used_count < max_uses AND revoked_at IS NULL AND expires_at > ?
279
360
  LIMIT 1`,
280
361
  )
281
362
  .get(username, now.toISOString());
@@ -295,11 +376,13 @@ export function listInvites(
295
376
  }
296
377
 
297
378
  /**
298
- * Validate an invite for redemption WITHOUT consuming it. Throws on every
299
- * not-redeemable branch (not-found / expired / used / revoked). Returns the
300
- * invite when it's redeemable. The redemption handler calls this FIRST (so a
301
- * bad token is rejected before any account/vault work), then does
302
- * createUser, then `consumeInvite` AFTER the user row commits.
379
+ * Validate an invite for redemption WITHOUT recording one. Throws on every
380
+ * not-redeemable branch (not-found / expired / exhausted / revoked). Returns
381
+ * the invite when it's redeemable. The redemption handler calls this FIRST (so
382
+ * a bad token is rejected before any account/vault work), then does createUser,
383
+ * then `recordInviteRedemption` INSIDE the user-row transaction (the atomic
384
+ * seat-lock — the `< max_uses` UPDATE guard is what actually prevents an
385
+ * over-seat under concurrency; this early check is the fast-path rejection).
303
386
  */
304
387
  export function assertInviteRedeemable(
305
388
  db: Database,
@@ -308,10 +391,15 @@ export function assertInviteRedeemable(
308
391
  ): Invite {
309
392
  const invite = findInviteByRawToken(db, rawToken);
310
393
  if (!invite) throw new InviteNotFoundError();
311
- // Revoked + used are terminal regardless of clock; check them before expiry
312
- // so a revoked-then-expired invite reports the more specific reason.
394
+ // Revoked + exhausted are terminal regardless of clock; check them before
395
+ // expiry so a revoked-then-expired invite reports the more specific reason.
313
396
  if (invite.revokedAt) throw new InviteRevokedError();
314
- if (invite.usedAt) throw new InviteUsedError();
397
+ // Exhaustion (v15): seats all taken. A single-use link (maxUses=1) throws
398
+ // the familiar InviteUsedError ("already used"); a multi-use one throws
399
+ // InviteExhaustedError ("all signups taken") so the copy can differ.
400
+ if (invite.usedCount >= invite.maxUses) {
401
+ throw invite.maxUses > 1 ? new InviteExhaustedError() : new InviteUsedError();
402
+ }
315
403
  if (now.getTime() > new Date(invite.expiresAt).getTime()) {
316
404
  throw new InviteExpiredError();
317
405
  }
@@ -319,39 +407,75 @@ export function assertInviteRedeemable(
319
407
  }
320
408
 
321
409
  /**
322
- * Mark an invite consumed stamp `used_at` + `redeemed_user_id`. Called
323
- * Called within the account-creation transaction (or after a committed user
324
- * row). Single-use + not-revoked is enforced by the
325
- * `used_at IS NULL AND revoked_at IS NULL` guard in the UPDATE: a racing
326
- * second redeem or a concurrent revoke updates zero rows and the caller
327
- * treats that as already-consumed/revoked. Race-safe because sqlite
328
- * serializes writes.
410
+ * Record ONE redemption against an invite — the multi-use-aware consume
411
+ * (v15). Atomically:
412
+ * - increments `used_count` by 1,
413
+ * - stamps `used_at` on the FIRST redeem only (COALESCE keeps the original),
414
+ * - records the redeemer's `email` (B2) + `redeemed_user_id` as the
415
+ * MOST-RECENT redeemer (the canonical per-account email lives on
416
+ * `users.email`; this is the admin's at-a-glance latest).
417
+ *
418
+ * Exhaustion + revocation are enforced by the `used_count < max_uses AND
419
+ * revoked_at IS NULL` guard in the UPDATE: a racing redeem that would take a
420
+ * seat past the cap — or one racing a revoke — updates zero rows, and the
421
+ * caller treats that as exhausted/revoked. Race-safe because sqlite
422
+ * serializes writes (a legacy single-use link is just the max_uses=1 case:
423
+ * the second concurrent redeem sees `used_count`=1, fails the `< max_uses`
424
+ * guard, and changes zero rows — exactly the old single-use behavior).
329
425
  *
330
- * Returns `true` if THIS call consumed the invite, `false` otherwise.
426
+ * Returns `true` if THIS call recorded a redemption, `false` otherwise.
331
427
  */
332
- export function consumeInvite(
428
+ export function recordInviteRedemption(
333
429
  db: Database,
334
430
  tokenHash: string,
335
431
  redeemedUserId: string,
432
+ email: string | null = null,
336
433
  now: Date = new Date(),
337
434
  ): boolean {
435
+ const stamp = now.toISOString();
338
436
  const res = db
339
437
  .prepare(
340
- "UPDATE invites SET used_at = ?, redeemed_user_id = ? WHERE token = ? AND used_at IS NULL AND revoked_at IS NULL",
438
+ `UPDATE invites
439
+ SET used_count = used_count + 1,
440
+ used_at = COALESCE(used_at, ?),
441
+ redeemed_user_id = ?,
442
+ email = ?
443
+ WHERE token = ? AND used_count < max_uses AND revoked_at IS NULL`,
341
444
  )
342
- .run(now.toISOString(), redeemedUserId, tokenHash);
445
+ .run(stamp, redeemedUserId, email, tokenHash);
343
446
  return res.changes > 0;
344
447
  }
345
448
 
449
+ /**
450
+ * Legacy single-use consume — thin wrapper over {@link recordInviteRedemption}
451
+ * for callers that don't capture email. Retained for backwards compatibility;
452
+ * the redeem path uses `recordInviteRedemption` directly so it can record the
453
+ * email. Race-safe + exhaustion-aware via the underlying function.
454
+ *
455
+ * Returns `true` if THIS call recorded a redemption, `false` otherwise.
456
+ */
457
+ export function consumeInvite(
458
+ db: Database,
459
+ tokenHash: string,
460
+ redeemedUserId: string,
461
+ now: Date = new Date(),
462
+ ): boolean {
463
+ return recordInviteRedemption(db, tokenHash, redeemedUserId, null, now);
464
+ }
465
+
346
466
  /**
347
467
  * Revoke a pending invite (admin DELETE). Stamps `revoked_at` only when the
348
- * invite isn't already used or revoked. Returns `true` if this call revoked
349
- * it, `false` if it was already consumed/revoked or not found.
468
+ * invite still has seats left (`used_count < max_uses`) and isn't already
469
+ * revoked. A multi-use link can be revoked mid-life to cut off its remaining
470
+ * seats; a fully-exhausted link is terminal and revoke is a no-op. (For a
471
+ * legacy single-use link `used_count < max_uses` is equivalent to the old
472
+ * `used_at IS NULL` guard.) Returns `true` if this call revoked it, `false`
473
+ * if it was already exhausted/revoked or not found.
350
474
  */
351
475
  export function revokeInvite(db: Database, tokenHash: string, now: Date = new Date()): boolean {
352
476
  const res = db
353
477
  .prepare(
354
- "UPDATE invites SET revoked_at = ? WHERE token = ? AND used_at IS NULL AND revoked_at IS NULL",
478
+ "UPDATE invites SET revoked_at = ? WHERE token = ? AND used_count < max_uses AND revoked_at IS NULL",
355
479
  )
356
480
  .run(now.toISOString(), tokenHash);
357
481
  return res.changes > 0;
@@ -373,7 +497,7 @@ export function revokeInvitesForVault(
373
497
  ): number {
374
498
  const res = db
375
499
  .prepare(
376
- "UPDATE invites SET revoked_at = ? WHERE vault_name = ? AND used_at IS NULL AND revoked_at IS NULL",
500
+ "UPDATE invites SET revoked_at = ? WHERE vault_name = ? AND used_count < max_uses AND revoked_at IS NULL",
377
501
  )
378
502
  .run(now.toISOString(), vaultName);
379
503
  return Number(res.changes);