@openparachute/hub 0.7.3-rc.7 → 0.7.3-rc.9
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__/hub-origins-env-set.test.ts +199 -0
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/setup-wizard.test.ts +18 -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/api-modules-ops.ts +12 -0
- package/src/commands/init.ts +10 -2
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/hub-origin.ts +64 -0
- package/src/jwt-sign.ts +14 -3
- package/src/scribe-config.ts +145 -0
- package/src/setup-wizard.ts +37 -4
- package/src/vault-hub-origin-env.ts +109 -16
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
|
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import { parseEnvFile, removeEnvLine, upsertEnvLine, writeEnvFile } from "./env-file.ts";
|
|
31
31
|
import { EXPOSE_STATE_PATH, readExposeState } from "./expose-state.ts";
|
|
32
|
-
import {
|
|
32
|
+
import { readHubPort } from "./hub-control.ts";
|
|
33
|
+
import { HUB_ORIGINS_ENV, HUB_ORIGIN_ENV, serializeHubOrigins } from "./hub-origin.ts";
|
|
34
|
+
import { HUB_UNIT_DEFAULT_PORT } from "./hub-unit.ts";
|
|
35
|
+
import { buildHubBoundOrigins } from "./origin-check.ts";
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* Loopback origins (`http://127.0.0.1:<port>`, `localhost`, `[::1]`) are the
|
|
@@ -91,11 +94,80 @@ function vaultEnvPath(configDir: string): string {
|
|
|
91
94
|
return join(configDir, "vault", ".env");
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Compose `https://${FLY_APP_NAME}.fly.dev` when FLY_APP_NAME is a plausible
|
|
99
|
+
* Fly app slug (no slashes — Fly slugs don't contain them). Mirrors the
|
|
100
|
+
* private helper in operator-token.ts / hub-server.ts; kept local so the
|
|
101
|
+
* origin-SET assembly here doesn't reach across modules for a 3-line guard.
|
|
102
|
+
*/
|
|
103
|
+
function flyDefaultOriginFromEnv(env: NodeJS.ProcessEnv): string | undefined {
|
|
104
|
+
const app = env.FLY_APP_NAME;
|
|
105
|
+
if (typeof app !== "string" || app.length === 0 || app.includes("/")) return undefined;
|
|
106
|
+
return `https://${app}.fly.dev`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Assemble the comma-separated `PARACHUTE_HUB_ORIGINS` value the hub injects
|
|
111
|
+
* into supervised resource servers (multi-origin iss-set). The SET is the
|
|
112
|
+
* hub's own legitimate origins — the env-injection sibling of the operator
|
|
113
|
+
* token's `buildKnownIssuersForOperatorToken` and the per-request
|
|
114
|
+
* `buildHubBoundOrigins` call in hub-server.ts. Inputs:
|
|
115
|
+
*
|
|
116
|
+
* - `issuer` — the canonical hub origin the child also receives as the
|
|
117
|
+
* single `PARACHUTE_HUB_ORIGIN` (the seed; always included).
|
|
118
|
+
* - loopback aliases — `http://127.0.0.1:<port>` ∪ `http://localhost:<port>`
|
|
119
|
+
* for the hub's port (`readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT`).
|
|
120
|
+
* - the expose-state public origin — `expose-state.json`'s `hubOrigin`.
|
|
121
|
+
* - the platform/env public origin — `RENDER_EXTERNAL_URL` ∪ the composed
|
|
122
|
+
* Fly default (container deploys where the public origin comes from the
|
|
123
|
+
* platform, not expose-state).
|
|
124
|
+
*
|
|
125
|
+
* SECURITY INVARIANT: every input is hub/operator-controlled config or
|
|
126
|
+
* on-disk state — NEVER an unvalidated request `Host` / `X-Forwarded-Host`.
|
|
127
|
+
* The accepted-`iss` widening this enables is safe only because the resource
|
|
128
|
+
* server verifies the JWKS signature first; this set is the belt-and-
|
|
129
|
+
* suspenders allowlist layered on top.
|
|
130
|
+
*
|
|
131
|
+
* Returns `undefined` when the seed issuer is absent AND nothing else resolves
|
|
132
|
+
* (caller skips the env var so the child keeps single-origin behavior).
|
|
133
|
+
*/
|
|
134
|
+
export function buildHubOriginsEnvValue(
|
|
135
|
+
configDir: string,
|
|
136
|
+
issuer: string | undefined,
|
|
137
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
138
|
+
exposeStatePath: string = EXPOSE_STATE_PATH,
|
|
139
|
+
): string | undefined {
|
|
140
|
+
const loopbackPort = readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT;
|
|
141
|
+
let exposeHubOrigin: string | undefined;
|
|
142
|
+
try {
|
|
143
|
+
exposeHubOrigin = readExposeState(exposeStatePath)?.hubOrigin;
|
|
144
|
+
} catch {
|
|
145
|
+
// A malformed expose-state.json must never block a module spawn — the seed
|
|
146
|
+
// issuer + loopback aliases already cover legitimate access.
|
|
147
|
+
exposeHubOrigin = undefined;
|
|
148
|
+
}
|
|
149
|
+
const platformOrigin = env.RENDER_EXTERNAL_URL ?? flyDefaultOriginFromEnv(env);
|
|
150
|
+
const origins = buildHubBoundOrigins({
|
|
151
|
+
// buildHubBoundOrigins requires `issuer`; pass "" when absent (it's dropped
|
|
152
|
+
// by the URL parse) so the loopback aliases still seed the set.
|
|
153
|
+
issuer: issuer ?? "",
|
|
154
|
+
loopbackPort,
|
|
155
|
+
...(exposeHubOrigin !== undefined ? { exposeHubOrigin } : {}),
|
|
156
|
+
...(platformOrigin !== undefined ? { platformOrigin } : {}),
|
|
157
|
+
});
|
|
158
|
+
return serializeHubOrigins(origins);
|
|
159
|
+
}
|
|
160
|
+
|
|
94
161
|
/**
|
|
95
162
|
* Upsert `PARACHUTE_HUB_ORIGIN=<origin>` into `vault/.env` when `origin` is a
|
|
96
|
-
* non-loopback public origin
|
|
97
|
-
*
|
|
98
|
-
*
|
|
163
|
+
* non-loopback public origin, AND the `PARACHUTE_HUB_ORIGINS` set (the
|
|
164
|
+
* multi-origin iss-set: origin ∪ loopback aliases ∪ expose-state ∪ platform)
|
|
165
|
+
* so the daemon-boot path validates `iss` against every URL the box answers on
|
|
166
|
+
* (a Caddy-fronted box reached via loopback + sslip.io + a custom domain at
|
|
167
|
+
* once). The set is assembled from hub-controlled inputs only (see
|
|
168
|
+
* `buildHubOriginsEnvValue`'s security invariant). Idempotent — skips the
|
|
169
|
+
* write (and the log) when BOTH values are already current so repeated
|
|
170
|
+
* `start`s don't churn the file. Returns true iff the file was written.
|
|
99
171
|
*/
|
|
100
172
|
export function persistVaultHubOrigin(
|
|
101
173
|
configDir: string,
|
|
@@ -105,26 +177,47 @@ export function persistVaultHubOrigin(
|
|
|
105
177
|
if (isLoopbackOrigin(origin)) return false;
|
|
106
178
|
const path = vaultEnvPath(configDir);
|
|
107
179
|
const parsed = parseEnvFile(path);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
180
|
+
const originsValue = buildHubOriginsEnvValue(configDir, origin);
|
|
181
|
+
const originCurrent = parsed.values[HUB_ORIGIN_ENV] === origin;
|
|
182
|
+
const originsCurrent =
|
|
183
|
+
originsValue === undefined || parsed.values[HUB_ORIGINS_ENV] === originsValue;
|
|
184
|
+
if (originCurrent && originsCurrent) return false;
|
|
185
|
+
let lines = parsed.lines;
|
|
186
|
+
if (!originCurrent) lines = upsertEnvLine(lines, HUB_ORIGIN_ENV, origin);
|
|
187
|
+
if (!originsCurrent && originsValue !== undefined) {
|
|
188
|
+
lines = upsertEnvLine(lines, HUB_ORIGINS_ENV, originsValue);
|
|
189
|
+
}
|
|
190
|
+
writeEnvFile(path, lines);
|
|
191
|
+
if (!originCurrent) {
|
|
192
|
+
log(` persisted ${HUB_ORIGIN_ENV}=${origin} to ${path} (survives daemon restart)`);
|
|
193
|
+
}
|
|
194
|
+
if (!originsCurrent && originsValue !== undefined) {
|
|
195
|
+
log(` persisted ${HUB_ORIGINS_ENV}=${originsValue} to ${path} (multi-origin iss-set)`);
|
|
196
|
+
}
|
|
111
197
|
return true;
|
|
112
198
|
}
|
|
113
199
|
|
|
114
200
|
/**
|
|
115
|
-
* Drop a previously-persisted `PARACHUTE_HUB_ORIGIN`
|
|
116
|
-
* on `expose … off`: once exposure is torn down, a
|
|
117
|
-
* with a loopback `iss`, so a stale public origin
|
|
118
|
-
* cause the mismatch. Removing the
|
|
119
|
-
* (`getHubOrigin`), which matches what the local
|
|
120
|
-
* false) when
|
|
201
|
+
* Drop a previously-persisted `PARACHUTE_HUB_ORIGIN` (and `PARACHUTE_HUB_ORIGINS`)
|
|
202
|
+
* from `vault/.env`. Called on `expose … off`: once exposure is torn down, a
|
|
203
|
+
* local-only hub mints tokens with a loopback `iss`, so a stale public origin
|
|
204
|
+
* left in `.env` would itself cause the mismatch. Removing the lines reverts
|
|
205
|
+
* vault to its loopback default (`getHubOrigin`), which matches what the local
|
|
206
|
+
* hub now stamps. No-op (returns false) when neither key is present. Returns
|
|
207
|
+
* true iff the file was rewritten.
|
|
121
208
|
*/
|
|
122
209
|
export function clearVaultHubOrigin(configDir: string, log: (line: string) => void): boolean {
|
|
123
210
|
const path = vaultEnvPath(configDir);
|
|
124
211
|
const parsed = parseEnvFile(path);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
212
|
+
const hadOrigin = parsed.values[HUB_ORIGIN_ENV] !== undefined;
|
|
213
|
+
const hadOrigins = parsed.values[HUB_ORIGINS_ENV] !== undefined;
|
|
214
|
+
if (!hadOrigin && !hadOrigins) return false;
|
|
215
|
+
let lines = parsed.lines;
|
|
216
|
+
if (hadOrigin) lines = removeEnvLine(lines, HUB_ORIGIN_ENV);
|
|
217
|
+
if (hadOrigins) lines = removeEnvLine(lines, HUB_ORIGINS_ENV);
|
|
218
|
+
writeEnvFile(path, lines);
|
|
219
|
+
if (hadOrigin) log(` cleared ${HUB_ORIGIN_ENV} from ${path} (exposure torn down)`);
|
|
220
|
+
if (hadOrigins) log(` cleared ${HUB_ORIGINS_ENV} from ${path} (exposure torn down)`);
|
|
128
221
|
return true;
|
|
129
222
|
}
|
|
130
223
|
|