@openparachute/hub 0.7.2 → 0.7.3-rc.10

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.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-agent-grants.test.ts +113 -0
  3. package/src/__tests__/admin-vaults.test.ts +20 -5
  4. package/src/__tests__/api-modules.test.ts +65 -10
  5. package/src/__tests__/api-vault-caps.test.ts +232 -0
  6. package/src/__tests__/cli.test.ts +20 -0
  7. package/src/__tests__/hub-command.test.ts +136 -0
  8. package/src/__tests__/hub-origins-env-set.test.ts +273 -0
  9. package/src/__tests__/init.test.ts +148 -0
  10. package/src/__tests__/jwt-sign.test.ts +79 -0
  11. package/src/__tests__/module-manifest.test.ts +3 -1
  12. package/src/__tests__/oauth-client.test.ts +75 -0
  13. package/src/__tests__/oauth-handlers.test.ts +413 -5
  14. package/src/__tests__/public-signup.test.ts +619 -0
  15. package/src/__tests__/rate-limit.test.ts +86 -0
  16. package/src/__tests__/scribe-config.test.ts +117 -0
  17. package/src/__tests__/serve-boot.test.ts +45 -0
  18. package/src/__tests__/serve.test.ts +67 -1
  19. package/src/__tests__/service-spec-discovery.test.ts +22 -2
  20. package/src/__tests__/setup-wizard.test.ts +18 -0
  21. package/src/__tests__/setup.test.ts +129 -15
  22. package/src/__tests__/two-factor-flow.test.ts +94 -0
  23. package/src/__tests__/users.test.ts +33 -0
  24. package/src/__tests__/vault-caps.test.ts +89 -0
  25. package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
  26. package/src/__tests__/wizard-transcription.test.ts +334 -0
  27. package/src/__tests__/wizard.test.ts +146 -0
  28. package/src/account-setup.ts +118 -32
  29. package/src/admin-agent-grants.ts +51 -3
  30. package/src/admin-handlers.ts +53 -16
  31. package/src/admin-login-ui.ts +30 -4
  32. package/src/admin-vaults.ts +9 -1
  33. package/src/api-invites.ts +163 -3
  34. package/src/api-modules-ops.ts +12 -0
  35. package/src/api-modules.ts +47 -13
  36. package/src/api-users.ts +3 -0
  37. package/src/api-vault-caps.ts +206 -0
  38. package/src/cli.ts +35 -1
  39. package/src/commands/hub.ts +173 -0
  40. package/src/commands/init.ts +73 -2
  41. package/src/commands/serve-boot.ts +16 -2
  42. package/src/commands/serve.ts +39 -3
  43. package/src/commands/setup.ts +31 -6
  44. package/src/commands/wizard-transcription.ts +296 -0
  45. package/src/commands/wizard.ts +73 -3
  46. package/src/help.ts +8 -0
  47. package/src/hub-db.ts +108 -1
  48. package/src/hub-origin.ts +64 -0
  49. package/src/hub-server.ts +27 -0
  50. package/src/invites.ts +155 -31
  51. package/src/jwt-sign.ts +72 -3
  52. package/src/module-manifest.ts +23 -9
  53. package/src/oauth-client.ts +102 -12
  54. package/src/oauth-flows-store.ts +12 -0
  55. package/src/oauth-handlers.ts +145 -20
  56. package/src/rate-limit.ts +111 -20
  57. package/src/scribe-config.ts +145 -0
  58. package/src/service-spec.ts +31 -12
  59. package/src/setup-wizard.ts +37 -4
  60. package/src/users.ts +62 -3
  61. package/src/vault-caps.ts +109 -0
  62. package/src/vault-hub-origin-env.ts +109 -16
  63. package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
  64. package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/web/ui/dist/assets/index-B5AUE359.js +0 -61
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
  /**
@@ -1,4 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { totalmem } from "node:os";
2
3
  import { dirname, join } from "node:path";
3
4
  import { parseEnvFile, upsertEnvLine, writeEnvFile } from "./env-file.ts";
4
5
 
@@ -60,6 +61,118 @@ export type ScribeProviderKey = (typeof SCRIBE_PROVIDERS)[number]["key"];
60
61
  /** Default provider scribe falls back to when the config doesn't pick one. */
