@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/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
+ }