@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.21

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 (75) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-auth.test.ts +128 -0
  3. package/src/__tests__/admin-clients.test.ts +103 -1
  4. package/src/__tests__/admin-lock.test.ts +7 -1
  5. package/src/__tests__/admin-vaults.test.ts +216 -10
  6. package/src/__tests__/api-account-2fa.test.ts +453 -0
  7. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  8. package/src/__tests__/api-mint-token.test.ts +75 -0
  9. package/src/__tests__/api-modules.test.ts +143 -0
  10. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  11. package/src/__tests__/auth.test.ts +336 -0
  12. package/src/__tests__/clients.test.ts +326 -8
  13. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  14. package/src/__tests__/cors.test.ts +138 -1
  15. package/src/__tests__/doctor.test.ts +755 -0
  16. package/src/__tests__/hub-command.test.ts +69 -2
  17. package/src/__tests__/hub-server.test.ts +127 -5
  18. package/src/__tests__/hub-settings.test.ts +188 -0
  19. package/src/__tests__/init.test.ts +153 -0
  20. package/src/__tests__/jwt-sign.test.ts +27 -0
  21. package/src/__tests__/managed-unit.test.ts +62 -0
  22. package/src/__tests__/oauth-handlers.test.ts +626 -0
  23. package/src/__tests__/oauth-ui.test.ts +107 -1
  24. package/src/__tests__/scope-explanations.test.ts +19 -0
  25. package/src/__tests__/setup-gate.test.ts +111 -3
  26. package/src/__tests__/setup-wizard.test.ts +124 -7
  27. package/src/__tests__/supervisor.test.ts +25 -0
  28. package/src/__tests__/vault-names.test.ts +32 -3
  29. package/src/__tests__/vault-remove.test.ts +40 -19
  30. package/src/__tests__/well-known.test.ts +37 -2
  31. package/src/admin-agent-grants.ts +16 -1
  32. package/src/admin-auth.ts +13 -4
  33. package/src/admin-clients.ts +66 -5
  34. package/src/admin-grants.ts +11 -2
  35. package/src/admin-vaults.ts +77 -27
  36. package/src/api-account-2fa.ts +395 -0
  37. package/src/api-admin-lock.ts +7 -0
  38. package/src/api-hub-upgrade.ts +52 -4
  39. package/src/api-hub.ts +10 -1
  40. package/src/api-invites.ts +18 -3
  41. package/src/api-me.ts +11 -2
  42. package/src/api-mint-token.ts +16 -1
  43. package/src/api-modules.ts +119 -1
  44. package/src/api-revoke-token.ts +14 -1
  45. package/src/api-settings-hub-origin.ts +14 -1
  46. package/src/api-settings-root-redirect.ts +201 -0
  47. package/src/api-tokens.ts +14 -1
  48. package/src/api-users.ts +15 -6
  49. package/src/api-vault-caps.ts +11 -2
  50. package/src/cli.ts +56 -5
  51. package/src/clients.ts +178 -0
  52. package/src/commands/auth.ts +263 -1
  53. package/src/commands/doctor.ts +1250 -0
  54. package/src/commands/hub.ts +102 -1
  55. package/src/commands/init.ts +108 -0
  56. package/src/commands/vault-remove.ts +16 -24
  57. package/src/cors.ts +7 -3
  58. package/src/help.ts +65 -1
  59. package/src/hub-db.ts +14 -0
  60. package/src/hub-server.ts +173 -25
  61. package/src/hub-settings.ts +163 -1
  62. package/src/jwt-sign.ts +25 -6
  63. package/src/managed-unit.ts +30 -1
  64. package/src/oauth-handlers.ts +110 -7
  65. package/src/oauth-ui.ts +174 -0
  66. package/src/rate-limit.ts +28 -0
  67. package/src/scope-explanations.ts +2 -1
  68. package/src/setup-wizard.ts +40 -21
  69. package/src/supervisor.ts +46 -2
  70. package/src/vault-names.ts +15 -4
  71. package/src/well-known.ts +10 -1
  72. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  73. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  74. package/web/ui/dist/index.html +2 -2
  75. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
