@openparachute/hub 0.6.5-rc.7 → 0.7.0
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 +1 -1
- package/src/__tests__/account-setup.test.ts +34 -0
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/hub-db-liveness.test.ts +12 -7
- package/src/__tests__/hub-server.test.ts +319 -21
- package/src/__tests__/invites.test.ts +27 -0
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +980 -0
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +390 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +161 -0
- package/src/grants.ts +50 -0
- package/src/hub-db-liveness.ts +33 -17
- package/src/hub-server.ts +354 -61
- package/src/invites.ts +22 -0
- package/src/jwt-sign.ts +41 -1
- package/src/module-manifest.ts +429 -23
- package/src/origin-check.ts +106 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /admin/module-token/<short>` — exchange a valid admin session cookie for
|
|
3
|
+
* a short-lived JWT carrying `<short>:admin` (audience = the bare module short).
|
|
4
|
+
*
|
|
5
|
+
* Why this exists (2026-06-09 modular-UI architecture, P3): modules now own
|
|
6
|
+
* their config/admin UIs and declare `configUiUrl` in `module.json` (scribe
|
|
7
|
+
* `/scribe/admin`, runner `/runner/admin`, surface `/surface/admin/`, …). The
|
|
8
|
+
* hub frames/links those surfaces consistently (the Modules page "Configure"
|
|
9
|
+
* action). Each module-owned config UI, served behind the hub proxy to a
|
|
10
|
+
* logged-in portal operator, needs an admin-scoped hub Bearer to call its own
|
|
11
|
+
* `<short>:admin`-gated endpoints — the same shape the channel config UI gets
|
|
12
|
+
* from `/admin/channel-token` and the vault admin SPA gets from
|
|
13
|
+
* `/admin/vault-admin-token/<name>`. This is the GENERIC mint that covers every
|
|
14
|
+
* other self-registered single-audience module, so the hub doesn't grow a
|
|
15
|
+
* bespoke per-module mint endpoint as each module ships a config UI.
|
|
16
|
+
*
|
|
17
|
+
* Scope + audience: `<short>:admin`, audience = `<short>` (the bare service
|
|
18
|
+
* prefix). Modules validate the JWT's `aud` against their literal short name
|
|
19
|
+
* (`scribe`, `runner`, `surface`, `channel`) — the same shape `inferAudience`
|
|
20
|
+
* stamps for the public OAuth flow, so a hub-minted and an OAuth-minted admin
|
|
21
|
+
* token are indistinguishable to the module. This mirrors the per-request
|
|
22
|
+
* `<short>:admin` proxy token `api-modules-config.ts` used to mint; the
|
|
23
|
+
* difference is this endpoint hands the token to the module's OWN UI rather
|
|
24
|
+
* than proxying a hub-side config form.
|
|
25
|
+
*
|
|
26
|
+
* Multi-vault note: VAULT is excluded here. Vault's admin scope is per-instance
|
|
27
|
+
* (`vault:<name>:admin`, audience `vault.<name>`), which needs a vault-name
|
|
28
|
+
* parameter and a known-vault check — that lives in `/admin/vault-admin-token/
|
|
29
|
+
* <name>` (`admin-vault-admin-token.ts`). A request for `vault` here returns a
|
|
30
|
+
* 400 pointing at that endpoint, so a caller can't accidentally mint a useless
|
|
31
|
+
* bare `vault:admin`.
|
|
32
|
+
*
|
|
33
|
+
* Gate: the session must belong to the first admin (the single hub admin under
|
|
34
|
+
* the Phase 1 multi-user model — `users.ts:isFirstAdmin`), exactly like
|
|
35
|
+
* host-admin-token / vault-admin-token / channel-token. A friend account holds
|
|
36
|
+
* a valid session but must not mint a module admin Bearer.
|
|
37
|
+
*
|
|
38
|
+
* Tokens are short-lived (10 min — matches the sibling admin-token mints); the
|
|
39
|
+
* config UI re-fetches on near-expiry.
|
|
40
|
+
*/
|
|
41
|
+
import type { Database } from "bun:sqlite";
|
|
42
|
+
import { signAccessToken } from "./jwt-sign.ts";
|
|
43
|
+
import {
|
|
44
|
+
type ModuleManifest,
|
|
45
|
+
readModuleManifest as defaultReadModuleManifest,
|
|
46
|
+
} from "./module-manifest.ts";
|
|
47
|
+
import { findServiceByShort, isKnownModuleShort } from "./service-spec.ts";
|
|
48
|
+
import type { ServiceEntry } from "./services-manifest.ts";
|
|
49
|
+
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
50
|
+
import { isFirstAdmin } from "./users.ts";
|
|
51
|
+
|
|
52
|
+
/** Short TTL — matches host/vault/channel admin-token. UI re-fetches on near-expiry. */
|
|
53
|
+
export const MODULE_TOKEN_TTL_SECONDS = 10 * 60;
|
|
54
|
+
const MODULE_TOKEN_CLIENT_ID = "parachute-hub-spa";
|
|
55
|
+
|
|
56
|
+
/** Lowercase short-name charset, matching the module-name rule + path parsing. */
|
|
57
|
+
const MODULE_SHORT_RE = /^[a-z][a-z0-9-]*$/;
|
|
58
|
+
|
|
59
|
+
export interface MintModuleTokenDeps {
|
|
60
|
+
db: Database;
|
|
61
|
+
/** Hub origin — written into JWT `iss`. */
|
|
62
|
+
issuer: string;
|
|
63
|
+
/**
|
|
64
|
+
* Snapshot of services.json rows, read at request time so a module that
|
|
65
|
+
* self-registered since hub boot is mintable without a restart. Used by the
|
|
66
|
+
* self-registration gate (boundary C5) — see {@link isSelfRegisteredModule}.
|
|
67
|
+
*/
|
|
68
|
+
readServices: () => readonly ServiceEntry[];
|
|
69
|
+
/** Test seam — defaults to the real `readModuleManifest` (disk read). */
|
|
70
|
+
readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The self-registration gate (C5 of the 2026-06-09 hub-module-boundary
|
|
75
|
+
* migration): a short is mintable when it resolves to a services.json row
|
|
76
|
+
* whose `installDir` carries a readable `.parachute/module.json`.
|
|
77
|
+
*
|
|
78
|
+
* Resolution mirrors the rest of the hub (`/api/modules`,
|
|
79
|
+
* `collectInstalledModules`): first-party rows resolve through
|
|
80
|
+
* `findServiceByShort` (manifest name ↔ short map); a genuinely third-party
|
|
81
|
+
* row matches by its literal services.json `name` — the same
|
|
82
|
+
* `shortNameForManifest(name) ?? name` convention the catalog uses.
|
|
83
|
+
*
|
|
84
|
+
* Why this is enough against a forged short: services.json is written only by
|
|
85
|
+
* same-disk modules, which the charter's trust statement already covers — an
|
|
86
|
+
* installed module runs a daemon on the operator's machine, strictly more
|
|
87
|
+
* power than an admin Bearer. Requiring the registered row AND a readable
|
|
88
|
+
* manifest still keeps a typo'd short from minting `<anything>:admin` and
|
|
89
|
+
* masquerading as a real (but unusable) credential.
|
|
90
|
+
*/
|
|
91
|
+
async function isSelfRegisteredModule(short: string, deps: MintModuleTokenDeps): Promise<boolean> {
|
|
92
|
+
const services = deps.readServices();
|
|
93
|
+
const row =
|
|
94
|
+
findServiceByShort(services, short) ?? services.find((s): boolean => s.name === short);
|
|
95
|
+
if (!row?.installDir) return false;
|
|
96
|
+
const readManifest = deps.readModuleManifest ?? defaultReadModuleManifest;
|
|
97
|
+
try {
|
|
98
|
+
const manifest = await readManifest(row.installDir);
|
|
99
|
+
return manifest !== null;
|
|
100
|
+
} catch {
|
|
101
|
+
// Malformed manifest — treat as not-a-module rather than 500ing the mint.
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function handleModuleToken(
|
|
107
|
+
req: Request,
|
|
108
|
+
short: string,
|
|
109
|
+
deps: MintModuleTokenDeps,
|
|
110
|
+
): Promise<Response> {
|
|
111
|
+
if (req.method !== "GET") {
|
|
112
|
+
return jsonError(405, "method_not_allowed", "use GET");
|
|
113
|
+
}
|
|
114
|
+
if (!MODULE_SHORT_RE.test(short)) {
|
|
115
|
+
return jsonError(400, "invalid_request", `module short "${short}" is not a valid identifier`);
|
|
116
|
+
}
|
|
117
|
+
// Vault is per-instance — its admin scope needs a vault name. Route the caller
|
|
118
|
+
// to the dedicated per-vault endpoint rather than minting a useless bare
|
|
119
|
+
// `vault:admin` (no vault validates that audience).
|
|
120
|
+
if (short === "vault") {
|
|
121
|
+
return jsonError(
|
|
122
|
+
400,
|
|
123
|
+
"use_vault_admin_token",
|
|
124
|
+
"vault admin tokens are per-instance — use GET /admin/vault-admin-token/<name>",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
// Only mint for modules the hub can verify exist. Two paths (boundary C5 —
|
|
128
|
+
// closes the charter's third-party-test gap, where this endpoint used to
|
|
129
|
+
// require bootstrap-registry presence):
|
|
130
|
+
// 1. SELF-REGISTERED: the short resolves to a services.json row whose
|
|
131
|
+
// installDir carries a readable module.json. This is the canonical
|
|
132
|
+
// gate — any module (first- or third-party) that completed
|
|
133
|
+
// self-registration mints with zero hub code changes, per
|
|
134
|
+
// `parachute-patterns/patterns/hub-module-boundary.md` (the seam,
|
|
135
|
+
// mechanism 1).
|
|
136
|
+
// 2. KNOWN bootstrap short (KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS): kept
|
|
137
|
+
// as a fallback so a first-party module mid-install (row not yet
|
|
138
|
+
// written) still mints.
|
|
139
|
+
// Either way a forged/typo'd short can't mint `<anything>:admin` and mask
|
|
140
|
+
// itself as a real (but unusable) credential.
|
|
141
|
+
if (!isKnownModuleShort(short) && !(await isSelfRegisteredModule(short, deps))) {
|
|
142
|
+
return jsonError(404, "not_found", `no module "${short}" known to this hub`);
|
|
143
|
+
}
|
|
144
|
+
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
145
|
+
const session = sid ? findSession(deps.db, sid) : null;
|
|
146
|
+
if (!session) {
|
|
147
|
+
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
148
|
+
}
|
|
149
|
+
// First-admin gate (mirrors host/vault/channel-admin-token). A friend account
|
|
150
|
+
// (non-first-admin user) holds a valid session but must not mint a module
|
|
151
|
+
// admin Bearer.
|
|
152
|
+
if (!isFirstAdmin(deps.db, session.userId)) {
|
|
153
|
+
return jsonError(
|
|
154
|
+
403,
|
|
155
|
+
"not_admin",
|
|
156
|
+
"module admin token mint is restricted to the hub admin — your account home is at /account/",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const scope = `${short}:admin`;
|
|
160
|
+
const minted = await signAccessToken(deps.db, {
|
|
161
|
+
sub: session.userId,
|
|
162
|
+
scopes: [scope],
|
|
163
|
+
// Bare service audience — modules validate `aud === <short>`, the same
|
|
164
|
+
// shape `inferAudience` stamps for the OAuth flow.
|
|
165
|
+
audience: short,
|
|
166
|
+
clientId: MODULE_TOKEN_CLIENT_ID,
|
|
167
|
+
issuer: deps.issuer,
|
|
168
|
+
ttlSeconds: MODULE_TOKEN_TTL_SECONDS,
|
|
169
|
+
// No per-user vault pin — this Bearer talks to a module-scoped endpoint,
|
|
170
|
+
// not a single vault. Empty `vault_scope` is the "no per-user restriction"
|
|
171
|
+
// sentinel, matching host-admin / channel tokens.
|
|
172
|
+
vaultScope: [],
|
|
173
|
+
});
|
|
174
|
+
return new Response(
|
|
175
|
+
JSON.stringify({
|
|
176
|
+
token: minted.token,
|
|
177
|
+
expires_at: minted.expiresAt,
|
|
178
|
+
scopes: [scope],
|
|
179
|
+
}),
|
|
180
|
+
{
|
|
181
|
+
status: 200,
|
|
182
|
+
headers: {
|
|
183
|
+
"content-type": "application/json",
|
|
184
|
+
// No browser cache — token rotates per-fetch, and a stale 200 from a
|
|
185
|
+
// back/forward navigation could hand the UI a long-expired JWT.
|
|
186
|
+
"cache-control": "no-store",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
193
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
194
|
+
status,
|
|
195
|
+
headers: { "content-type": "application/json" },
|
|
196
|
+
});
|
|
197
|
+
}
|
package/src/admin-vaults.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `POST /vaults` — provision a new vault on the host.
|
|
3
|
+
* `DELETE /vaults/<name>` — destroy a vault WITH the identity cascade
|
|
4
|
+
* (B1, 2026-06-09 hub-module-boundary migration — see `handleDeleteVault`).
|
|
3
5
|
*
|
|
4
6
|
* The hub's first authenticated, mutating endpoint. Until now the hub has
|
|
5
7
|
* been a pure issuer; Phase 1 of the vault-config-and-scopes design (D1)
|
|
@@ -57,30 +59,29 @@
|
|
|
57
59
|
*/
|
|
58
60
|
import type { Database } from "bun:sqlite";
|
|
59
61
|
import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
62
|
+
import { type ConnectionsDeps, teardownConnection } from "./admin-connections.ts";
|
|
60
63
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
64
|
+
import { readConnections } from "./connections-store.ts";
|
|
65
|
+
import { rewriteGrantsRemovingVault } from "./grants.ts";
|
|
66
|
+
import { revokeInvitesForVault } from "./invites.ts";
|
|
67
|
+
import { revokeTokensNamingVault, signAccessToken } from "./jwt-sign.ts";
|
|
61
68
|
import { findService, type readManifest, readManifestLenient } from "./services-manifest.ts";
|
|
62
69
|
import { enrichedPath } from "./spawn-path.ts";
|
|
63
|
-
import {
|
|
70
|
+
import { removeVaultAssignments } from "./users.ts";
|
|
71
|
+
import { RESERVED_VAULT_NAMES, VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
|
|
64
72
|
import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
|
|
65
73
|
|
|
66
74
|
/** Scope required to call POST /vaults. */
|
|
67
75
|
export const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
68
76
|
|
|
69
|
-
/**
|
|
70
|
-
* Mirror parachute-vault's `cmdCreate` validation rules, plus hub-only
|
|
71
|
-
* reservations for SPA-route shadowing. `list` matches the CLI; `new` and
|
|
72
|
-
* `assets` would collide with `/vault/new` (the SPA's create-vault route)
|
|
73
|
-
* and `/vault/assets/*` (the SPA's static asset bundle) respectively, so
|
|
74
|
-
* the hub rejects them at the API edge before a vault under those names
|
|
75
|
-
* can register and capture the proxy path.
|
|
76
|
-
*/
|
|
77
77
|
// Lowercase-only (item I) — single source of truth in vault-name.ts. Vault's
|
|
78
78
|
// init enforces `[a-z0-9_-]`; a hub-side `[a-zA-Z0-9_-]` superset let an
|
|
79
79
|
// uppercased name through that vault would then lowercase or reject, drifting
|
|
80
|
-
// the hub's idea of the vault from vault's. The
|
|
81
|
-
// `
|
|
80
|
+
// the hub's idea of the vault from vault's. The reserved set is the ONE
|
|
81
|
+
// consolidated `RESERVED_VAULT_NAMES` from vault-name.ts (B2h) — this file
|
|
82
|
+
// used to carry its own `{list, new, assets}` copy that drifted from the
|
|
83
|
+
// `{list}`-only set gating the wizard + invite redemption.
|
|
82
84
|
const VAULT_NAME_PATTERN = VAULT_NAME_CHARSET_RE;
|
|
83
|
-
const RESERVED_VAULT_NAMES = new Set(["list", "new", "assets"]);
|
|
84
85
|
|
|
85
86
|
export interface CreateVaultRequest {
|
|
86
87
|
name: string;
|
|
@@ -504,3 +505,380 @@ export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Pr
|
|
|
504
505
|
headers: { "content-type": "application/json" },
|
|
505
506
|
});
|
|
506
507
|
}
|
|
508
|
+
|
|
509
|
+
// ===========================================================================
|
|
510
|
+
// DELETE /vaults/<name> — the identity cascade (B1, 2026-06-09
|
|
511
|
+
// hub-module-boundary migration: lifecycle symmetry)
|
|
512
|
+
// ===========================================================================
|
|
513
|
+
//
|
|
514
|
+
// "Every provision flow must have a deprovision flow that cascades the
|
|
515
|
+
// identity artifacts it created." Mechanics deletion without identity
|
|
516
|
+
// cascade is a security hole: before B1, vault deletion was CLI-only
|
|
517
|
+
// (`parachute-vault remove`) and left every hub-side identity artifact
|
|
518
|
+
// naming the vault alive — scoped tokens, grants, user assignments, pinned
|
|
519
|
+
// invites, connections. This handler enumerates the full artifact list and
|
|
520
|
+
// handles every entry.
|
|
521
|
+
//
|
|
522
|
+
// Documented bound: short-lived (≤10-min) unregistered interactive mints
|
|
523
|
+
// ride to expiry by design — the cascade revokes persisted registry rows
|
|
524
|
+
// and publishes the revocation list; it does not claim instant revocation
|
|
525
|
+
// of in-flight interactive JWTs.
|
|
526
|
+
|
|
527
|
+
/** Client id stamped on the short-lived channel-scan mint. */
|
|
528
|
+
const DELETE_VAULT_CLIENT_ID = "parachute-hub-spa";
|
|
529
|
+
/** TTL for the cascade's interactive provisioning mints (channel scan). */
|
|
530
|
+
const DELETE_VAULT_PROVISION_TTL_SECONDS = 60;
|
|
531
|
+
|
|
532
|
+
export interface DeleteVaultDeps {
|
|
533
|
+
db: Database;
|
|
534
|
+
/** Hub origin — JWT `iss` validation + cascade mint issuer. */
|
|
535
|
+
issuer: string;
|
|
536
|
+
/** Override the services.json path. Defaults to `~/.parachute/services.json`. */
|
|
537
|
+
manifestPath?: string;
|
|
538
|
+
/** Absolute path to `connections.json` in the hub state dir. */
|
|
539
|
+
connectionsStorePath: string;
|
|
540
|
+
/** Loopback origin for the channel daemon, or `null` when not installed. */
|
|
541
|
+
channelOrigin: string | null;
|
|
542
|
+
/** Resolve a vault's loopback origin from services.json (trigger teardown). */
|
|
543
|
+
resolveVaultOrigin: (vaultName: string) => string | null;
|
|
544
|
+
/** Test seam: run `parachute-vault remove` — same Runner seam as create. */
|
|
545
|
+
runCommand?: CreateVaultDeps["runCommand"];
|
|
546
|
+
/**
|
|
547
|
+
* Supervisor-restart the vault module — the daemon-eviction cascade step.
|
|
548
|
+
* The running daemon caches open store handles, so rmSync alone leaves the
|
|
549
|
+
* deleted vault SERVING from the open fd; and vault's boot `selfRegister`
|
|
550
|
+
* rebuilds services.json paths from `listVaults()`, dropping the deleted
|
|
551
|
+
* path. Wired to the same supervisor machinery the lifecycle verbs use.
|
|
552
|
+
* Absent (no supervisor — CLI-mode hub, tests) → recorded as a warning in
|
|
553
|
+
* the response, not silently skipped. (The boundary-conformant per-daemon
|
|
554
|
+
* store-eviction endpoint is tracked as E9.)
|
|
555
|
+
*/
|
|
556
|
+
restartVaultModule?: () => Promise<void>;
|
|
557
|
+
/** Test seam — `globalThis.fetch` in production. */
|
|
558
|
+
fetchImpl?: typeof fetch;
|
|
559
|
+
/** Test seam for the clock. */
|
|
560
|
+
now?: () => Date;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
interface DeleteVaultBody {
|
|
564
|
+
confirm?: unknown;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Wire shape of the cascade summary in the 200/500 response. */
|
|
568
|
+
interface CascadeSummary {
|
|
569
|
+
tokens_revoked: number;
|
|
570
|
+
grants_rewritten: number;
|
|
571
|
+
grants_dropped: number;
|
|
572
|
+
user_vaults_removed: number;
|
|
573
|
+
invites_invalidated: number;
|
|
574
|
+
connections_torn_down: number;
|
|
575
|
+
/**
|
|
576
|
+
* Vault-backed channel-daemon entries still referencing the deleted vault
|
|
577
|
+
* AFTER connection teardown (legacy pre-Connections wiring). Surfaced for
|
|
578
|
+
* the operator — the hub does NOT silently delete channel's config; remove
|
|
579
|
+
* them from channel's own UI.
|
|
580
|
+
*/
|
|
581
|
+
orphaned_channels: string[];
|
|
582
|
+
vault_removed: boolean;
|
|
583
|
+
module_restarted: boolean;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function emptyCascadeSummary(): CascadeSummary {
|
|
587
|
+
return {
|
|
588
|
+
tokens_revoked: 0,
|
|
589
|
+
grants_rewritten: 0,
|
|
590
|
+
grants_dropped: 0,
|
|
591
|
+
user_vaults_removed: 0,
|
|
592
|
+
invites_invalidated: 0,
|
|
593
|
+
connections_torn_down: 0,
|
|
594
|
+
orphaned_channels: [],
|
|
595
|
+
vault_removed: false,
|
|
596
|
+
module_restarted: false,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Every vault instance name currently registered in services.json. */
|
|
601
|
+
function listVaultInstanceNames(manifestPath: string): Set<string> {
|
|
602
|
+
const names = new Set<string>();
|
|
603
|
+
let manifest: ReturnType<typeof readManifest>;
|
|
604
|
+
try {
|
|
605
|
+
manifest = readManifestLenient(manifestPath);
|
|
606
|
+
} catch {
|
|
607
|
+
return names;
|
|
608
|
+
}
|
|
609
|
+
for (const svc of manifest.services) {
|
|
610
|
+
if (!isVaultEntry(svc)) continue;
|
|
611
|
+
if (svc.paths.length === 0) {
|
|
612
|
+
names.add(vaultInstanceNameFor(svc.name, undefined));
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
for (const path of svc.paths) names.add(vaultInstanceNameFor(svc.name, path));
|
|
616
|
+
}
|
|
617
|
+
return names;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* `DELETE /vaults/<name>` — destroy a vault with the full identity cascade.
|
|
622
|
+
*
|
|
623
|
+
* Gate: Bearer `parachute:host:admin` (same gate as create). Body MUST carry
|
|
624
|
+
* `{"confirm": "<name>"}` — a deliberate retype-the-name guard against
|
|
625
|
+
* fat-finger disasters (mismatch → 400).
|
|
626
|
+
*
|
|
627
|
+
* Refusals:
|
|
628
|
+
* - unknown vault → 404;
|
|
629
|
+
* - LAST remaining vault → 409. Vault's boot auto-creates `default` at
|
|
630
|
+
* zero vaults, so deleting the last one would silently resurrect a fresh
|
|
631
|
+
* `default` (with a fresh global API key) — refusing sidesteps the
|
|
632
|
+
* resurrection class entirely. The CLI (`parachute-vault remove`) is the
|
|
633
|
+
* escape hatch for an operator who really means it.
|
|
634
|
+
* - RESERVED names are deliberately ALLOWED (no reserved-name gate): a
|
|
635
|
+
* squatted `admin`/`new`/`assets` vault created before the B2h
|
|
636
|
+
* reservation must be removable through this endpoint.
|
|
637
|
+
*
|
|
638
|
+
* Cascade, in order (identity first, mechanics last — revocation is the safe
|
|
639
|
+
* direction if a later step fails):
|
|
640
|
+
* 1. tokens-registry sweep (exact scope-segment match — never SQL LIKE);
|
|
641
|
+
* 2. grants rewrite (drop `vault:<name>:*` entries; drop the row only when
|
|
642
|
+
* it empties — a (user,client) grant can span multiple vaults);
|
|
643
|
+
* 3. `user_vaults` rows;
|
|
644
|
+
* 4. unredeemed invites pinned to the vault (redemption would resurrect
|
|
645
|
+
* the name);
|
|
646
|
+
* 5. connections whose source/provisioned vault is the deleted vault
|
|
647
|
+
* (via `teardownConnection`, which post-B0 also revokes the registered
|
|
648
|
+
* long-lived mints) + a report-only scan of channel's `/api/channels`
|
|
649
|
+
* for legacy vault-backed entries (`orphaned_channels`);
|
|
650
|
+
* 6. mechanics: shell to `parachute-vault remove <name> --yes` (the module
|
|
651
|
+
* CLI stays the single source of truth for vault destruction,
|
|
652
|
+
* mirroring create);
|
|
653
|
+
* 7. daemon eviction: supervisor-restart the vault module (open
|
|
654
|
+
* store-handle eviction + `selfRegister` services.json path rebuild).
|
|
655
|
+
*
|
|
656
|
+
* Response: 200 with a structured per-step summary (counts +
|
|
657
|
+
* `orphaned_channels` + warnings). A mechanics failure responds 500 with
|
|
658
|
+
* the partial summary — the identity artifacts already revoked stay revoked.
|
|
659
|
+
*/
|
|
660
|
+
export async function handleDeleteVault(
|
|
661
|
+
req: Request,
|
|
662
|
+
rawName: string,
|
|
663
|
+
deps: DeleteVaultDeps,
|
|
664
|
+
): Promise<Response> {
|
|
665
|
+
if (req.method !== "DELETE") {
|
|
666
|
+
return new Response("method not allowed", { status: 405 });
|
|
667
|
+
}
|
|
668
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
669
|
+
const runCommand = deps.runCommand ?? defaultRunCommand;
|
|
670
|
+
const now = deps.now?.() ?? new Date();
|
|
671
|
+
|
|
672
|
+
// Auth gate: parachute:host:admin — the same gate as POST /vaults.
|
|
673
|
+
let adminSub: string;
|
|
674
|
+
try {
|
|
675
|
+
const auth = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
676
|
+
adminSub = auth.sub;
|
|
677
|
+
} catch (err) {
|
|
678
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Name shape guard. NOTE: no reserved-name check — see docstring.
|
|
682
|
+
const name = rawName.trim();
|
|
683
|
+
if (!VAULT_NAME_PATTERN.test(name)) {
|
|
684
|
+
return jsonError(
|
|
685
|
+
400,
|
|
686
|
+
"invalid_request",
|
|
687
|
+
"vault name must contain only lowercase letters, numbers, hyphens, and underscores",
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Confirm body — the retype-the-name guard.
|
|
692
|
+
let body: DeleteVaultBody;
|
|
693
|
+
try {
|
|
694
|
+
body = (await req.json()) as DeleteVaultBody;
|
|
695
|
+
} catch {
|
|
696
|
+
return jsonError(400, "confirm_mismatch", `body must be JSON: {"confirm": "${name}"}`);
|
|
697
|
+
}
|
|
698
|
+
if (!body || typeof body !== "object" || body.confirm !== name) {
|
|
699
|
+
return jsonError(
|
|
700
|
+
400,
|
|
701
|
+
"confirm_mismatch",
|
|
702
|
+
`deleting a vault requires the body {"confirm": "${name}"} (retype the vault name)`,
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Existence.
|
|
707
|
+
const existing = findExistingVault(manifestPath, name);
|
|
708
|
+
if (!existing) {
|
|
709
|
+
return jsonError(404, "not_found", `no vault named "${name}" on this hub`);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Last-vault refusal (resurrection guard).
|
|
713
|
+
const instanceNames = listVaultInstanceNames(manifestPath);
|
|
714
|
+
instanceNames.delete(name);
|
|
715
|
+
if (instanceNames.size === 0) {
|
|
716
|
+
return jsonError(
|
|
717
|
+
409,
|
|
718
|
+
"last_vault",
|
|
719
|
+
`"${name}" is the last vault on this hub. Vault's boot auto-creates "default" at zero vaults, so deleting the last one would silently resurrect it with fresh credentials. Create another vault first, or use the CLI (parachute-vault remove ${name} --yes) if you really mean to empty the hub.`,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const summary = emptyCascadeSummary();
|
|
724
|
+
const warnings: { step: string; detail: string }[] = [];
|
|
725
|
+
|
|
726
|
+
// --- 1. Registry sweep: revoke every tokens row naming the vault. --------
|
|
727
|
+
summary.tokens_revoked = revokeTokensNamingVault(deps.db, name, now);
|
|
728
|
+
|
|
729
|
+
// NOTE on `auth_codes.scopes` — the one identity-artifact column from the
|
|
730
|
+
// charter's enumeration deliberately NOT swept here. Authorization codes
|
|
731
|
+
// are transient by construction: AUTH_CODE_TTL_SECONDS = 60 (auth-codes.ts)
|
|
732
|
+
// and single-use. A code naming the deleted vault either (a) expires
|
|
733
|
+
// unredeemed within the minute, or (b) redeems into tokens whose registry
|
|
734
|
+
// rows the sweep above already governs — and whose requests the evicted
|
|
735
|
+
// daemon no longer serves. Sweeping a 60-second-lived table adds a step
|
|
736
|
+
// with no security delta; same class as the documented ≤10-min
|
|
737
|
+
// unregistered interactive-mint bound.
|
|
738
|
+
|
|
739
|
+
// --- 2. Grants rewrite (drop rows only when emptied). ---------------------
|
|
740
|
+
const grants = rewriteGrantsRemovingVault(deps.db, name);
|
|
741
|
+
summary.grants_rewritten = grants.rewritten;
|
|
742
|
+
summary.grants_dropped = grants.dropped;
|
|
743
|
+
|
|
744
|
+
// --- 3. user_vaults assignments. ------------------------------------------
|
|
745
|
+
summary.user_vaults_removed = removeVaultAssignments(deps.db, name);
|
|
746
|
+
|
|
747
|
+
// --- 4. Unredeemed invites pinned to the vault. ----------------------------
|
|
748
|
+
summary.invites_invalidated = revokeInvitesForVault(deps.db, name, now);
|
|
749
|
+
|
|
750
|
+
// --- 5. Connections teardown (+ legacy channel scan, report-only). --------
|
|
751
|
+
// Runs BEFORE the CLI remove so the vault daemon is still alive to accept
|
|
752
|
+
// the trigger-deregister calls.
|
|
753
|
+
const connectionsDeps: ConnectionsDeps = {
|
|
754
|
+
db: deps.db,
|
|
755
|
+
hubOrigin: deps.issuer,
|
|
756
|
+
modules: [], // teardown never consults the catalog
|
|
757
|
+
resolveVaultOrigin: deps.resolveVaultOrigin,
|
|
758
|
+
channelOrigin: deps.channelOrigin,
|
|
759
|
+
storePath: deps.connectionsStorePath,
|
|
760
|
+
...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
|
|
761
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
762
|
+
};
|
|
763
|
+
const records = readConnections(deps.connectionsStorePath).filter(
|
|
764
|
+
(r) => r.source.vault === name || r.provisioned?.vault === name,
|
|
765
|
+
);
|
|
766
|
+
for (const record of records) {
|
|
767
|
+
try {
|
|
768
|
+
const res = await teardownConnection(record.id, adminSub, connectionsDeps);
|
|
769
|
+
if (res.status === 200) {
|
|
770
|
+
summary.connections_torn_down++;
|
|
771
|
+
} else {
|
|
772
|
+
// 207 partial — the record is removed + mints revoked; remote steps
|
|
773
|
+
// failed. Surface as warnings, count as torn down (the identity side
|
|
774
|
+
// is done; the operator can clean the remote residue).
|
|
775
|
+
summary.connections_torn_down++;
|
|
776
|
+
const out = (await res.json()) as { errors?: { step: string; detail: string }[] };
|
|
777
|
+
for (const e of out.errors ?? []) {
|
|
778
|
+
warnings.push({ step: `connection_${record.id}_${e.step}`, detail: e.detail });
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
} catch (err) {
|
|
782
|
+
warnings.push({
|
|
783
|
+
step: `connection_${record.id}`,
|
|
784
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Legacy channel scan — vault-backed channel entries still referencing the
|
|
790
|
+
// vault after connection teardown (pre-Connections wiring). REPORT ONLY:
|
|
791
|
+
// channel's config is the channel module's domain; the operator removes
|
|
792
|
+
// them from channel's own UI.
|
|
793
|
+
if (deps.channelOrigin !== null) {
|
|
794
|
+
try {
|
|
795
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
796
|
+
// Short-lived (60s) — stays below the registered-mint threshold by
|
|
797
|
+
// design (the documented ≤10-min interactive bound; see B0's
|
|
798
|
+
// REGISTERED_MINT_TTL_THRESHOLD_SECONDS in admin-connections.ts).
|
|
799
|
+
const scanToken = (
|
|
800
|
+
await signAccessToken(deps.db, {
|
|
801
|
+
sub: adminSub,
|
|
802
|
+
scopes: ["channel:admin"],
|
|
803
|
+
audience: "channel",
|
|
804
|
+
clientId: DELETE_VAULT_CLIENT_ID,
|
|
805
|
+
issuer: deps.issuer,
|
|
806
|
+
ttlSeconds: DELETE_VAULT_PROVISION_TTL_SECONDS,
|
|
807
|
+
vaultScope: [],
|
|
808
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
809
|
+
})
|
|
810
|
+
).token;
|
|
811
|
+
const res = await fetchImpl(`${deps.channelOrigin}/api/channels`, {
|
|
812
|
+
headers: { authorization: `Bearer ${scanToken}`, accept: "application/json" },
|
|
813
|
+
});
|
|
814
|
+
if (res.ok) {
|
|
815
|
+
const listed = (await res.json()) as { channels?: unknown };
|
|
816
|
+
const rawList = Array.isArray(listed?.channels) ? listed.channels : [];
|
|
817
|
+
for (const c of rawList) {
|
|
818
|
+
const row = (c ?? {}) as Record<string, unknown>;
|
|
819
|
+
if (row.transport === "vault" && row.vault === name && typeof row.name === "string") {
|
|
820
|
+
summary.orphaned_channels.push(row.name);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
} else {
|
|
824
|
+
warnings.push({ step: "channel_scan", detail: `channel list returned ${res.status}` });
|
|
825
|
+
}
|
|
826
|
+
} catch (err) {
|
|
827
|
+
warnings.push({
|
|
828
|
+
step: "channel_scan",
|
|
829
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// --- 6. Mechanics: the module CLI is the source of truth for destruction. -
|
|
835
|
+
try {
|
|
836
|
+
const result = await runCommand(["parachute-vault", "remove", name, "--yes"]);
|
|
837
|
+
if (result.exitCode !== 0) {
|
|
838
|
+
const stderrTail = result.stderr.trim();
|
|
839
|
+
const tailSuffix = stderrTail ? `: ${stderrTail.slice(-500)}` : "";
|
|
840
|
+
return jsonError(
|
|
841
|
+
500,
|
|
842
|
+
"server_error",
|
|
843
|
+
`parachute-vault remove ${name} --yes exited with code ${result.exitCode}${tailSuffix} — identity artifacts already revoked (summary: ${JSON.stringify(summary)})`,
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
summary.vault_removed = true;
|
|
847
|
+
} catch (err) {
|
|
848
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
849
|
+
return jsonError(
|
|
850
|
+
500,
|
|
851
|
+
"server_error",
|
|
852
|
+
`orchestration failed: ${msg} — identity artifacts already revoked (summary: ${JSON.stringify(summary)})`,
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// --- 7. Daemon eviction: supervisor-restart the vault module. -------------
|
|
857
|
+
if (deps.restartVaultModule) {
|
|
858
|
+
try {
|
|
859
|
+
await deps.restartVaultModule();
|
|
860
|
+
summary.module_restarted = true;
|
|
861
|
+
} catch (err) {
|
|
862
|
+
warnings.push({
|
|
863
|
+
step: "module_restart",
|
|
864
|
+
detail: `vault module restart failed: ${err instanceof Error ? err.message : String(err)} — the running daemon may still serve the deleted vault from its open store handle until restarted (parachute restart vault)`,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
} else {
|
|
868
|
+
warnings.push({
|
|
869
|
+
step: "module_restart",
|
|
870
|
+
detail:
|
|
871
|
+
"no supervisor available — restart the vault module (parachute restart vault) to evict the deleted vault's open store handle and refresh services.json",
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return new Response(
|
|
876
|
+
JSON.stringify({
|
|
877
|
+
ok: true,
|
|
878
|
+
name,
|
|
879
|
+
cascade: summary,
|
|
880
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
881
|
+
}),
|
|
882
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
883
|
+
);
|
|
884
|
+
}
|
package/src/api-hub-upgrade.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
*
|
|
5
5
|
* ── WHY A DEDICATED ENDPOINT (not /api/modules/hub/*) ──────────────────────
|
|
6
6
|
*
|
|
7
|
-
* The hub is NOT a supervised module — `
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
7
|
+
* The hub is NOT a supervised module — `parseModulesPath` rejects `hub`
|
|
8
|
+
* (`isKnownModuleShort("hub")` is false: hub isn't in KNOWN_MODULES /
|
|
9
|
+
* FIRST_PARTY_FALLBACKS), so `parseModulesPath("/api/modules/hub/upgrade")`
|
|
10
|
+
* returns undefined and the module-ops switch never reaches a hub case. The hub needs its OWN endpoint
|
|
10
11
|
* because the constraint is unique: the hub can't restart itself synchronously
|
|
11
12
|
* (the request dies with the old process before it can report success). So:
|
|
12
13
|
*
|