@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/src/rate-limit.ts CHANGED
@@ -1,7 +1,12 @@
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
11
  * - `/login` (per-IP, hub#187 / hub#188): 5 attempts / 15 min.
7
12
  * Lands as a floor under brute-force after hub#187 collapsed the
@@ -109,6 +114,27 @@ export const VAULT_TOKEN_MINT_WINDOW_MS = 10 * 60 * 1000;
109
114
  * minutes is generous for a human and still chokes a stolen-cookie flood.
110
115
  */
111
116
  export const VAULT_TOKEN_MINT_MAX_ATTEMPTS = 10;
117
+ /**
118
+ * `POST /account/setup/<token>` (public invite redemption / signup) window
119
+ * length: 15 minutes — same window as `/login`, but a MUCH larger cap (below)
120
+ * because the threat model is the opposite. A public multi-use signup link is
121
+ * meant to be redeemed by a ROOM OF PEOPLE, and a demo room shares ONE NAT'd
122
+ * egress IP — so a per-IP `/login`-sized cap (5/15min) would 429 the ~6th
123
+ * legitimate signer. This bucket is deliberately generous so a shared egress
124
+ * IP comfortably handles a cohort, while still being a floor against an
125
+ * attacker scripting account creation against an open link (the invite's
126
+ * own `max_uses` + expiry are the primary bound; this is the abuse floor).
127
+ * Separate bucket from `/login` so a signup flurry never burns the login
128
+ * window, and vice-versa.
129
+ */
130
+ export const SIGNUP_WINDOW_MS = 15 * 60 * 1000;
131
+ /**
132
+ * `POST /account/setup/<token>` attempts allowed per IP per window. 60 is
133
+ * comfortably above a single demo room behind one NAT (and well above any
134
+ * legitimate human's retries) while still capping a scripted-abuse rate to
135
+ * ~4/min sustained. The 61st attempt within the window is denied.
136
+ */
137
+ export const SIGNUP_MAX_ATTEMPTS = 60;
112
138
  /** Sentinel for the IP-extraction priority chain when nothing parsed. */
113
139
  export const UNKNOWN_IP_SENTINEL = "unknown";
114
140
 
@@ -246,6 +272,16 @@ export const vaultTokenMintRateLimiter = new RateLimiter(
246
272
  VAULT_TOKEN_MINT_WINDOW_MS,
247
273
  );
248
274
 
275
+ /**
276
+ * `POST /account/setup/<token>` rate limiter — per-IP, 60 attempts / 15 min
277
+ * (public invite redemption / signup). DELIBERATELY generous: a public
278
+ * multi-use signup link is redeemed by a room of people sharing one NAT'd
279
+ * egress IP, so a `/login`-sized cap would 429 legitimate signers mid-demo.
280
+ * Separate bucket from `/login` so the two never share a window. The invite's
281
+ * own `max_uses` + expiry are the primary bound; this is the abuse floor.
282
+ */
283
+ export const signupRateLimiter = new RateLimiter(SIGNUP_MAX_ATTEMPTS, SIGNUP_WINDOW_MS);
284
+
249
285
  /**
250
286
  * Backwards-compat shim for hub#188's call sites: the original
251
287
  * top-level `checkAndRecord` was the login limiter. New code should
@@ -266,6 +302,7 @@ export function __resetForTests(): void {
266
302
  changePasswordRateLimiter.reset();
267
303
  totpRateLimiter.reset();
268
304
  vaultTokenMintRateLimiter.reset();
305
+ signupRateLimiter.reset();
269
306
  }
270
307
 
271
308
  /**
package/src/users.ts CHANGED
@@ -34,6 +34,15 @@ export interface User {
34
34
  * as `users.password_changed INTEGER 0|1` (added in migration v8).
35
35
  */
36
36
  passwordChanged: boolean;
37
+ /**
38
+ * Contactable email captured at signup (migration v15, B2). The username
39
+ * is the login + URL identity ([a-z0-9_-]); email is the SEPARATE contact
40
+ * field the operator sees + uses to reach a signup. `null` for every
41
+ * account created before email capture (wizard admin, env-seeded admin,
42
+ * pre-named friend invites that didn't collect one). Not unique at the
43
+ * schema level — see migration v15.
44
+ */
45
+ email: string | null;
37
46
  /**
38
47
  * The vault instance names this user has access to (multi-user Phase 2
39
48
  * PR 2 — many-to-many via the `user_vaults` table; design
@@ -81,6 +90,7 @@ interface Row {
81
90
  created_at: string;
82
91
  updated_at: string;
83
92
  password_changed: number;
93
+ email: string | null;
84
94
  }
85
95
 
86
96
  /**
@@ -126,6 +136,7 @@ function rowToUser(r: Row, assignedVaults: string[]): User {
126
136
  createdAt: r.created_at,
127
137
  updatedAt: r.updated_at,
128
138
  passwordChanged: r.password_changed === 1,
139
+ email: r.email ?? null,
129
140
  assignedVaults,
130
141
  };
131
142
  }
@@ -232,6 +243,15 @@ export interface CreateUserOpts {
232
243
  * each name against `services.json` before passing through.
233
244
  */
234
245
  assignedVaults?: string[];
246
+ /**
247
+ * Contactable email to store on the new account (migration v15, B2).
248
+ * Default `null` — the wizard/env-seeded admin paths and pre-named
249
+ * friend invites that don't collect email omit it. The public-signup
250
+ * redeem path passes the validated email so the operator can reach the
251
+ * signup. Validation (format) is the caller's responsibility
252
+ * (`validateEmail`); this just persists what it's given.
253
+ */
254
+ email?: string | null;
235
255
  /**
236
256
  * The `user_vaults.role` to write for every entry in `assignedVaults`.
237
257
  * Default `'write'` (= owner; `vaultVerbsForRole('write')` grants the
@@ -268,6 +288,7 @@ export async function createUser(
268
288
  const passwordHash = await argonHash(password);
269
289
  const stamp = (opts.now?.() ?? new Date()).toISOString();
270
290
  const passwordChanged = opts.passwordChanged === true ? 1 : 0;
291
+ const email = opts.email ?? null;
271
292
  // De-dupe + preserve insert order so the returned array matches what
272
293
  // `getUserById` would load right after (which sorts by created_at +
273
294
  // vault_name). Empty array is "no vaults" — admin posture or a non-
@@ -284,9 +305,9 @@ export async function createUser(
284
305
  db.transaction(() => {
285
306
  db.prepare(
286
307
  `INSERT INTO users
287
- (id, username, password_hash, created_at, updated_at, password_changed)
288
- VALUES (?, ?, ?, ?, ?, ?)`,
289
- ).run(id, username, passwordHash, stamp, stamp, passwordChanged);
308
+ (id, username, password_hash, created_at, updated_at, password_changed, email)
309
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
310
+ ).run(id, username, passwordHash, stamp, stamp, passwordChanged, email);
290
311
  if (assignedVaults.length > 0) {
291
312
  const role = opts.role ?? "write";
292
313
  const insertVault = db.prepare(
@@ -315,6 +336,7 @@ export async function createUser(
315
336
  createdAt: stamp,
316
337
  updatedAt: stamp,
317
338
  passwordChanged: passwordChanged === 1,
339
+ email,
318
340
  assignedVaults,
319
341
  };
320
342
  }
@@ -749,3 +771,40 @@ export function validatePassword(password: string): ValidatePasswordResult {
749
771
  }
750
772
  return { valid: true };
751
773
  }
774
+
775
+ /**
776
+ * Email validation (migration v15, B2 — public-signup email capture).
777
+ *
778
+ * Deliberately PERMISSIVE: a single `local@domain.tld` shape check, not a
779
+ * full RFC 5322 parser. The goal is "the operator can plausibly reach this
780
+ * person," not RFC compliance — over-strict regexes reject valid real-world
781
+ * addresses (plus-tags, subdomains, long TLDs) and add no security. We require:
782
+ * * exactly one `@`,
783
+ * * a non-empty local part with no whitespace,
784
+ * * a domain with at least one `.` and a 2+ char final label,
785
+ * * no whitespace anywhere, and an overall length ceiling (254, the SMTP
786
+ * practical max) so a megabyte string can't be stored.
787
+ *
788
+ * The address is lowercased + trimmed before the check and returned in that
789
+ * canonical form. Same discriminated-union shape as the other validators so
790
+ * the API/redeem edge can surface the reason.
791
+ */
792
+ export const EMAIL_MAX_LEN = 254;
793
+
794
+ // One @, no whitespace, a dotted domain ending in a 2+ char label.
795
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@.]{2,}$/;
796
+
797
+ export type ValidateEmailResult =
798
+ | { valid: true; email: string }
799
+ | { valid: false; reason: "format" | "length" };
800
+
801
+ export function validateEmail(raw: string): ValidateEmailResult {
802
+ const email = raw.trim().toLowerCase();
803
+ if (email.length === 0 || email.length > EMAIL_MAX_LEN) {
804
+ return { valid: false, reason: "length" };
805
+ }
806
+ if (!EMAIL_REGEX.test(email)) {
807
+ return { valid: false, reason: "format" };
808
+ }
809
+ return { valid: true, email };
810
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Per-vault storage caps (migration v15, DEMO-PREP-2026-06-25 Workstream B,
3
+ * task B4). A vault provisioned through a capped signup link gets a row in
4
+ * the `vault_caps` table recording its byte ceiling. This module is the
5
+ * read/write seam over that table.
6
+ *
7
+ * Split of responsibility, on purpose:
8
+ * - THIS PR (B1/B2/B4) PERSISTS the cap at provision time — nothing more.
9
+ * The public-signup flow stamps a row here when it provisions a vault.
10
+ * - A SEPARATE Phase-2 PR (B3, parachute-vault + hub wiring) READS this
11
+ * row and ENFORCES it at upload time (4xx over cap). The brief is
12
+ * explicit: "at minimum store the cap so the later PR can read + enforce
13
+ * it." So `getVaultCapBytes` exists for that future reader; this PR only
14
+ * calls `setVaultCap`.
15
+ *
16
+ * Keyed by `vault_name` — the same instance-name space used across
17
+ * services.json / `user_vaults` / `invites.vault_name`. No FK to a vaults
18
+ * table (there isn't one — vault names resolve through services.json, the
19
+ * established hub pattern). A vault with NO row is "uncapped," which is what
20
+ * the Phase-2 reader treats every pre-existing / admin-provisioned vault as.
21
+ */
22
+ import type { Database } from "bun:sqlite";
23
+
24
+ /**
25
+ * Default per-vault cap stamped by the public-signup flow when an invite
26
+ * carries no explicit cap but the flow wants one: ~1 GB (DEMO-PREP decision
27
+ * "1 GB per vault, configurable"). 1 GiB = 1024^3 bytes.
28
+ */
29
+ export const DEFAULT_VAULT_CAP_BYTES = 1024 * 1024 * 1024;
30
+
31
+ export interface VaultCap {
32
+ vaultName: string;
33
+ capBytes: number;
34
+ createdAt: string;
35
+ updatedAt: string;
36
+ }
37
+
38
+ interface Row {
39
+ vault_name: string;
40
+ cap_bytes: number;
41
+ created_at: string;
42
+ updated_at: string;
43
+ }
44
+
45
+ function rowToCap(r: Row): VaultCap {
46
+ return {
47
+ vaultName: r.vault_name,
48
+ capBytes: r.cap_bytes,
49
+ createdAt: r.created_at,
50
+ updatedAt: r.updated_at,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Persist (or update) a vault's storage cap. Upsert on `vault_name`: a
56
+ * re-provision (or a future admin edit) overwrites the cap and bumps
57
+ * `updated_at` while preserving the original `created_at`. `capBytes` must
58
+ * be a positive integer — callers validate before reaching here; this
59
+ * floors to an int defensively.
60
+ */
61
+ export function setVaultCap(
62
+ db: Database,
63
+ vaultName: string,
64
+ capBytes: number,
65
+ now: Date = new Date(),
66
+ ): VaultCap {
67
+ const stamp = now.toISOString();
68
+ const bytes = Math.floor(capBytes);
69
+ db.prepare(
70
+ `INSERT INTO vault_caps (vault_name, cap_bytes, created_at, updated_at)
71
+ VALUES (?, ?, ?, ?)
72
+ ON CONFLICT(vault_name) DO UPDATE SET
73
+ cap_bytes = excluded.cap_bytes,
74
+ updated_at = excluded.updated_at`,
75
+ ).run(vaultName, bytes, stamp, stamp);
76
+ // Re-read so the returned createdAt reflects the preserved original on an
77
+ // update (excluded.created_at is NOT written on conflict).
78
+ const cap = getVaultCap(db, vaultName);
79
+ // The row was just written, so it always exists; the non-null assertion
80
+ // is safe but we fall back defensively rather than throw.
81
+ return cap ?? { vaultName, capBytes: bytes, createdAt: stamp, updatedAt: stamp };
82
+ }
83
+
84
+ /** Read a vault's cap, or `null` when the vault has no cap row (uncapped). */
85
+ export function getVaultCap(db: Database, vaultName: string): VaultCap | null {
86
+ const row = db
87
+ .query<Row, [string]>("SELECT * FROM vault_caps WHERE vault_name = ?")
88
+ .get(vaultName);
89
+ return row ? rowToCap(row) : null;
90
+ }
91
+
92
+ /**
93
+ * Convenience for the Phase-2 enforcement reader: the cap in bytes, or
94
+ * `null` (uncapped) when no row exists. Thin wrapper over {@link getVaultCap}.
95
+ */
96
+ export function getVaultCapBytes(db: Database, vaultName: string): number | null {
97
+ return getVaultCap(db, vaultName)?.capBytes ?? null;
98
+ }
99
+
100
+ /**
101
+ * Vault-delete cascade hook (parity with the other per-vault identity
102
+ * artifacts swept in admin-vaults.ts `handleDeleteVault`): drop the cap row
103
+ * when its vault is deleted so a re-created same-name vault doesn't inherit a
104
+ * stale cap. Exact `=` match, no pattern. Returns rows deleted (0 or 1).
105
+ */
106
+ export function removeVaultCap(db: Database, vaultName: string): number {
107
+ const res = db.prepare("DELETE FROM vault_caps WHERE vault_name = ?").run(vaultName);
108
+ return Number(res.changes);
109
+ }