@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.
- package/README.md +7 -7
- package/package.json +4 -12
- package/src/__tests__/account-api.test.ts +29 -2
- package/src/__tests__/account-session.test.ts +9 -1
- package/src/__tests__/account-token.test.ts +26 -2
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +210 -38
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +17 -26
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +0 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +22 -92
- package/src/__tests__/setup-wizard.test.ts +301 -22
- package/src/__tests__/setup.test.ts +8 -13
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/__tests__/wizard-transcription.test.ts +35 -0
- package/src/__tests__/wizard.test.ts +79 -0
- package/src/account-api.ts +6 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +9 -7
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +72 -27
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +0 -2
- package/src/commands/wizard-transcription.ts +24 -0
- package/src/commands/wizard.ts +34 -1
- package/src/help.ts +0 -1
- package/src/hub-server.ts +111 -54
- package/src/hub-settings.ts +147 -0
- package/src/hub.ts +64 -31
- package/src/module-ops-client.ts +2 -1
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +2 -5
- package/src/service-spec.ts +18 -47
- package/src/services-manifest.ts +9 -42
- package/src/setup-wizard.ts +144 -31
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `GET|PUT /api/settings/root-redirect` — operator-settable
|
|
3
|
-
*
|
|
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)
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
15
|
-
* so the SPA can render "current: /surface/x (from env)" while the
|
|
16
|
-
* the empty stored row — same separation rationale as
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
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,
|
|
258
|
+
if (redirectNormalized !== undefined) setRootRedirect(deps.db, redirectNormalized);
|
|
259
|
+
if (modeNormalized !== undefined) setRootMode(deps.db, modeNormalized);
|
|
188
260
|
|
|
189
|
-
|
|
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
|
-
|
|
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();
|
package/src/audience-gate.ts
CHANGED
|
@@ -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
|
-
|
|
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()]);
|
package/src/chrome-strip.ts
CHANGED
|
@@ -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
|
|
57
|
-
*
|
|
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[] = [
|
|
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.
|
package/src/commands/hub.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/install.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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 /
|
|
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
|
|
1368
|
-
//
|
|
1369
|
-
// clobbering an operator's
|
|
1370
|
-
//
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
1375
|
-
//
|
|
1376
|
-
//
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
//
|
|
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
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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();
|
package/src/commands/migrate.ts
CHANGED
|
@@ -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>([
|
|
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
|
package/src/commands/setup.ts
CHANGED
|
@@ -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)");
|