@openparachute/hub 0.7.7-rc.12 → 0.7.7-rc.13

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.
@@ -1,19 +1,24 @@
1
1
  /**
2
- * `GET|PUT /api/settings/root-redirect` — operator-settable target for the
3
- * bare-`/` 302.
2
+ * `GET|PUT /api/settings/root-redirect` — operator-settable behavior for the
3
+ * hub's origin root: the `root_mode` (redirect vs. serve the app) and the
4
+ * bare-`/` 302 target.
4
5
  *
5
6
  * The hub's root (`/`) redirects to `/admin` by default. This endpoint lets an
6
7
  * operator point it at a surface instead (e.g. a custom-domain hub fronting a
7
- * team reading-room surface) without redeploying. The stored value resolves
8
- * tier-1 in `resolveRootRedirect` (hub-settings.ts):
8
+ * team reading-room surface), OR flip `root_mode` to `serve-app` so the hub
9
+ * serves the installed Parachute app AT the origin root (the hosted-door
10
+ * experience), all without redeploying. The stored values resolve tier-1 in
11
+ * `resolveRootRedirect` / `resolveRootMode` (hub-settings.ts):
9
12
  *
10
- * 1. hub_settings.root_redirect (this endpoint writes here)
11
- * 2. PARACHUTE_HUB_ROOT_REDIRECT env
12
- * 3. `/admin` default (unchanged behavior)
13
+ * root_redirect: hub_settings.root_redirect PARACHUTE_HUB_ROOT_REDIRECT env → `/admin`
14
+ * root_mode: hub_settings.root_mode → PARACHUTE_HUB_ROOT_MODE env → `redirect`
13
15
  *
14
- * The endpoint surfaces both the stored value *and* the resolved value + source
15
- * so the SPA can render "current: /surface/x (from env)" while the input shows
16
- * the empty stored row — same separation rationale as `/api/settings/hub-origin`.
16
+ * The endpoint surfaces both the stored values *and* the resolved values +
17
+ * sources so the SPA can render "current: /surface/x (from env)" while the
18
+ * input shows the empty stored row — same separation rationale as
19
+ * `/api/settings/hub-origin`. PUT accepts `root_redirect` and/or `root_mode`
20
+ * (at least one); a body with only `root_redirect` (the pre-serve-app shape)
21
+ * works unchanged.
17
22
  *
18
23
  * OPEN-REDIRECT SAFETY is the highest-stakes part: the resolved value lands in a
19
24
  * `Location:` header, so an off-origin value would be a textbook open redirect.
@@ -29,10 +34,17 @@
29
34
 
30
35
  import type { Database } from "bun:sqlite";
31
36
  import {
37
+ ROOT_MODES,
38
+ type RootMode,
39
+ type RootModeSource,
32
40
  type RootRedirectSource,
41
+ getRootMode,
33
42
  getRootRedirect,
43
+ isRootMode,
34
44
  isSafeRedirectPath,
45
+ resolveRootModeDetailed,
35
46
  resolveRootRedirectDetailed,
47
+ setRootMode,
36
48
  setRootRedirect,
37
49
  } from "./hub-settings.ts";
38
50
  import { validateAccessToken } from "./jwt-sign.ts";
@@ -68,11 +80,19 @@ interface GetResponseBody {
68
80
  resolved: string;
69
81
  /** Which precedence layer the resolved value came from. */
70
82
  source: RootRedirectSource;
83
+ /** Raw stored value from hub_settings.root_mode, or null. */
84
+ root_mode: RootMode | null;
85
+ /** Resolved root mode (precedence-aware): "redirect" (302) or "serve-app". */
86
+ resolved_mode: RootMode;
87
+ /** Which precedence layer the resolved mode came from. */
88
+ mode_source: RootModeSource;
71
89
  }
72
90
 
73
91
  interface PutResponseBody {
74
- /** Echo of the now-stored value (null if cleared). */
92
+ /** Echo of the now-stored redirect (null if cleared / not part of this PUT). */
75
93
  root_redirect: string | null;
94
+ /** Echo of the now-stored mode (null if cleared / default). */
95
+ root_mode: RootMode | null;
76
96
  }
77
97
 
