@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.
Files changed (80) hide show
  1. package/README.md +7 -7
  2. package/package.json +4 -12
  3. package/src/__tests__/account-api.test.ts +29 -2
  4. package/src/__tests__/account-session.test.ts +9 -1
  5. package/src/__tests__/account-token.test.ts +26 -2
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-lock.test.ts +3 -14
  8. package/src/__tests__/admin-module-token.test.ts +10 -30
  9. package/src/__tests__/admin-surfaces.test.ts +21 -0
  10. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  11. package/src/__tests__/api-mint-token.test.ts +25 -0
  12. package/src/__tests__/api-modules-ops.test.ts +34 -29
  13. package/src/__tests__/api-modules.test.ts +50 -58
  14. package/src/__tests__/api-revoke-token.test.ts +23 -0
  15. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  16. package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
  17. package/src/__tests__/api-tokens.test.ts +44 -0
  18. package/src/__tests__/audience-gate.test.ts +24 -0
  19. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  20. package/src/__tests__/chrome-strip.test.ts +18 -1
  21. package/src/__tests__/doctor.test.ts +10 -17
  22. package/src/__tests__/hub-command.test.ts +70 -2
  23. package/src/__tests__/hub-server.test.ts +322 -1
  24. package/src/__tests__/hub-settings.test.ts +110 -6
  25. package/src/__tests__/hub.test.ts +29 -0
  26. package/src/__tests__/install.test.ts +359 -5
  27. package/src/__tests__/migrate.test.ts +3 -1
  28. package/src/__tests__/notes-serve.test.ts +216 -0
  29. package/src/__tests__/oauth-handlers.test.ts +19 -8
  30. package/src/__tests__/operator-token.test.ts +1 -2
  31. package/src/__tests__/port-assign.test.ts +37 -17
  32. package/src/__tests__/root-serve.test.ts +139 -0
  33. package/src/__tests__/scope-explanations.test.ts +0 -2
  34. package/src/__tests__/serve-boot.test.ts +25 -36
  35. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  36. package/src/__tests__/services-manifest.test.ts +372 -132
  37. package/src/__tests__/setup-wizard.test.ts +301 -22
  38. package/src/__tests__/setup.test.ts +13 -14
  39. package/src/__tests__/status.test.ts +0 -5
  40. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  41. package/src/__tests__/wizard-transcription.test.ts +35 -0
  42. package/src/__tests__/wizard.test.ts +79 -0
  43. package/src/account-api.ts +6 -0
  44. package/src/admin-connections.ts +4 -2
  45. package/src/admin-lock.ts +1 -2
  46. package/src/admin-module-token.ts +13 -8
  47. package/src/admin-surfaces.ts +2 -1
  48. package/src/api-hub-upgrade.ts +2 -1
  49. package/src/api-mint-token.ts +2 -1
  50. package/src/api-modules-ops.ts +2 -1
  51. package/src/api-modules.ts +10 -8
  52. package/src/api-revoke-token.ts +2 -1
  53. package/src/api-settings-hub-origin.ts +2 -1
  54. package/src/api-settings-root-redirect.ts +97 -20
  55. package/src/api-tokens.ts +2 -1
  56. package/src/audience-gate.ts +2 -1
  57. package/src/chrome-strip.ts +16 -4
  58. package/src/commands/hub.ts +104 -0
  59. package/src/commands/install.ts +97 -8
  60. package/src/commands/migrate.ts +5 -1
  61. package/src/commands/setup.ts +9 -6
  62. package/src/commands/wizard-transcription.ts +24 -0
  63. package/src/commands/wizard.ts +34 -1
  64. package/src/help.ts +6 -6
  65. package/src/hub-server.ts +111 -54
  66. package/src/hub-settings.ts +147 -0
  67. package/src/hub.ts +64 -31
  68. package/src/module-ops-client.ts +2 -1
  69. package/src/notes-serve.ts +73 -31
  70. package/src/oauth-handlers.ts +1 -11
  71. package/src/operator-token.ts +0 -1
  72. package/src/origin-check.ts +2 -2
  73. package/src/root-serve.ts +156 -0
  74. package/src/scope-explanations.ts +2 -5
  75. package/src/service-spec.ts +129 -74
  76. package/src/services-manifest.ts +112 -52
  77. package/src/setup-wizard.ts +144 -31
  78. package/src/surface-notes-alias.ts +126 -0
  79. package/src/__tests__/admin-agent-token.test.ts +0 -173
  80. package/src/admin-agent-token.ts +0 -147
