@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.
Files changed (60) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-clients.test.ts +103 -1
  3. package/src/__tests__/admin-lock.test.ts +7 -1
  4. package/src/__tests__/admin-vaults.test.ts +216 -10
  5. package/src/__tests__/api-account-2fa.test.ts +453 -0
  6. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  7. package/src/__tests__/api-modules.test.ts +143 -0
  8. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  9. package/src/__tests__/auth.test.ts +336 -0
  10. package/src/__tests__/clients.test.ts +326 -8
  11. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  12. package/src/__tests__/cors.test.ts +138 -1
  13. package/src/__tests__/doctor.test.ts +755 -0
  14. package/src/__tests__/hub-command.test.ts +69 -2
  15. package/src/__tests__/hub-server.test.ts +127 -5
  16. package/src/__tests__/hub-settings.test.ts +188 -0
  17. package/src/__tests__/init.test.ts +153 -0
  18. package/src/__tests__/managed-unit.test.ts +62 -0
  19. package/src/__tests__/oauth-handlers.test.ts +626 -0
  20. package/src/__tests__/oauth-ui.test.ts +107 -1
  21. package/src/__tests__/scope-explanations.test.ts +19 -0
  22. package/src/__tests__/setup-gate.test.ts +111 -3
  23. package/src/__tests__/setup-wizard.test.ts +124 -7
  24. package/src/__tests__/supervisor.test.ts +25 -0
  25. package/src/__tests__/vault-names.test.ts +32 -3
  26. package/src/__tests__/vault-remove.test.ts +40 -19
  27. package/src/__tests__/well-known.test.ts +37 -2
  28. package/src/admin-clients.ts +55 -3
  29. package/src/admin-vaults.ts +52 -25
  30. package/src/api-account-2fa.ts +395 -0
  31. package/src/api-admin-lock.ts +7 -0
  32. package/src/api-hub-upgrade.ts +38 -3
  33. package/src/api-me.ts +11 -2
  34. package/src/api-modules.ts +105 -0
  35. package/src/api-settings-root-redirect.ts +188 -0
  36. package/src/cli.ts +56 -5
  37. package/src/clients.ts +178 -0
  38. package/src/commands/auth.ts +263 -1
  39. package/src/commands/doctor.ts +1250 -0
  40. package/src/commands/hub.ts +102 -1
  41. package/src/commands/init.ts +108 -0
  42. package/src/commands/vault-remove.ts +16 -24
  43. package/src/cors.ts +7 -3
  44. package/src/help.ts +65 -1
  45. package/src/hub-db.ts +14 -0
  46. package/src/hub-server.ts +139 -24
  47. package/src/hub-settings.ts +163 -1
  48. package/src/managed-unit.ts +30 -1
  49. package/src/oauth-handlers.ts +103 -6
  50. package/src/oauth-ui.ts +174 -0
  51. package/src/rate-limit.ts +28 -0
  52. package/src/scope-explanations.ts +2 -1
  53. package/src/setup-wizard.ts +40 -21
  54. package/src/supervisor.ts +46 -2
  55. package/src/vault-names.ts +15 -4
  56. package/src/well-known.ts +10 -1
  57. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  58. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  59. package/web/ui/dist/index.html +2 -2
  60. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
@@ -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
- // ── 409 in-flight guard ────────────────────────────────────────────────────
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 = { hasSession: false } | { hasSession: true; user: SignedInUser; csrf: string };
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
  }
@@ -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
- const noBrowser = channelExtract.rest.includes("--no-browser");
424
- const noExposePrompt = channelExtract.rest.includes("--no-expose-prompt");
425
- const cliWizard = channelExtract.rest.includes("--cli-wizard");
426
- const browserWizard = channelExtract.rest.includes("--browser-wizard");
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 = channelExtract.rest.find((a) => !known.has(a));
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) {