@openparachute/hub 0.7.7-rc.9 → 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 (78) 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 +210 -38
  27. package/src/__tests__/migrate.test.ts +3 -1
  28. package/src/__tests__/oauth-handlers.test.ts +19 -8
  29. package/src/__tests__/operator-token.test.ts +1 -2
  30. package/src/__tests__/port-assign.test.ts +17 -26
  31. package/src/__tests__/root-serve.test.ts +139 -0
  32. package/src/__tests__/scope-explanations.test.ts +0 -2
  33. package/src/__tests__/serve-boot.test.ts +25 -36
  34. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  35. package/src/__tests__/services-manifest.test.ts +22 -92
  36. package/src/__tests__/setup-wizard.test.ts +301 -22
  37. package/src/__tests__/setup.test.ts +8 -13
  38. package/src/__tests__/status.test.ts +0 -5
  39. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  40. package/src/__tests__/wizard-transcription.test.ts +35 -0
  41. package/src/__tests__/wizard.test.ts +79 -0
  42. package/src/account-api.ts +6 -0
  43. package/src/admin-connections.ts +4 -2
  44. package/src/admin-lock.ts +1 -2
  45. package/src/admin-module-token.ts +13 -8
  46. package/src/admin-surfaces.ts +2 -1
  47. package/src/api-hub-upgrade.ts +2 -1
  48. package/src/api-mint-token.ts +2 -1
  49. package/src/api-modules-ops.ts +2 -1
  50. package/src/api-modules.ts +9 -7
  51. package/src/api-revoke-token.ts +2 -1
  52. package/src/api-settings-hub-origin.ts +2 -1
  53. package/src/api-settings-root-redirect.ts +97 -20
  54. package/src/api-tokens.ts +2 -1
  55. package/src/audience-gate.ts +2 -1
  56. package/src/chrome-strip.ts +16 -4
  57. package/src/commands/hub.ts +104 -0
  58. package/src/commands/install.ts +72 -27
  59. package/src/commands/migrate.ts +5 -1
  60. package/src/commands/setup.ts +0 -2
  61. package/src/commands/wizard-transcription.ts +24 -0
  62. package/src/commands/wizard.ts +34 -1
  63. package/src/help.ts +0 -1
  64. package/src/hub-server.ts +111 -54
  65. package/src/hub-settings.ts +147 -0
  66. package/src/hub.ts +64 -31
  67. package/src/module-ops-client.ts +2 -1
  68. package/src/oauth-handlers.ts +1 -11
  69. package/src/operator-token.ts +0 -1
  70. package/src/origin-check.ts +2 -2
  71. package/src/root-serve.ts +156 -0
  72. package/src/scope-explanations.ts +2 -5
  73. package/src/service-spec.ts +18 -47
  74. package/src/services-manifest.ts +9 -42
  75. package/src/setup-wizard.ts +144 -31
  76. package/src/surface-notes-alias.ts +126 -0
  77. package/src/__tests__/admin-agent-token.test.ts +0 -173
  78. 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
  }
