@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/package.json +1 -1
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/oauth-handlers.test.ts +122 -1
- package/src/__tests__/public-signup.test.ts +619 -0
- package/src/__tests__/rate-limit.test.ts +86 -0
- package/src/__tests__/two-factor-flow.test.ts +94 -0
- package/src/__tests__/users.test.ts +33 -0
- package/src/__tests__/vault-caps.test.ts +89 -0
- package/src/account-setup.ts +118 -32
- package/src/admin-handlers.ts +53 -16
- package/src/admin-login-ui.ts +30 -4
- package/src/admin-vaults.ts +9 -1
- package/src/api-invites.ts +163 -3
- package/src/api-users.ts +3 -0
- package/src/hub-db.ts +85 -1
- package/src/invites.ts +155 -31
- package/src/oauth-handlers.ts +35 -1
- package/src/rate-limit.ts +111 -20
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
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
|
+
}
|