78
98
  /**
@@ -108,6 +128,30 @@ export function validateRootRedirect(value: unknown): ValidateOutcome {
108
128
  return { ok: true, normalized: value };
109
129
  }
110
130
 
131
+ /** Validation outcome for `root_mode` — string (a valid mode) or null (clear). */
132
+ type ValidateModeOutcome =
133
+ | { ok: true; normalized: RootMode | null }
134
+ | { ok: false; description: string };
135
+
136
+ /**
137
+ * Validate the body's `root_mode` field. Accepts:
138
+ * - `null` (or empty string) → clear the stored value, revert to env/default
139
+ * (the `redirect` default).
140
+ * - `"redirect"` | `"serve-app"` — a valid {@link RootMode}.
141
+ * Everything else → 400 with an operator-friendly description.
142
+ */
143
+ export function validateRootMode(value: unknown): ValidateModeOutcome {
144
+ if (value === null) return { ok: true, normalized: null };
145
+ if (typeof value !== "string") {
146
+ return { ok: false, description: `root_mode must be a string or null (got ${typeof value})` };
147
+ }
148
+ if (value.length === 0) return { ok: true, normalized: null };
149
+ if (!isRootMode(value)) {
150
+ return { ok: false, description: `root_mode must be one of ${ROOT_MODES.join(", ")}` };
151
+ }
152
+ return { ok: true, normalized: value };
153
+ }
154
+
111
155
  export async function handleApiSettingsRootRedirect(
112
156
  req: Request,
113
157
  deps: ApiSettingsRootRedirectDeps,
@@ -156,10 +200,14 @@ export async function handleApiSettingsRootRedirect(
156
200
 
157
201
  if (req.method === "GET") {
158
202
  const resolved = resolveRootRedirectDetailed(deps.db, { env: deps.env });
203
+ const resolvedMode = resolveRootModeDetailed(deps.db, { env: deps.env });
159
204
  const body: GetResponseBody = {
160
205
  root_redirect: getRootRedirect(deps.db),
161
206
  resolved: resolved.value,
162
207
  source: resolved.source,
208
+ root_mode: getRootMode(deps.db),
209
+ resolved_mode: resolvedMode.value,
210
+ mode_source: resolvedMode.source,
163
211
  };
164
212
  return new Response(JSON.stringify(body), {
165
213
  status: 200,
@@ -167,7 +215,12 @@ export async function handleApiSettingsRootRedirect(
167
215
  });
168
216
  }
169
217
 
170
- // PUT — parse + validate body.
218
+ // PUT — parse + validate body. Either `root_redirect` OR `root_mode` (or
219
+ // both) may be present; at least one is required. Each is validated +
220
+ // applied independently, so an operator can flip just the mode, just the
221
+ // redirect target, or both in one call. Back-compat: a body with only
222
+ // `root_redirect` (the pre-serve-app shape the admin SPA / CLI already send)
223
+ // works unchanged.
171
224
  let parsed: unknown;
172
225
  try {
173
226
  parsed = await req.json();
@@ -177,17 +230,40 @@ export async function handleApiSettingsRootRedirect(
177
230
  if (typeof parsed !== "object" || parsed === null) {
178
231
  return jsonError(400, "invalid_request", "request body must be a JSON object");
179
232
  }
180
- if (!("root_redirect" in parsed)) {
181
- return jsonError(400, "invalid_request", "request body must include a `root_redirect` field");
233
+ const hasRedirect = "root_redirect" in parsed;
234
+ const hasMode = "root_mode" in parsed;
235
+ if (!hasRedirect && !hasMode) {
236
+ return jsonError(
237
+ 400,
238
+ "invalid_request",
239
+ "request body must include a `root_redirect` and/or `root_mode` field",
240
+ );
241
+ }
242
+
243
+ // Validate BOTH before applying EITHER — a partial write (redirect stored,
244
+ // mode rejected) would leave the operator's config half-applied.
245
+ let redirectNormalized: string | null | undefined;
246
+ if (hasRedirect) {
247
+ const result = validateRootRedirect((parsed as { root_redirect: unknown }).root_redirect);
248
+ if (!result.ok) return jsonError(400, "invalid_root_redirect", result.description);
249
+ redirectNormalized = result.normalized;
182
250
  }
183
- const result = validateRootRedirect((parsed as { root_redirect: unknown }).root_redirect);
184
- if (!result.ok) {
185
- return jsonError(400, "invalid_root_redirect", result.description);
251
+ let modeNormalized: RootMode | null | undefined;
252
+ if (hasMode) {
253
+ const result = validateRootMode((parsed as { root_mode: unknown }).root_mode);
254
+ if (!result.ok) return jsonError(400, "invalid_root_mode", result.description);
255
+ modeNormalized = result.normalized;
186
256
  }
187
257
 
188
- setRootRedirect(deps.db, result.normalized);
258
+ if (redirectNormalized !== undefined) setRootRedirect(deps.db, redirectNormalized);
259
+ if (modeNormalized !== undefined) setRootMode(deps.db, modeNormalized);
189
260
 
190
- const body: PutResponseBody = { root_redirect: result.normalized };
261
+ // Echo the now-current stored values (re-read so an untouched field reflects
262
+ // what's actually in the row, not `undefined`).
263
+ const body: PutResponseBody = {
264
+ root_redirect: getRootRedirect(deps.db),
265
+ root_mode: getRootMode(deps.db),
266
+ };
191
267
  return new Response(JSON.stringify(body), {
192
268
  status: 200,
193
269
  headers: { "content-type": "application/json" },
@@ -34,8 +34,11 @@ import { CONFIG_DIR } from "../config.ts";
34
34
  import { hubDbPath, openHubDb } from "../hub-db.ts";
35
35
  import {
36
36
  DEFAULT_ROOT_REDIRECT,
37
+ ROOT_MODES,
38
+ isRootMode,
37
39
  isSafeRedirectPath,
38
40
  setHubOrigin,
41
+ setRootMode,
39
42
  setRootRedirect,
40
43
  } from "../hub-settings.ts";
41
44
  import { type CommandResult, type Runner, defaultRunner } from "../tailscale/run.ts";
@@ -427,6 +430,84 @@ export async function hubSetRootRedirect(
427
430
  return 0;
428
431
  }
429
432
 
433
+ /**
434
+ * `parachute hub set-root-mode <redirect|serve-app>` — persist how the hub
435
+ * answers its origin root into `hub_settings.root_mode` (tier-1 in
436
+ * `resolveRootMode`).
437
+ *
438
+ * - `redirect` (the default) — the bare `/` 302s to `root_redirect` (default
439
+ * `/admin`). This is what `--clear` reverts to.
440
+ * - `serve-app` — the hub SERVES the installed Parachute app AT the origin
441
+ * root (the hosted-door experience), no redirect hop. Requires the app to
442
+ * be installed (`parachute install app`); if it isn't, `/` falls back to
443
+ * the redirect until it is.
444
+ *
445
+ * Takes effect on the next request, no restart. Returns 0 on success, 1 on a
446
+ * usage / validation / DB-write failure. Mirrors `hubSetRootRedirect`'s shape.
447
+ */
448
+ export async function hubSetRootMode(
449
+ args: readonly string[],
450
+ deps: HubCommandDeps = {},
451
+ ): Promise<number> {
452
+ const configDir = deps.configDir ?? CONFIG_DIR;
453
+ const log = deps.log ?? ((line) => console.log(line));
454
+ const err = (line: string) => console.error(line);
455
+ const openDb = deps.openDb ?? ((dir: string) => openHubDb(hubDbPath(dir)));
456
+
457
+ const clear = args.includes("--clear");
458
+ const positional = args.filter((a) => !a.startsWith("-"));
459
+
460
+ if (clear) {
461
+ if (positional.length > 0) {
462
+ err("parachute hub set-root-mode: --clear takes no mode argument");
463
+ return 1;
464
+ }
465
+ const db = openDb(configDir);
466
+ try {
467
+ setRootMode(db, null);
468
+ } finally {
469
+ db.close();
470
+ }
471
+ log("✓ Cleared the root mode — `/` reverts to the redirect default.");
472
+ return 0;
473
+ }
474
+
475
+ const raw = positional[0];
476
+ if (raw === undefined) {
477
+ err(`usage: parachute hub set-root-mode <${ROOT_MODES.join("|")}> (or --clear)`);
478
+ err("example: parachute hub set-root-mode serve-app");
479
+ return 1;
480
+ }
481
+ if (positional.length > 1) {
482
+ err(`parachute hub set-root-mode: unexpected argument "${positional[1]}"`);
483
+ err(`usage: parachute hub set-root-mode <${ROOT_MODES.join("|")}> (or --clear)`);
484
+ return 1;
485
+ }
486
+ if (!isRootMode(raw)) {
487
+ err(`parachute hub set-root-mode: "${raw}" is not a valid mode`);
488
+ err(` Expected one of: ${ROOT_MODES.join(", ")}. Example: serve-app`);
489
+ return 1;
490
+ }
491
+
492
+ const db = openDb(configDir);
493
+ try {
494
+ setRootMode(db, raw);
495
+ } finally {
496
+ db.close();
497
+ }
498
+
499
+ if (raw === "serve-app") {
500
+ log("✓ Bare `/` now serves the Parachute app (root_mode=serve-app).");
501
+ log(" Requires the app to be installed (`parachute install app`); otherwise `/`");
502
+ log(" falls back to the redirect. Takes effect on the next request, no restart.");
503
+ log(" Revert with: parachute hub set-root-mode redirect (or --clear)");
504
+ } else {
505
+ log("✓ Bare `/` now uses redirect mode (the default).");
506
+ log(" Stored in hub_settings.root_mode — takes effect on the next request.");
507
+ }
508
+ return 0;
509
+ }
510
+
430
511
  /**
431
512
  * `parachute hub <subcommand>` dispatcher. Mirrors `auth`'s shape (a thin
432
513
  * router over subcommand handlers, each catching its own errors).
@@ -457,6 +538,16 @@ export async function hub(args: readonly string[], deps: HubCommandDeps = {}): P
457
538
  return 1;
458
539
  }
459
540
  }
541
+ if (sub === "set-root-mode") {
542
+ try {
543
+ return await hubSetRootMode(args.slice(1), deps);
544
+ } catch (err) {
545
+ console.error(
546
+ `parachute hub set-root-mode: ${err instanceof Error ? err.message : String(err)}`,
547
+ );
548
+ return 1;
549
+ }
550
+ }
460
551
  console.error(`parachute hub: unknown subcommand "${sub}"`);
461
552
  console.error("");
462
553
  console.error(hubHelp());
@@ -469,6 +560,7 @@ export function hubHelp(): string {
469
560
  Usage:
470
561
  parachute hub set-origin <url> [--no-caddy] [--no-restart]
471
562
  parachute hub set-root-redirect <path> | --clear
563
+ parachute hub set-root-mode <redirect|serve-app> | --clear
472
564
 
473
565
  Subcommands:
474
566
  set-origin <url> Persist the canonical public origin (OAuth issuer) to the
@@ -500,11 +592,23 @@ Subcommands:
500
592
  \`/\\\`, scheme, or whitespace). Pass --clear to revert to the
501
593
  env / /admin default. (Env equivalent: PARACHUTE_HUB_ROOT_REDIRECT.)
502
594
 
595
+ set-root-mode <mode>
596
+ How the hub answers its origin root: \`redirect\` (default —
597
+ the bare \`/\` 302s to the set-root-redirect target) or
598
+ \`serve-app\` (the hub SERVES the installed Parachute app AT
599
+ \`/\`, the hosted-door experience, no redirect hop). serve-app
600
+ needs the app installed (\`parachute install app\`); until then
601
+ \`/\` falls back to the redirect. Stored in hub_settings.root_mode;
602
+ takes effect on the next request, no restart. Pass --clear to
603
+ revert to the redirect default. (Env equivalent: PARACHUTE_HUB_ROOT_MODE.)
604
+
503
605
  Examples:
504
606
  parachute hub set-origin https://box.sslip.io
505
607
  parachute hub set-origin https://parachute.example.com
506
608
  parachute hub set-origin https://parachute.example.com --no-caddy
507
609
  parachute hub set-root-redirect /surface/reading-room
508
610
  parachute hub set-root-redirect --clear
611
+ parachute hub set-root-mode serve-app
612
+ parachute hub set-root-mode --clear
509
613
  `;
510
614
  }
@@ -17,7 +17,11 @@ import {
17
17
  readHubPort,
18
18
  } from "../hub-control.ts";
19
19
  import { hubDbPath, openHubDb } from "../hub-db.ts";
20
- import { resolveRootRedirectDetailed, setRootRedirect } from "../hub-settings.ts";
20
+ import {
21
+ resolveRootModeDetailed,
22
+ resolveRootRedirectDetailed,
23
+ setRootMode,
24
+ } from "../hub-settings.ts";
21
25
  import { type HubUnitDeps, defaultHubUnitDeps, isHubUnitInstalled } from "../hub-unit.ts";
22
26
  import {
23
27
  type ModuleManifest,
@@ -1398,30 +1402,37 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
1398
1402
  }
1399
1403
  }
1400
1404
 
1401
- // App-only: default the hub's bare `/` redirect to the app's front door on
1402
- // first install (hub-parity P5, 2026-07-11)SET-IF-UNSET ONLY, never
1403
- // clobbering an operator's existing choice (`parachute hub
1404
- // set-root-redirect` or the admin SPA PUT both still win on any later run,
1405
- // and either can change it back). "Unset" means the resolved redirect is
1406
- // still the built-in `/admin` DEFAULT i.e. NEITHER the DB row NOR the
1407
- // `PARACHUTE_HUB_ROOT_REDIRECT` env var (container deploys pin their landing
1408
- // page there) has set it. Gating on `getRootRedirect(db) === null` would
1409
- // miss the env tier and silently override an env-configured operator, since
1410
- // the DB row we'd write wins over env on read (`resolveRootRedirectDetailed`
1411
- // is DB-first). Gated on the same production-vs-test discriminant the
1412
- // guidance probe below uses: a test driving install against a tempdir
1413
- // manifestPath never opens the real `~/.parachute/hub.db` unless it opts in
1414
- // via `opts.rootRedirectDb`.
1405
+ // App-only: default the hub's origin root to SERVE the app on first install
1406
+ // (the self-hosted mirror of the hosted door hit the box's URL, land in the
1407
+ // app, no redirect hop). SET-IF-UNSET ONLY, never clobbering an operator's
1408
+ // existing choice. We only flip `root_mode` to `serve-app` when the hub's
1409
+ // root behavior is entirely PRISTINE DEFAULT i.e. the resolved mode is still
1410
+ // `redirect` (no `root_mode` DB row, no `PARACHUTE_HUB_ROOT_MODE` env) AND the
1411
+ // resolved redirect is still the built-in `/admin` (no `root_redirect` DB row,
1412
+ // no `PARACHUTE_HUB_ROOT_REDIRECT` env). If the operator has set EITHER (a
1413
+ // custom landing surface, a pinned env, or an explicit mode), we leave their
1414
+ // choice untouched. This also means a hub that ran the PRIOR app-install code
1415
+ // (which wrote `root_redirect = /app/`) is NOT flipped its redirect is no
1416
+ // longer default, so it keeps 302-ing to `/app/`. Later runs of
1417
+ // `parachute hub set-root-mode` / `set-root-redirect` (or the admin SPA PUT)
1418
+ // still win over this and can change it back. Gated on the same
1419
+ // production-vs-test discriminant the guidance probe below uses: a test
1420
+ // driving install against a tempdir manifestPath never opens the real
1421
+ // `~/.parachute/hub.db` unless it opts in via `opts.rootRedirectDb`.
1415
1422
  if (short === "app") {
1416
1423
  const dbProbeAllowed =
1417
1424
  opts.rootRedirectDb !== undefined || manifestPath === SERVICES_MANIFEST_PATH;
1418
1425
  if (dbProbeAllowed) {
1419
1426
  const db = opts.rootRedirectDb ?? openHubDb(hubDbPath(configDir));
1420
1427
  try {
1421
- if (resolveRootRedirectDetailed(db).source === "default") {
1422
- setRootRedirect(db, "/app/");
1423
- log("✓ The hub's front page (`/`) now opens the app at /app/.");
1424
- log(" Change it any time: `parachute hub set-root-redirect <path>` or the admin SPA.");
1428
+ const modePristine = resolveRootModeDetailed(db).source === "default";
1429
+ const redirectPristine = resolveRootRedirectDetailed(db).source === "default";
1430
+ if (modePristine && redirectPristine) {
1431
+ setRootMode(db, "serve-app");
1432
+ log("✓ The hub's front page (`/`) now serves the Parachute app.");
1433
+ log(
1434
+ " Change it any time: `parachute hub set-root-mode redirect` (or set-root-redirect) or the admin SPA.",
1435
+ );
1425
1436
  }
1426
1437
  } finally {
1427
1438
  if (!opts.rootRedirectDb) db.close();
package/src/hub-server.ts CHANGED
@@ -38,7 +38,15 @@
38
38
  * installs (surface-notes-alias.ts)
39
39
  *
40
40
  * # Discovery + well-known.
41
- * /, /hub.html hub.html (the discovery page)
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)
42
50
  * /.well-known/parachute.json → built dynamically from services.json
43
51
  * /.well-known/parachute-revocation.json → revoked-jti list (hub#212 Phase 1)
44
52
  * /.well-known/jwks.json → JWKS from hub.db
@@ -171,7 +179,13 @@
171
179
  * # Generic services.json-driven proxy (non-vault modules: notes, scribe, agent).
172
180
  * /<service-mount>/* → proxy via services.json longest-prefix
173
181
  *
174
- * anything else → 404
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)
175
189
  *
176
190
  * Invoked as:
177
191
  * bun <this-file> --port <n> --well-known-dir <path> [--db <path>] [--issuer <url>]
@@ -297,7 +311,7 @@ import {
297
311
  startDbPathLivenessTimer,
298
312
  } from "./hub-db-liveness.ts";
299
313
  import { hubDbPath, openHubDb } from "./hub-db.ts";
300
- import { getHubOrigin, resolveRootRedirect } from "./hub-settings.ts";
314
+ import { getHubOrigin, resolveRootMode, resolveRootRedirect } from "./hub-settings.ts";
301
315
  import { type RenderHubOpts, renderHub } from "./hub.ts";
302
316
  import { pemToJwk } from "./jwks.ts";
303
317
  import {
@@ -321,6 +335,7 @@ import { clearPid, writePid } from "./process-state.ts";
321
335
  import { toResponse as proxyErrorToResponse, renderProxyError } from "./proxy-error-ui.ts";
322
336
  import { classifyUpstream } from "./proxy-state.ts";
323
337
  import { isHttpsRequest } from "./request-protocol.ts";
338
+ import { makeAppDistResolver, serveAppAtRoot } from "./root-serve.ts";
324
339
  import {
325
340
  FIRST_PARTY_FALLBACKS,
326
341
  KNOWN_MODULES,
@@ -1420,6 +1435,15 @@ export interface HubFetchDeps {
1420
1435
  * tracker between fetch fn and bridge handlers is impossible.
1421
1436
  */
1422
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;
1423
1447
  }
1424
1448
 
1425
1449
  /**
@@ -2119,6 +2143,13 @@ export function hubFetch(
2119
2143
  const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
2120
2144
  const gitRoot = deps?.gitRoot ?? join(CONFIG_DIR, "hub", "git");
2121
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;
2122
2153
  const loopbackPort = deps?.loopbackPort;
2123
2154
  const loadExposeHubOrigin =
2124
2155
  deps?.loadExposeHubOrigin ??
@@ -2636,6 +2667,24 @@ export function hubFetch(
2636
2667
  // page (used by the static `parachute expose --set-path=/` disk file and
2637
2668
  // any bookmark to the explicit `.html`). Only the bare `/` redirects.
2638
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
+ }
2639
2688
  return new Response(null, {
2640
2689
  status: 302,
2641
2690
  headers: { location: resolveRootRedirect(getDb ? getDb() : null) },
@@ -4210,6 +4259,25 @@ export function hubFetch(
4210
4259
  );
4211
4260
  }
4212
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
+
4213
4281
  // Branded fall-through 404 (closes hub#392) — the operator who mistyped
4214
4282
  // a URL sees a clear "not found" page with a path back home, not the
4215
4283
  // browser's default empty-body chrome. Only HTML clients get the