@@ -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,
@@ -36,6 +40,7 @@ import {
36
40
  type FirstPartyFallback,
37
41
  KNOWN_MODULES,
38
42
  type KnownModule,
43
+ RETIRED_MODULES,
39
44
  type ServiceSpec,
40
45
  composeServiceSpec,
41
46
  isCanonicalPort,
@@ -129,15 +134,9 @@ export function resolveInstallChannel(opts: {
129
134
  * launch sinks in and `parachute install lens` has stopped appearing
130
135
  * in support threads.
131
136
  *
132
- * `channel → agent` (2026-06-17): parachute-channel was renamed to
133
- * parachute-agent. `parachute install channel` keeps resolving to the
134
- * agent module for one release cycle so operators mid-upgrade aren't
135
- * stranded. Remove alongside the `parachute-channel → agent` legacy
136
- * manifest alias in service-spec.ts.
137
137
  */
138
138
  const SERVICE_ALIASES: Record<string, string> = {
139
139
  lens: "notes",
140
- channel: "agent",
141
140
  };
142
141
 
143
142
  /**
@@ -148,14 +147,41 @@ const SERVICE_ALIASES: Record<string, string> = {
148
147
  * real, non-Parachute package on npm). Install refuses with the message
149
148
  * instead.
150
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
+
151
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.",
152
159
  runner:
153
160
  "parachute-runner was retired from the hub's module registry on 2026-07-01 " +
154
- "(the module set of record is vault, hub, agent, scribe, surface). " +
161
+ "(the module set of record is vault, hub, scribe, surface, app). " +
155
162
  "An existing install keeps running under `parachute serve`; to install it anyway, " +
156
163
  "pass the explicit npm package name (@openparachute/runner) or a local checkout path.",
157
164
  };
158
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
+
159
185
  export interface InstallOpts {
160
186
  runner?: Runner;
161
187
  manifestPath?: string;
@@ -747,12 +773,20 @@ function resolveInstallTarget(
747
773
  log(`✗ ${input} has no readable package.json — can't install as a Parachute module.`);
748
774
  return null;
749
775
  }
776
+ if (isRetiredAgentPackage(packageName)) {
777
+ log(`✗ ${AGENT_RETIRED_MESSAGE}`);
778
+ return null;
779
+ }
750
780
  return { kind: "local-path", absPath: input, packageName };
751
781
  }
752
782
 
753
783
  // Anything else is treated as an npm package (bare or @scope/name). The
754
784
  // module.json contract gates this — third-party packages without a
755
785
  // manifest fail post-install with a clear error, not silently.
786
+ if (isRetiredAgentPackage(input)) {
787
+ log(`✗ ${AGENT_RETIRED_MESSAGE}`);
788
+ return null;
789
+ }
756
790
  return { kind: "npm", packageName: input };
757
791
  }
758
792
 
@@ -975,6 +1009,10 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
975
1009
  log,
976
1010
  });
977
1011
  if (installedManifest === "error") return 1;
1012
+ if (installedManifest && isRetiredAgentManifest(installedManifest)) {
1013
+ log(`✗ ${target.packageName}: ${AGENT_RETIRED_MESSAGE}`);
1014
+ return 1;
1015
+ }
978
1016
 
979
1017
  let manifest: ModuleManifest;
980
1018
  let extras: FirstPartyExtras | undefined;
@@ -982,7 +1020,7 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
982
1020
  manifest = installedManifest ?? target.fallback.manifest;
983
1021
  extras = target.fallback.extras;
984
1022
  } else if (target.kind === "known-module") {
985
- // KNOWN_MODULES shorts (vault / scribe / agent / surface) carry no vendored
1023
+ // KNOWN_MODULES shorts (vault / scribe / surface) carry no vendored
986
1024
  // manifest (hub#310). The module's own `.parachute/module.json` is the
987
1025
  // canonical source. When it's unreadable (legacy installs from before
988
1026
  // module.json shipped, or test fixtures that mock the disk path without
@@ -1364,30 +1402,37 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
1364
1402
  }
1365
1403
  }
1366
1404
 
1367
- // App-only: default the hub's bare `/` redirect to the app's front door on
1368
- // first install (hub-parity P5, 2026-07-11)SET-IF-UNSET ONLY, never
1369
- // clobbering an operator's existing choice (`parachute hub
1370
- // set-root-redirect` or the admin SPA PUT both still win on any later run,
1371
- // and either can change it back). "Unset" means the resolved redirect is
1372
- // still the built-in `/admin` DEFAULT i.e. NEITHER the DB row NOR the
1373
- // `PARACHUTE_HUB_ROOT_REDIRECT` env var (container deploys pin their landing
1374
- // page there) has set it. Gating on `getRootRedirect(db) === null` would
1375
- // miss the env tier and silently override an env-configured operator, since
1376
- // the DB row we'd write wins over env on read (`resolveRootRedirectDetailed`
1377
- // is DB-first). Gated on the same production-vs-test discriminant the
1378
- // guidance probe below uses: a test driving install against a tempdir
1379
- // manifestPath never opens the real `~/.parachute/hub.db` unless it opts in
1380
- // 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`.
1381
1422
  if (short === "app") {
1382
1423
  const dbProbeAllowed =
1383
1424
  opts.rootRedirectDb !== undefined || manifestPath === SERVICES_MANIFEST_PATH;
1384
1425
  if (dbProbeAllowed) {
1385
1426
  const db = opts.rootRedirectDb ?? openHubDb(hubDbPath(configDir));
1386
1427
  try {
1387
- if (resolveRootRedirectDetailed(db).source === "default") {
1388
- setRootRedirect(db, "/app/");
1389
- log("✓ The hub's front page (`/`) now opens the app at /app/.");
1390
- 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
+ );
1391
1436
  }
1392
1437
  } finally {
1393
1438
  if (!opts.rootRedirectDb) db.close();
@@ -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
@@ -186,8 +186,6 @@ const BLURBS: Record<string, string> = {
186
186
  // renders if a legacy install surfaces it in the survey.
187
187
  notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `surface`)",
188
188
  scribe: "audio transcription for dictation + recordings",
189
- agent:
190
- "(exploratory) chat with your Claude Code sessions — a channel per session (renamed from channel)",
191
189
  };
192
190
 
193
191
  function blurbFor(choice: ServiceChoice): string {
@@ -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)");