@openparachute/hub 0.7.1 → 0.7.2-rc.1

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 (73) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  4. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  5. package/src/__tests__/admin-connections-credentials.test.ts +8 -4
  6. package/src/__tests__/admin-connections.test.ts +211 -57
  7. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  8. package/src/__tests__/admin-lock.test.ts +600 -0
  9. package/src/__tests__/admin-module-token.test.ts +36 -8
  10. package/src/__tests__/admin-vaults.test.ts +8 -8
  11. package/src/__tests__/api-modules-ops.test.ts +17 -16
  12. package/src/__tests__/api-modules.test.ts +35 -36
  13. package/src/__tests__/api-ready.test.ts +2 -2
  14. package/src/__tests__/clients.test.ts +91 -0
  15. package/src/__tests__/grants-store.test.ts +219 -0
  16. package/src/__tests__/hub-server.test.ts +9 -5
  17. package/src/__tests__/migrate.test.ts +1 -1
  18. package/src/__tests__/module-manifest.test.ts +11 -11
  19. package/src/__tests__/oauth-client.test.ts +446 -0
  20. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  21. package/src/__tests__/oauth-handlers.test.ts +124 -26
  22. package/src/__tests__/operator-token.test.ts +2 -2
  23. package/src/__tests__/scope-explanations.test.ts +3 -3
  24. package/src/__tests__/serve-boot.test.ts +14 -14
  25. package/src/__tests__/serve.test.ts +26 -0
  26. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  27. package/src/__tests__/services-manifest.test.ts +60 -48
  28. package/src/__tests__/setup-gate.test.ts +52 -3
  29. package/src/__tests__/setup-wizard.test.ts +86 -280
  30. package/src/__tests__/setup.test.ts +1 -1
  31. package/src/__tests__/upgrade.test.ts +276 -0
  32. package/src/__tests__/vault-remove.test.ts +393 -0
  33. package/src/admin-agent-grants.ts +1365 -0
  34. package/src/admin-agent-token.ts +147 -0
  35. package/src/admin-connections.ts +67 -50
  36. package/src/admin-host-admin-token.ts +14 -1
  37. package/src/admin-lock.ts +281 -0
  38. package/src/admin-module-token.ts +15 -7
  39. package/src/admin-vault-admin-token.ts +8 -1
  40. package/src/admin-vaults.ts +12 -12
  41. package/src/api-admin-lock.ts +335 -0
  42. package/src/api-modules-ops.ts +3 -2
  43. package/src/api-modules.ts +9 -9
  44. package/src/cli.ts +13 -1
  45. package/src/clients.ts +88 -0
  46. package/src/commands/install.ts +7 -0
  47. package/src/commands/serve-boot.ts +5 -4
  48. package/src/commands/serve.ts +45 -19
  49. package/src/commands/setup.ts +4 -3
  50. package/src/commands/upgrade.ts +118 -2
  51. package/src/commands/vault-remove.ts +361 -0
  52. package/src/commands/wizard.ts +4 -4
  53. package/src/connections-store.ts +3 -3
  54. package/src/grants-store.ts +272 -0
  55. package/src/help.ts +4 -1
  56. package/src/hub-server.ts +209 -27
  57. package/src/hub-settings.ts +23 -8
  58. package/src/jwt-sign.ts +5 -1
  59. package/src/module-manifest.ts +2 -2
  60. package/src/oauth-client.ts +497 -0
  61. package/src/oauth-flows-store.ts +163 -0
  62. package/src/oauth-handlers.ts +40 -13
  63. package/src/operator-token.ts +1 -1
  64. package/src/origin-check.ts +7 -2
  65. package/src/resource-binding.ts +4 -4
  66. package/src/scope-explanations.ts +3 -3
  67. package/src/service-spec.ts +56 -43
  68. package/src/setup-wizard.ts +56 -240
  69. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  70. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/admin-channel-token.ts +0 -135
  73. package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
