@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.
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +113 -0
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/api-modules.test.ts +65 -10
- package/src/__tests__/api-vault-caps.test.ts +232 -0
- package/src/__tests__/cli.test.ts +20 -0
- package/src/__tests__/hub-command.test.ts +136 -0
- package/src/__tests__/hub-origins-env-set.test.ts +273 -0
- package/src/__tests__/init.test.ts +148 -0
- package/src/__tests__/jwt-sign.test.ts +79 -0
- package/src/__tests__/module-manifest.test.ts +3 -1
- package/src/__tests__/oauth-client.test.ts +75 -0
- package/src/__tests__/oauth-handlers.test.ts +413 -5
- package/src/__tests__/public-signup.test.ts +619 -0
- package/src/__tests__/rate-limit.test.ts +86 -0
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/serve.test.ts +67 -1
- package/src/__tests__/service-spec-discovery.test.ts +22 -2
- package/src/__tests__/setup-wizard.test.ts +18 -0
- package/src/__tests__/setup.test.ts +129 -15
- 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/__tests__/vault-hub-origin-env.test.ts +38 -4
- package/src/__tests__/wizard-transcription.test.ts +334 -0
- package/src/__tests__/wizard.test.ts +146 -0
- package/src/account-setup.ts +118 -32
- package/src/admin-agent-grants.ts +51 -3
- 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-modules-ops.ts +12 -0
- package/src/api-modules.ts +47 -13
- package/src/api-users.ts +3 -0
- package/src/api-vault-caps.ts +206 -0
- package/src/cli.ts +35 -1
- package/src/commands/hub.ts +173 -0
- package/src/commands/init.ts +73 -2
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/serve.ts +39 -3
- package/src/commands/setup.ts +31 -6
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/help.ts +8 -0
- package/src/hub-db.ts +108 -1
- package/src/hub-origin.ts +64 -0
- package/src/hub-server.ts +27 -0
- package/src/invites.ts +155 -31
- package/src/jwt-sign.ts +72 -3
- package/src/module-manifest.ts +23 -9
- package/src/oauth-client.ts +102 -12
- package/src/oauth-flows-store.ts +12 -0
- package/src/oauth-handlers.ts +145 -20
- package/src/rate-limit.ts +111 -20
- package/src/scribe-config.ts +145 -0
- package/src/service-spec.ts +31 -12
- package/src/setup-wizard.ts +37 -4
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
- package/src/vault-hub-origin-env.ts +109 -16
- package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
- package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- 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
|
-
*
|
|
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-
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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-
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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-
|
|
230
|
-
*
|
|
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
|
|
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
|
/**
|
package/src/scribe-config.ts
CHANGED
|
@@ -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
|
package/src/service-spec.ts
CHANGED
|
@@ -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
|
|
642
|
-
* fallback when `module.json` omits
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
* `
|
|
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: "
|
|
654
|
-
notes: "
|
|
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`
|
|
678
|
-
* for legacy installs;
|
|
679
|
-
*
|
|
680
|
-
*
|
|
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>();
|
package/src/setup-wizard.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
2600
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|