@@ -32,7 +32,12 @@ import { validateHubOrigin } from "../api-settings-hub-origin.ts";
32
32
  import { restart } from "../commands/lifecycle.ts";
33
33
  import { CONFIG_DIR } from "../config.ts";
34
34
  import { hubDbPath, openHubDb } from "../hub-db.ts";
35
- import { setHubOrigin } from "../hub-settings.ts";
35
+ import {
36
+ DEFAULT_ROOT_REDIRECT,
37
+ isSafeRedirectPath,
38
+ setHubOrigin,
39
+ setRootRedirect,
40
+ } from "../hub-settings.ts";
36
41
  import { type CommandResult, type Runner, defaultRunner } from "../tailscale/run.ts";
37
42
  import { isLoopbackOrigin } from "../vault-hub-origin-env.ts";
38
43
 
@@ -347,6 +352,81 @@ async function runCaddyReload(run: Runner): Promise<CommandResult> {
347
352
  return run(["systemctl", "reload", "caddy"]);
348
353
  }
349
354
 
355
+ /**
356
+ * `parachute hub set-root-redirect <path>` — persist the operator's bare-`/`
357
+ * redirect target into `hub_settings.root_redirect` (tier-1 in
358
+ * `resolveRootRedirect`). Lets a headless box (the canonical use case is a
359
+ * custom-domain hub fronting a team surface) flip its landing page from `/admin`
360
+ * to a surface without a browser session OR a redeploy.
361
+ *
362
+ * `--clear` deletes the row, reverting to the env / `/admin` default.
363
+ *
364
+ * The path is validated through `isSafeRedirectPath` — the SAME open-redirect
365
+ * guard the admin PUT enforces — so the CLI can never plant an off-origin
366
+ * `Location` target either. Returns 0 on success, 1 on a usage / validation /
367
+ * DB-write failure.
368
+ */
369
+ export async function hubSetRootRedirect(
370
+ args: readonly string[],
371
+ deps: HubCommandDeps = {},
372
+ ): Promise<number> {
373
+ const configDir = deps.configDir ?? CONFIG_DIR;
374
+ const log = deps.log ?? ((line) => console.log(line));
375
+ const err = (line: string) => console.error(line);
376
+ const openDb = deps.openDb ?? ((dir: string) => openHubDb(hubDbPath(dir)));
377
+
378
+ const clear = args.includes("--clear");
379
+ const positional = args.filter((a) => !a.startsWith("-"));
380
+
381
+ if (clear) {
382
+ if (positional.length > 0) {
383
+ err("parachute hub set-root-redirect: --clear takes no path argument");
384
+ return 1;
385
+ }
386
+ const db = openDb(configDir);
387
+ try {
388
+ setRootRedirect(db, null);
389
+ } finally {
390
+ db.close();
391
+ }
392
+ log(
393
+ `✓ Cleared the root redirect — \`/\` reverts to env / the ${DEFAULT_ROOT_REDIRECT} default.`,
394
+ );
395
+ return 0;
396
+ }
397
+
398
+ const raw = positional[0];
399
+ if (raw === undefined) {
400
+ err("usage: parachute hub set-root-redirect <path> (or --clear)");
401
+ err("example: parachute hub set-root-redirect /surface/reading-room");
402
+ return 1;
403
+ }
404
+ if (positional.length > 1) {
405
+ err(`parachute hub set-root-redirect: unexpected argument "${positional[1]}"`);
406
+ err("usage: parachute hub set-root-redirect <path> (or --clear)");
407
+ return 1;
408
+ }
409
+
410
+ if (!isSafeRedirectPath(raw)) {
411
+ err(`parachute hub set-root-redirect: "${raw}" is not a safe same-origin path`);
412
+ err(" It must start with a single `/` (no `//`, `/\\`, scheme, or whitespace) and");
413
+ err(" not be `/` itself. Example: /surface/reading-room");
414
+ return 1;
415
+ }
416
+
417
+ const db = openDb(configDir);
418
+ try {
419
+ setRootRedirect(db, raw);
420
+ } finally {
421
+ db.close();
422
+ }
423
+
424
+ log(`✓ Bare \`/\` now redirects to ${raw}.`);
425
+ log(" Stored in hub_settings.root_redirect — takes effect on the next request,");
426
+ log(" no restart needed. Clear it with: parachute hub set-root-redirect --clear");
427
+ return 0;
428
+ }
429
+
350
430
  /**
351
431
  * `parachute hub <subcommand>` dispatcher. Mirrors `auth`'s shape (a thin
352
432
  * router over subcommand handlers, each catching its own errors).
@@ -367,6 +447,16 @@ export async function hub(args: readonly string[], deps: HubCommandDeps = {}): P
367
447
  return 1;
368
448
  }
369
449
  }
450
+ if (sub === "set-root-redirect") {
451
+ try {
452
+ return await hubSetRootRedirect(args.slice(1), deps);
453
+ } catch (err) {
454
+ console.error(
455
+ `parachute hub set-root-redirect: ${err instanceof Error ? err.message : String(err)}`,
456
+ );
457
+ return 1;
458
+ }
459
+ }
370
460
  console.error(`parachute hub: unknown subcommand "${sub}"`);
371
461
  console.error("");
372
462
  console.error(hubHelp());
@@ -378,6 +468,7 @@ export function hubHelp(): string {
378
468
 
379
469
  Usage:
380
470
  parachute hub set-origin <url> [--no-caddy] [--no-restart]
471
+ parachute hub set-root-redirect <path> | --clear
381
472
 
382
473
  Subcommands:
383
474
  set-origin <url> Persist the canonical public origin (OAuth issuer) to the
@@ -401,9 +492,19 @@ Subcommands:
401
492
  Caddyfile rewrite + reload, or --no-restart to skip the
402
493
  module restart.
403
494
 
495
+ set-root-redirect <path>
496
+ Point the bare \`/\` 302 at a same-origin path instead of the
497
+ default /admin (e.g. a team surface). Stored in
498
+ hub_settings.root_redirect; takes effect on the next request,
499
+ no restart. The path must start with a single \`/\` (no \`//\`,
500
+ \`/\\\`, scheme, or whitespace). Pass --clear to revert to the
501
+ env / /admin default. (Env equivalent: PARACHUTE_HUB_ROOT_REDIRECT.)
502
+
404
503
  Examples:
405
504
  parachute hub set-origin https://box.sslip.io
406
505
  parachute hub set-origin https://parachute.example.com
407
506
  parachute hub set-origin https://parachute.example.com --no-caddy
507
+ parachute hub set-root-redirect /surface/reading-room
508
+ parachute hub set-root-redirect --clear
408
509
  `;
409
510
  }
@@ -52,6 +52,7 @@ import { issueOperatorToken, readOperatorTokenFile } from "../operator-token.ts"
52
52
  import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
53
53
  import { findService, readManifestLenient } from "../services-manifest.ts";
54
54
  import { listUsers } from "../users.ts";
55
+ import { validateVaultName } from "../vault-name.ts";
55
56
  import { type InstallOpts, install as defaultInstall } from "./install.ts";
56
57
 
57
58
  /** The three options the exposure prompt offers — also the `--expose` flag's domain. */
