@openparachute/hub 0.7.0 → 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.
- package/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +275 -5
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +980 -61
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +21 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +35 -5
- package/src/grants-store.ts +272 -0
- package/src/help.ts +17 -7
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +710 -39
- package/src/hub-settings.ts +23 -8
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +11 -1
- package/src/module-manifest.ts +109 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +13 -5
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +56 -240
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
|
@@ -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 };
|
package/src/commands/wizard.ts
CHANGED
|
@@ -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
|
|
753
|
-
//
|
|
754
|
-
// the
|
|
755
|
-
//
|
|
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.`);
|
package/src/connections-store.ts
CHANGED
|
@@ -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. `
|
|
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;
|
|
@@ -45,12 +45,26 @@ export interface ConnectionSink {
|
|
|
45
45
|
|
|
46
46
|
/** What the provisioning engine actually wired, for teardown + display. */
|
|
47
47
|
export interface ConnectionProvisioned {
|
|
48
|
-
/** How the action was provisioned, e.g. `vault-trigger`. */
|
|
48
|
+
/** How the action was provisioned, e.g. `vault-trigger` or `credential`. */
|
|
49
49
|
readonly type: string;
|
|
50
|
-
/** The vault instance the trigger was registered on (vault-trigger)
|
|
50
|
+
/** The vault instance the trigger was registered on (vault-trigger), or
|
|
51
|
+
* the vault a credential connection grants access to (credential). Either
|
|
52
|
+
* way it's the field the vault-delete cascade matches on. */
|
|
51
53
|
readonly vault?: string;
|
|
52
54
|
/** The exact vault trigger name registered — DELETE removes this. */
|
|
53
55
|
readonly triggerName?: string;
|
|
56
|
+
/** Credential connections (H4): the exact scope minted, e.g.
|
|
57
|
+
* `vault:default:read` — renewal re-mints THIS, never request input. */
|
|
58
|
+
readonly scope?: string;
|
|
59
|
+
/** Credential connections (H4): the tag allowlist baked into the minted
|
|
60
|
+
* token's `permissions.scoped_tags`. Empty/absent = vault-wide (read
|
|
61
|
+
* scopes only — writes always carry tags). */
|
|
62
|
+
readonly scopedTags?: readonly string[];
|
|
63
|
+
/** Credential connections (H4): the declared credential key. */
|
|
64
|
+
readonly credentialKey?: string;
|
|
65
|
+
/** Credential connections (H4): the module's daemon-root-relative delivery
|
|
66
|
+
* endpoint — also the best-effort removal-notification target. */
|
|
67
|
+
readonly endpoint?: string;
|
|
54
68
|
/**
|
|
55
69
|
* jtis of the LONG-LIVED tokens minted for this connection (the webhook
|
|
56
70
|
* bearer, and for a channel sink the vault-write reply token). Each is
|
|
@@ -66,6 +80,22 @@ export interface ConnectionProvisioned {
|
|
|
66
80
|
|
|
67
81
|
export interface ConnectionRecord {
|
|
68
82
|
readonly id: string;
|
|
83
|
+
/**
|
|
84
|
+
* Connection kind discriminator (H4). Absent = the original event→action
|
|
85
|
+
* shape; `"credential"` = a standing tag-scoped vault credential held by a
|
|
86
|
+
* module (the source is the granting vault, the sink is the holding
|
|
87
|
+
* module). Optional for back-compat: pre-H4 records read back undefined.
|
|
88
|
+
*/
|
|
89
|
+
readonly kind?: "credential";
|
|
90
|
+
/**
|
|
91
|
+
* Approval state (surface#113 claim/reconcile). Absent = active (every
|
|
92
|
+
* operator-provisioned record, and pre-claim records, read back undefined
|
|
93
|
+
* = active). `"pending"` = a module-initiated CLAIM for a directly-delivered
|
|
94
|
+
* credential, awaiting operator approval in the hub admin Connections view.
|
|
95
|
+
* A pending record grants nothing: renewal refuses it, and only the
|
|
96
|
+
* operator-gated approve endpoint flips it to active.
|
|
97
|
+
*/
|
|
98
|
+
readonly status?: "pending";
|
|
69
99
|
readonly source: ConnectionSource;
|
|
70
100
|
readonly sink: ConnectionSink;
|
|
71
101
|
readonly provisioned: ConnectionProvisioned;
|
|
@@ -73,8 +103,8 @@ export interface ConnectionRecord {
|
|
|
73
103
|
/**
|
|
74
104
|
* Provenance — WHO requested this connection (modular-UI R2, module-initiated
|
|
75
105
|
* connections). A module-owned config UI that creates a connection on the
|
|
76
|
-
* operator's behalf (e.g. the
|
|
77
|
-
* labels itself here (e.g. `"
|
|
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
|
|
78
108
|
* hub's own Connections builder is `"custom"`. Lets the operator see which
|
|
79
109
|
* connections a module initiated vs which they wired themselves. Optional for
|
|
80
110
|
* back-compat: records written before R2 read back as `undefined`, which the
|