@openparachute/hub 0.7.7-rc.8 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/package.json +4 -12
- package/src/__tests__/account-api.test.ts +29 -2
- package/src/__tests__/account-session.test.ts +9 -1
- package/src/__tests__/account-token.test.ts +26 -2
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +0 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/setup-wizard.test.ts +301 -22
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/__tests__/wizard-transcription.test.ts +35 -0
- package/src/__tests__/wizard.test.ts +79 -0
- package/src/account-api.ts +6 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/commands/wizard-transcription.ts +24 -0
- package/src/commands/wizard.ts +34 -1
- package/src/help.ts +6 -6
- package/src/hub-server.ts +111 -54
- package/src/hub-settings.ts +147 -0
- package/src/hub.ts +64 -31
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +2 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/setup-wizard.ts +144 -31
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
|
@@ -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
|
@@ -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,
|
|
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 /
|
|
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.
|
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
|
@@ -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 —
|
|
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
|
-
//
|
|
179
|
-
|
|
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 {
|