@@ -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,
@@ -119,7 +163,8 @@ export async function handleApiSettingsRootRedirect(
119
163
  // Bearer presence + parsing — identical shape to api-settings-hub-origin
120
164
  // for consistency across hub-internal admin endpoints.
121
165
  const auth = req.headers.get("authorization");
122
- if (!auth || !auth.startsWith("Bearer ")) {
166
+ // Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
167
+ if (!auth || !/^Bearer\s+/i.test(auth)) {
123
168
  return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
124
169
  }
125
170
  const bearer = auth.slice("Bearer ".length).trim();
@@ -155,10 +200,14 @@ export async function handleApiSettingsRootRedirect(
155
200
 
156
201
  if (req.method === "GET") {
157
202
  const resolved = resolveRootRedirectDetailed(deps.db, { env: deps.env });
203
+ const resolvedMode = resolveRootModeDetailed(deps.db, { env: deps.env });
158
204
  const body: GetResponseBody = {
159
205
  root_redirect: getRootRedirect(deps.db),
160
206
  resolved: resolved.value,
161
207
  source: resolved.source,
208
+ root_mode: getRootMode(deps.db),
209
+ resolved_mode: resolvedMode.value,
210
+ mode_source: resolvedMode.source,
162
211
  };
163
212
  return new Response(JSON.stringify(body), {
164
213
  status: 200,
@@ -166,7 +215,12 @@ export async function handleApiSettingsRootRedirect(
166
215
  });
167
216
  }
168
217
 
169
- // 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.
170
224
  let parsed: unknown;
171
225
  try {
172
226
  parsed = await req.json();
@@ -176,17 +230,40 @@ export async function handleApiSettingsRootRedirect(
176
230
  if (typeof parsed !== "object" || parsed === null) {
177
231
  return jsonError(400, "invalid_request", "request body must be a JSON object");
178
232
  }
179
- if (!("root_redirect" in parsed)) {
180
- 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;
181
250
  }
182
- const result = validateRootRedirect((parsed as { root_redirect: unknown }).root_redirect);
183
- if (!result.ok) {
184
- 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;
185
256
  }
186
257
 
187
- setRootRedirect(deps.db, result.normalized);
258
+ if (redirectNormalized !== undefined) setRootRedirect(deps.db, redirectNormalized);
259
+ if (modeNormalized !== undefined) setRootMode(deps.db, modeNormalized);
188
260
 
189
- 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
+ };
190
267
  return new Response(JSON.stringify(body), {
191
268
  status: 200,
192
269
  headers: { "content-type": "application/json" },
package/src/api-tokens.ts CHANGED
@@ -113,7 +113,8 @@ export async function handleApiTokens(req: Request, deps: ApiTokensDeps): Promis
113
113
 
114
114
  // 1. Bearer presence + parsing.
115
115
  const auth = req.headers.get("authorization");
116
- if (!auth || !auth.startsWith("Bearer ")) {
116
+ // Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
117
+ if (!auth || !/^Bearer\s+/i.test(auth)) {
117
118
  return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
118
119
  }
119
120
  const bearer = auth.slice("Bearer ".length).trim();
@@ -235,7 +235,8 @@ export async function gateUiAudience(
235
235
  if (session) return null;
236
236
 
237
237
  const auth = req.headers.get("authorization");
238
- if (auth?.startsWith("Bearer ")) {
238
+ // Bearer scheme is case-insensitive per RFC 7235; token passed verbatim (V1.4/C1.3 parity).
239
+ if (auth && /^Bearer\s+/i.test(auth)) {
239
240
  const token = auth.slice("Bearer ".length).trim();
240
241
  try {
241
242
  const validated = await validateHostAdminToken(db, token, [...deps.knownIssuers()]);
@@ -17,7 +17,12 @@
17
17
  * is the canonical opt-out — it owns its own chrome (see design-system §7
18
18
  * "Where NOT to inject" + AUDIT §4: "Notes is the proof this can work: own
19
19
  * application, looks distinctively Notes, reads as Parachute because the
20
- * tokens are continuous").
20
+ * tokens are continuous"). `/surface/parachute/*` (W2-12) is the same
21
+ * application under its post-rename surface identity — the parachute-app
22
+ * super-surface mounted as a surface — and owns its own chrome for the
23
+ * same reason. `/surface/notes/` stays on the list for existing notes-ui
24
+ * installs (legacy mount); the two entries coexist, they don't replace
25
+ * each other.
21
26
  *
22
27
  * H5 (surface-runtime design): the opt-out generalized — when a UI
23
28
  * sub-unit's declared `audience` resolves `public` at the proxy's audience
@@ -53,10 +58,17 @@ import { CSRF_FIELD_NAME, ensureCsrfToken } from "./csrf.ts";
53
58
  * prefix" or "pathname startsWith prefix" — the same shape as
54
59
  * `findServiceUpstream`'s mount comparison.
55
60
  *
56
- * `/surface/notes/` covers the Notes PWA bundled by parachute-app. Notes is a
57
- * destination, not chrome; it owns its own header (see design-system.md §7).
61
+ * `/surface/notes/` covers the legacy notes-ui mount (existing installs keep
62
+ * this identity on disk). `/surface/parachute/` covers the app's surface
63
+ * mount — the same application under its renamed surface identity (W2-12).
64
+ * Both are destinations, not chrome; each owns its own header (see
65
+ * design-system.md §7). Additive: neither entry replaces the other, so an
66
+ * install serving either mount (or, transiently, both) gets the opt-out.
58
67
  */
59
- export const CHROME_OPT_OUT_PREFIXES: readonly string[] = ["/surface/notes/"];
68
+ export const CHROME_OPT_OUT_PREFIXES: readonly string[] = [
69
+ "/surface/notes/",
70
+ "/surface/parachute/",
71
+ ];
60
72
 
61
73
  /**
62
74
  * Buffer size cap. Responses larger than this are passed through unchanged.
@@ -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
  }
@@ -1,3 +1,4 @@
1
+ import type { Database } from "bun:sqlite";
1
2
  import { existsSync, readFileSync, realpathSync } from "node:fs";
2
3
  import { createConnection } from "node:net";
3
4
  import { dirname, join } from "node:path";
@@ -15,6 +16,12 @@ import {
15
16
  defaultSleep,
16
17
  readHubPort,
17
18
  } from "../hub-control.ts";
19
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
20
+ import {
21
+ resolveRootModeDetailed,
22
+ resolveRootRedirectDetailed,
23
+ setRootMode,
24
+ } from "../hub-settings.ts";
18
25
  import { type HubUnitDeps, defaultHubUnitDeps, isHubUnitInstalled } from "../hub-unit.ts";
19
26
  import {
20
27
  type ModuleManifest,
@@ -33,6 +40,7 @@ import {
33
40
  type FirstPartyFallback,
34
41
  KNOWN_MODULES,
35
42
  type KnownModule,
43
+ RETIRED_MODULES,
36
44
  type ServiceSpec,
37
45
  composeServiceSpec,
38
46
  isCanonicalPort,
@@ -126,15 +134,9 @@ export function resolveInstallChannel(opts: {
126
134
  * launch sinks in and `parachute install lens` has stopped appearing
127
135
  * in support threads.
128
136
  *
129
- * `channel → agent` (2026-06-17): parachute-channel was renamed to
130
- * parachute-agent. `parachute install channel` keeps resolving to the
131
- * agent module for one release cycle so operators mid-upgrade aren't
132
- * stranded. Remove alongside the `parachute-channel → agent` legacy
133
- * manifest alias in service-spec.ts.
134
137
  */
135
138
  const SERVICE_ALIASES: Record<string, string> = {
136
139
  lens: "notes",
137
- channel: "agent",
138
140
  };
139
141
 
140
142
  /**
@@ -145,14 +147,41 @@ const SERVICE_ALIASES: Record<string, string> = {
145
147
  * real, non-Parachute package on npm). Install refuses with the message
146
148
  * instead.
147
149
  */
150
+ const AGENT_RETIRED_MESSAGE =
151
+ "parachute-agent was retired from the hub on 2026-07-15. " +
152
+ "Parachute product development is focused on Vault and Surface.";
153
+
148
154
  const RETIRED_INSTALL_SHORTS: Record<string, string> = {
155
+ agent: AGENT_RETIRED_MESSAGE,
156
+ channel:
157
+ "parachute-channel (later parachute-agent) was retired from the hub on 2026-07-15. " +
158
+ "Parachute product development is focused on Vault and Surface.",
149
159
  runner:
150
160
  "parachute-runner was retired from the hub's module registry on 2026-07-01 " +
151
- "(the module set of record is vault, hub, agent, scribe, surface). " +
161
+ "(the module set of record is vault, hub, scribe, surface, app). " +
152
162
  "An existing install keeps running under `parachute serve`; to install it anyway, " +
153
163
  "pass the explicit npm package name (@openparachute/runner) or a local checkout path.",
154
164
  };
155
165
 
166
+ const RETIRED_AGENT_PACKAGES = [
167
+ "@openparachute/agent",
168
+ "@openparachute/parachute-agent",
169
+ "@openparachute/channel",
170
+ "@openparachute/parachute-channel",
171
+ ] as const;
172
+
173
+ function isRetiredAgentPackage(packageName: string): boolean {
174
+ return RETIRED_AGENT_PACKAGES.some(
175
+ (retired) => packageName === retired || packageName.startsWith(`${retired}@`),
176
+ );
177
+ }
178
+
179
+ function isRetiredAgentManifest(manifest: ModuleManifest): boolean {
180
+ if (RETIRED_MODULES[manifest.name] !== undefined) return true;
181
+ if (manifest.manifestName && RETIRED_MODULES[manifest.manifestName] !== undefined) return true;
182
+ return manifest.name === "claw" && manifest.paths[0] === "/claw";
183
+ }
184
+
156
185
  export interface InstallOpts {
157
186
  runner?: Runner;
158
187
  manifestPath?: string;
@@ -408,6 +437,16 @@ export interface InstallOpts {
408
437
  * origin for stable assertions.
409
438
  */
410
439
  wellKnownOrigin?: string;
440
+ /**
441
+ * Test seam for the `app`-only set-if-unset root-redirect write (hub-parity
442
+ * P5). Production opens `openHubDb(hubDbPath(configDir))` fresh and closes
443
+ * it after the write; passing this injects an already-open DB (e.g. a
444
+ * tempdir `hub.db`) so a test never touches the real `~/.parachute/hub.db`.
445
+ * Passing this ALSO opts a tempdir-manifestPath test INTO the write path —
446
+ * mirrors `guidanceCtx`'s discriminant: without it, the production gate
447
+ * (`manifestPath === SERVICES_MANIFEST_PATH`) skips the write entirely.
448
+ */
449
+ rootRedirectDb?: Database;
411
450
  }
412
451
 
413
452
  async function defaultRunner(cmd: readonly string[]): Promise<number> {
@@ -734,12 +773,20 @@ function resolveInstallTarget(
734
773
  log(`✗ ${input} has no readable package.json — can't install as a Parachute module.`);
735
774
  return null;
736
775
  }
776
+ if (isRetiredAgentPackage(packageName)) {
777
+ log(`✗ ${AGENT_RETIRED_MESSAGE}`);
778
+ return null;
779
+ }
737
780
  return { kind: "local-path", absPath: input, packageName };
738
781
  }
739
782
 
740
783
  // Anything else is treated as an npm package (bare or @scope/name). The
741
784
  // module.json contract gates this — third-party packages without a
742
785
  // manifest fail post-install with a clear error, not silently.
786
+ if (isRetiredAgentPackage(input)) {
787
+ log(`✗ ${AGENT_RETIRED_MESSAGE}`);
788
+ return null;
789
+ }
743
790
  return { kind: "npm", packageName: input };
744
791
  }
745
792
 
@@ -962,6 +1009,10 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
962
1009
  log,
963
1010
  });
964
1011
  if (installedManifest === "error") return 1;
1012
+ if (installedManifest && isRetiredAgentManifest(installedManifest)) {
1013
+ log(`✗ ${target.packageName}: ${AGENT_RETIRED_MESSAGE}`);
1014
+ return 1;
1015
+ }
965
1016
 
966
1017
  let manifest: ModuleManifest;
967
1018
  let extras: FirstPartyExtras | undefined;
@@ -969,7 +1020,7 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
969
1020
  manifest = installedManifest ?? target.fallback.manifest;
970
1021
  extras = target.fallback.extras;
971
1022
  } else if (target.kind === "known-module") {
972
- // KNOWN_MODULES shorts (vault / scribe / agent / surface) carry no vendored
1023
+ // KNOWN_MODULES shorts (vault / scribe / surface) carry no vendored
973
1024
  // manifest (hub#310). The module's own `.parachute/module.json` is the
974
1025
  // canonical source. When it's unreadable (legacy installs from before
975
1026
  // module.json shipped, or test fixtures that mock the disk path without
@@ -1351,6 +1402,44 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
1351
1402
  }
1352
1403
  }
1353
1404
 
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`.
1422
+ if (short === "app") {
1423
+ const dbProbeAllowed =
1424
+ opts.rootRedirectDb !== undefined || manifestPath === SERVICES_MANIFEST_PATH;
1425
+ if (dbProbeAllowed) {
1426
+ const db = opts.rootRedirectDb ?? openHubDb(hubDbPath(configDir));
1427
+ try {
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
+ );
1436
+ }
1437
+ } finally {
1438
+ if (!opts.rootRedirectDb) db.close();
1439
+ }
1440
+ }
1441
+ }
1442
+
1354
1443
  // Per-service install footer — canonical next-step URLs and configuration
1355
1444
  // hints. Vault prints its own (richer) footer from `parachute-vault init`
1356
1445
  // (PR #166), so the spec leaves vault out and we don't double up here.
@@ -106,7 +106,11 @@ export function safelistEntries(): Set<string> {
106
106
  * list once enough operator cycles have passed to be confident no
107
107
  * install is still on the Lens-era code path.
108
108
  */
109
- export const KNOWN_ARCHIVABLE_DIRS: ReadonlySet<string> = new Set<string>(["lens"]);
109
+ export const KNOWN_ARCHIVABLE_DIRS: ReadonlySet<string> = new Set<string>([
110
+ "lens",
111
+ "agent",
112
+ "channel",
113
+ ]);
110
114
 
111
115
  /**
112
116
  * Known-cruft rules. Each rule names a label (the friendly description in
@@ -110,7 +110,7 @@ function defaultAvailability(): InteractiveAvailability {
110
110
 
111
111
  /**
112
112
  * Survey ALL known first-party shortnames (vault / notes / scribe / agent /
113
- * surface) regardless of tier — `installed` is true when the service
113
+ * surface / app) regardless of tier — `installed` is true when the service
114
114
  * has a row in services.json. The fresh-install OFFER is narrowed downstream
115
115
  * by `isOfferable` (drops already-installed + `deprecated`-tier shorts —
116
116
  * notes); agent (`experimental`) is flagged exploratory in its blurb
@@ -121,7 +121,7 @@ function defaultAvailability(): InteractiveAvailability {
121
121
  * like any unknown row it neither blocks setup nor appears in the offer.
122
122
  *
123
123
  * The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
124
- * shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
124
+ * shorts (notes / app both carry a vendored manifest). KNOWN_MODULES shorts
125
125
  * (vault / scribe / agent / surface) ship `.parachute/module.json`
126
126
  * and self-register; pre-install we know manifestName + the urlForEntry quirk
127
127
  * from `KNOWN_MODULES[short].extras`, which is all the survey/summary needs.
@@ -175,14 +175,17 @@ export function isOfferable(choice: { short: string; installed: boolean }): bool
175
175
  const BLURBS: Record<string, string> = {
176
176
  vault: "knowledge graph (MCP) — your owner-authenticated note + tag store",
177
177
  surface: "Parachute UI host — auto-installs Notes on first boot (the recommended UI path)",
178
- // `app` is the pre-2026-05-27 name for `surface`; kept for any legacy survey row.
179
- app: "Parachute UI host auto-installs Notes on first boot (recommended over notes-daemon)",
178
+ // hub-parity P5 (2026-07-11): `app` is now the NEW super-surface front
179
+ // door (@openparachute/parachute-app)a real FIRST_PARTY_FALLBACKS
180
+ // entry, not a legacy survey row. It is UNRELATED to the pre-2026-05-27
181
+ // `app` package that renamed to `surface` (that blurb lives under the
182
+ // `surface` key above); the two just happen to share the short name
183
+ // across a rename.
184
+ app: "the super-surface front door — one app to sign in, browse your vault, and manage your hub/cloud",
180
185
  // notes is `deprecated` (not offered on a fresh setup) — this blurb only
181
186
  // renders if a legacy install surfaces it in the survey.
182
187
  notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `surface`)",
183
188
  scribe: "audio transcription for dictation + recordings",
184
- agent:
185
- "(exploratory) chat with your Claude Code sessions — a channel per session (renamed from channel)",
186
189
  };
187
190
 
188
191
  function blurbFor(choice: ServiceChoice): string {