@openparachute/hub 0.7.4-rc.8 → 0.7.4

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 (71) hide show
  1. package/package.json +1 -1
  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-handlers.test.ts +28 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +58 -1
  6. package/src/__tests__/admin-lock.test.ts +33 -1
  7. package/src/__tests__/admin-vaults.test.ts +52 -9
  8. package/src/__tests__/api-account-2fa.test.ts +453 -0
  9. package/src/__tests__/api-mint-token.test.ts +75 -0
  10. package/src/__tests__/api-modules.test.ts +143 -0
  11. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  12. package/src/__tests__/auth.test.ts +336 -0
  13. package/src/__tests__/clients.test.ts +298 -0
  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-settings.test.ts +188 -0
  18. package/src/__tests__/jwt-sign.test.ts +27 -0
  19. package/src/__tests__/oauth-handlers.test.ts +276 -21
  20. package/src/__tests__/oauth-ui.test.ts +52 -0
  21. package/src/__tests__/scope-explanations.test.ts +20 -9
  22. package/src/__tests__/sessions.test.ts +80 -0
  23. package/src/__tests__/setup-gate.test.ts +111 -3
  24. package/src/__tests__/vault-remove.test.ts +40 -19
  25. package/src/__tests__/well-known.test.ts +37 -2
  26. package/src/account-setup.ts +2 -0
  27. package/src/admin-agent-grants.ts +16 -1
  28. package/src/admin-auth.ts +13 -4
  29. package/src/admin-clients.ts +66 -5
  30. package/src/admin-grants.ts +11 -2
  31. package/src/admin-handlers.ts +2 -0
  32. package/src/admin-host-admin-token.ts +24 -1
  33. package/src/admin-lock.ts +16 -0
  34. package/src/admin-vaults.ts +70 -15
  35. package/src/api-account-2fa.ts +395 -0
  36. package/src/api-admin-lock.ts +7 -0
  37. package/src/api-hub-upgrade.ts +14 -1
  38. package/src/api-hub.ts +10 -1
  39. package/src/api-invites.ts +18 -3
  40. package/src/api-me.ts +11 -2
  41. package/src/api-mint-token.ts +16 -1
  42. package/src/api-modules.ts +119 -1
  43. package/src/api-revoke-token.ts +14 -1
  44. package/src/api-settings-hub-origin.ts +14 -1
  45. package/src/api-settings-root-redirect.ts +201 -0
  46. package/src/api-tokens.ts +14 -1
  47. package/src/api-users.ts +15 -6
  48. package/src/api-vault-caps.ts +11 -2
  49. package/src/cli.ts +29 -0
  50. package/src/clients.ts +164 -0
  51. package/src/commands/auth.ts +263 -1
  52. package/src/commands/doctor.ts +1250 -0
  53. package/src/commands/hub.ts +102 -1
  54. package/src/commands/vault-remove.ts +16 -24
  55. package/src/cors.ts +7 -3
  56. package/src/help.ts +53 -0
  57. package/src/hub-db.ts +14 -0
  58. package/src/hub-server.ts +123 -19
  59. package/src/hub-settings.ts +163 -1
  60. package/src/jwt-sign.ts +25 -6
  61. package/src/oauth-handlers.ts +25 -5
  62. package/src/oauth-ui.ts +51 -0
  63. package/src/rate-limit.ts +28 -0
  64. package/src/scope-explanations.ts +23 -9
  65. package/src/sessions.ts +43 -2
  66. package/src/setup-wizard.ts +2 -0
  67. package/src/well-known.ts +10 -1
  68. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  69. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  70. package/web/ui/dist/index.html +2 -2
  71. 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
  }
@@ -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)
@@ -367,6 +368,58 @@ Example:
367
368
  `;
368
369
  }
369
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
+
370
423
  export function exposeHelp(): string {
371
424
  return `parachute expose — route your services behind HTTPS on a network layer
372
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 {