@openparachute/hub 0.7.7-rc.8 → 0.7.7
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/README.md +7 -7
- package/package.json +4 -12
- package/src/__tests__/account-api.test.ts +29 -2
- package/src/__tests__/account-session.test.ts +9 -1
- package/src/__tests__/account-token.test.ts +26 -2
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +0 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/setup-wizard.test.ts +301 -22
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/__tests__/wizard-transcription.test.ts +35 -0
- package/src/__tests__/wizard.test.ts +79 -0
- package/src/account-api.ts +6 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/commands/wizard-transcription.ts +24 -0
- package/src/commands/wizard.ts +34 -1
- package/src/help.ts +6 -6
- package/src/hub-server.ts +111 -54
- package/src/hub-settings.ts +147 -0
- package/src/hub.ts +64 -31
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +2 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/setup-wizard.ts +144 -31
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
|
@@ -72,6 +72,14 @@ export interface TranscriptionStepOpts {
|
|
|
72
72
|
|
|
73
73
|
/** Default readline prompt (matches wizard.ts's defaultPrompt). */
|
|
74
74
|
async function defaultPrompt(question: string): Promise<string> {
|
|
75
|
+
// Non-TTY guard (headless-hardening): unlike wizard.ts's required-step
|
|
76
|
+
// prompts (which throw), the transcription step is optional and must NEVER
|
|
77
|
+
// block setup. On a closed / non-interactive stdin, return the empty answer
|
|
78
|
+
// — which the callers already treat as a benign default (a blank cloud API
|
|
79
|
+
// key means "set it later"; a blank local-install confirm takes the [Y]
|
|
80
|
+
// default). `resolveChoice` short-circuits to "none" before ever reaching
|
|
81
|
+
// here without a flag; this covers the flag-supplied-mode downstream prompts.
|
|
82
|
+
if (!process.stdin.isTTY) return "";
|
|
75
83
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
76
84
|
try {
|
|
77
85
|
return await rl.question(question);
|
|
@@ -139,6 +147,22 @@ async function resolveChoice(
|
|
|
139
147
|
log: (l: string) => void,
|
|
140
148
|
): Promise<ResolvedChoice> {
|
|
141
149
|
if (opts.transcribeMode !== undefined) return opts.transcribeMode;
|
|
150
|
+
// Non-TTY guard (headless-hardening): with no `--transcribe-mode` flag AND a
|
|
151
|
+
// closed / non-interactive stdin (cloud-init, ssh heredoc, the e2e
|
|
152
|
+
// container), the real `defaultPrompt`'s `prompt("Pick [1]:")` would busy-hang
|
|
153
|
+
// forever on Bun's `readline/promises` question() — the exact wedge that
|
|
154
|
+
// timed out the Tier-1 e2e. Transcription is optional / opt-in and documented
|
|
155
|
+
// as NEVER blocking setup, so we DEFAULT to "none" (not throw — unlike the
|
|
156
|
+
// required account / vault / expose prompts in wizard.ts) with an honest log
|
|
157
|
+
// line. Guarded on `opts.prompt === undefined` so an injected prompt seam (a
|
|
158
|
+
// scripted test queue, or a caller supplying its own reader) is still honored
|
|
159
|
+
// — the wedge only exists for the real readline-backed default prompt.
|
|
160
|
+
if (opts.prompt === undefined && !process.stdin.isTTY) {
|
|
161
|
+
log("");
|
|
162
|
+
log(" stdin is not interactive — defaulting transcription to none.");
|
|
163
|
+
log(" Turn it on later with `parachute install scribe` (or re-run with --transcribe-mode).");
|
|
164
|
+
return "none";
|
|
165
|
+
}
|
|
142
166
|
log("");
|
|
143
167
|
log(" 1) None — skip transcription (default)");
|
|
144
168
|
log(" 2) Local — run the engine on this box (no API key, needs ~2 GB RAM)");
|
package/src/commands/wizard.ts
CHANGED
|
@@ -156,8 +156,24 @@ interface FetchSetupResult {
|
|
|
156
156
|
/**
|
|
157
157
|
* Default readline prompt. Lives at module scope so tests can inject a
|
|
158
158
|
* deterministic alternative through `opts.prompt`.
|
|
159
|
+
*
|
|
160
|
+
* Non-TTY guard (headless-hardening): Bun's `readline/promises` `question()`
|
|
161
|
+
* NEVER settles when stdin is closed / not a terminal (cloud-init, `ssh
|
|
162
|
+
* <host> 'parachute setup-wizard …'`, the e2e container's `run.sh`-exec'd
|
|
163
|
+
* stages.sh) — it busy-waits forever, wedging the wizard until the outer job
|
|
164
|
+
* timeout kills it. So when stdin is not interactive we FAIL FAST with an
|
|
165
|
+
* actionable message naming the flag that would have answered the prompt,
|
|
166
|
+
* rather than blocking on a prompt no one can ever answer. Account / vault /
|
|
167
|
+
* expose are required steps, so a missing answer here is fatal (the
|
|
168
|
+
* transcription step, being optional, defaults to "none" instead — see
|
|
169
|
+
* wizard-transcription.ts).
|
|
159
170
|
*/
|
|
160
171
|
async function defaultPrompt(question: string): Promise<string> {
|
|
172
|
+
if (!process.stdin.isTTY) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`stdin is not interactive, so the wizard cannot prompt for: ${question.trim()}\nRe-run non-interactively by passing the corresponding flag (e.g. --account-username / --account-password / --vault-mode / --vault-name / --expose-mode / --transcribe-mode), or run in a real terminal.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
161
177
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
162
178
|
try {
|
|
163
179
|
return await rl.question(question);
|
|
@@ -224,6 +240,14 @@ async function setupFetch(
|
|
|
224
240
|
// Don't auto-follow — the wizard returns 303 to /admin/setup, and
|
|
225
241
|
// we want to inspect the Location header rather than chase it.
|
|
226
242
|
redirect: "manual",
|
|
243
|
+
// Per-request 30s ceiling so a wedged hub call TRIPS instead of
|
|
244
|
+
// suspending forever. `pollOperation` (below) bounds the whole op with
|
|
245
|
+
// POLL_TIMEOUT_MS, but that deadline is only checked BETWEEN fetches — a
|
|
246
|
+
// single fetch that never resolves would defeat it. AbortSignal.timeout
|
|
247
|
+
// makes each individual call fail-fast; a real hub responds in well under
|
|
248
|
+
// 30s. Test-injected fetch stubs ignore the signal, so this is inert in
|
|
249
|
+
// unit tests.
|
|
250
|
+
signal: AbortSignal.timeout(30_000),
|
|
227
251
|
});
|
|
228
252
|
const bodyText = await res.text();
|
|
229
253
|
// Collect Set-Cookie. Try `getAll` first (Bun + node18+ supported it
|
|
@@ -783,7 +807,16 @@ export async function runCliWizard(opts: RunCliWizardOpts): Promise<number> {
|
|
|
783
807
|
await walkTranscriptionStep({
|
|
784
808
|
configDir: opts.configDir,
|
|
785
809
|
log,
|
|
786
|
-
prompt
|
|
810
|
+
// Only forward a REAL injected prompt seam — NOT the resolved
|
|
811
|
+
// `prompt` (which is the throwing `defaultPrompt` when the caller
|
|
812
|
+
// supplied none). Passing the default here would defeat
|
|
813
|
+
// `resolveChoice`'s `opts.prompt === undefined` non-TTY guard: on a
|
|
814
|
+
// headless box with no `--transcribe-mode`, the transcription step
|
|
815
|
+
// would call the throwing default mid-wizard instead of defaulting to
|
|
816
|
+
// `none`. With the seam forwarded only when present, headless runs hit
|
|
817
|
+
// the guard (default none) and interactive/test runs still get their
|
|
818
|
+
// reader.
|
|
819
|
+
...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
|
|
787
820
|
...(opts.transcribeMode !== undefined ? { transcribeMode: opts.transcribeMode } : {}),
|
|
788
821
|
...(opts.transcribeApiKey !== undefined ? { transcribeApiKey: opts.transcribeApiKey } : {}),
|
|
789
822
|
...(opts.transcribeRunCommand !== undefined ? { runCommand: opts.transcribeRunCommand } : {}),
|
package/src/help.ts
CHANGED
|
@@ -362,11 +362,11 @@ Exit codes:
|
|
|
362
362
|
|
|
363
363
|
Example:
|
|
364
364
|
$ parachute status
|
|
365
|
-
SERVICE
|
|
366
|
-
parachute-vault
|
|
365
|
+
SERVICE PORT VERSION STATE PID UPTIME LATENCY SOURCE
|
|
366
|
+
parachute-vault 1940 0.2.4 active 12345 2h 13m 2ms bun-linked → parachute-vault @ 8aa167b
|
|
367
367
|
→ http://127.0.0.1:1940/vault/default/mcp
|
|
368
|
-
parachute-
|
|
369
|
-
→ http://127.0.0.1:1946/surface
|
|
368
|
+
parachute-surface 1946 0.2.0 active 12346 2h 12m 3ms npm (0.2.0-rc.4)
|
|
369
|
+
→ http://127.0.0.1:1946/surface
|
|
370
370
|
`;
|
|
371
371
|
}
|
|
372
372
|
|
|
@@ -551,8 +551,8 @@ Examples:
|
|
|
551
551
|
Module start commands (run by the supervisor under \`serve\`):
|
|
552
552
|
vault parachute-vault serve
|
|
553
553
|
scribe parachute-scribe serve
|
|
554
|
-
|
|
555
|
-
|
|
554
|
+
surface parachute-surface serve
|
|
555
|
+
app bun <cli>/notes-serve.ts --port <configured> --mount <paths[0]> --package @openparachute/parachute-app
|
|
556
556
|
notes bun <cli>/notes-serve.ts --port <configured> --mount <paths[0]> # back-compat: legacy notes-daemon
|
|
557
557
|
`;
|
|
558
558
|
}
|
package/src/hub-server.ts
CHANGED
|
@@ -24,12 +24,29 @@
|
|
|
24
24
|
* /admin/login, /admin/logout → 301 → /login, /logout
|
|
25
25
|
*
|
|
26
26
|
* # Notes-as-app migration Phase 2 (parachute-app design doc §16).
|
|
27
|
+
* # `/surface/notes` = the legacy notes-ui mount (existing installs);
|
|
28
|
+
* # `/surface/parachute` = the app's surface mount post-W2-12 rename.
|
|
27
29
|
* /notes, /notes/, /notes/* → 301 → /surface/notes[/...]
|
|
28
30
|
* (opt-out via
|
|
29
31
|
* hub_settings.notes_redirect_disabled)
|
|
32
|
+
* /surface/notes[/...] → 301 → /surface/parachute[/...]
|
|
33
|
+
* CONDITIONAL (W2-12, dispatched just
|
|
34
|
+
* before the generic services proxy):
|
|
35
|
+
* only when uis{} mounts show
|
|
36
|
+
* /surface/notes gone + /surface/parachute
|
|
37
|
+
* present — inert on existing notes-ui
|
|
38
|
+
* installs (surface-notes-alias.ts)
|
|
30
39
|
*
|
|
31
40
|
* # Discovery + well-known.
|
|
32
|
-
*
|
|
41
|
+
* / → root_mode-dependent: `redirect`
|
|
42
|
+
* (default) 302s to root_redirect
|
|
43
|
+
* (default /admin); `serve-app`
|
|
44
|
+
* serves the installed app's dist
|
|
45
|
+
* index.html AT `/` (no chrome).
|
|
46
|
+
* Fresh-hub wizard funnel + pre-
|
|
47
|
+
* admin 503 still preempt it.
|
|
48
|
+
* /hub.html → hub.html (the discovery page;
|
|
49
|
+
* unaffected by root_mode)
|
|
33
50
|
* /.well-known/parachute.json → built dynamically from services.json
|
|
34
51
|
* /.well-known/parachute-revocation.json → revoked-jti list (hub#212 Phase 1)
|
|
35
52
|
* /.well-known/jwks.json → JWKS from hub.db
|
|
@@ -59,8 +76,6 @@
|
|
|
59
76
|
* /account/vaults/<name>/caps (GET/PUT) → read / set the storage cap
|
|
60
77
|
* /admin/host-admin-token (GET) → SPA bearer mint (cookie-gated)
|
|
61
78
|
* /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
|
|
62
|
-
* /admin/agent-token (GET) → agent UI bearer mint (cookie-gated)
|
|
63
|
-
* /admin/channel-token (GET) → 301 → /admin/agent-token (legacy; channel→agent rename 2026-06-17)
|
|
64
79
|
* /admin/module-token/<short> (GET) → generic module config-UI bearer mint <short>:admin (cookie-gated)
|
|
65
80
|
* /api/connections/catalog (GET) → events/actions across installed modules (cookie-gated)
|
|
66
81
|
* /admin/connections (POST/GET) → connection provision/list (cookie-gated; POST CSRF-belted)
|
|
@@ -164,7 +179,13 @@
|
|
|
164
179
|
* # Generic services.json-driven proxy (non-vault modules: notes, scribe, agent).
|
|
165
180
|
* /<service-mount>/* → proxy via services.json longest-prefix
|
|
166
181
|
*
|
|
167
|
-
*
|
|
182
|
+
* # serve-app tail (only when root_mode = serve-app): an unclaimed GET is
|
|
183
|
+
* # handed to the app's root-based dist — an existing bundle file (/assets/*,
|
|
184
|
+
* # /icon.svg, /manifest.webmanifest, /sw.js) serves that file; an HTML deep
|
|
185
|
+
* # link gets the SPA shell. Inert in redirect mode (the 404 below is
|
|
186
|
+
* # byte-identical to before). See root-serve.ts.
|
|
187
|
+
* anything else → 404 (or app asset / SPA shell
|
|
188
|
+
* in serve-app mode)
|
|
168
189
|
*
|
|
169
190
|
* Invoked as:
|
|
170
191
|
* bun <this-file> --port <n> --well-known-dir <path> [--db <path>] [--issuer <url>]
|
|
@@ -208,7 +229,7 @@ import {
|
|
|
208
229
|
handleAgentGrants,
|
|
209
230
|
handleOAuthGrantCallback,
|
|
210
231
|
} from "./admin-agent-grants.ts";
|
|
211
|
-
|
|
232
|
+
|
|
212
233
|
import { adminAuthErrorResponse } from "./admin-auth.ts";
|
|
213
234
|
import { handleApproveClient, handleDeleteClient, handleGetClient } from "./admin-clients.ts";
|
|
214
235
|
import {
|
|
@@ -290,7 +311,7 @@ import {
|
|
|
290
311
|
startDbPathLivenessTimer,
|
|
291
312
|
} from "./hub-db-liveness.ts";
|
|
292
313
|
import { hubDbPath, openHubDb } from "./hub-db.ts";
|
|
293
|
-
import { getHubOrigin, resolveRootRedirect } from "./hub-settings.ts";
|
|
314
|
+
import { getHubOrigin, resolveRootMode, resolveRootRedirect } from "./hub-settings.ts";
|
|
294
315
|
import { type RenderHubOpts, renderHub } from "./hub.ts";
|
|
295
316
|
import { pemToJwk } from "./jwks.ts";
|
|
296
317
|
import {
|
|
@@ -314,6 +335,7 @@ import { clearPid, writePid } from "./process-state.ts";
|
|
|
314
335
|
import { toResponse as proxyErrorToResponse, renderProxyError } from "./proxy-error-ui.ts";
|
|
315
336
|
import { classifyUpstream } from "./proxy-state.ts";
|
|
316
337
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
338
|
+
import { makeAppDistResolver, serveAppAtRoot } from "./root-serve.ts";
|
|
317
339
|
import {
|
|
318
340
|
FIRST_PARTY_FALLBACKS,
|
|
319
341
|
KNOWN_MODULES,
|
|
@@ -333,6 +355,7 @@ import {
|
|
|
333
355
|
} from "./setup-wizard.ts";
|
|
334
356
|
import { getAllPublicKeys } from "./signing-keys.ts";
|
|
335
357
|
import type { Supervisor } from "./supervisor.ts";
|
|
358
|
+
import { isSurfaceNotesPath, maybeRedirectSurfaceNotes } from "./surface-notes-alias.ts";
|
|
336
359
|
import { handleTwoFactorGet, handleTwoFactorPost } from "./two-factor-handlers.ts";
|
|
337
360
|
import { getUserById, userCount } from "./users.ts";
|
|
338
361
|
import { sanitizePublicOrigin } from "./vault-hub-origin-env.ts";
|
|
@@ -1412,6 +1435,15 @@ export interface HubFetchDeps {
|
|
|
1412
1435
|
* tracker between fetch fn and bridge handlers is impossible.
|
|
1413
1436
|
*/
|
|
1414
1437
|
wsConnectionTracker?: WsConnectionTracker;
|
|
1438
|
+
/**
|
|
1439
|
+
* Resolve the installed app's `dist/` directory for `root_mode = serve-app`.
|
|
1440
|
+
* Returns the dist dir, or `null` when the app isn't installed (→ serve-app
|
|
1441
|
+
* falls back to the redirect). Production defaults to a memoizing resolver
|
|
1442
|
+
* over the real `@openparachute/parachute-app` package (`makeAppDistResolver`);
|
|
1443
|
+
* tests inject a fixture dir or a `() => null` to drive the not-installed
|
|
1444
|
+
* fallback without a real global install.
|
|
1445
|
+
*/
|
|
1446
|
+
resolveAppDist?: () => string | null;
|
|
1415
1447
|
}
|
|
1416
1448
|
|
|
1417
1449
|
/**
|
|
@@ -2111,6 +2143,13 @@ export function hubFetch(
|
|
|
2111
2143
|
const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
2112
2144
|
const gitRoot = deps?.gitRoot ?? join(CONFIG_DIR, "hub", "git");
|
|
2113
2145
|
const spaDistDir = deps?.spaDistDir ?? defaultSpaDistDir();
|
|
2146
|
+
// `root_mode = serve-app` resolver (memoizing over the real app package by
|
|
2147
|
+
// default; tests inject their own). Instantiated once per hubFetch so the
|
|
2148
|
+
// success-memoization spans the serve process's lifetime.
|
|
2149
|
+
const resolveAppDist = deps?.resolveAppDist ?? makeAppDistResolver();
|
|
2150
|
+
// One-shot log guard: when serve-app is set but the app can't be resolved we
|
|
2151
|
+
// fall back to the redirect and say so ONCE, not on every `/` hit.
|
|
2152
|
+
let warnedAppServeFallback = false;
|
|
2114
2153
|
const loopbackPort = deps?.loopbackPort;
|
|
2115
2154
|
const loadExposeHubOrigin =
|
|
2116
2155
|
deps?.loadExposeHubOrigin ??
|
|
@@ -2350,27 +2389,6 @@ export function hubFetch(
|
|
|
2350
2389
|
}
|
|
2351
2390
|
}
|
|
2352
2391
|
|
|
2353
|
-
// `/channel/*` 301-redirects to `/agent/*` — back-compat for the
|
|
2354
|
-
// 2026-06-17 channel→agent module rename. Operator bookmarks, an
|
|
2355
|
-
// un-upgraded chat/config UI's deep links, and any externally-shared
|
|
2356
|
-
// `/channel/mcp/<name>` URL keep resolving for one release cycle while
|
|
2357
|
-
// the module's canonical mount moves to `/agent`. Method-agnostic, same
|
|
2358
|
-
// shape as the `/notes/*` redirect above (the agent's read-write surface
|
|
2359
|
-
// is its own daemon API, not the hub mount, so a re-issued GET is fine).
|
|
2360
|
-
// Matches `/channel` exactly and any `/channel/...` subpath, but NOT a
|
|
2361
|
-
// longer-prefix module like a hypothetical `/channelthing` — the guard is
|
|
2362
|
-
// exact-or-slash-delimited. Query string is preserved. (The generic
|
|
2363
|
-
// services-proxy fallthrough below would otherwise 404 a `/channel/*`
|
|
2364
|
-
// request once the module self-registers under `/agent`.)
|
|
2365
|
-
if (pathname === "/channel" || pathname.startsWith("/channel/")) {
|
|
2366
|
-
const dest = new URL(req.url);
|
|
2367
|
-
dest.pathname = `/agent${pathname.slice("/channel".length)}`;
|
|
2368
|
-
return new Response("", {
|
|
2369
|
-
status: 301,
|
|
2370
|
-
headers: { location: dest.pathname + dest.search },
|
|
2371
|
-
});
|
|
2372
|
-
}
|
|
2373
|
-
|
|
2374
2392
|
// CORS preflight for the public OAuth + discovery surface. Browsers
|
|
2375
2393
|
// issue OPTIONS before any non-simple cross-origin request — third-party
|
|
2376
2394
|
// SPAs hitting `/oauth/register` (RFC 7591 DCR), `/oauth/token`,
|
|
@@ -2649,6 +2667,24 @@ export function hubFetch(
|
|
|
2649
2667
|
// page (used by the static `parachute expose --set-path=/` disk file and
|
|
2650
2668
|
// any bookmark to the explicit `.html`). Only the bare `/` redirects.
|
|
2651
2669
|
if (pathname === "/") {
|
|
2670
|
+
// `root_mode = serve-app`: serve the installed app's shell AT the origin
|
|
2671
|
+
// root (no redirect hop), the self-hosted mirror of the hosted door.
|
|
2672
|
+
// GET only — a non-GET `/` keeps its 302 below. Runs AFTER the fresh-hub
|
|
2673
|
+
// wizard funnel + pre-admin 503 above (a not-yet-set-up hub still lands
|
|
2674
|
+
// on setup, never a half-working app shell). Falls back to the redirect
|
|
2675
|
+
// when the app isn't installed (dist unresolvable), with a one-time log.
|
|
2676
|
+
if (req.method === "GET" && resolveRootMode(getDb ? getDb() : null) === "serve-app") {
|
|
2677
|
+
const dist = resolveAppDist();
|
|
2678
|
+
if (dist) {
|
|
2679
|
+
const served = serveAppAtRoot(dist, req, "/");
|
|
2680
|
+
if (served) return served;
|
|
2681
|
+
} else if (!warnedAppServeFallback) {
|
|
2682
|
+
warnedAppServeFallback = true;
|
|
2683
|
+
console.warn(
|
|
2684
|
+
"[hub] root_mode=serve-app but the Parachute app is not installed (its dist could not be resolved) — falling back to the root redirect. Install it with `parachute install app`.",
|
|
2685
|
+
);
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2652
2688
|
return new Response(null, {
|
|
2653
2689
|
status: 302,
|
|
2654
2690
|
headers: { location: resolveRootRedirect(getDb ? getDb() : null) },
|
|
@@ -3063,31 +3099,13 @@ export function hubFetch(
|
|
|
3063
3099
|
});
|
|
3064
3100
|
}
|
|
3065
3101
|
|
|
3066
|
-
// Back-compat: the agent module's admin-token mint moved from
|
|
3067
|
-
// `/admin/channel-token` to `/admin/agent-token` in the 2026-06-17
|
|
3068
|
-
// channel→agent rename. 301-redirect the old path so operator bookmarks
|
|
3069
|
-
// + any un-upgraded UI fallback keep working for one release cycle.
|
|
3070
|
-
if (pathname === "/admin/channel-token") {
|
|
3071
|
-
const dest = new URL(req.url);
|
|
3072
|
-
dest.pathname = "/admin/agent-token";
|
|
3073
|
-
return Response.redirect(dest.toString(), 301);
|
|
3074
|
-
}
|
|
3075
|
-
|
|
3076
|
-
if (pathname === "/admin/agent-token") {
|
|
3077
|
-
if (!getDb) return dbNotConfigured();
|
|
3078
|
-
return handleAgentToken(req, {
|
|
3079
|
-
db: getDb(),
|
|
3080
|
-
issuer: oauthDeps(req).issuer,
|
|
3081
|
-
});
|
|
3082
|
-
}
|
|
3083
|
-
|
|
3084
3102
|
// Generic per-module config-UI bearer mint (2026-06-09 modular-UI
|
|
3085
3103
|
// architecture, P3). `<short>:admin` for any single-audience module —
|
|
3086
3104
|
// the admin scope each module-owned config UI needs to call its own
|
|
3087
|
-
// endpoints. Cookie-gated to the first-admin operator,
|
|
3088
|
-
// /admin
|
|
3089
|
-
//
|
|
3090
|
-
//
|
|
3105
|
+
// endpoints. Cookie-gated to the first-admin operator, matching the
|
|
3106
|
+
// host/vault admin-token endpoints. Gated on self-registration
|
|
3107
|
+
// (services.json row + readable module.json) with the bootstrap
|
|
3108
|
+
// registries as a fallback (boundary C5) — a genuinely
|
|
3091
3109
|
// third-party module mints here with zero hub code changes. Vault is
|
|
3092
3110
|
// per-instance and routed to /admin/vault-admin-token/<name> instead.
|
|
3093
3111
|
if (pathname.startsWith("/admin/module-token/")) {
|
|
@@ -3105,9 +3123,7 @@ export function hubFetch(
|
|
|
3105
3123
|
|
|
3106
3124
|
// Note: the legacy `/admin/channels` bespoke vault-channel orchestration
|
|
3107
3125
|
// endpoint (pre-Connections, hub#624 era) was retired in boundary D1 —
|
|
3108
|
-
// superseded by the general engine below.
|
|
3109
|
-
// page (renamed from channel 2026-06-17) drives `/admin/connections` +
|
|
3110
|
-
// `/admin/agent-token`.
|
|
3126
|
+
// superseded by the general engine below.
|
|
3111
3127
|
|
|
3112
3128
|
// Connections — the GENERAL module event→action engine (2026-06-09
|
|
3113
3129
|
// modular-UI architecture, P5). `/api/connections/catalog` (GET) returns
|
|
@@ -3410,7 +3426,7 @@ export function hubFetch(
|
|
|
3410
3426
|
// the 2026-06-09 modular-UI architecture P3. Config is module-owned +
|
|
3411
3427
|
// hub-framed now: the Modules page "Configure" action opens the module's
|
|
3412
3428
|
// OWN config UI (`configUiUrl`), which mints its admin Bearer from the
|
|
3413
|
-
// cookie-gated `/admin/module-token/<short
|
|
3429
|
+
// cookie-gated `/admin/module-token/<short>`.
|
|
3414
3430
|
|
|
3415
3431
|
// Per-module action endpoints: /api/modules/:short/{install,restart,upgrade,uninstall}.
|
|
3416
3432
|
if (pathname.startsWith("/api/modules/")) {
|
|
@@ -4173,6 +4189,28 @@ export function hubFetch(
|
|
|
4173
4189
|
// `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
|
|
4174
4190
|
// `/api/*` are excluded by ordering, not by an explicit denylist (#182).
|
|
4175
4191
|
//
|
|
4192
|
+
// W2-12 — conditional `/surface/notes/*` → `/surface/parachute/*`
|
|
4193
|
+
// alias, the "never 404 an old bookmark after an in-place upgrade"
|
|
4194
|
+
// safety net for the app's notes→parachute surface-identity rename.
|
|
4195
|
+
// Fires ONLY when the manifest's `uis{}` sub-units show the legacy
|
|
4196
|
+
// `/surface/notes` mount gone AND a `/surface/parachute` mount
|
|
4197
|
+
// present (see surface-notes-alias.ts for the full condition +
|
|
4198
|
+
// inert-today proof). On every existing notes-ui install the
|
|
4199
|
+
// condition fails and dispatch falls through to the generic proxy
|
|
4200
|
+
// below unchanged. Reads the same lenient manifest source
|
|
4201
|
+
// `resolveUiMount` consumes — hoisted to one read shared by both.
|
|
4202
|
+
const manifestServices = readManifestLenient(manifestPath).services;
|
|
4203
|
+
if (isSurfaceNotesPath(pathname)) {
|
|
4204
|
+
const aliasTarget = maybeRedirectSurfaceNotes(pathname, url.search, manifestServices);
|
|
4205
|
+
if (aliasTarget !== undefined) {
|
|
4206
|
+
logNotesRedirect(pathname, aliasTarget);
|
|
4207
|
+
return new Response("", {
|
|
4208
|
+
status: 301,
|
|
4209
|
+
headers: { location: aliasTarget },
|
|
4210
|
+
});
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
|
|
4176
4214
|
// H3 — per-UI audience gate. When the path falls under a declared UI
|
|
4177
4215
|
// sub-unit (a `uis{}` entry on the matched service row — surface-hosted
|
|
4178
4216
|
// UI mounts like /surface/<name>/*), the sub-unit's audience is
|
|
@@ -4187,7 +4225,7 @@ export function hubFetch(
|
|
|
4187
4225
|
// which also means a 'surface'/'public' mount on a loopback-only row
|
|
4188
4226
|
// stays unreachable from tailnet/funnel: exposure is orthogonal to
|
|
4189
4227
|
// audience.
|
|
4190
|
-
const uiMatch = resolveUiMount(
|
|
4228
|
+
const uiMatch = resolveUiMount(manifestServices, pathname);
|
|
4191
4229
|
if (uiMatch) {
|
|
4192
4230
|
const cloaked =
|
|
4193
4231
|
effectivePublicExposure(uiMatch.entry) === "loopback" &&
|
|
@@ -4221,6 +4259,25 @@ export function hubFetch(
|
|
|
4221
4259
|
);
|
|
4222
4260
|
}
|
|
4223
4261
|
|
|
4262
|
+
// serve-app tail (`root_mode = serve-app`). This is exactly the "would
|
|
4263
|
+
// have been the branded 404" point: every hub-owned prefix above (/,
|
|
4264
|
+
// /admin, /oauth, /.well-known, /vault, /git, /api, service mounts) has
|
|
4265
|
+
// already had its turn, so anything here is genuinely unclaimed. Hand an
|
|
4266
|
+
// unclaimed GET to the app's root-based dist — an existing bundle file
|
|
4267
|
+
// (/assets/*, /icon.svg, /manifest.webmanifest, /sw.js) serves that file;
|
|
4268
|
+
// an HTML deep link the app routes client-side gets the SPA shell.
|
|
4269
|
+
// `serveAppAtRoot` returns null (→ branded 404 below) for a non-GET, a
|
|
4270
|
+
// non-HTML unclaimed request, or a reserved /api|/oauth|/.well-known
|
|
4271
|
+
// prefix. NO chrome injection — the app owns its whole page. In redirect
|
|
4272
|
+
// mode this branch is inert and the tail stays byte-identical to before.
|
|
4273
|
+
if (req.method === "GET" && resolveRootMode(getDb ? getDb() : null) === "serve-app") {
|
|
4274
|
+
const dist = resolveAppDist();
|
|
4275
|
+
if (dist) {
|
|
4276
|
+
const served = serveAppAtRoot(dist, req, pathname);
|
|
4277
|
+
if (served) return served;
|
|
4278
|
+
}
|
|
4279
|
+
}
|
|
4280
|
+
|
|
4224
4281
|
// Branded fall-through 404 (closes hub#392) — the operator who mistyped
|
|
4225
4282
|
// a URL sees a clear "not found" page with a path back home, not the
|
|
4226
4283
|
// browser's default empty-body chrome. Only HTML clients get the
|
package/src/hub-settings.ts
CHANGED
|
@@ -92,6 +92,16 @@ export type HubSettingKey =
|
|
|
92
92
|
// the deprecation window. Stored as the literal string "true" /
|
|
93
93
|
// "false"; any other value parses as "redirect on" (the migration
|
|
94
94
|
// default — operators must opt out, not opt in).
|
|
95
|
+
//
|
|
96
|
+
// Mount vocabulary post-W2-12: `/surface/notes` is the LEGACY notes-ui
|
|
97
|
+
// mount (what existing installs serve — this redirect's target);
|
|
98
|
+
// `/surface/parachute` is the app's surface mount under its renamed
|
|
99
|
+
// identity. A separate CONDITIONAL alias (surface-notes-alias.ts, no
|
|
100
|
+
// hub_settings row — it keys off the manifest's uis{} mounts) bridges
|
|
101
|
+
// `/surface/notes/*` → `/surface/parachute/*` only on installs where
|
|
102
|
+
// the legacy mount is gone and the new one exists, so the two
|
|
103
|
+
// redirects compose for post-rename installs: /notes/x →
|
|
104
|
+
// /surface/notes/x → /surface/parachute/x.
|
|
95
105
|
| "notes_redirect_disabled"
|
|
96
106
|
// Admin-UI screen-lock PIN (hub admin-lock feature). The argon2id hash of
|
|
97
107
|
// the operator's lock PIN. Absent row = lock feature OFF (today's behavior
|
|
@@ -123,6 +133,25 @@ export type HubSettingKey =
|
|
|
123
133
|
// not-yet-set-up hub still lands on setup, not a surface that can't work
|
|
124
134
|
// yet.
|
|
125
135
|
| "root_redirect"
|
|
136
|
+
// hub: how the hub answers its origin root. `"redirect"` (default / absent)
|
|
137
|
+
// is the historical behavior — the bare `/` 302s to `root_redirect` (a safe
|
|
138
|
+
// same-origin path, default `/admin`). `"serve-app"` makes the hub SERVE the
|
|
139
|
+
// installed Parachute app's built bundle AT the origin root (index.html at
|
|
140
|
+
// `/`, its assets from the same dist, SPA-fallback for HTML deep links) — the
|
|
141
|
+
// same front-door experience as the hosted door, no redirect hop. The app's
|
|
142
|
+
// build is root-based (absolute `/assets`, PWA scope `/`, root OAuth redirect
|
|
143
|
+
// URIs) so serving its dist verbatim at `/` is asset-correct with no rebuild.
|
|
144
|
+
//
|
|
145
|
+
// Precedence on each request (resolveRootMode): this row, then
|
|
146
|
+
// `PARACHUTE_HUB_ROOT_MODE` env, then the `"redirect"` default. DB-first (like
|
|
147
|
+
// `root_redirect`) so an operator can flip the front door from the admin SPA /
|
|
148
|
+
// CLI without a redeploy. When `serve-app` is set but the app isn't installed
|
|
149
|
+
// (its dist can't be resolved), the hub FALLS BACK to redirect behavior (using
|
|
150
|
+
// `root_redirect` → env → `/admin`) with a one-time log line — so an operator
|
|
151
|
+
// who set the mode before installing the app never sees a broken root. The
|
|
152
|
+
// fresh-hub wizard funnel + pre-admin 503 lockout still run BEFORE this, so a
|
|
153
|
+
// not-yet-set-up hub lands on setup, never a half-working app shell.
|
|
154
|
+
| "root_mode"
|
|
126
155
|
// hub-parity P2 (Q2): the RAW token of the newest PUBLIC (multi-use,
|
|
127
156
|
// `max_uses > 1`) invite — persisted so `GET /.well-known/parachute-account`
|
|
128
157
|
// can advertise `signup_path` without being able to reconstruct it from the
|
|
@@ -607,3 +636,121 @@ export function resolveRootRedirect(
|
|
|
607
636
|
): string {
|
|
608
637
|
return resolveRootRedirectDetailed(db, opts).value;
|
|
609
638
|
}
|
|
639
|
+
|
|
640
|
+
// --- domain helpers: root mode (redirect vs serve the app at `/`) ---------
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* How the hub answers its origin root.
|
|
644
|
+
*
|
|
645
|
+
* - `"redirect"` — historical behavior: the bare `/` 302s to the resolved
|
|
646
|
+
* `root_redirect` (default `/admin`). Every unclaimed path keeps the
|
|
647
|
+
* branded 404.
|
|
648
|
+
* - `"serve-app"` — the hub serves the installed Parachute app's built
|
|
649
|
+
* bundle AT the origin root (index.html at `/`, its assets from the same
|
|
650
|
+
* dist, SPA-fallback for HTML deep links), matching the hosted door's
|
|
651
|
+
* front-door experience. Falls back to `redirect` when the app isn't
|
|
652
|
+
* installed (dist unresolvable).
|
|
653
|
+
*/
|
|
654
|
+
export type RootMode = "redirect" | "serve-app";
|
|
655
|
+
|
|
656
|
+
/** Exported so the API handler + the CLI can share validation. */
|
|
657
|
+
export const ROOT_MODES: readonly RootMode[] = ["redirect", "serve-app"];
|
|
658
|
+
|
|
659
|
+
export function isRootMode(s: unknown): s is RootMode {
|
|
660
|
+
return typeof s === "string" && ROOT_MODES.includes(s as RootMode);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Env override for the root mode. Below the DB row, above the default. */
|
|
664
|
+
export const PARACHUTE_HUB_ROOT_MODE_ENV = "PARACHUTE_HUB_ROOT_MODE";
|
|
665
|
+
|
|
666
|
+
/** Fallback when neither DB row nor env is set — historical redirect behavior. */
|
|
667
|
+
export const DEFAULT_ROOT_MODE: RootMode = "redirect";
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Read the raw stored root mode from hub_settings (or `null` when absent),
|
|
671
|
+
* WITHOUT applying env / default precedence — mirrors `getRootRedirect`. The
|
|
672
|
+
* admin GET surfaces this raw value so the operator sees exactly what's stored.
|
|
673
|
+
*/
|
|
674
|
+
export function getRootMode(db: Database): RootMode | null {
|
|
675
|
+
const raw = getSetting(db, "root_mode");
|
|
676
|
+
if (raw === undefined) return null;
|
|
677
|
+
return isRootMode(raw) ? raw : null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Write or clear the root mode. Passing `null` (or the `"redirect"` default)
|
|
682
|
+
* deletes the row, reverting to env / default precedence — the absent-row
|
|
683
|
+
* state IS the redirect default, so leaving an explicit `"redirect"` in the
|
|
684
|
+
* row would be a footgun if a future default flip ever made absence mean
|
|
685
|
+
* something else (mirrors `setRootRedirect` / `setHubOrigin` semantics). The
|
|
686
|
+
* caller must have validated via `isRootMode`; the typed signature enforces it
|
|
687
|
+
* at TypeScript-clean call sites.
|
|
688
|
+
*/
|
|
689
|
+
export function setRootMode(db: Database, mode: RootMode | null): void {
|
|
690
|
+
if (mode === null || mode === DEFAULT_ROOT_MODE) {
|
|
691
|
+
deleteSetting(db, "root_mode");
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
setSetting(db, "root_mode", mode);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Which precedence layer the resolved mode came from. */
|
|
698
|
+
export type RootModeSource = "db" | "env" | "default";
|
|
699
|
+
|
|
700
|
+
export interface ResolvedRootMode {
|
|
701
|
+
/** The mode the origin root should apply. */
|
|
702
|
+
value: RootMode;
|
|
703
|
+
/** Which layer it came from (for admin-UI attribution + install set-if-unset). */
|
|
704
|
+
source: RootModeSource;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Resolve the root mode with source attribution.
|
|
709
|
+
*
|
|
710
|
+
* Precedence: hub_settings.root_mode → `PARACHUTE_HUB_ROOT_MODE` env →
|
|
711
|
+
* `"redirect"` default. An invalid value at any layer is warned + skipped so a
|
|
712
|
+
* hand-edited row / typo'd env can never wedge the root (worst case falls to
|
|
713
|
+
* the redirect default — today's behavior).
|
|
714
|
+
*
|
|
715
|
+
* `db` may be `null` (hub-server running without state) — the DB layer is then
|
|
716
|
+
* skipped and resolution starts from env. The `env` / `warn` knobs are test
|
|
717
|
+
* seams (production uses `process.env` + `console.warn`).
|
|
718
|
+
*/
|
|
719
|
+
export function resolveRootModeDetailed(
|
|
720
|
+
db: Database | null,
|
|
721
|
+
opts: { env?: NodeJS.ProcessEnv; warn?: (msg: string) => void } = {},
|
|
722
|
+
): ResolvedRootMode {
|
|
723
|
+
const env = opts.env ?? process.env;
|
|
724
|
+
const warn = opts.warn ?? ((msg: string) => console.warn(msg));
|
|
725
|
+
|
|
726
|
+
// 1. DB row (operator-set via the admin PUT / `parachute hub set-root-mode`).
|
|
727
|
+
if (db) {
|
|
728
|
+
const fromDb = getSetting(db, "root_mode");
|
|
729
|
+
if (fromDb !== undefined) {
|
|
730
|
+
if (isRootMode(fromDb)) return { value: fromDb, source: "db" };
|
|
731
|
+
warn(
|
|
732
|
+
`[hub-settings] root_mode="${fromDb}" in hub_settings is not a valid mode (expected one of ${ROOT_MODES.join(", ")}) — ignoring (falling through to env/default).`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// 2. Env override.
|
|
738
|
+
const fromEnv = env[PARACHUTE_HUB_ROOT_MODE_ENV];
|
|
739
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) {
|
|
740
|
+
if (isRootMode(fromEnv)) return { value: fromEnv, source: "env" };
|
|
741
|
+
warn(
|
|
742
|
+
`[hub-settings] ${PARACHUTE_HUB_ROOT_MODE_ENV}="${fromEnv}" is not a valid mode — falling back to "${DEFAULT_ROOT_MODE}".`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// 3. Default — historical redirect behavior.
|
|
747
|
+
return { value: DEFAULT_ROOT_MODE, source: "default" };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** Convenience: just the resolved mode (see `resolveRootModeDetailed`). */
|
|
751
|
+
export function resolveRootMode(
|
|
752
|
+
db: Database | null,
|
|
753
|
+
opts: { env?: NodeJS.ProcessEnv; warn?: (msg: string) => void } = {},
|
|
754
|
+
): RootMode {
|
|
755
|
+
return resolveRootModeDetailed(db, opts).value;
|
|
756
|
+
}
|