61
62
  export const SCRIBE_DEFAULT_PROVIDER: ScribeProviderKey = "parakeet-mlx";
62
63
 
64
+ /**
65
+ * Resolve the "local" choice to the CORRECT platform backend. The setup
66
+ * wizard (browser + CLI) lets the operator pick "local" without knowing the
67
+ * engine name; this picks the one that actually runs here.
68
+ *
69
+ * - macOS → `parakeet-mlx` (Apple Silicon MLX)
70
+ * - Linux → `onnx-asr` (cross-platform Sherpa-ONNX)
71
+ * - other → `null` (no local backend — steer to cloud)
72
+ *
73
+ * Mirrors scribe's own `platformLocalProvider` (parachute-scribe
74
+ * src/install-backend.ts) so hub and scribe can't drift on the mapping.
75
+ * Fixes the long-standing bug where the wizard mapped `local` UNCONDITIONALLY
76
+ * to `parakeet-mlx`, which silently fails on every Linux box (the common
77
+ * DigitalOcean / VPS deploy).
78
+ */
79
+ export function platformLocalProvider(
80
+ platform: NodeJS.Platform,
81
+ ): "parakeet-mlx" | "onnx-asr" | null {
82
+ if (platform === "darwin") return "parakeet-mlx";
83
+ if (platform === "linux") return "onnx-asr";
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Minimum available RAM (MiB) below which a local ASR model would be
89
+ * OOM-killed. Mirrors scribe's `MIN_RAM_MIB` (parachute-scribe
90
+ * src/install-backend.ts) — the 1 GB DigitalOcean droplet is the box this
91
+ * guards against; a local Parakeet/ONNX model needs ~2 GB to load.
92
+ */
93
+ export const MIN_RAM_MIB = 2048;
94
+
95
+ /**
96
+ * Available RAM in MiB, or `null` when it can't be determined.
97
+ *
98
+ * Linux: reads `MemAvailable` from `/proc/meminfo` (the honest figure — free
99
+ * plus reclaimable cache), matching scribe's probe so the two layers agree on
100
+ * the same droplet. Falls back to `MemFree` on very old kernels that predate
101
+ * MemAvailable.
102
+ *
103
+ * Non-Linux: falls back to `os.totalmem()` (there's no MemAvailable analogue;
104
+ * total is a coarse upper bound but enough to keep a tiny VM from offering
105
+ * local). macOS dev boxes comfortably clear the floor, so the coarseness is
106
+ * harmless there.
107
+ *
108
+ * Sync by design: the wizard's provider-decision path is synchronous, and the
109
+ * `/proc/meminfo` read is a tiny file. Tests inject `availableRamMib` directly
110
+ * to exercise the gate without touching the real host.
111
+ */
112
+ export function readAvailableRamMib(platform: NodeJS.Platform = process.platform): number | null {
113
+ if (platform === "linux") {
114
+ try {
115
+ const text = readFileSync("/proc/meminfo", "utf8");
116
+ const avail = /^MemAvailable:\s+(\d+)\s*kB/m.exec(text);
117
+ const free = /^MemFree:\s+(\d+)\s*kB/m.exec(text);
118
+ const kb = avail ? Number(avail[1]) : free ? Number(free[1]) : null;
119
+ if (kb === null || !Number.isFinite(kb)) return null;
120
+ return Math.floor(kb / 1024);
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+ // Non-Linux: total physical memory as a coarse fallback.
126
+ try {
127
+ const bytes = totalmem();
128
+ if (!Number.isFinite(bytes) || bytes <= 0) return null;
129
+ return Math.floor(bytes / (1024 * 1024));
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Decide whether a LOCAL transcription provider is offerable / acceptable on
137
+ * this host, and if not, why + what to steer to. Both wizard surfaces (browser
138
+ * POST handler + CLI) consult this BEFORE recording a `local` choice so we
139
+ * never write a dead provider string that scribe can't run.
140
+ *
141
+ * `ok: false` carries a `reason` (shown inline) and a `steerTo` cloud provider
142
+ * the caller should redirect to.
143
+ */
144
+ export interface LocalProviderDecision {
145
+ ok: boolean;
146
+ /** The resolved platform backend when `ok` (parakeet-mlx / onnx-asr). */
147
+ provider?: "parakeet-mlx" | "onnx-asr";
148
+ /** Human-readable reason when `ok` is false (no local backend / too little RAM). */
149
+ reason?: string;
150
+ /** Cloud provider to redirect to when local is unavailable. */
151
+ steerTo?: "groq";
152
+ }
153
+
154
+ export function decideLocalProvider(
155
+ platform: NodeJS.Platform,
156
+ availableRamMib: number | null,
157
+ ): LocalProviderDecision {
158
+ const provider = platformLocalProvider(platform);
159
+ if (provider === null) {
160
+ return {
161
+ ok: false,
162
+ reason: `No local transcription backend runs on "${platform}". Use a cloud provider instead.`,
163
+ steerTo: "groq",
164
+ };
165
+ }
166
+ if (availableRamMib !== null && availableRamMib < MIN_RAM_MIB) {
167
+ return {
168
+ ok: false,
169
+ reason: `This box has ${availableRamMib} MiB available RAM, below the ${MIN_RAM_MIB} MiB a local ASR model needs (it would be OOM-killed). Use a cloud provider instead — groq is fast (~$0.04/hr of audio).`,
170
+ steerTo: "groq",
171
+ };
172
+ }
173
+ return { ok: true, provider };
174
+ }
175
+
63
176
  export function isKnownScribeProvider(value: string): value is ScribeProviderKey {
64
177
  return SCRIBE_PROVIDERS.some((p) => p.key === value);
65
178
  }
@@ -136,6 +249,38 @@ export function writeScribeProvider(configDir: string, provider: ScribeProviderK
136
249
  renameSync(tmp, path);
137
250
  }
138
251
 
252
+ /**
253
+ * Remove `transcribe.provider` from scribe's config.json (preserving every
254
+ * other key). Used by the wizard's local-install path to UNDO a provisional
255
+ * provider record when the engine install fails — so we never leave a dead
256
+ * provider string scribe can't honor. A no-op when the file or the
257
+ * `transcribe` block is absent.
258
+ */
259
+ export function clearScribeProvider(configDir: string): void {
260
+ const path = scribeConfigPath(configDir);
261
+ if (!existsSync(path)) return;
262
+ let current: Record<string, unknown>;
263
+ try {
264
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
265
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
266
+ current = parsed as Record<string, unknown>;
267
+ } catch {
268
+ return; // malformed → leave it; auto-wire repairs on next run
269
+ }
270
+ const transcribe = current.transcribe;
271
+ if (typeof transcribe !== "object" || transcribe === null || Array.isArray(transcribe)) {
272
+ return;
273
+ }
274
+ // Drop `provider` from the transcribe block (destructure-omit, no `delete`).
275
+ const { provider: _dropped, ...block } = transcribe as Record<string, unknown>;
276
+ const { transcribe: _omit, ...rest } = current;
277
+ const next: Record<string, unknown> =
278
+ Object.keys(block).length === 0 ? rest : { ...rest, transcribe: block };
279
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
280
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
281
+ renameSync(tmp, path);
282
+ }
283
+
139
284
  /**
140
285
  * Idempotent upsert of a single `KEY=value` into `<configDir>/scribe/.env`.
141
286
  * Used for the API-key prompt result. Other lines (auto-wire keys, manual
@@ -637,12 +637,22 @@ export function knownServices(): string[] {
637
637
  }
638
638
 
639
639
  /**
640
- * Default discovery tier per short name (2026-06-09 modular-UI architecture).
641
- * The hub PREFERS a module's manifest-declared `focus`; this map is the
642
- * fallback when `module.json` omits it (and the bootstrap value before a
643
- * module is installed). vault / scribe / hub / surface are `core`; everything
644
- * else (agent / runner / notes / unknown third-party) defaults to
645
- * `experimental`. **Show all; never hide** `focus` only groups + labels.
640
+ * Default discovery tier per short name (2026-06-09 modular-UI architecture;
641
+ * `deprecated` tier added 2026-06-25). The hub PREFERS a module's
642
+ * manifest-declared `focus`; this map is the fallback when `module.json` omits
643
+ * it (and the bootstrap value before a module is installed).
644
+ *
645
+ * - `core` vault / scribe / hub / surface (the product surface).
646
+ * - `experimental` — agent (legit preview; still OFFERED on a fresh install)
647
+ * + any unlisted third-party short.
648
+ * - `deprecated` — notes (notes-daemon deprecated 2026-05-22; notes-ui moved
649
+ * into parachute-surface) + runner (per Aaron 2026-06-25: not for new
650
+ * installs). Still RESOLVABLE (discoverableShorts unchanged) and
651
+ * SHOWN-IF-INSTALLED so an existing operator can manage/uninstall, but NOT
652
+ * OFFERED on a fresh setup.
653
+ *
654
+ * **Show all installed; never hide** — `focus` groups + labels; the one
655
+ * behavioral lever is the fresh-install OFFER, which drops `deprecated` shorts.
646
656
  */
647
657
  const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
648
658
  vault: "core",
@@ -650,8 +660,8 @@ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
650
660
  hub: "core",
651
661
  surface: "core",
652
662
  agent: "experimental",
653
- runner: "experimental",
654
- notes: "experimental",
663
+ runner: "deprecated",
664
+ notes: "deprecated",
655
665
  };
656
666
 
657
667
  /**
@@ -659,6 +669,13 @@ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
659
669
  * `module.json` `focus`) is present it wins; otherwise fall back to
660
670
  * `FOCUS_DEFAULTS`, defaulting any unlisted short to `experimental`. Never
661
671
  * returns undefined — the Modules screen always has a tier to group by.
672
+ *
673
+ * Tier semantics: `core`/`experimental` are both OFFERED on a fresh install;
674
+ * `deprecated` (notes / runner) is NOT offered on a fresh setup but stays
675
+ * resolvable + shown-if-installed (the `isKnownModuleShort` /
676
+ * `discoverableShorts` resolution surface is unchanged). The fresh-install
677
+ * filters in `setup.ts` + `api-modules.ts` consult this tier to drop
678
+ * `deprecated` shorts from the "available to install" set.
662
679
  */
663
680
  export function focusForShort(short: string, declared?: ModuleFocus): ModuleFocus {
664
681
  if (declared !== undefined) return declared;
@@ -674,10 +691,12 @@ export function focusForShort(short: string, declared?: ModuleFocus): ModuleFocu
674
691
  * regardless of `focus` tier. Deduped, with FIRST_PARTY_FALLBACKS shorts first
675
692
  * (notes) then KNOWN_MODULES (vault / scribe / runner / agent / surface).
676
693
  *
677
- * `notes` is intentionally included — it's still resolvable (vendored fallback)
678
- * for legacy installs; it surfaces as `experimental` and isn't pushed as a
679
- * fresh install. Callers that want only the "recommended fresh-install" subset
680
- * can filter by `focus === "core"`.
694
+ * `notes` (and `runner`) are intentionally included — still resolvable
695
+ * (vendored fallback / KNOWN_MODULES) for legacy installs; they surface as
696
+ * `deprecated` (2026-06-25) and aren't OFFERED on a fresh install. The
697
+ * fresh-install OFFER (setup wizard + admin SPA) filters by tier
698
+ * (`focus !== "deprecated"`); `discoverableShorts` itself stays the full
699
+ * resolution surface so existing installs keep working.
681
700
  */
682
701
  export function discoverableShorts(): string[] {
683
702
  const seen = new Set<string>();
@@ -74,6 +74,11 @@ import {
74
74
  readOperatorTokenFile,
75
75
  } from "./operator-token.ts";
76
76
  import { isHttpsRequest } from "./request-protocol.ts";
77
+ import {
78
+ decideLocalProvider,
79
+ platformLocalProvider,
80
+ readAvailableRamMib,
81
+ } from "./scribe-config.ts";
77
82
  import { SEED_VERSION } from "./service-spec.ts";
78
83
  import { findService, readManifestLenient } from "./services-manifest.ts";
79
84
  import {
@@ -2410,8 +2415,23 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
2410
2415
  // Cleanup-without-transcribe is a valid combo: the operator can
2411
2416
  // hit scribe's REST cleanup endpoint directly with their own raw
2412
2417
  // text. We install scribe + write the cleanup block in that case.
2413
- const scribeProvider = String(form.get("scribe_provider") ?? "").trim();
2418
+ let scribeProvider = String(form.get("scribe_provider") ?? "").trim();
2414
2419
  const scribeCleanupProvider = String(form.get("scribe_cleanup_provider") ?? "").trim();
2420
+ // RAM/platform gate: if the operator asked for `local` on a box that can't
2421
+ // run a local ASR model (no local backend for the platform, or too little
2422
+ // RAM — the 1 GB droplet would OOM), redirect the choice to a cloud provider
2423
+ // (groq) rather than recording a dead `local` string scribe can never honor.
2424
+ // The reason is logged; the inline UI surfaces it via the scribe op poll.
2425
+ if (scribeProvider === "local") {
2426
+ const decision = decideLocalProvider(process.platform, readAvailableRamMib());
2427
+ if (!decision.ok) {
2428
+ console.warn(
2429
+ `[setup-wizard] local transcription unavailable on this host: ${decision.reason} ` +
2430
+ `Steering to "${decision.steerTo}".`,
2431
+ );
2432
+ scribeProvider = decision.steerTo ?? "groq";
2433
+ }
2434
+ }
2415
2435
  const wantsTranscribe = scribeProvider !== "" && scribeProvider !== "none";
2416
2436
  const wantsCleanup = scribeCleanupProvider !== "" && scribeCleanupProvider !== "none";
2417
2437
  let scribeOpId: string | undefined;
@@ -2590,16 +2610,29 @@ interface WizardScribeConfig {
2590
2610
  transcribe?: { provider: string; apiKey: string };
2591
2611
  /** Set when the operator chose a cleanup provider (anything other than "none"). */
2592
2612
  cleanup?: { provider: string; apiKey: string };
2613
+ /**
2614
+ * Platform override for resolving the `local` choice (test seam). Defaults to
2615
+ * the real host platform. Mac → parakeet-mlx, Linux → onnx-asr.
2616
+ */
2617
+ platform?: NodeJS.Platform;
2593
2618
  }
2594
2619
  function writeScribeConfigForWizard(configDir: string, config: WizardScribeConfig): void {
2595
2620
  const update: Record<string, unknown> = {};
2621
+ const platform = config.platform ?? process.platform;
2596
2622
 
2597
2623
  if (config.transcribe) {
2598
2624
  const { provider, apiKey } = config.transcribe;
2599
- // For `local` (Mac MLX / cross-platform ONNX), just set the
2600
- // provider name no key needed.
2625
+ // For `local`, resolve to the CORRECT platform backend parakeet-mlx on
2626
+ // macOS, onnx-asr on Linux. (Was hardcoded to parakeet-mlx, which silently
2627
+ // fails on every Linux box.) No key needed for local. The caller's
2628
+ // RAM/platform gate is the single place that decides "local isn't possible
2629
+ // here" and should have steered to cloud before reaching this writer — but
2630
+ // if that gate is ever bypassed and the platform has no local backend, we
2631
+ // write "none" (transcription off) rather than a dead provider string, so
2632
+ // this writer can never record something that silently fails.
2601
2633
  if (provider === "local") {
2602
- update.transcribe = { provider: "parakeet-mlx" };
2634
+ const resolved = platformLocalProvider(platform);
2635
+ update.transcribe = { provider: resolved ?? "none" };
2603
2636
  } else {
2604
2637
  // Cloud providers need a key. Empty key → just set provider;
2605
2638
  // the operator can paste the key later via /scribe/admin
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
+ }