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

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/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);
@@ -72,6 +72,12 @@ import {
72
72
  } from "./oauth-ui.ts";
73
73
  import { isSameOriginRequest } from "./origin-check.ts";
74
74
  import { buildPendingLoginCookie, createPendingLogin } from "./pending-login.ts";
75
+ import {
76
+ authIpCeilingRateLimiter,
77
+ clientIpFromRequest,
78
+ compositeKey,
79
+ loginRateLimiter,
80
+ } from "./rate-limit.ts";
75
81
  import { isHttpsRequest } from "./request-protocol.ts";
76
82
  import { narrowResourceVaultScopes, resolveResourceVault } from "./resource-binding.ts";
77
83
  import { isNonRequestableScope, isRequestableScope, scopeIsAdmin } from "./scope-explanations.ts";
@@ -1386,12 +1392,40 @@ async function handleLoginSubmit(
1386
1392
  db: Database,
1387
1393
  req: Request,
1388
1394
  form: Awaited<ReturnType<Request["formData"]>>,
1389
- _deps: OAuthDeps,
1395
+ deps: OAuthDeps,
1390
1396
  csrfToken: string,
1391
1397
  ): Promise<Response> {
1392
1398
  const username = String(form.get("username") ?? "");
1393
1399
  const password = String(form.get("password") ?? "");
1394
1400
  const params = paramsFromForm(form);
1401
+ // Rate-limit gate — AFTER CSRF (verified by the `handleAuthorizePost` caller)
1402
+ // and BEFORE the credential check. This `/oauth/authorize` password door had
1403
+ // NO rate-limit before; without it an attacker got an unbounded brute-force
1404
+ // channel that bypassed `/login`'s floor entirely. Two tiers, reusing the
1405
+ // SAME `loginRateLimiter` instance + the SAME `compositeKey(ip, username)`
1406
+ // scheme as `/login`, so the two password doors share ONE per-account bucket
1407
+ // (an attacker can't get 5 tries at `/login` PLUS another 5 here). The coarse
1408
+ // per-IP CEILING backstops username-rotation; the per-(ip,username) FLOOR is
1409
+ // the brute-force floor.
1410
+ const clientIp = clientIpFromRequest(req);
1411
+ const now = deps.now ? deps.now() : new Date();
1412
+ const ceiling = authIpCeilingRateLimiter.checkAndRecord(clientIp, now);
1413
+ const floor = loginRateLimiter.checkAndRecord(compositeKey(clientIp, username), now);
1414
+ if (!ceiling.allowed || !floor.allowed) {
1415
+ const retryAfterSeconds = Math.max(
1416
+ ceiling.allowed ? 0 : (ceiling.retryAfterSeconds ?? 1),
1417
+ floor.allowed ? 0 : (floor.retryAfterSeconds ?? 1),
1418
+ );
1419
+ return htmlResponse(
1420
+ renderLogin({
1421
+ params,
1422
+ csrfToken,
1423
+ errorMessage: `Too many login attempts. Try again in ${retryAfterSeconds} seconds.`,
1424
+ }),
1425
+ 429,
1426
+ { "retry-after": String(retryAfterSeconds) },
1427
+ );
1428
+ }
1395
1429
  if (!username || !password) {
1396
1430
  return htmlResponse(
1397
1431
  renderLogin({ params, csrfToken, errorMessage: "Username and password are required." }),
package/src/rate-limit.ts CHANGED
@@ -1,17 +1,23 @@
1
1
  /**
2
2
  * Rate-limit primitives for hub auth-surface endpoints.
3
3
  *
4
- * Two limiters today (one floor each neither is the primary defense):
4
+ * Each limiter is a floor, not the primary defense. Two are highlighted below
5
+ * for their contrasting threat models; the rest (`/login/2fa`, vault-token
6
+ * mint, public signup) are documented at their `export const` definitions.
7
+ * The public-signup limiter is the deliberate OUTLIER — generous, not tight —
8
+ * because its endpoint is meant to be redeemed by a room of people sharing one
9
+ * egress IP (see `SIGNUP_MAX_ATTEMPTS`).
5
10
  *
6
- * - `/login` (per-IP, hub#187 / hub#188): 5 attempts / 15 min.
7
- * Lands as a floor under brute-force after hub#187 collapsed the
8
- * public-reach matrix: with a cloudflare tunnel up, `/login` is now
9
- * reachable from the open internet, and 2FA (#186) is the next PR
10
- * rather than this one. A 5-attempts-per-15-minute bucket per IP is
11
- * the standard login-form floor; it's not the primary defense, just
12
- * the one that turns "infinite credential grinding" into "rotate IPs".
13
- * (Endpoint was `/admin/login` pre-rename; bucket logic is path-
14
- * agnostic so the rename was a comment-only change here.)
11
+ * - `/login` (per-account floor + per-IP ceiling): the FLOOR is keyed by
12
+ * `compositeKey(ip, username)` at 5 attempts / 15 min, shared with the
13
+ * `/oauth/authorize` password door so both doors feed ONE per-account
14
+ * bucket; the CEILING is `authIpCeilingRateLimiter` at 60 / 15 min keyed
15
+ * by IP alone. Re-keyed from pure per-IP so a room of users behind one
16
+ * NAT'd egress IP (shared wifi / cloudflare tunnel) no longer pool into a
17
+ * single 5-slot bucket the per-account floor still turns "infinite
18
+ * credential grinding" into "rotate IPs", while the IP ceiling backstops
19
+ * username-rotation. (Endpoint was `/admin/login` pre-rename; bucket
20
+ * logic is path-agnostic so the rename was a comment-only change here.)
15
21
  *
16
22
  * - `/account/change-password` (per-user, hub#282): 3 attempts / 5 min.
17
23
  * The endpoint is session-gated, so the threat model isn't open-
@@ -86,13 +92,27 @@ export const CHANGE_PASSWORD_MAX_ATTEMPTS = 3;
86
92
  * factor step (hub#473) sits behind a verified password + a short-lived
87
93
  * pending-login token, so the threat model is "attacker who already has the
88
94
  * password grinding 6-digit codes / backup codes." A 5-attempt / 15-min
89
- * bucket per IP turns 10^6-space TOTP grinding into "rotate IPs," same floor
90
- * as `/login`. Keyed by IP (the pending-login token is short-lived and an
91
- * attacker could mint many, so IP is the stable actor key here).
95
+ * floor turns 10^6-space TOTP grinding into "rotate IPs," same floor as
96
+ * `/login`. Keyed by `compositeKey(ip, userId)` (the shared-egress-IP fix) so
97
+ * each 2FA-enrolled user behind one NAT'd / Cloudflare egress IP gets their own
98
+ * floor — the STABLE userId from the pending-login, NOT the rotating pending
99
+ * token. The coarse per-IP `authIpCeilingRateLimiter` backstops it.
92
100
  */
93
101
  export const TOTP_WINDOW_MS = 15 * 60 * 1000;
94
102
  /** `/login/2fa` attempts allowed per window. 6th within the window is denied. */
95
103
  export const TOTP_MAX_ATTEMPTS = 5;
104
+ /**
105
+ * Coarse per-IP CEILING shared by all interactive auth doors (`/login`, the
106
+ * `/oauth/authorize` password door, `/login/2fa`). 60 attempts / 15 min — the
107
+ * same generous cap as public signup (`SIGNUP_MAX_ATTEMPTS`), chosen so a
108
+ * ~20-person demo room behind ONE NAT'd / Cloudflare egress IP clears it
109
+ * comfortably. It exists ONLY as a backstop against username-rotation: the
110
+ * per-(ip,identity) FLOORs stay tight at 5/15min, but an attacker who rotates
111
+ * usernames against one IP would otherwise get a fresh 5-slot floor per name.
112
+ * This ceiling caps total per-IP auth volume regardless of identity. Reuses the
113
+ * 15-min `WINDOW_MS`.
114
+ */
115
+ export const AUTH_IP_CEILING_MAX_ATTEMPTS = 60;
96
116
  /**
97
117
  * `POST /account/vault-token/<name>` window length: 10 minutes. The endpoint
98
118
  * is session-gated and assignment-capped (a friend can only mint
@@ -109,6 +129,27 @@ export const VAULT_TOKEN_MINT_WINDOW_MS = 10 * 60 * 1000;
109
129
  * minutes is generous for a human and still chokes a stolen-cookie flood.
110
130
  */
111
131
  export const VAULT_TOKEN_MINT_MAX_ATTEMPTS = 10;
132
+ /**
133
+ * `POST /account/setup/<token>` (public invite redemption / signup) window
134
+ * length: 15 minutes — same window as `/login`, but a MUCH larger cap (below)
135
+ * because the threat model is the opposite. A public multi-use signup link is
136
+ * meant to be redeemed by a ROOM OF PEOPLE, and a demo room shares ONE NAT'd
137
+ * egress IP — so a per-IP `/login`-sized cap (5/15min) would 429 the ~6th
138
+ * legitimate signer. This bucket is deliberately generous so a shared egress
139
+ * IP comfortably handles a cohort, while still being a floor against an
140
+ * attacker scripting account creation against an open link (the invite's
141
+ * own `max_uses` + expiry are the primary bound; this is the abuse floor).
142
+ * Separate bucket from `/login` so a signup flurry never burns the login
143
+ * window, and vice-versa.
144
+ */
145
+ export const SIGNUP_WINDOW_MS = 15 * 60 * 1000;
146
+ /**
147
+ * `POST /account/setup/<token>` attempts allowed per IP per window. 60 is
148
+ * comfortably above a single demo room behind one NAT (and well above any
149
+ * legitimate human's retries) while still capping a scripted-abuse rate to
150
+ * ~4/min sustained. The 61st attempt within the window is denied.
151
+ */
152
+ export const SIGNUP_MAX_ATTEMPTS = 60;
112
153
  /** Sentinel for the IP-extraction priority chain when nothing parsed. */
113
154
  export const UNKNOWN_IP_SENTINEL = "unknown";
114
155
 
@@ -209,9 +250,19 @@ export class RateLimiter {
209
250
  }
210
251
 
211
252
  /**
212
- * `/login` rate limiter — per-IP, 5 attempts / 15 min. Exported as a
213
- * singleton so all callers share one bucket map (rotating IPs across the
214
- * test suite or across a real attack must hit the same backing store).
253
+ * `/login` (+ the `/oauth/authorize` password door) rate limiter — per-
254
+ * account/per-half-login floor, keyed (ip,identity), 5 attempts / 15 min.
255
+ * RE-KEYED from per-IP to `compositeKey(ip, username)` (the shared-egress-IP
256
+ * fix): behind NAT / Cloudflare every user in a room presents one
257
+ * CF-Connecting-IP, so a per-IP-only key pooled the whole room into one 5-slot
258
+ * bucket and 429'd the 6th legitimate sign-in. Keyed by (ip,username) instead,
259
+ * each account gets its own floor while still pinning the floor to the IP so a
260
+ * single attacker can't grind one account from one box past 5 tries. BOTH
261
+ * password doors (`/login` and `/oauth/authorize` `__action=login`) share this
262
+ * ONE instance + the SAME key scheme, so an attacker can't get 5 tries per door
263
+ * against the same account. Exported as a singleton so all callers share one
264
+ * bucket map. The coarse per-IP `authIpCeilingRateLimiter` backstops
265
+ * username-rotation across this floor.
215
266
  */
216
267
  export const loginRateLimiter = new RateLimiter(MAX_ATTEMPTS, WINDOW_MS);
217
268
 
@@ -226,14 +277,30 @@ export const changePasswordRateLimiter = new RateLimiter(
226
277
  );
227
278
 
228
279
  /**
229
- * `/login/2fa` rate limiter — per-IP, 5 attempts / 15 min (hub#473). Bounds
230
- * second-factor grinding by an attacker who already has the password. Separate
280
+ * `/login/2fa` rate limiter — per-account/per-half-login floor, keyed
281
+ * (ip,identity), 5 attempts / 15 min (hub#473). Bounds second-factor grinding
282
+ * by an attacker who already has the password. RE-KEYED from per-IP to
283
+ * `compositeKey(ip, userId)` (the shared-egress-IP fix): keyed by the STABLE
284
+ * userId resolved from the pending-login (NOT the pendingToken, which rotates
285
+ * on every /login success and would reset the bucket). Behind NAT every 2FA-
286
+ * enrolled user in a room would otherwise pool into one per-IP bucket. Separate
231
287
  * bucket from `/login` so a password failure and a TOTP failure don't share a
232
- * window but both are per-IP so rotating egress IPs is the only escape, same
233
- * as the password floor.
288
+ * window. The coarse per-IP `authIpCeilingRateLimiter` backstops this floor.
234
289
  */
235
290
  export const totpRateLimiter = new RateLimiter(TOTP_MAX_ATTEMPTS, TOTP_WINDOW_MS);
236
291
 
292
+ /**
293
+ * Coarse per-IP CEILING rate limiter — 60 attempts / 15 min, keyed by client
294
+ * IP ONLY. Shared by all interactive auth doors (`/login`, the
295
+ * `/oauth/authorize` password door, `/login/2fa`) as a backstop against
296
+ * username-rotation: the per-(ip,identity) floors above stay tight, but an
297
+ * attacker rotating usernames against one IP would otherwise get a fresh floor
298
+ * per name. This ceiling caps total per-IP auth volume regardless of identity.
299
+ * Deliberately generous (matches the signup precedent) so a demo room sharing
300
+ * one egress IP clears it.
301
+ */
302
+ export const authIpCeilingRateLimiter = new RateLimiter(AUTH_IP_CEILING_MAX_ATTEMPTS, WINDOW_MS);
303
+
237
304
  /**
238
305
  * `POST /account/vault-token/<name>` rate limiter — per-user, 10 attempts /
239
306
  * 10 min (friend vault-token mint). Keyed by user-id (session-gated endpoint,
@@ -246,6 +313,16 @@ export const vaultTokenMintRateLimiter = new RateLimiter(
246
313
  VAULT_TOKEN_MINT_WINDOW_MS,
247
314
  );
248
315
 
316
+ /**
317
+ * `POST /account/setup/<token>` rate limiter — per-IP, 60 attempts / 15 min
318
+ * (public invite redemption / signup). DELIBERATELY generous: a public
319
+ * multi-use signup link is redeemed by a room of people sharing one NAT'd
320
+ * egress IP, so a `/login`-sized cap would 429 legitimate signers mid-demo.
321
+ * Separate bucket from `/login` so the two never share a window. The invite's
322
+ * own `max_uses` + expiry are the primary bound; this is the abuse floor.
323
+ */
324
+ export const signupRateLimiter = new RateLimiter(SIGNUP_MAX_ATTEMPTS, SIGNUP_WINDOW_MS);
325
+
249
326
  /**
250
327
  * Backwards-compat shim for hub#188's call sites: the original
251
328
  * top-level `checkAndRecord` was the login limiter. New code should
@@ -266,6 +343,20 @@ export function __resetForTests(): void {
266
343
  changePasswordRateLimiter.reset();
267
344
  totpRateLimiter.reset();
268
345
  vaultTokenMintRateLimiter.reset();
346
+ signupRateLimiter.reset();
347
+ authIpCeilingRateLimiter.reset();
348
+ }
349
+
350
+ /**
351
+ * Compose a per-(ip,identity) rate-limit key. Used by the per-account auth
352
+ * FLOORs (`/login` + `/oauth/authorize` keyed by username; `/login/2fa` keyed
353
+ * by userId) so each account behind a shared egress IP gets its own bucket
354
+ * while the floor still pins to the IP. The identity is trimmed + lowercased so
355
+ * `'Alice'` and `'alice'` share one bucket — otherwise an attacker could
356
+ * case-flip a username to mint a fresh 5-slot floor per casing.
357
+ */
358
+ export function compositeKey(ip: string, id: string): string {
359
+ return `${ip}|${id.trim().toLowerCase()}`;
269
360
  }
270
361
 
271
362
  /**