@openparachute/hub 0.7.7-rc.12 → 0.7.7-rc.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/api-settings-root-redirect.test.ts +148 -5
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +285 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/install.test.ts +115 -35
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/api-settings-root-redirect.ts +95 -19
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +30 -19
- package/src/hub-server.ts +71 -3
- package/src/hub-settings.ts +137 -0
- package/src/root-serve.ts +156 -0
- package/src/service-spec.ts +3 -2
|
@@ -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,
|
|
@@ -156,10 +200,14 @@ export async function handleApiSettingsRootRedirect(
|
|
|
156
200
|
|
|
157
201
|
if (req.method === "GET") {
|
|
158
202
|
const resolved = resolveRootRedirectDetailed(deps.db, { env: deps.env });
|
|
203
|
+
const resolvedMode = resolveRootModeDetailed(deps.db, { env: deps.env });
|
|
159
204
|
const body: GetResponseBody = {
|
|
160
205
|
root_redirect: getRootRedirect(deps.db),
|
|
161
206
|
resolved: resolved.value,
|
|
162
207
|
source: resolved.source,
|
|
208
|
+
root_mode: getRootMode(deps.db),
|
|
209
|
+
resolved_mode: resolvedMode.value,
|
|
210
|
+
mode_source: resolvedMode.source,
|
|
163
211
|
};
|
|
164
212
|
return new Response(JSON.stringify(body), {
|
|
165
213
|
status: 200,
|
|
@@ -167,7 +215,12 @@ export async function handleApiSettingsRootRedirect(
|
|
|
167
215
|
});
|
|
168
216
|
}
|
|
169
217
|
|
|
170
|
-
// PUT — parse + validate body.
|
|
218
|
+
// PUT — parse + validate body. Either `root_redirect` OR `root_mode` (or
|
|
219
|
+
// both) may be present; at least one is required. Each is validated +
|
|
220
|
+
// applied independently, so an operator can flip just the mode, just the
|
|
221
|
+
// redirect target, or both in one call. Back-compat: a body with only
|
|
222
|
+
// `root_redirect` (the pre-serve-app shape the admin SPA / CLI already send)
|
|
223
|
+
// works unchanged.
|
|
171
224
|
let parsed: unknown;
|
|
172
225
|
try {
|
|
173
226
|
parsed = await req.json();
|
|
@@ -177,17 +230,40 @@ export async function handleApiSettingsRootRedirect(
|
|
|
177
230
|
if (typeof parsed !== "object" || parsed === null) {
|
|
178
231
|
return jsonError(400, "invalid_request", "request body must be a JSON object");
|
|
179
232
|
}
|
|
180
|
-
|
|
181
|
-
|
|
233
|
+
const hasRedirect = "root_redirect" in parsed;
|
|
234
|
+
const hasMode = "root_mode" in parsed;
|
|
235
|
+
if (!hasRedirect && !hasMode) {
|
|
236
|
+
return jsonError(
|
|
237
|
+
400,
|
|
238
|
+
"invalid_request",
|
|
239
|
+
"request body must include a `root_redirect` and/or `root_mode` field",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Validate BOTH before applying EITHER — a partial write (redirect stored,
|
|
244
|
+
// mode rejected) would leave the operator's config half-applied.
|
|
245
|
+
let redirectNormalized: string | null | undefined;
|
|
246
|
+
if (hasRedirect) {
|
|
247
|
+
const result = validateRootRedirect((parsed as { root_redirect: unknown }).root_redirect);
|
|
248
|
+
if (!result.ok) return jsonError(400, "invalid_root_redirect", result.description);
|
|
249
|
+
redirectNormalized = result.normalized;
|
|
182
250
|
}
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
|
|
251
|
+
let modeNormalized: RootMode | null | undefined;
|
|
252
|
+
if (hasMode) {
|
|
253
|
+
const result = validateRootMode((parsed as { root_mode: unknown }).root_mode);
|
|
254
|
+
if (!result.ok) return jsonError(400, "invalid_root_mode", result.description);
|
|
255
|
+
modeNormalized = result.normalized;
|
|
186
256
|
}
|
|
187
257
|
|
|
188
|
-
setRootRedirect(deps.db,
|
|
258
|
+
if (redirectNormalized !== undefined) setRootRedirect(deps.db, redirectNormalized);
|
|
259
|
+
if (modeNormalized !== undefined) setRootMode(deps.db, modeNormalized);
|
|
189
260
|
|
|
190
|
-
|
|
261
|
+
// Echo the now-current stored values (re-read so an untouched field reflects
|
|
262
|
+
// what's actually in the row, not `undefined`).
|
|
263
|
+
const body: PutResponseBody = {
|
|
264
|
+
root_redirect: getRootRedirect(deps.db),
|
|
265
|
+
root_mode: getRootMode(deps.db),
|
|
266
|
+
};
|
|
191
267
|
return new Response(JSON.stringify(body), {
|
|
192
268
|
status: 200,
|
|
193
269
|
headers: { "content-type": "application/json" },
|
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,
|
|
@@ -1398,30 +1402,37 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
|
|
|
1398
1402
|
}
|
|
1399
1403
|
}
|
|
1400
1404
|
|
|
1401
|
-
// App-only: default the hub's
|
|
1402
|
-
//
|
|
1403
|
-
// clobbering an operator's
|
|
1404
|
-
//
|
|
1405
|
-
//
|
|
1406
|
-
//
|
|
1407
|
-
//
|
|
1408
|
-
//
|
|
1409
|
-
//
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1413
|
-
//
|
|
1414
|
-
//
|
|
1405
|
+
// App-only: default the hub's origin root to SERVE the app on first install
|
|
1406
|
+
// (the self-hosted mirror of the hosted door — hit the box's URL, land in the
|
|
1407
|
+
// app, no redirect hop). SET-IF-UNSET ONLY, never clobbering an operator's
|
|
1408
|
+
// existing choice. We only flip `root_mode` to `serve-app` when the hub's
|
|
1409
|
+
// root behavior is entirely PRISTINE DEFAULT — i.e. the resolved mode is still
|
|
1410
|
+
// `redirect` (no `root_mode` DB row, no `PARACHUTE_HUB_ROOT_MODE` env) AND the
|
|
1411
|
+
// resolved redirect is still the built-in `/admin` (no `root_redirect` DB row,
|
|
1412
|
+
// no `PARACHUTE_HUB_ROOT_REDIRECT` env). If the operator has set EITHER (a
|
|
1413
|
+
// custom landing surface, a pinned env, or an explicit mode), we leave their
|
|
1414
|
+
// choice untouched. This also means a hub that ran the PRIOR app-install code
|
|
1415
|
+
// (which wrote `root_redirect = /app/`) is NOT flipped — its redirect is no
|
|
1416
|
+
// longer default, so it keeps 302-ing to `/app/`. Later runs of
|
|
1417
|
+
// `parachute hub set-root-mode` / `set-root-redirect` (or the admin SPA PUT)
|
|
1418
|
+
// still win over this and can change it back. Gated on the same
|
|
1419
|
+
// production-vs-test discriminant the guidance probe below uses: a test
|
|
1420
|
+
// driving install against a tempdir manifestPath never opens the real
|
|
1421
|
+
// `~/.parachute/hub.db` unless it opts in via `opts.rootRedirectDb`.
|
|
1415
1422
|
if (short === "app") {
|
|
1416
1423
|
const dbProbeAllowed =
|
|
1417
1424
|
opts.rootRedirectDb !== undefined || manifestPath === SERVICES_MANIFEST_PATH;
|
|
1418
1425
|
if (dbProbeAllowed) {
|
|
1419
1426
|
const db = opts.rootRedirectDb ?? openHubDb(hubDbPath(configDir));
|
|
1420
1427
|
try {
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1428
|
+
const modePristine = resolveRootModeDetailed(db).source === "default";
|
|
1429
|
+
const redirectPristine = resolveRootRedirectDetailed(db).source === "default";
|
|
1430
|
+
if (modePristine && redirectPristine) {
|
|
1431
|
+
setRootMode(db, "serve-app");
|
|
1432
|
+
log("✓ The hub's front page (`/`) now serves the Parachute app.");
|
|
1433
|
+
log(
|
|
1434
|
+
" Change it any time: `parachute hub set-root-mode redirect` (or set-root-redirect) or the admin SPA.",
|
|
1435
|
+
);
|
|
1425
1436
|
}
|
|
1426
1437
|
} finally {
|
|
1427
1438
|
if (!opts.rootRedirectDb) db.close();
|
package/src/hub-server.ts
CHANGED
|
@@ -38,7 +38,15 @@
|
|
|
38
38
|
* installs (surface-notes-alias.ts)
|
|
39
39
|
*
|
|
40
40
|
* # Discovery + well-known.
|
|
41
|
-
*
|
|
41
|
+
* / → root_mode-dependent: `redirect`
|
|
42
|
+
* (default) 302s to root_redirect
|
|
43
|
+
* (default /admin); `serve-app`
|
|
44
|
+
* serves the installed app's dist
|
|
45
|
+
* index.html AT `/` (no chrome).
|
|
46
|
+
* Fresh-hub wizard funnel + pre-
|
|
47
|
+
* admin 503 still preempt it.
|
|
48
|
+
* /hub.html → hub.html (the discovery page;
|
|
49
|
+
* unaffected by root_mode)
|
|
42
50
|
* /.well-known/parachute.json → built dynamically from services.json
|
|
43
51
|
* /.well-known/parachute-revocation.json → revoked-jti list (hub#212 Phase 1)
|
|
44
52
|
* /.well-known/jwks.json → JWKS from hub.db
|
|
@@ -171,7 +179,13 @@
|
|
|
171
179
|
* # Generic services.json-driven proxy (non-vault modules: notes, scribe, agent).
|
|
172
180
|
* /<service-mount>/* → proxy via services.json longest-prefix
|
|
173
181
|
*
|
|
174
|
-
*
|
|
182
|
+
* # serve-app tail (only when root_mode = serve-app): an unclaimed GET is
|
|
183
|
+
* # handed to the app's root-based dist — an existing bundle file (/assets/*,
|
|
184
|
+
* # /icon.svg, /manifest.webmanifest, /sw.js) serves that file; an HTML deep
|
|
185
|
+
* # link gets the SPA shell. Inert in redirect mode (the 404 below is
|
|
186
|
+
* # byte-identical to before). See root-serve.ts.
|
|
187
|
+
* anything else → 404 (or app asset / SPA shell
|
|
188
|
+
* in serve-app mode)
|
|
175
189
|
*
|
|
176
190
|
* Invoked as:
|
|
177
191
|
* bun <this-file> --port <n> --well-known-dir <path> [--db <path>] [--issuer <url>]
|
|
@@ -297,7 +311,7 @@ import {
|
|
|
297
311
|
startDbPathLivenessTimer,
|
|
298
312
|
} from "./hub-db-liveness.ts";
|
|
299
313
|
import { hubDbPath, openHubDb } from "./hub-db.ts";
|
|
300
|
-
import { getHubOrigin, resolveRootRedirect } from "./hub-settings.ts";
|
|
314
|
+
import { getHubOrigin, resolveRootMode, resolveRootRedirect } from "./hub-settings.ts";
|
|
301
315
|
import { type RenderHubOpts, renderHub } from "./hub.ts";
|
|
302
316
|
import { pemToJwk } from "./jwks.ts";
|
|
303
317
|
import {
|
|
@@ -321,6 +335,7 @@ import { clearPid, writePid } from "./process-state.ts";
|
|
|
321
335
|
import { toResponse as proxyErrorToResponse, renderProxyError } from "./proxy-error-ui.ts";
|
|
322
336
|
import { classifyUpstream } from "./proxy-state.ts";
|
|
323
337
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
338
|
+
import { makeAppDistResolver, serveAppAtRoot } from "./root-serve.ts";
|
|
324
339
|
import {
|
|
325
340
|
FIRST_PARTY_FALLBACKS,
|
|
326
341
|
KNOWN_MODULES,
|
|
@@ -1420,6 +1435,15 @@ export interface HubFetchDeps {
|
|
|
1420
1435
|
* tracker between fetch fn and bridge handlers is impossible.
|
|
1421
1436
|
*/
|
|
1422
1437
|
wsConnectionTracker?: WsConnectionTracker;
|
|
1438
|
+
/**
|
|
1439
|
+
* Resolve the installed app's `dist/` directory for `root_mode = serve-app`.
|
|
1440
|
+
* Returns the dist dir, or `null` when the app isn't installed (→ serve-app
|
|
1441
|
+
* falls back to the redirect). Production defaults to a memoizing resolver
|
|
1442
|
+
* over the real `@openparachute/parachute-app` package (`makeAppDistResolver`);
|
|
1443
|
+
* tests inject a fixture dir or a `() => null` to drive the not-installed
|
|
1444
|
+
* fallback without a real global install.
|
|
1445
|
+
*/
|
|
1446
|
+
resolveAppDist?: () => string | null;
|
|
1423
1447
|
}
|
|
1424
1448
|
|
|
1425
1449
|
/**
|
|
@@ -2119,6 +2143,13 @@ export function hubFetch(
|
|
|
2119
2143
|
const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
2120
2144
|
const gitRoot = deps?.gitRoot ?? join(CONFIG_DIR, "hub", "git");
|
|
2121
2145
|
const spaDistDir = deps?.spaDistDir ?? defaultSpaDistDir();
|
|
2146
|
+
// `root_mode = serve-app` resolver (memoizing over the real app package by
|
|
2147
|
+
// default; tests inject their own). Instantiated once per hubFetch so the
|
|
2148
|
+
// success-memoization spans the serve process's lifetime.
|
|
2149
|
+
const resolveAppDist = deps?.resolveAppDist ?? makeAppDistResolver();
|
|
2150
|
+
// One-shot log guard: when serve-app is set but the app can't be resolved we
|
|
2151
|
+
// fall back to the redirect and say so ONCE, not on every `/` hit.
|
|
2152
|
+
let warnedAppServeFallback = false;
|
|
2122
2153
|
const loopbackPort = deps?.loopbackPort;
|
|
2123
2154
|
const loadExposeHubOrigin =
|
|
2124
2155
|
deps?.loadExposeHubOrigin ??
|
|
@@ -2636,6 +2667,24 @@ export function hubFetch(
|
|
|
2636
2667
|
// page (used by the static `parachute expose --set-path=/` disk file and
|
|
2637
2668
|
// any bookmark to the explicit `.html`). Only the bare `/` redirects.
|
|
2638
2669
|
if (pathname === "/") {
|
|
2670
|
+
// `root_mode = serve-app`: serve the installed app's shell AT the origin
|
|
2671
|
+
// root (no redirect hop), the self-hosted mirror of the hosted door.
|
|
2672
|
+
// GET only — a non-GET `/` keeps its 302 below. Runs AFTER the fresh-hub
|
|
2673
|
+
// wizard funnel + pre-admin 503 above (a not-yet-set-up hub still lands
|
|
2674
|
+
// on setup, never a half-working app shell). Falls back to the redirect
|
|
2675
|
+
// when the app isn't installed (dist unresolvable), with a one-time log.
|
|
2676
|
+
if (req.method === "GET" && resolveRootMode(getDb ? getDb() : null) === "serve-app") {
|
|
2677
|
+
const dist = resolveAppDist();
|
|
2678
|
+
if (dist) {
|
|
2679
|
+
const served = serveAppAtRoot(dist, req, "/");
|
|
2680
|
+
if (served) return served;
|
|
2681
|
+
} else if (!warnedAppServeFallback) {
|
|
2682
|
+
warnedAppServeFallback = true;
|
|
2683
|
+
console.warn(
|
|
2684
|
+
"[hub] root_mode=serve-app but the Parachute app is not installed (its dist could not be resolved) — falling back to the root redirect. Install it with `parachute install app`.",
|
|
2685
|
+
);
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2639
2688
|
return new Response(null, {
|
|
2640
2689
|
status: 302,
|
|
2641
2690
|
headers: { location: resolveRootRedirect(getDb ? getDb() : null) },
|
|
@@ -4210,6 +4259,25 @@ export function hubFetch(
|
|
|
4210
4259
|
);
|
|
4211
4260
|
}
|
|
4212
4261
|
|
|
4262
|
+
// serve-app tail (`root_mode = serve-app`). This is exactly the "would
|
|
4263
|
+
// have been the branded 404" point: every hub-owned prefix above (/,
|
|
4264
|
+
// /admin, /oauth, /.well-known, /vault, /git, /api, service mounts) has
|
|
4265
|
+
// already had its turn, so anything here is genuinely unclaimed. Hand an
|
|
4266
|
+
// unclaimed GET to the app's root-based dist — an existing bundle file
|
|
4267
|
+
// (/assets/*, /icon.svg, /manifest.webmanifest, /sw.js) serves that file;
|
|
4268
|
+
// an HTML deep link the app routes client-side gets the SPA shell.
|
|
4269
|
+
// `serveAppAtRoot` returns null (→ branded 404 below) for a non-GET, a
|
|
4270
|
+
// non-HTML unclaimed request, or a reserved /api|/oauth|/.well-known
|
|
4271
|
+
// prefix. NO chrome injection — the app owns its whole page. In redirect
|
|
4272
|
+
// mode this branch is inert and the tail stays byte-identical to before.
|
|
4273
|
+
if (req.method === "GET" && resolveRootMode(getDb ? getDb() : null) === "serve-app") {
|
|
4274
|
+
const dist = resolveAppDist();
|
|
4275
|
+
if (dist) {
|
|
4276
|
+
const served = serveAppAtRoot(dist, req, pathname);
|
|
4277
|
+
if (served) return served;
|
|
4278
|
+
}
|
|
4279
|
+
}
|
|
4280
|
+
|
|
4213
4281
|
// Branded fall-through 404 (closes hub#392) — the operator who mistyped
|
|
4214
4282
|
// a URL sees a clear "not found" page with a path back home, not the
|
|
4215
4283
|
// browser's default empty-body chrome. Only HTML clients get the
|