@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/package.json +1 -1
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/public-signup.test.ts +619 -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-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/rate-limit.ts +38 -1
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
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
|
-
*
|
|
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
|
+
}
|