@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.20
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 +4 -11
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-lock.test.ts +7 -1
- package/src/__tests__/admin-vaults.test.ts +216 -10
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +326 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/init.test.ts +153 -0
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +626 -0
- package/src/__tests__/oauth-ui.test.ts +107 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-clients.ts +55 -3
- package/src/admin-vaults.ts +52 -25
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +38 -3
- package/src/api-me.ts +11 -2
- package/src/api-modules.ts +105 -0
- package/src/api-settings-root-redirect.ts +188 -0
- package/src/cli.ts +56 -5
- package/src/clients.ts +178 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/init.ts +108 -0
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +65 -1
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +139 -24
- package/src/hub-settings.ts +163 -1
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +103 -6
- package/src/oauth-ui.ts +174 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
package/src/api-hub-upgrade.ts
CHANGED
|
@@ -67,6 +67,34 @@ export const HUB_UPGRADE_REQUIRED_SCOPE = "parachute:host:admin";
|
|
|
67
67
|
*/
|
|
68
68
|
const IN_FLIGHT_PHASES = new Set<HubUpgradeStatus["phase"]>(["pending", "running", "restarting"]);
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* #506: TTL for the 409 in-flight guard. The status file is single-slot, and a
|
|
72
|
+
* helper that CRASHES (OOM, killed mid-rewrite, host reboot) never reaches a
|
|
73
|
+
* terminal phase — leaving the slot stuck in `pending`/`running`/`restarting`
|
|
74
|
+
* FOREVER and 409-deadlocking every future upgrade. So: an in-flight slot whose
|
|
75
|
+
* `started_at` is older than this bound is treated as ABANDONED and the new
|
|
76
|
+
* request proceeds (overwriting the stale slot).
|
|
77
|
+
*
|
|
78
|
+
* 15 minutes — comfortably past the longest expected in-place upgrade (an
|
|
79
|
+
* `npm view` + `bun add -g` rewrite + restart is seconds-to-low-minutes even on
|
|
80
|
+
* a slow box / cold cache). A live upgrade finishing under the bound is never
|
|
81
|
+
* mistaken for abandoned; a crashed one frees the slot within 15 min instead of
|
|
82
|
+
* never. (A missing/garbage `started_at` is treated as stale → not 409, so a
|
|
83
|
+
* malformed file can't deadlock either.)
|
|
84
|
+
*/
|
|
85
|
+
const IN_FLIGHT_TTL_MS = 15 * 60 * 1000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Is an in-flight slot still FRESH (within the TTL), so a second POST must be
|
|
89
|
+
* rejected 409? An unparseable / missing `started_at` is treated as stale
|
|
90
|
+
* (not fresh) so a malformed file frees the slot rather than deadlocking it.
|
|
91
|
+
*/
|
|
92
|
+
function isInFlightFresh(existing: HubUpgradeStatus, now: Date): boolean {
|
|
93
|
+
const startedMs = Date.parse(existing.started_at);
|
|
94
|
+
if (Number.isNaN(startedMs)) return false;
|
|
95
|
+
return now.getTime() - startedMs < IN_FLIGHT_TTL_MS;
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
export interface SpawnHelperArgs {
|
|
71
99
|
operationId: string;
|
|
72
100
|
channel: "rc" | "latest";
|
|
@@ -213,7 +241,9 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
|
|
|
213
241
|
const parsed = await parseBody(req);
|
|
214
242
|
if (parsed instanceof Response) return parsed;
|
|
215
243
|
|
|
216
|
-
|
|
244
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
245
|
+
|
|
246
|
+
// ── 409 in-flight guard (TTL-bounded) ──────────────────────────────────────
|
|
217
247
|
// The status file is single-slot (one hub, one upgrade). If a prior upgrade
|
|
218
248
|
// is still in a non-terminal phase (pending/running/restarting), starting a
|
|
219
249
|
// SECOND would overwrite its operation_id — and a still-running first helper
|
|
@@ -222,9 +252,15 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
|
|
|
222
252
|
// server-side too (a second tab, a stale page, a scripted POST). Reject with
|
|
223
253
|
// 409 unless the slot is free (no file) or the prior op reached a terminal
|
|
224
254
|
// phase (failed / redeploy-required / succeeded).
|
|
255
|
+
//
|
|
256
|
+
// #506: BUT a non-terminal slot is only a real block while it's FRESH. A
|
|
257
|
+
// helper that crashed (OOM / killed / host reboot) leaves the slot stuck
|
|
258
|
+
// in-flight forever and would 409-deadlock every future upgrade. So an
|
|
259
|
+
// in-flight slot older than IN_FLIGHT_TTL_MS is treated as ABANDONED and the
|
|
260
|
+
// request proceeds (the seeded status below overwrites the stale slot).
|
|
225
261
|
const readStatus = deps.readStatus ?? readHubUpgradeStatus;
|
|
226
262
|
const existing = readStatus(deps.configDir);
|
|
227
|
-
if (existing && IN_FLIGHT_PHASES.has(existing.phase)) {
|
|
263
|
+
if (existing && IN_FLIGHT_PHASES.has(existing.phase) && isInFlightFresh(existing, now)) {
|
|
228
264
|
return jsonError(
|
|
229
265
|
409,
|
|
230
266
|
"upgrade_in_flight",
|
|
@@ -234,7 +270,6 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
|
|
|
234
270
|
|
|
235
271
|
const hubSrcDir = deps.hubSrcDir ?? dirname(fileURLToPath(import.meta.url));
|
|
236
272
|
const env = deps.env ?? process.env;
|
|
237
|
-
const now = (deps.now ?? (() => new Date()))();
|
|
238
273
|
|
|
239
274
|
const currentVersion = (deps.currentVersion ?? (() => defaultCurrentVersion(hubSrcDir)))();
|
|
240
275
|
// Auto-detect the channel from the current version when not explicitly set —
|
package/src/api-me.ts
CHANGED
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
* Response shape:
|
|
15
15
|
*
|
|
16
16
|
* { hasSession: false }
|
|
17
|
-
* { hasSession: true, user: { id, displayName }, csrf: "<token>"
|
|
17
|
+
* { hasSession: true, user: { id, displayName }, csrf: "<token>",
|
|
18
|
+
* two_factor_enabled: boolean }
|
|
19
|
+
*
|
|
20
|
+
* `two_factor_enabled` (hub#85) lets the SPA's "My account" page render the
|
|
21
|
+
* 2FA status without a separate read. It reflects `users.totp_secret` being
|
|
22
|
+
* set for the signed-in user.
|
|
18
23
|
*
|
|
19
24
|
* `displayName` is the user's `username` today — there's no separate
|
|
20
25
|
* display-name field on the User shape. Surfaced under a different key
|
|
@@ -39,6 +44,7 @@
|
|
|
39
44
|
import type { Database } from "bun:sqlite";
|
|
40
45
|
import { ensureCsrfToken } from "./csrf.ts";
|
|
41
46
|
import { findActiveSession } from "./sessions.ts";
|
|
47
|
+
import { isTotpEnrolled } from "./two-factor-store.ts";
|
|
42
48
|
import { getUserById } from "./users.ts";
|
|
43
49
|
|
|
44
50
|
export interface ApiMeDeps {
|
|
@@ -59,7 +65,9 @@ interface SignedInUser {
|
|
|
59
65
|
* that mixes states — e.g. `{ hasSession: false, user: staleUser }`
|
|
60
66
|
* fails at the type-check, not just at code-review.
|
|
61
67
|
*/
|
|
62
|
-
type ApiMeResponse =
|
|
68
|
+
type ApiMeResponse =
|
|
69
|
+
| { hasSession: false }
|
|
70
|
+
| { hasSession: true; user: SignedInUser; csrf: string; two_factor_enabled: boolean };
|
|
63
71
|
|
|
64
72
|
export function handleApiMe(req: Request, deps: ApiMeDeps): Response {
|
|
65
73
|
if (req.method !== "GET") {
|
|
@@ -99,6 +107,7 @@ export function handleApiMe(req: Request, deps: ApiMeDeps): Response {
|
|
|
99
107
|
displayName: user.username,
|
|
100
108
|
},
|
|
101
109
|
csrf: csrf.token,
|
|
110
|
+
two_factor_enabled: isTotpEnrolled(deps.db, user.id),
|
|
102
111
|
};
|
|
103
112
|
return new Response(JSON.stringify(body), { status: 200, headers });
|
|
104
113
|
}
|
package/src/api-modules.ts
CHANGED
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
44
|
import type { Database } from "bun:sqlite";
|
|
45
|
+
import { readFileSync } from "node:fs";
|
|
46
|
+
import { join } from "node:path";
|
|
47
|
+
import { compareVersions } from "./commands/upgrade.ts";
|
|
45
48
|
import { validateHostAdminToken } from "./host-admin-token-validation.ts";
|
|
46
49
|
import {
|
|
47
50
|
type ModuleInstallChannel,
|
|
@@ -178,6 +181,40 @@ export interface ApiModulesDeps {
|
|
|
178
181
|
* (hub#342 — drives the admin SPA Modules page's "Open" button).
|
|
179
182
|
*/
|
|
180
183
|
readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
|
|
184
|
+
/**
|
|
185
|
+
* Read the LIVE installed version of a module from its install dir's
|
|
186
|
+
* `package.json` `version` (hub#243). The services.json `version` field is a
|
|
187
|
+
* CACHE the module writes on its own boot — on the bun-linked dev path it
|
|
188
|
+
* goes stale the moment the checkout is rebuilt to a newer version without a
|
|
189
|
+
* restart that re-stamps services.json. The admin Modules view reads
|
|
190
|
+
* `installed_version` straight from that cache, so it can show a stale version
|
|
191
|
+
* (the live symptom: services.json said 0.5.4-rc.15 while the linked checkout
|
|
192
|
+
* was already 0.6.4-rc.15). When this resolves a version, it WINS over the
|
|
193
|
+
* services.json cache for the `installed_version` field so the operator sees
|
|
194
|
+
* what's actually on disk. Returns null on any failure (no install dir,
|
|
195
|
+
* missing/unreadable package.json, no version) → fall back to the
|
|
196
|
+
* services.json cache, which is the only source for a not-yet-booted module.
|
|
197
|
+
*
|
|
198
|
+
* Production reads `<installDir>/package.json`; tests inject a fake.
|
|
199
|
+
*/
|
|
200
|
+
readInstalledVersion?: (installDir: string) => string | null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Default `readInstalledVersion`. Reads `<installDir>/package.json`'s `version`
|
|
205
|
+
* — the authoritative live version of a module's code on disk, which the
|
|
206
|
+
* bun-linked dev path keeps current while the services.json cache can lag
|
|
207
|
+
* (hub#243). Returns null on any failure so the caller falls back to the
|
|
208
|
+
* services.json `version`.
|
|
209
|
+
*/
|
|
210
|
+
export function defaultReadInstalledVersion(installDir: string): string | null {
|
|
211
|
+
try {
|
|
212
|
+
const raw = readFileSync(join(installDir, "package.json"), "utf8");
|
|
213
|
+
const parsed = JSON.parse(raw) as { version?: unknown };
|
|
214
|
+
return typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : null;
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
181
218
|
}
|
|
182
219
|
|
|
183
220
|
/**
|
|
@@ -239,6 +276,24 @@ interface ModuleWireShape {
|
|
|
239
276
|
installed: boolean;
|
|
240
277
|
installed_version: string | null;
|
|
241
278
|
latest_version: string | null;
|
|
279
|
+
/**
|
|
280
|
+
* Whether the channel-resolved `latest_version` is a REAL upgrade — i.e.
|
|
281
|
+
* STRICTLY NEWER than `installed_version` under semver ordering (hub#243).
|
|
282
|
+
* Computed server-side via `compareVersions` so the rc/prerelease ordering is
|
|
283
|
+
* correct and there's ONE tested source of truth (the SPA used to derive this
|
|
284
|
+
* client-side with a string `!==`, which framed a *downgrade* as an upgrade:
|
|
285
|
+
* an rc operator on `0.6.4-rc.15` was offered an "upgrade" to the older
|
|
286
|
+
* `@latest` `0.6.3` purely because the two strings differed).
|
|
287
|
+
*
|
|
288
|
+
* `false` when: not installed · no `latest_version` (probe failed) · target ≤
|
|
289
|
+
* installed (same or older — incl. the rc→older-stable downgrade) · either
|
|
290
|
+
* version is unparseable (`compareVersions` returns null → fail-closed: don't
|
|
291
|
+
* offer a move we can't verify is forward). `true` ONLY when the target
|
|
292
|
+
* parses AND sorts strictly above installed — so `0.6.4-rc.15` → stable
|
|
293
|
+
* `0.6.4` IS an upgrade (stable > its own rc per semver §11.4.3), but
|
|
294
|
+
* `0.6.4-rc.15` → `0.6.3` is NOT.
|
|
295
|
+
*/
|
|
296
|
+
upgrade_available: boolean;
|
|
242
297
|
supervisor_status: ModuleState["status"] | null;
|
|
243
298
|
pid: number | null;
|
|
244
299
|
/**
|
|
@@ -451,6 +506,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
451
506
|
// Lenient read so a single bad row written by a buggy module install
|
|
452
507
|
// (e.g. app@0.2.0-rc.4) doesn't take down /api/modules — see hub#406.
|
|
453
508
|
const manifest = readManifestLenient(deps.manifestPath);
|
|
509
|
+
// Live on-disk version reader (hub#243) — see `defaultReadInstalledVersion`.
|
|
510
|
+
const readInstalledVersion = deps.readInstalledVersion ?? defaultReadInstalledVersion;
|
|
454
511
|
const installedByShort = new Map<
|
|
455
512
|
string,
|
|
456
513
|
{
|
|
@@ -476,6 +533,18 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
476
533
|
uis?: Record<string, UiSubUnit>;
|
|
477
534
|
mountPath?: string;
|
|
478
535
|
} = { version: entry.version };
|
|
536
|
+
// Prefer the LIVE on-disk version over the services.json cache when we can
|
|
537
|
+
// read it (hub#243). `entry.version` is a cache the module stamps on its own
|
|
538
|
+
// boot; on the bun-linked dev path it lags after a rebuild-without-restart,
|
|
539
|
+
// so the admin view shows a stale "current" (the live symptom: cache said
|
|
540
|
+
// 0.5.4-rc.15 while the linked checkout was already 0.6.4-rc.15). The
|
|
541
|
+
// module's `<installDir>/package.json` `version` is authoritative for the
|
|
542
|
+
// code actually on disk. Falls back to the cache when there's no installDir
|
|
543
|
+
// or the package.json can't be read (e.g. a not-yet-booted seed row).
|
|
544
|
+
if (entry.installDir !== undefined) {
|
|
545
|
+
const live = readInstalledVersion(entry.installDir);
|
|
546
|
+
if (live !== null) value.version = live;
|
|
547
|
+
}
|
|
479
548
|
if (entry.installDir !== undefined) value.installDir = entry.installDir;
|
|
480
549
|
if (entry.uis !== undefined) value.uis = entry.uis;
|
|
481
550
|
// First non-`.parachute` path is the module's user-facing mount
|
|
@@ -622,6 +691,10 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
622
691
|
installed: installed !== undefined,
|
|
623
692
|
installed_version: installed?.version ?? null,
|
|
624
693
|
latest_version: latestByShort.get(short) ?? null,
|
|
694
|
+
upgrade_available: isUpgradeAvailable(
|
|
695
|
+
installed?.version ?? null,
|
|
696
|
+
latestByShort.get(short) ?? null,
|
|
697
|
+
),
|
|
625
698
|
supervisor_status: state?.status ?? null,
|
|
626
699
|
pid: state?.pid ?? null,
|
|
627
700
|
supervisor_start_error: state?.startError ?? null,
|
|
@@ -644,6 +717,9 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
644
717
|
|
|
645
718
|
// Every supervised module's run-state — curated AND non-curated (hub#539).
|
|
646
719
|
// Built from the same supervisor.list() snapshot already in `stateByShort`.
|
|
720
|
+
// `installed_version` here inherits the live-on-disk override already applied
|
|
721
|
+
// to `installedByShort[].version` in the manifest loop above (hub#243), so the
|
|
722
|
+
// `supervised` array reports the same corrected version as the `modules` rows.
|
|
647
723
|
const supervised: SupervisedSnapshotWire[] = Array.from(stateByShort.values()).map((s) => ({
|
|
648
724
|
short: s.short,
|
|
649
725
|
installed: installedByShort.has(s.short),
|
|
@@ -842,6 +918,35 @@ function toUisWireShape(uis: Record<string, UiSubUnit> | undefined): UiSubUnitWi
|
|
|
842
918
|
}));
|
|
843
919
|
}
|
|
844
920
|
|
|
921
|
+
/**
|
|
922
|
+
* Whether `latest` is a REAL upgrade over `installed` — strictly newer under
|
|
923
|
+
* semver ordering (hub#243). The single tested source of truth for the
|
|
924
|
+
* "upgrade available?" decision, shared by the wire shape's `upgrade_available`
|
|
925
|
+
* field and (transitively) the admin SPA's Upgrade button.
|
|
926
|
+
*
|
|
927
|
+
* Returns `false` unless BOTH versions parse AND `latest` sorts strictly above
|
|
928
|
+
* `installed`:
|
|
929
|
+
* - no installed version (not installed) → false.
|
|
930
|
+
* - no latest version (probe failed / unknown package) → false.
|
|
931
|
+
* - `compareVersions` can't parse either side → false (FAIL-CLOSED: never
|
|
932
|
+
* offer a move we can't prove is forward).
|
|
933
|
+
* - target ≤ installed → false. This is the load-bearing downgrade guard:
|
|
934
|
+
* an rc operator on `0.6.4-rc.15` whose channel resolves `latest_version`
|
|
935
|
+
* to the OLDER `@latest` `0.6.3` is NOT offered an "upgrade" (the live bug
|
|
936
|
+
* — a downgrade framed as an upgrade by the old string `!==`).
|
|
937
|
+
*
|
|
938
|
+
* Reuses `commands/upgrade.ts:compareVersions`, the same comparator the CLI
|
|
939
|
+
* `parachute upgrade` downgrade guard uses, so the SPA offer and the CLI guard
|
|
940
|
+
* can't disagree about rc/prerelease ordering. Note `0.6.4-rc.15` → stable
|
|
941
|
+
* `0.6.4` IS strictly-newer (stable > its own rc per semver §11.4.3), so a real
|
|
942
|
+
* rc→its-stable promotion still surfaces as an upgrade.
|
|
943
|
+
*/
|
|
944
|
+
export function isUpgradeAvailable(installed: string | null, latest: string | null): boolean {
|
|
945
|
+
if (installed === null || latest === null) return false;
|
|
946
|
+
const cmp = compareVersions(latest, installed);
|
|
947
|
+
return cmp !== null && cmp > 0;
|
|
948
|
+
}
|
|
949
|
+
|
|
845
950
|
/**
|
|
846
951
|
* Reset the in-memory `latest_version` cache. Tests call this between
|
|
847
952
|
* runs to prevent state leakage across test cases; production never
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET|PUT /api/settings/root-redirect` — operator-settable target for the
|
|
3
|
+
* bare-`/` 302.
|
|
4
|
+
*
|
|
5
|
+
* The hub's root (`/`) redirects to `/admin` by default. This endpoint lets an
|
|
6
|
+
* operator point it at a surface instead (e.g. a custom-domain hub fronting a
|
|
7
|
+
* team reading-room surface) without redeploying. The stored value resolves
|
|
8
|
+
* tier-1 in `resolveRootRedirect` (hub-settings.ts):
|
|
9
|
+
*
|
|
10
|
+
* 1. hub_settings.root_redirect (this endpoint writes here)
|
|
11
|
+
* 2. PARACHUTE_HUB_ROOT_REDIRECT env
|
|
12
|
+
* 3. `/admin` default (unchanged behavior)
|
|
13
|
+
*
|
|
14
|
+
* The endpoint surfaces both the stored value *and* the resolved value + source
|
|
15
|
+
* so the SPA can render "current: /surface/x (from env)" while the input shows
|
|
16
|
+
* the empty stored row — same separation rationale as `/api/settings/hub-origin`.
|
|
17
|
+
*
|
|
18
|
+
* OPEN-REDIRECT SAFETY is the highest-stakes part: the resolved value lands in a
|
|
19
|
+
* `Location:` header, so an off-origin value would be a textbook open redirect.
|
|
20
|
+
* PUT validation (and the read-time resolver) require a SAME-ORIGIN relative
|
|
21
|
+
* path via `isSafeRedirectPath` — must start with a single `/`, never `//` /
|
|
22
|
+
* `/\` / a scheme, no control chars / whitespace, and must not resolve back to
|
|
23
|
+
* `/` (redirect loop). Anything else is rejected (PUT 400 / resolver fallback to
|
|
24
|
+
* `/admin`).
|
|
25
|
+
*
|
|
26
|
+
* Bearer-gated on `parachute:host:admin`, mirroring `handleApiSettingsHubOrigin`
|
|
27
|
+
* — same Bearer parsing, scope-check posture, and error vocabulary.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { Database } from "bun:sqlite";
|
|
31
|
+
import {
|
|
32
|
+
type RootRedirectSource,
|
|
33
|
+
getRootRedirect,
|
|
34
|
+
isSafeRedirectPath,
|
|
35
|
+
resolveRootRedirectDetailed,
|
|
36
|
+
setRootRedirect,
|
|
37
|
+
} from "./hub-settings.ts";
|
|
38
|
+
import { validateAccessToken } from "./jwt-sign.ts";
|
|
39
|
+
|
|
40
|
+
/** Scope required on the bearer token to call either endpoint. */
|
|
41
|
+
export const API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE = "parachute:host:admin";
|
|
42
|
+
|
|
43
|
+
export interface ApiSettingsRootRedirectDeps {
|
|
44
|
+
db: Database;
|
|
45
|
+
/** Issuer the bearer token must validate against (the hub's resolved issuer). */
|
|
46
|
+
issuer: string;
|
|
47
|
+
/**
|
|
48
|
+
* Env seam for the resolver's env layer. Defaults to `process.env`. Threaded
|
|
49
|
+
* so the dispatcher (and tests) can resolve `PARACHUTE_HUB_ROOT_REDIRECT`
|
|
50
|
+
* deterministically.
|
|
51
|
+
*/
|
|
52
|
+
env?: NodeJS.ProcessEnv;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface GetResponseBody {
|
|
56
|
+
/** Raw stored value from hub_settings.root_redirect, or null. */
|
|
57
|
+
root_redirect: string | null;
|
|
58
|
+
/** Resolved target applied to the bare-`/` 302 (precedence-aware, guarded). */
|
|
59
|
+
resolved: string;
|
|
60
|
+
/** Which precedence layer the resolved value came from. */
|
|
61
|
+
source: RootRedirectSource;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface PutResponseBody {
|
|
65
|
+
/** Echo of the now-stored value (null if cleared). */
|
|
66
|
+
root_redirect: string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validation outcome. The "normalized" branch is what gets passed to
|
|
71
|
+
* setRootRedirect — string (a safe path) or null (clear the row).
|
|
72
|
+
*/
|
|
73
|
+
type ValidateOutcome = { ok: true; normalized: string | null } | { ok: false; description: string };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate the body's `root_redirect` field. Accepts:
|
|
77
|
+
* - `null` (or empty string) → clear the stored value, revert to env/default.
|
|
78
|
+
* - A safe SAME-ORIGIN relative path per `isSafeRedirectPath`.
|
|
79
|
+
* Everything else → 400 with an operator-friendly description.
|
|
80
|
+
*/
|
|
81
|
+
export function validateRootRedirect(value: unknown): ValidateOutcome {
|
|
82
|
+
if (value === null) return { ok: true, normalized: null };
|
|
83
|
+
if (typeof value !== "string") {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
description: `root_redirect must be a string or null (got ${typeof value})`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// Empty string is the canonical "clear" shape — store as null (mirrors
|
|
90
|
+
// setHubOrigin's footgun guard; an empty Location would be meaningless).
|
|
91
|
+
if (value.length === 0) return { ok: true, normalized: null };
|
|
92
|
+
if (!isSafeRedirectPath(value)) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
description:
|
|
96
|
+
"root_redirect must be a same-origin relative path (start with a single `/`, no `//`/`/\\`/scheme, no whitespace, and not `/` itself)",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, normalized: value };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function handleApiSettingsRootRedirect(
|
|
103
|
+
req: Request,
|
|
104
|
+
deps: ApiSettingsRootRedirectDeps,
|
|
105
|
+
): Promise<Response> {
|
|
106
|
+
if (req.method !== "GET" && req.method !== "PUT") {
|
|
107
|
+
return jsonError(405, "method_not_allowed", "use GET or PUT");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Bearer presence + parsing — identical shape to api-settings-hub-origin
|
|
111
|
+
// for consistency across hub-internal admin endpoints.
|
|
112
|
+
const auth = req.headers.get("authorization");
|
|
113
|
+
if (!auth || !auth.startsWith("Bearer ")) {
|
|
114
|
+
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
115
|
+
}
|
|
116
|
+
const bearer = auth.slice("Bearer ".length).trim();
|
|
117
|
+
if (!bearer) {
|
|
118
|
+
return jsonError(401, "unauthenticated", "empty bearer token");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Bearer validation + scope check.
|
|
122
|
+
try {
|
|
123
|
+
const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
|
|
124
|
+
if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
|
|
125
|
+
return jsonError(401, "unauthenticated", "bearer token has no sub claim");
|
|
126
|
+
}
|
|
127
|
+
const scopes =
|
|
128
|
+
typeof validated.payload.scope === "string"
|
|
129
|
+
? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
|
|
130
|
+
: [];
|
|
131
|
+
if (!scopes.includes(API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE)) {
|
|
132
|
+
return jsonError(
|
|
133
|
+
403,
|
|
134
|
+
"insufficient_scope",
|
|
135
|
+
`bearer token lacks ${API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
140
|
+
return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (req.method === "GET") {
|
|
144
|
+
const resolved = resolveRootRedirectDetailed(deps.db, { env: deps.env });
|
|
145
|
+
const body: GetResponseBody = {
|
|
146
|
+
root_redirect: getRootRedirect(deps.db),
|
|
147
|
+
resolved: resolved.value,
|
|
148
|
+
source: resolved.source,
|
|
149
|
+
};
|
|
150
|
+
return new Response(JSON.stringify(body), {
|
|
151
|
+
status: 200,
|
|
152
|
+
headers: { "content-type": "application/json" },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// PUT — parse + validate body.
|
|
157
|
+
let parsed: unknown;
|
|
158
|
+
try {
|
|
159
|
+
parsed = await req.json();
|
|
160
|
+
} catch {
|
|
161
|
+
return jsonError(400, "invalid_request", "request body must be JSON");
|
|
162
|
+
}
|
|
163
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
164
|
+
return jsonError(400, "invalid_request", "request body must be a JSON object");
|
|
165
|
+
}
|
|
166
|
+
if (!("root_redirect" in parsed)) {
|
|
167
|
+
return jsonError(400, "invalid_request", "request body must include a `root_redirect` field");
|
|
168
|
+
}
|
|
169
|
+
const result = validateRootRedirect((parsed as { root_redirect: unknown }).root_redirect);
|
|
170
|
+
if (!result.ok) {
|
|
171
|
+
return jsonError(400, "invalid_root_redirect", result.description);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
setRootRedirect(deps.db, result.normalized);
|
|
175
|
+
|
|
176
|
+
const body: PutResponseBody = { root_redirect: result.normalized };
|
|
177
|
+
return new Response(JSON.stringify(body), {
|
|
178
|
+
status: 200,
|
|
179
|
+
headers: { "content-type": "application/json" },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function jsonError(status: number, code: string, description: string): Response {
|
|
184
|
+
return new Response(JSON.stringify({ error: code, error_description: description }), {
|
|
185
|
+
status,
|
|
186
|
+
headers: { "content-type": "application/json" },
|
|
187
|
+
});
|
|
188
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { setup } from "./commands/setup.ts";
|
|
|
22
22
|
import type { upgrade } from "./commands/upgrade.ts";
|
|
23
23
|
import { ExposeStateError } from "./expose-state.ts";
|
|
24
24
|
import {
|
|
25
|
+
doctorHelp,
|
|
25
26
|
exposeHelp,
|
|
26
27
|
initHelp,
|
|
27
28
|
installHelp,
|
|
@@ -420,17 +421,37 @@ async function main(argv: string[]): Promise<number> {
|
|
|
420
421
|
);
|
|
421
422
|
return 1;
|
|
422
423
|
}
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
424
|
+
// #478 Part 2: --vault-name <name> creates the first vault in one shot.
|
|
425
|
+
const vaultNameExtract = extractNamedFlag(channelExtract.rest, "--vault-name");
|
|
426
|
+
if (vaultNameExtract.error) {
|
|
427
|
+
console.error(`parachute init: ${vaultNameExtract.error}`);
|
|
428
|
+
return 1;
|
|
429
|
+
}
|
|
430
|
+
let validatedVaultName: string | undefined;
|
|
431
|
+
if (vaultNameExtract.value !== undefined) {
|
|
432
|
+
if (vaultNameExtract.value.trim() === "") {
|
|
433
|
+
console.error("parachute init: --vault-name must not be empty.");
|
|
434
|
+
return 1;
|
|
435
|
+
}
|
|
436
|
+
const { validateVaultName: vvn } = await import("./vault-name.ts");
|
|
437
|
+
const vr = vvn(vaultNameExtract.value);
|
|
438
|
+
if (!vr.ok) {
|
|
439
|
+
console.error(`parachute init: invalid --vault-name: ${vr.error}`);
|
|
440
|
+
return 1;
|
|
441
|
+
}
|
|
442
|
+
validatedVaultName = vr.name;
|
|
443
|
+
}
|
|
444
|
+
const noBrowser = vaultNameExtract.rest.includes("--no-browser");
|
|
445
|
+
const noExposePrompt = vaultNameExtract.rest.includes("--no-expose-prompt");
|
|
446
|
+
const cliWizard = vaultNameExtract.rest.includes("--cli-wizard");
|
|
447
|
+
const browserWizard = vaultNameExtract.rest.includes("--browser-wizard");
|
|
427
448
|
const known = new Set([
|
|
428
449
|
"--no-browser",
|
|
429
450
|
"--no-expose-prompt",
|
|
430
451
|
"--cli-wizard",
|
|
431
452
|
"--browser-wizard",
|
|
432
453
|
]);
|
|
433
|
-
const unknown =
|
|
454
|
+
const unknown = vaultNameExtract.rest.find((a) => !known.has(a));
|
|
434
455
|
if (unknown !== undefined) {
|
|
435
456
|
console.error(`parachute init: unknown argument "${unknown}"`);
|
|
436
457
|
console.error(
|
|
@@ -438,6 +459,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
438
459
|
" [--expose none|tailnet|cloudflare]\n" +
|
|
439
460
|
" [--channel rc|latest]\n" +
|
|
440
461
|
" [--hub-origin <url>]\n" +
|
|
462
|
+
" [--vault-name <name>]\n" +
|
|
441
463
|
" [--cli-wizard | --browser-wizard]",
|
|
442
464
|
);
|
|
443
465
|
return 1;
|
|
@@ -456,6 +478,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
456
478
|
if (channelExtract.value === "rc" || channelExtract.value === "latest") {
|
|
457
479
|
initOpts.channel = channelExtract.value;
|
|
458
480
|
}
|
|
481
|
+
if (validatedVaultName !== undefined) initOpts.vaultName = validatedVaultName;
|
|
459
482
|
if (cliWizard) initOpts.wizardChoice = "cli";
|
|
460
483
|
else if (browserWizard) initOpts.wizardChoice = "browser";
|
|
461
484
|
const mod = await loadCommand("init", () => import("./commands/init.ts"));
|
|
@@ -552,6 +575,34 @@ async function main(argv: string[]): Promise<number> {
|
|
|
552
575
|
return await mod.status({ supervisor: {} });
|
|
553
576
|
}
|
|
554
577
|
|
|
578
|
+
case "doctor": {
|
|
579
|
+
if (isHelpFlag(rest[0])) {
|
|
580
|
+
console.log(doctorHelp());
|
|
581
|
+
return 0;
|
|
582
|
+
}
|
|
583
|
+
const json = rest.includes("--json");
|
|
584
|
+
const fix = rest.includes("--fix");
|
|
585
|
+
const yes = rest.includes("--yes") || rest.includes("-y");
|
|
586
|
+
const known = new Set(["--json", "--fix", "--yes", "-y"]);
|
|
587
|
+
const unknown = rest.find((a) => !known.has(a));
|
|
588
|
+
if (unknown !== undefined) {
|
|
589
|
+
console.error(`parachute doctor: unknown argument "${unknown}"`);
|
|
590
|
+
console.error("usage: parachute doctor [--json] [--fix [--yes]]");
|
|
591
|
+
return 1;
|
|
592
|
+
}
|
|
593
|
+
if (json && fix) {
|
|
594
|
+
console.error("parachute doctor: --json and --fix are mutually exclusive");
|
|
595
|
+
return 1;
|
|
596
|
+
}
|
|
597
|
+
if (yes && !fix) {
|
|
598
|
+
console.error("parachute doctor: --yes has no effect without --fix");
|
|
599
|
+
return 1;
|
|
600
|
+
}
|
|
601
|
+
const mod = await loadCommand("doctor", () => import("./commands/doctor.ts"));
|
|
602
|
+
if (!mod) return 1;
|
|
603
|
+
return await mod.doctor({ json, fix, yes });
|
|
604
|
+
}
|
|
605
|
+
|
|
555
606
|
case "expose": {
|
|
556
607
|
const hubExtract = extractHubOrigin(rest);
|
|
557
608
|
if (hubExtract.error) {
|