@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/commands/hub.ts
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
-
* ##
|
|
34
|
+
* ## Last-vault handling (#678)
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 +
|
|
60
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|