@@ -44,6 +44,26 @@
44
44
  * downgrades: if `npm view <pkg>@<channel> version` resolves to something
45
45
  * lower than what's installed, we abort with an actionable message
46
46
  * (override with `--allow-downgrade`).
47
+ *
48
+ * RC-channel best-of resolution (hub#659). Channel preservation (#332) made
49
+ * the rc channel sticky — `parachute upgrade` follows `@rc`. But the rc
50
+ * channel is a *canary*: when a train ships stable-direct without cutting an
51
+ * rc (the common case under the post-#332 governance where trains start at
52
+ * stable), `@rc` doesn't advance and the box strands BELOW `@latest` with no
53
+ * visible path forward (the live case: friends.parachute.computer pinned at
54
+ * 0.6.5-rc.8 while @latest moved to 0.7.1). The fix: on the rc channel we
55
+ * resolve the target to the HIGHEST version above installed across BOTH `@rc`
56
+ * AND `@latest`.
57
+ * - mid-chain (a newer rc exists) → take the rc; the canary stays ahead
58
+ * (UNCHANGED — this is the #332 behavior).
59
+ * - end-of-chain / skipped-rc train (no newer rc, but @latest > installed)
60
+ * → CONVERGE to @latest, with a loud log line. The operator stays ON the
61
+ * rc channel (the install/config notion of channel is unchanged) — only
62
+ * the resolved VERSION changes; they pick up the next rc when it ships.
63
+ * - nothing newer anywhere → the existing up-to-date no-op.
64
+ * The `@latest` (stable) channel path is UNCHANGED — stable never reaches for
65
+ * `@rc`. `--channel rc|latest` explicit overrides and `--allow-downgrade`
66
+ * keep working.
47
67
  */
48
68
 
49
69
  import { existsSync, readFileSync, realpathSync } from "node:fs";
@@ -689,10 +709,102 @@ function pickChannel(installedVersion: string | null, r: Resolved): string {
689
709
  return "latest";
690
710
  }
691
711
 
712
+ /**
713
+ * The dist-tag / version `upgradeNpm` will hand to `bun add -g`, after the
714
+ * rc-channel best-of resolution (hub#659). `installSpec` is what follows the
715
+ * `@` in `bun add -g <pkg>@<installSpec>` — usually a dist-tag (`rc` /
716
+ * `latest`), but a pinned concrete version when we converge an rc-channel box
717
+ * onto stable (so a moving `@latest` can't race the resolution). `channel` is
718
+ * the dist-tag we resolved against, used by the downgrade-guard messaging.
719
+ */
720
+ interface ResolvedNpmTarget {
721
+ installSpec: string;
722
+ channel: string;
723
+ }
724
+
725
+ /**
726
+ * Resolve which version the rc channel should actually move to (hub#659).
727
+ *
728
+ * Channel preservation (#332) keeps an rc operator following `@rc`. But `@rc`
729
+ * is a canary that only advances when a train cuts an rc; a stable-direct
730
+ * train leaves it stranded below `@latest`. So on the rc channel we look at
731
+ * BOTH dist-tags and take the highest one that's actually ABOVE installed:
732
+ * - a newer `@rc` exists → stay on rc (mid-chain canary; UNCHANGED).
733
+ * - no newer rc but `@latest` > installed → converge to `@latest`, pinned to
734
+ * the concrete resolved version, with a LOUD log. The operator stays ON
735
+ * the rc channel; only this upgrade's resolved version changes.
736
+ * - neither tag is above installed → fall through to the rc tag and let the
737
+ * normal "already at <v>" no-op fire after `bun add -g`.
738
+ *
739
+ * Returns the original `{ installSpec: "rc" }` unchanged whenever we can't read
740
+ * the installed version or can't resolve a higher stable (fail-open: never
741
+ * block a legitimate `@rc` upgrade on a flaky probe).
742
+ */
743
+ async function resolveRcBestOf(
744
+ target: ResolvedTarget,
745
+ beforeVersion: string | null,
746
+ r: Resolved,
747
+ ): Promise<ResolvedNpmTarget> {
748
+ const rcTarget: ResolvedNpmTarget = { installSpec: "rc", channel: "rc" };
749
+ if (!beforeVersion) return rcTarget;
750
+
751
+ const [rcVersion, latestVersion] = await Promise.all([
752
+ r.resolveChannelVersion(target.packageName, "rc"),
753
+ r.resolveChannelVersion(target.packageName, "latest"),
754
+ ]);
755
+
756
+ const rcAbove = rcVersion !== null && (compareVersions(rcVersion, beforeVersion) ?? -1) > 0;
757
+ if (rcAbove) {
758
+ // A newer rc exists — the canary stays ahead (the #332 mid-chain path).
759
+ return rcTarget;
760
+ }
761
+
762
+ const latestAbove =
763
+ latestVersion !== null && (compareVersions(latestVersion, beforeVersion) ?? -1) > 0;
764
+ // (`latestVersion !== null` already guaranteed by `latestAbove`; the second
765
+ // term only narrows it for TS.)
766
+ if (latestAbove && latestVersion) {
767
+ // End-of-chain / skipped-rc train: nothing newer on @rc, but stable moved
768
+ // ahead. Converge to @latest — pinned to the concrete version so a moving
769
+ // dist-tag can't race us — and say so LOUDLY (the hub#659 stranding fix).
770
+ r.log(
771
+ `${target.short}: the rc channel has nothing newer than ${beforeVersion}; ` +
772
+ `converging to stable ${latestVersion} — you'll pick up the next rc when it ships.`,
773
+ );
774
+ return { installSpec: latestVersion, channel: "latest" };
775
+ }
776
+
777
+ // Neither tag is above installed — stay on @rc and let the post-install
778
+ // "already at <v>" no-op fire (handled in upgradeNpm). When both probes
779
+ // resolved and both are ≤ installed, name that explicitly.
780
+ if (rcVersion !== null && latestVersion !== null) {
781
+ r.log(
782
+ `${target.short}: on the rc channel — @rc (${rcVersion}) and @latest (${latestVersion}) ` +
783
+ `are both at or below installed ${beforeVersion}.`,
784
+ );
785
+ }
786
+ return rcTarget;
787
+ }
788
+
692
789
  async function upgradeNpm(target: ResolvedTarget, sourceDir: string, r: Resolved): Promise<number> {
693
790
  r.log(`${target.short}: npm-installed (${sourceDir})`);
694
791
  const beforeVersion = readPackageVersion(join(sourceDir, "package.json"));
695
- const channel = pickChannel(beforeVersion, r);
792
+ const pickedChannel = pickChannel(beforeVersion, r);
793
+
794
+ // RC-channel best-of resolution (hub#659). On the rc channel, resolve to the
795
+ // highest version above installed across @rc AND @latest — so an end-of-chain
796
+ // box converges to stable instead of stranding below it. The stable channel
797
+ // and explicit programmatic `--tag` are UNCHANGED: only an *auto-detected or
798
+ // --channel-rc* resolution reaches for stable. An explicit `--channel rc`
799
+ // ALSO flows through best-of (a deliberate rc operator still gets converge);
800
+ // `r.tag` (programmatic pin) and `--channel latest` both leave it untouched.
801
+ let installSpec = pickedChannel;
802
+ let channel = pickedChannel;
803
+ if (pickedChannel === "rc" && !r.tag) {
804
+ const resolved = await resolveRcBestOf(target, beforeVersion, r);
805
+ installSpec = resolved.installSpec;
806
+ channel = resolved.channel;
807
+ }
696
808
 
697
809
  // Downgrade guard: refuse to silently move backward. Only applies when
698
810
  // we can read both sides — beforeVersion from disk, targetVersion via
@@ -700,6 +812,10 @@ async function upgradeNpm(target: ResolvedTarget, sourceDir: string, r: Resolved
700
812
  // behavior: just run `bun add -g`). This is the load-bearing fix for
701
813
  // hub#332 — Aaron got `0.5.13-rc.13` → `0.5.10` because the implicit
702
814
  // `@latest` resolved to a prior stable while he was on the rc chain.
815
+ //
816
+ // After hub#659's best-of resolution `channel` is already the tag we're
817
+ // actually shipping (rc on the canary path, latest on the converge path), so
818
+ // the guard checks the version we'll really install.
703
819
  if (beforeVersion && !r.allowDowngrade) {
704
820
  const targetVersion = await r.resolveChannelVersion(target.packageName, channel);
705
821
  if (targetVersion) {
@@ -725,7 +841,7 @@ async function upgradeNpm(target: ResolvedTarget, sourceDir: string, r: Resolved
725
841
  }
726
842
  }
727
843
 
728
- const spec = `${target.packageName}@${channel}`;
844
+ const spec = `${target.packageName}@${installSpec}`;
729
845
  r.log(`${target.short}: bun add -g ${spec}`);
730
846
  const code = await r.runner.run(["bun", "add", "-g", spec]);
731
847
  if (code !== 0) {
@@ -0,0 +1,361 @@
1
+ /**
2
+ * `parachute vault remove <name>` — route the CLI vault-delete verb through the
3
+ * hub's identity cascade instead of the mechanics-only `parachute-vault remove`.
4
+ *
5
+ * ## Why this command exists (B3)
6
+ *
7
+ * The transparent `parachute vault <args>` passthrough (`commands/vault.ts`)
8
+ * forwards `remove <name>` verbatim to `parachute-vault remove` — which only
9
+ * does the MECHANICS of destruction (`rmSync` the vault dir + rewrite the vault
10
+ * module's own config). That path BYPASSES every hub-side identity artifact tied
11
+ * to the vault: live `vault:<name>:*` access tokens stay valid (and reachable
12
+ * via the polled revocation list), `user_vaults` rows linger, grants keep their
13
+ * `vault:<name>:*` entries, unredeemed invites pinned to the vault stay
14
+ * redeemable, and Connections that source/provision the vault keep their
15
+ * long-lived mints. Deleting a vault that way orphans all of it.
16
+ *
17
+ * The hub ALREADY ships the correct path: `DELETE /vaults/<name>`
18
+ * (`admin-vaults.ts:handleDeleteVault`) runs the full 7-step identity cascade
19
+ * (revoke tokens → rewrite grants → drop user_vaults → revoke invites → tear
20
+ * down connections → shell `parachute-vault remove` → restart vault) and is
21
+ * exactly what the vault-admin SPA drives. This command routes the CLI verb
22
+ * through that SAME endpoint over loopback, so `parachute vault remove` and the
23
+ * SPA delete are one code path.
24
+ *
25
+ * ## Credential + transport (reused from module-ops-client.ts)
26
+ *
27
+ * We drive the RUNNING hub over loopback — never open hub.db's vault registry
28
+ * directly (the daemon holds it). The bearer is the on-disk
29
+ * `~/.parachute/operator.token`, read (never minted) via
30
+ * `useOperatorTokenWithAutoRotate`. Its default `admin` scope-set carries
31
+ * `parachute:host:admin` — exactly the scope the endpoint gates on. This is the
32
+ * same read-never-mint credential path `parachute start/stop/restart <svc>` use.
33
+ *
34
+ * ## The 409 guardrail (load-bearing)
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.
42
+ */
43
+
44
+ import { CONFIG_DIR } from "../config.ts";
45
+ import { readExposeState } from "../expose-state.ts";
46
+ import { readHubPort } from "../hub-control.ts";
47
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
48
+ import { HUB_UNIT_DEFAULT_PORT } from "../hub-unit.ts";
49
+ import {
50
+ DEFAULT_HUB_BASE_URL,
51
+ ModuleOpHttpError,
52
+ NoOperatorTokenError,
53
+ OperatorTokenExpiredError,
54
+ resolveOperatorBearer,
55
+ } from "../module-ops-client.ts";
56
+
57
+ /**
58
+ * 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.
61
+ */
62
+ export interface VaultRemoveDeps {
63
+ /**
64
+ * Resolve the operator-token bearer to present to the loopback hub. Default
65
+ * opens hub.db, reads `~/.parachute/operator.token` (auto-rotating if near
66
+ * expiry), and returns the JWT. Throws {@link NoOperatorTokenError} /
67
+ * {@link OperatorTokenExpiredError} with already-actionable messages.
68
+ */
69
+ readonly resolveBearer?: () => Promise<string>;
70
+ /** fetch seam — `globalThis.fetch` in production; a recorder in tests. */
71
+ readonly fetch?: typeof fetch;
72
+ /** Loopback hub base URL. Defaults to {@link DEFAULT_HUB_BASE_URL}. */
73
+ readonly baseUrl?: string;
74
+ /** Output sink. Defaults to `console.log`. */
75
+ readonly log?: (line: string) => void;
76
+ /** Error sink. Defaults to `console.error`. */
77
+ readonly logError?: (line: string) => void;
78
+ }
79
+
80
+ /** Wire shape of the cascade summary nested under `cascade` in the 200 body. */
81
+ interface CascadeSummaryWire {
82
+ tokens_revoked?: number;
83
+ grants_rewritten?: number;
84
+ grants_dropped?: number;
85
+ user_vaults_removed?: number;
86
+ invites_invalidated?: number;
87
+ connections_torn_down?: number;
88
+ orphaned_channels?: unknown;
89
+ vault_removed?: boolean;
90
+ module_restarted?: boolean;
91
+ }
92
+
93
+ interface DeleteVaultSuccessWire {
94
+ ok?: boolean;
95
+ name?: string;
96
+ cascade?: CascadeSummaryWire;
97
+ warnings?: unknown;
98
+ }
99
+
100
+ /**
101
+ * Resolve the hub origin the operator token's `iss` is validated against —
102
+ * mirrors `lifecycle.ts:resolveOperatorTokenIssuer`. Unlike the spawn-env
103
+ * derivation, the operator token always carries an `iss`, so we fall back to
104
+ * the canonical loopback origin (never `undefined`). The known-issuer SET inside
105
+ * `useOperatorTokenWithAutoRotate` also accepts the public `iss` from
106
+ * expose-state, so a seed of loopback still validates an exposed-origin token.
107
+ */
108
+ function resolveOperatorTokenIssuer(configDir: string): string {
109
+ const state = readExposeState(`${configDir}/expose-state.json`);
110
+ if (state?.hubOrigin) return state.hubOrigin;
111
+ const port = readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT;
112
+ return `http://127.0.0.1:${port}`;
113
+ }
114
+
115
+ /**
116
+ * Default bearer resolver: open hub.db, read+auto-rotate the operator token,
117
+ * return the JWT. Closes the db before returning. Read-never-mint — no second
118
+ * SQLite writer racing the running hub.
119
+ */
120
+ async function defaultResolveBearer(configDir: string): Promise<string> {
121
+ const issuer = resolveOperatorTokenIssuer(configDir);
122
+ const db = openHubDb(hubDbPath(configDir));
123
+ try {
124
+ return await resolveOperatorBearer({ db, issuer, configDir });
125
+ } finally {
126
+ db.close();
127
+ }
128
+ }
129
+
130
+ function n(v: number | undefined): number {
131
+ return typeof v === "number" ? v : 0;
132
+ }
133
+
134
+ function asStringArray(v: unknown): string[] {
135
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
136
+ }
137
+
138
+ /**
139
+ * Render the structured cascade summary the endpoint returns — the operator's
140
+ * proof every identity artifact was revoked, not just the vault dir removed.
141
+ */
142
+ function renderCascadeSummary(
143
+ name: string,
144
+ body: DeleteVaultSuccessWire,
145
+ log: (line: string) => void,
146
+ ): void {
147
+ const c = body.cascade ?? {};
148
+ log(`Removed vault "${name}" with the full identity cascade:`);
149
+ log(` tokens revoked: ${n(c.tokens_revoked)}`);
150
+ log(` grants rewritten: ${n(c.grants_rewritten)}`);
151
+ log(` grants dropped: ${n(c.grants_dropped)}`);
152
+ log(` user_vaults removed: ${n(c.user_vaults_removed)}`);
153
+ log(` invites invalidated: ${n(c.invites_invalidated)}`);
154
+ log(` connections torn down: ${n(c.connections_torn_down)}`);
155
+ log(` vault removed: ${c.vault_removed === true ? "yes" : "no"}`);
156
+ log(` vault module restarted:${c.module_restarted === true ? " yes" : " no"}`);
157
+
158
+ const orphaned = asStringArray(c.orphaned_channels);
159
+ if (orphaned.length > 0) {
160
+ log("");
161
+ log(`WARNING: ${orphaned.length} vault-backed agent channel(s) still reference "${name}":`);
162
+ for (const ch of orphaned) log(` - ${ch}`);
163
+ log("Remove them in the agent UI — the hub does not delete the agent's config.");
164
+ }
165
+
166
+ // Top-level warnings[] — each a { step, detail } the cascade recorded
167
+ // (e.g. daemon-restart skipped, a partial connection teardown).
168
+ const warnings = Array.isArray(body.warnings) ? body.warnings : [];
169
+ if (warnings.length > 0) {
170
+ log("");
171
+ log("Warnings:");
172
+ for (const w of warnings) {
173
+ const rec = (w ?? {}) as { step?: unknown; detail?: unknown };
174
+ const step = typeof rec.step === "string" ? rec.step : "warning";
175
+ const detail = typeof rec.detail === "string" ? rec.detail : JSON.stringify(w);
176
+ log(` - [${step}] ${detail}`);
177
+ }
178
+ }
179
+ }
180
+
181
+ const USAGE = "usage: parachute vault remove <name> [--yes] [--hub-origin <url>]";
182
+
183
+ /**
184
+ * Route `parachute vault remove <name>` (and the `rm` alias) through the hub's
185
+ * `DELETE /vaults/<name>` identity cascade. Returns the process exit code.
186
+ */
187
+ export async function vaultRemove(args: string[], deps: VaultRemoveDeps = {}): Promise<number> {
188
+ const log = deps.log ?? ((line: string) => console.log(line));
189
+ const logError = deps.logError ?? ((line: string) => console.error(line));
190
+
191
+ // --- Parse args: positional <name> + flags (--yes/-y, --hub-origin <url>). --
192
+ let name: string | undefined;
193
+ let baseUrlOverride: string | undefined = deps.baseUrl;
194
+ // `--yes`/`-y` is accepted for parity with `parachute-vault remove --yes` and
195
+ // to telegraph non-interactive intent; the endpoint's confirm guard is
196
+ // satisfied from the name arg, so no extra prompt is shown either way. We
197
+ // parse it so it isn't mistaken for a positional, but the behaviour is the
198
+ // same with or without it (this is an admin op that already requires the
199
+ // operator token + a deliberate name retype on the wire).
200
+ for (let i = 0; i < args.length; i++) {
201
+ const a = args[i];
202
+ if (a === "--yes" || a === "-y") continue;
203
+ if (a === "--hub-origin") {
204
+ const v = args[i + 1];
205
+ if (!v) {
206
+ logError("parachute vault remove: --hub-origin requires a URL argument");
207
+ return 1;
208
+ }
209
+ baseUrlOverride = v;
210
+ i++;
211
+ continue;
212
+ }
213
+ if (a?.startsWith("--hub-origin=")) {
214
+ baseUrlOverride = a.slice("--hub-origin=".length);
215
+ continue;
216
+ }
217
+ if (a?.startsWith("-")) {
218
+ logError(`parachute vault remove: unknown flag "${a}"`);
219
+ logError(USAGE);
220
+ return 1;
221
+ }
222
+ if (name === undefined) {
223
+ name = a;
224
+ continue;
225
+ }
226
+ logError(`parachute vault remove: unexpected argument "${a}"`);
227
+ logError(USAGE);
228
+ return 1;
229
+ }
230
+
231
+ if (!name) {
232
+ logError("parachute vault remove: a vault name is required");
233
+ logError(USAGE);
234
+ return 1;
235
+ }
236
+
237
+ // --- Resolve the operator-token bearer (read, never mint). ------------------
238
+ let bearer: string;
239
+ try {
240
+ bearer = deps.resolveBearer
241
+ ? await deps.resolveBearer()
242
+ : await defaultResolveBearer(CONFIG_DIR);
243
+ } catch (err) {
244
+ if (err instanceof NoOperatorTokenError || err instanceof OperatorTokenExpiredError) {
245
+ // Already-actionable ("run `parachute auth rotate-operator`") — surface
246
+ // verbatim, never a raw 401.
247
+ logError(`parachute vault remove: ${err.message}`);
248
+ return 1;
249
+ }
250
+ logError(`parachute vault remove: ${err instanceof Error ? err.message : String(err)}`);
251
+ return 1;
252
+ }
253
+
254
+ // --- DELETE /vaults/<name> with the confirm body. --------------------------
255
+ const doFetch = deps.fetch ?? fetch;
256
+ const baseUrl = (baseUrlOverride ?? DEFAULT_HUB_BASE_URL).replace(/\/+$/, "");
257
+ const url = `${baseUrl}/vaults/${encodeURIComponent(name)}`;
258
+
259
+ let res: Response;
260
+ try {
261
+ res = await doFetch(url, {
262
+ method: "DELETE",
263
+ headers: {
264
+ authorization: `Bearer ${bearer}`,
265
+ "content-type": "application/json",
266
+ },
267
+ body: JSON.stringify({ confirm: name }),
268
+ });
269
+ } catch (err) {
270
+ // Loopback connection refused → the hub isn't running. The cascade needs
271
+ // the live hub; there's no DB-side fallback we'd take (that's the bug).
272
+ const msg = err instanceof Error ? err.message : String(err);
273
+ const refused = /econnrefused|connection refused|failed to fetch|unable to connect/i.test(msg);
274
+ if (refused) {
275
+ logError(
276
+ "parachute vault remove: the hub must be running to delete a vault with the identity cascade.",
277
+ );
278
+ logError("Run `parachute start`, then retry.");
279
+ logError(
280
+ `(The raw escape hatch \`parachute-vault remove ${name} --yes\` deletes the vault dir but SKIPS the cascade, leaving orphaned tokens.)`,
281
+ );
282
+ return 1;
283
+ }
284
+ logError(`parachute vault remove: request failed: ${msg}`);
285
+ return 1;
286
+ }
287
+
288
+ const body = await parseJsonSafe(res);
289
+
290
+ // --- Success: render the cascade summary. ----------------------------------
291
+ if (res.status === 200) {
292
+ renderCascadeSummary(name, (body ?? {}) as DeleteVaultSuccessWire, log);
293
+ return 0;
294
+ }
295
+
296
+ // --- Error mapping (actionable, never a raw status dump). -------------------
297
+ const { error, error_description } = asErrorBody(body, res.status);
298
+
299
+ if (res.status === 404 && error === "not_found") {
300
+ // Idempotent: a re-run after a successful delete lands here. Not scary.
301
+ log(`Vault "${name}" does not exist on this hub (already removed). Nothing to do.`);
302
+ return 0;
303
+ }
304
+
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
+ if (res.status === 400 && error === "confirm_mismatch") {
321
+ // Pass the hub's confirm message through.
322
+ logError(`parachute vault remove: ${error_description}`);
323
+ return 1;
324
+ }
325
+
326
+ if (res.status === 401 || res.status === 403) {
327
+ // The operator token was rejected by the hub — guide to re-mint rather than
328
+ // dumping the raw status (matches module-ops-client's posture).
329
+ logError(`parachute vault remove: the hub rejected the operator token (${error_description}).`);
330
+ logError("Run `parachute auth rotate-operator` to mint a fresh one, then retry.");
331
+ return 1;
332
+ }
333
+
334
+ // Any other non-2xx — name the failure class without a raw dump.
335
+ logError(`parachute vault remove: ${error}: ${error_description}`);
336
+ return 1;
337
+ }
338
+
339
+ async function parseJsonSafe(res: Response): Promise<unknown> {
340
+ try {
341
+ return await res.json();
342
+ } catch {
343
+ return undefined;
344
+ }
345
+ }
346
+
347
+ function asErrorBody(body: unknown, status: number): { error: string; error_description: string } {
348
+ const fallback = `hub returned HTTP ${status} with no error detail`;
349
+ if (body && typeof body === "object") {
350
+ const b = body as Record<string, unknown>;
351
+ const error = typeof b.error === "string" ? b.error : "error";
352
+ const error_description =
353
+ typeof b.error_description === "string" ? b.error_description : fallback;
354
+ return { error, error_description };
355
+ }
356
+ return { error: "error", error_description: fallback };
357
+ }
358
+
359
+ // Re-export so the test (and future CLI callers) can catch the typed errors
360
+ // without a second import path.
361
+ export { ModuleOpHttpError, NoOperatorTokenError, OperatorTokenExpiredError };
@@ -749,10 +749,10 @@ export async function runCliWizard(opts: RunCliWizardOpts): Promise<number> {
749
749
  state = await fetchWizardState(hubUrl, jar, fetchImpl);
750
750
  }
751
751
  // Done screen — fetch + show a brief summary. The browser wizard's
752
- // done screen surfaces the MCP install command + auto-minted token;
753
- // we mirror that by reading the same `setup_minted_token` row via
754
- // the GET endpoint's JSON envelope. Best-effort: a missing token
755
- // just means we point the operator at /admin/tokens instead.
752
+ // done screen surfaces the bare OAuth `claude mcp add` command (no
753
+ // token, no header vault is OAuth-default per parachute-vault #491);
754
+ // the CLI path never minted a token, so we just point the operator at
755
+ // the admin SPA + /admin/tokens for the headless-client case.
756
756
  log("");
757
757
  log("✓ Setup complete.");
758
758
  log(` Visit ${hubUrl}/admin/ to open the admin SPA.`);
@@ -35,7 +35,7 @@ export interface ConnectionSource {
35
35
 
36
36
  /** The sink side — an action a module accepts (the sink is ALWAYS an action). */
37
37
  export interface ConnectionSink {
38
- /** Sink module short name, e.g. `channel`. */
38
+ /** Sink module short name, e.g. `agent`. */
39
39
  readonly module: string;
40
40
  /** Action key declared in the sink module's `module.json`, e.g. `message.deliver`. */
41
41
  readonly action: string;
@@ -103,8 +103,8 @@ export interface ConnectionRecord {
103
103
  /**
104
104
  * Provenance — WHO requested this connection (modular-UI R2, module-initiated
105
105
  * connections). A module-owned config UI that creates a connection on the
106
- * operator's behalf (e.g. the channel admin page's "link to a vault" flow)
107
- * labels itself here (e.g. `"channel"`); a connection built by hand in the
106
+ * operator's behalf (e.g. the agent module's admin page "link to a vault" flow)
107
+ * labels itself here (e.g. `"agent"`); a connection built by hand in the
108
108
  * hub's own Connections builder is `"custom"`. Lets the operator see which
109
109
  * connections a module initiated vs which they wired themselves. Optional for
110
110
  * back-compat: records written before R2 read back as `undefined`, which the