@@ -202,6 +203,44 @@ export interface InitOpts {
202
203
  * already known so there's no question to ask).
203
204
  */
204
205
  noWizardPrompt?: boolean;
206
+ /**
207
+ * Vault name to create as part of `parachute init --vault-name <name>`
208
+ * (#478 Part 2). When set, init creates the first vault via
209
+ * `createFirstVaultImpl` immediately after Step 1.5 (operator-token
210
+ * guarantee), before the admin-URL resolution. Validated with
211
+ * `validateVaultName` in the CLI before reaching here.
212
+ *
213
+ * Idempotency lives in `parachute-vault create`, NOT in a services.json
214
+ * precheck: `create <name>` exits 0 + creates when `<name>` is new, and
215
+ * exits non-zero ("Vault \"<name>\" already exists.") on a re-run. We
216
+ * therefore ALWAYS attempt the create when this field is set and treat a
217
+ * non-zero exit as non-fatal (warn + continue). A services.json
218
+ * `parachute-vault` row is the MODULE-installed marker (Step 0.5 seeds it
219
+ * via `spec.seedEntry` on EVERY fresh install), not an instance marker —
220
+ * keying idempotency off it would silently no-op the create on the exact
221
+ * fresh-box path this feature targets.
222
+ *
223
+ * Without this field (the default), init makes NO vault — the wizard
224
+ * owns Create/Import/Skip as before. The --no-browser / scripted path
225
+ * remains vault-free unless --vault-name is explicitly passed.
226
+ */
227
+ vaultName?: string;
228
+ /**
229
+ * Test seam: injectable impl for the `--vault-name` create step (#478
230
+ * Part 2). This IS the whole create implementation — tests swap the
231
+ * entire function for a stub that records the call without touching a
232
+ * live vault binary. It takes the vault name plus a ctx carrying a
233
+ * runner shim, and returns an exit code (0 = success).
234
+ *
235
+ * The production default (`defaultCreateFirstVault`) uses that runner to
236
+ * shell out `["parachute-vault", "create", name]`, following the `Runner`
237
+ * type pattern established across every command that shells out:
238
+ * `readonly string[] => Promise<number>`.
239
+ */
240
+ createFirstVaultImpl?: (
241
+ name: string,
242
+ ctx: { runner: (cmd: readonly string[]) => Promise<number> },
243
+ ) => Promise<number>;
205
244
  /**
206
245
  * Canonical public hub origin (the OAuth issuer / `iss` claim). Persisted to
207
246
  * `hub_settings.hub_origin` BEFORE the hub unit starts + modules spawn, so
@@ -599,6 +638,38 @@ async function defaultFetchBootstrapToken(loopbackUrl: string): Promise<string |
599
638
  }
600
639
  }
601
640
 
641
+ /**
642
+ * Default runner shim for the `--vault-name` create step (#478 Part 2).
643
+ * Shells out `parachute-vault create <name>` via Bun.spawn, inheriting
644
+ * the operator's full env (so PARACHUTE_HOME, PATH, etc. pass through).
645
+ *
646
+ * This is the same pattern every other command that shells out uses — a
647
+ * `Runner` (`readonly string[] => Promise<number>`) so tests can inject a
648
+ * stub without touching the real vault binary.
649
+ */
650
+ async function defaultRunner(cmd: readonly string[]): Promise<number> {
651
+ const proc = Bun.spawn([...cmd], {
652
+ stdio: ["inherit", "inherit", "inherit"],
653
+ env: process.env,
654
+ });
655
+ return await proc.exited;
656
+ }
657
+
658
+ /**
659
+ * Default impl for the `--vault-name` first-vault create step (#478 Part 2).
660
+ * Invokes `parachute-vault create <name>` via the injectable runner. The
661
+ * `parachute-vault` binary must already be on PATH (guaranteed by Step 0.5's
662
+ * vault-module install). Exit code is forwarded directly — callers log the
663
+ * outcome and continue (a non-zero create doesn't abort init; the operator
664
+ * can re-run `parachute vault create <name>` or use the wizard to retry).
665
+ */
666
+ async function defaultCreateFirstVault(
667
+ name: string,
668
+ ctx: { runner: (cmd: readonly string[]) => Promise<number> },
669
+ ): Promise<number> {
670
+ return await ctx.runner(["parachute-vault", "create", name]);
671
+ }
672
+
602
673
  /**
603
674
  * Prompt for the wizard-choice question (hub#168 Cut 4). Returns the
604
675
  * picked option, or `undefined` if the operator quit. Default is
@@ -735,6 +806,7 @@ export async function init(opts: InitOpts = {}): Promise<number> {
735
806
  const runCliWizardImpl = opts.runCliWizardImpl ?? defaultRunCliWizard;
736
807
  const fetchBootstrapTokenImpl = opts.fetchBootstrapTokenImpl ?? defaultFetchBootstrapToken;
737
808
  const setHubOriginImpl = opts.setHubOriginImpl ?? defaultSetHubOrigin;
809
+ const createFirstVaultImpl = opts.createFirstVaultImpl ?? defaultCreateFirstVault;
738
810
 
739
811
  log("Parachute init — getting your hub set up.");
740
812
  log("");
@@ -885,6 +957,42 @@ export async function init(opts: InitOpts = {}): Promise<number> {
885
957
  // to the wizard regardless.
886
958
  await guaranteeOperatorToken({ configDir, hubPort, log });
887
959
 
960
+ // Step 1.6 (#478 Part 2): if `--vault-name <name>` was given, create the
961
+ // first vault now — after the hub is up (Step 1) and the operator token is
962
+ // guaranteed (Step 1.5). The vault module was installed at Step 0.5, so
963
+ // `parachute-vault` is on PATH.
964
+ //
965
+ // We ALWAYS attempt the create when `--vault-name` is set. Idempotency lives
966
+ // in `parachute-vault create` itself, NOT in a services.json precheck:
967
+ // - `create <name>` exits 0 + creates the vault when `<name>` is new.
968
+ // - `create <name>` exits non-zero ("Vault \"<name>\" already exists.") when
969
+ // that exact name already exists (a benign re-run).
970
+ //
971
+ // We DON'T precheck the services.json `parachute-vault` row: Step 0.5's
972
+ // `install("vault", { noCreate: true })` seeds that row via `spec.seedEntry`
973
+ // on EVERY fresh install (the module-installed marker — see install.ts's
974
+ // InstallOpts doc), so on the exact fresh-box path this feature targets the
975
+ // row is ALWAYS present and a row-keyed precheck would silently no-op the
976
+ // create. The row marks "module installed", not "instance exists" — only the
977
+ // create command's own exit reliably distinguishes the two.
978
+ //
979
+ // A non-zero exit is non-fatal: warn + continue. It could mean the vault
980
+ // already exists (a fine re-run) OR a genuine creation failure — init's
981
+ // contract is hub up → wizard regardless, so we never abort here. The
982
+ // operator can check `parachute status` / re-run `parachute vault create`.
983
+ if (opts.vaultName !== undefined) {
984
+ log(`Creating vault "${opts.vaultName}"…`);
985
+ const createCode = await createFirstVaultImpl(opts.vaultName, { runner: defaultRunner });
986
+ if (createCode === 0) {
987
+ log(`✓ Vault "${opts.vaultName}" created.`);
988
+ } else {
989
+ log(
990
+ `⚠ \`parachute-vault create ${opts.vaultName}\` exited ${createCode} — the vault may already exist, or creation failed. Check \`parachute status\` / re-run \`parachute vault create ${opts.vaultName}\`.`,
991
+ );
992
+ }
993
+ log("");
994
+ }
995
+
888
996
  // Step 2: exposure chain. Skipped when already exposed, in non-TTY,
889
997
  // or when --no-expose-prompt was passed. `--expose <choice>` jumps
890
998
  // straight to the corresponding chain without asking.
@@ -31,14 +31,18 @@
31
31
  * `parachute:host:admin` — exactly the scope the endpoint gates on. This is the
32
32
  * same read-never-mint credential path `parachute start/stop/restart <svc>` use.
33
33
  *
34
- * ## The 409 guardrail (load-bearing)
34
+ * ## Last-vault handling (#678)
35
35
  *
36
- * On a `409 last_vault` the endpoint refuses (deleting the last vault would let
37
- * vault's boot silently resurrect a fresh `default`). We print the endpoint's
38
- * message + note the raw escape hatch (`parachute-vault remove <name> --yes`)
39
- * which SKIPS the cascade, then return NON-ZERO. We MUST NOT fall through to
40
- * spawning `parachute-vault` ourselves: a "helpful" fall-through would re-open
41
- * the exact orphaning bug B3 closes. The test locks this invariant.
36
+ * The last/only vault is deleted IDENTICALLY to any other vault: the endpoint
37
+ * runs the full cascade-then-delete and returns 200. There is no special-case
38
+ * here. (Older builds refused the last vault with a `409 last_vault` and steered
39
+ * the operator to the raw `parachute-vault remove --yes` but that escape hatch
40
+ * SKIPS the cascade, orphaning the very identity artifacts B3 set out to clean
41
+ * up. hub#678 removed that refusal: vault's boot can no longer silently
42
+ * resurrect a fresh first vault because vault's CLI writes an
43
+ * `auto_create: false` marker on last-vault removal and the boot gate honors
44
+ * it.) This command therefore needs no 409 branch — the 200 path renders the
45
+ * cascade summary for the last vault just like every other delete.
42
46
  */
43
47
 
44
48
  import { CONFIG_DIR } from "../config.ts";
@@ -56,8 +60,9 @@ import {
56
60
 
57
61
  /**
58
62
  * Injectable seams. Production wires the real operator-token bearer resolver +
59
- * the global `fetch`; tests inject fakes to assert the request shape + lock the
60
- * 409 guardrail without a live hub or a real socket.
63
+ * the global `fetch`; tests inject fakes to assert the request shape + that
64
+ * destruction always goes through the hub endpoint (never a direct
65
+ * `parachute-vault` spawn) without a live hub or a real socket.
61
66
  */
62
67
  export interface VaultRemoveDeps {
63
68
  /**
@@ -84,6 +89,7 @@ interface CascadeSummaryWire {
84
89
  grants_dropped?: number;
85
90
  user_vaults_removed?: number;
86
91
  invites_invalidated?: number;
92
+ vault_cap_removed?: boolean;
87
93
  connections_torn_down?: number;
88
94
  orphaned_channels?: unknown;
89
95
  vault_removed?: boolean;
@@ -151,6 +157,7 @@ function renderCascadeSummary(
151
157
  log(` grants dropped: ${n(c.grants_dropped)}`);
152
158
  log(` user_vaults removed: ${n(c.user_vaults_removed)}`);
153
159
  log(` invites invalidated: ${n(c.invites_invalidated)}`);
160
+ log(` storage cap removed: ${c.vault_cap_removed === true ? "yes" : "no"}`);
154
161
  log(` connections torn down: ${n(c.connections_torn_down)}`);
155
162
  log(` vault removed: ${c.vault_removed === true ? "yes" : "no"}`);
156
163
  log(` vault module restarted:${c.module_restarted === true ? " yes" : " no"}`);
@@ -302,21 +309,6 @@ export async function vaultRemove(args: string[], deps: VaultRemoveDeps = {}): P
302
309
  return 0;
303
310
  }
304
311
 
305
- if (res.status === 409 && error === "last_vault") {
306
- // CRITICAL GUARDRAIL: print + exit non-zero. Do NOT fall through to spawning
307
- // `parachute-vault` — that would re-open the orphaned-identity bug B3 closes.
308
- logError(`parachute vault remove: ${error_description}`);
309
- logError("");
310
- logError(
311
- `The raw mechanics-only path \`parachute-vault remove ${name} --yes\` can delete the last vault,`,
312
- );
313
- logError(
314
- "but it SKIPS the identity cascade — live tokens, grants, and user_vaults rows for that",
315
- );
316
- logError("vault would be left orphaned. Create another vault first if you can.");
317
- return 1;
318
- }
319
-
320
312
  if (res.status === 400 && error === "confirm_mismatch") {
321
313
  // Pass the hub's confirm message through.
322
314
  logError(`parachute vault remove: ${error_description}`);
package/src/cors.ts CHANGED
@@ -89,8 +89,9 @@
89
89
  * leaking the wrong ACAO and breaking CORS in unpredictable ways.
90
90
  * Critical for cache correctness.
91
91
  *
92
- * Access-Control-Allow-Methods: GET, POST, OPTIONS
93
- * The union of methods the in-scope route family supports. Per-route
92
+ * Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
93
+ * The union of methods the in-scope route family supports (DELETE for the
94
+ * RFC 7592 `DELETE /oauth/clients/<id>` deregistration, hub#640). Per-route
94
95
  * could be narrower (e.g. /oauth/token is POST-only), but advertising
95
96
  * the union is the simpler shape and browsers don't enforce a per-route
96
97
  * check anyway — the *actual* request method gates execution at the
@@ -137,7 +138,10 @@ const CORS_STATIC_RESPONSE_HEADERS: Readonly<Record<string, string>> = {
137
138
  * `corsPreflightResponse`.
138
139
  */
139
140
  const CORS_STATIC_PREFLIGHT_HEADERS: Readonly<Record<string, string>> = {
140
- "access-control-allow-methods": "GET, POST, OPTIONS",
141
+ // DELETE is in the union for RFC 7592 client deregistration
142
+ // (`DELETE /oauth/clients/<id>`, hub#640). A cross-origin browser caller
143
+ // (vs the server-side surface daemon) would otherwise fail the preflight.
144
+ "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
141
145
  "access-control-allow-headers": "Authorization, Content-Type, X-Requested-With",
142
146
  "access-control-max-age": "86400",
143
147
  };
package/src/help.ts CHANGED
@@ -17,6 +17,7 @@ Usage:
17
17
  parachute install <service> install and register a service
18
18
  services: ${services}
19
19
  parachute status show installed services, run state, health
20
+ parachute doctor run health checks + tell you the one thing to fix
20
21
  parachute start [service] start a module via the supervisor (or ensure the hub is up)
21
22
  parachute stop [service] stop a module via the supervisor (or stop the hub unit)
22
23
  parachute restart [service] restart a module via the supervisor (or restart the hub unit)
@@ -134,6 +135,7 @@ Usage:
134
135
  [--expose none|tailnet|cloudflare]
135
136
  [--channel rc|latest]
136
137
  [--hub-origin <url>]
138
+ [--vault-name <name>]
137
139
  [--cli-wizard | --browser-wizard]
138
140
 
139
141
  What it does:
@@ -178,6 +180,14 @@ Flags:
178
180
  accepting it in one pass. For reverse-proxy /
179
181
  Caddy-direct boxes that bind loopback but are reached
180
182
  over a public HTTPS URL (e.g. https://<ip>.sslip.io).
183
+ --vault-name <name> create the first vault in one shot (#478 Part 2).
184
+ Runs \`parachute-vault create <name>\` after the hub
185
+ is up. Non-fatal on re-run — \`create\` exits
186
+ non-zero if the vault already exists, and that's
187
+ tolerated. Must be a valid vault name: lowercase
188
+ alphanumeric + hyphens/underscores, 2–32 chars.
189
+ Without this flag, the wizard owns vault creation
190
+ (the default experience is unchanged).
181
191
  --cli-wizard skip the "browser or CLI?" prompt and walk the wizard
182
192
  in this terminal (hub#168 Cut 4)
183
193
  --browser-wizard skip the prompt and open the browser wizard directly
@@ -190,7 +200,9 @@ Examples:
190
200
  parachute init --expose tailnet # CI/scripted: chain straight into Tailscale
191
201
  parachute init --no-browser # don't shell out to open / xdg-open
192
202
  parachute init --cli-wizard # walk the wizard in this terminal (hub#168)
193
- parachute init --channel rc # rc box: install the vault module from @rc
203
+ parachute init --channel rc # rc box: install the vault module from @rc
204
+ parachute init --vault-name default --no-browser
205
+ # CI/scripted: hub + first vault in one pass
194
206
  `;
195
207
  }
196
208
 
@@ -356,6 +368,58 @@ Example:
356
368
  `;
357
369
  }
358
370
 
371
+ export function doctorHelp(): string {
372
+ return `parachute doctor — health / diagnostics for your Parachute install
373
+
374
+ Usage:
375
+ parachute doctor [--json]
376
+ parachute doctor --fix [--yes]
377
+
378
+ What it does:
379
+ Runs a set of independent health checks and prints a grouped report
380
+ (✓ pass / ⚠ warn / ✗ fail), each with a one-line detail and — where there
381
+ is one — a copy-pasteable fix-it command. The single command that answers
382
+ "is my Parachute healthy, and if not, what's the one thing to fix?"
383
+
384
+ Checks (each PASSES on a fresh / fully-current install — doctor positively
385
+ detects a known-bad condition and never treats "not configured" as broken):
386
+ - Hub supervisor reachable on :1939 (/health).
387
+ - Each CONFIGURED module alive via its loopback /health (2xx or 401 = live).
388
+ - services.json parses + required fields valid (a missing file is the
389
+ fresh pre-install state, not a failure).
390
+ - Services on canonical ports — flags any KNOWN module whose port has
391
+ drifted off its canonical slot, or two services sharing one port
392
+ (legacy services.json written before the validation gate). A
393
+ third-party service with no canonical port is never flagged.
394
+ - operator.token exists, parses, and its issuer matches the hub (the
395
+ recurring "not signed in to the hub" / issuer-mismatch class).
396
+ - Each first-party module bin is executable (catches the lost-+x-bit
397
+ start-failure class).
398
+ - Migration: legacy detached install? known cruft at the ecosystem root?
399
+ (allowlist detectors only — a fresh root flags nothing).
400
+ - Exposure: if exposed, is the public origin reachable? If not exposed,
401
+ "loopback only" is reported as benign info, never a warning.
402
+ - Version freshness (cosmetic) — drift is WARN at most, never a failure.
403
+
404
+ Flags:
405
+ --json emit a single JSON object instead of the human report
406
+ --fix repair canonical-port drift in services.json — and ONLY that.
407
+ It is NOT a "fix everything" flag; every other check stays
408
+ report-only. Shows the old→new diff first, then confirms before
409
+ writing (a TTY prompts; --yes skips the prompt; a non-TTY without
410
+ --yes bails without writing). Idempotent: a clean file is a no-op.
411
+ Duplicate-port collisions are reported, not auto-resolved.
412
+ --yes skip the --fix confirmation prompt (required to apply in a
413
+ non-interactive shell)
414
+
415
+ Exit codes:
416
+ 0 no failures (warnings are advisory and still exit 0); --fix: applied or
417
+ nothing-to-fix
418
+ 1 one or more checks failed; or --fix bailed (non-TTY without --yes /
419
+ aborted at the prompt / unreadable services.json)
420
+ `;
421
+ }
422
+
359
423
  export function exposeHelp(): string {
360
424
  return `parachute expose — route your services behind HTTPS on a network layer
361
425
 
package/src/hub-db.ts CHANGED
@@ -558,6 +558,20 @@ const MIGRATIONS: readonly Migration[] = [
558
558
  );
559
559
  `,
560
560
  },
561
+ {
562
+ version: 16,
563
+ sql: `
564
+ -- Index tokens by client_id for the OAuth client GC reaper (#640). The
565
+ -- reaper's gate runs a correlated NOT EXISTS (SELECT 1 FROM tokens WHERE
566
+ -- client_id = ? AND ...) per candidate client; tokens previously had no
567
+ -- client_id index (only user_id / refresh_token_hash / family_id /
568
+ -- revoked_at / subject), so each check was a full tokens-table walk. Under
569
+ -- the DCR reconnect churn this GC targets — thousands of dead token rows
570
+ -- accumulating before a sweep — that is O(total tokens) per client. This
571
+ -- makes it O(tokens for that client). IF NOT EXISTS so re-opens are inert.
572
+ CREATE INDEX IF NOT EXISTS tokens_client ON tokens (client_id);
573
+ `,
574
+ },
561
575
  ];
562
576
 
563
577
  export function openHubDb(path: string = hubDbPath()): Database {