@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -11
- package/src/__tests__/admin-auth.test.ts +128 -0
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-lock.test.ts +7 -1
- package/src/__tests__/admin-vaults.test.ts +216 -10
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/api-mint-token.test.ts +75 -0
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +326 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/init.test.ts +153 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +626 -0
- package/src/__tests__/oauth-ui.test.ts +107 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-agent-grants.ts +16 -1
- package/src/admin-auth.ts +13 -4
- package/src/admin-clients.ts +66 -5
- package/src/admin-grants.ts +11 -2
- package/src/admin-vaults.ts +77 -27
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +52 -4
- package/src/api-hub.ts +10 -1
- package/src/api-invites.ts +18 -3
- package/src/api-me.ts +11 -2
- package/src/api-mint-token.ts +16 -1
- package/src/api-modules.ts +119 -1
- package/src/api-revoke-token.ts +14 -1
- package/src/api-settings-hub-origin.ts +14 -1
- package/src/api-settings-root-redirect.ts +201 -0
- package/src/api-tokens.ts +14 -1
- package/src/api-users.ts +15 -6
- package/src/api-vault-caps.ts +11 -2
- package/src/cli.ts +56 -5
- package/src/clients.ts +178 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/init.ts +108 -0
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +65 -1
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +173 -25
- package/src/hub-settings.ts +163 -1
- package/src/jwt-sign.ts +25 -6
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +110 -7
- package/src/oauth-ui.ts +174 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
|
@@ -131,6 +131,16 @@ export interface AgentGrantsDeps {
|
|
|
131
131
|
* (`<hubOrigin>/oauth/agent-grant/callback`).
|
|
132
132
|
*/
|
|
133
133
|
hubOrigin: string;
|
|
134
|
+
/**
|
|
135
|
+
* SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
|
|
136
|
+
* per-request issuer), built via `buildHubBoundOrigins`. The module's
|
|
137
|
+
* host-admin bearer `iss` is validated against THIS set rather than the
|
|
138
|
+
* single `hubOrigin`, so the agent module's credential minted under a
|
|
139
|
+
* still-valid prior origin keeps working across an origin switch (hub#516
|
|
140
|
+
* parity). Minted tokens still carry `hubOrigin`. Absent → falls back to
|
|
141
|
+
* `[hubOrigin]` (the prior strict per-request behavior).
|
|
142
|
+
*/
|
|
143
|
+
knownIssuers?: readonly string[];
|
|
134
144
|
/** Absolute path to `agent-grants.json` in the hub state dir. */
|
|
135
145
|
storePath: string;
|
|
136
146
|
/** Absolute path to `agent-oauth-flows.json` (the in-flight OAuth consents, 4b-2). */
|
|
@@ -249,7 +259,12 @@ async function requireModuleAuth(
|
|
|
249
259
|
deps: AgentGrantsDeps,
|
|
250
260
|
): Promise<AdminAuthContext | Response> {
|
|
251
261
|
try {
|
|
252
|
-
return await requireScope(
|
|
262
|
+
return await requireScope(
|
|
263
|
+
deps.db,
|
|
264
|
+
req,
|
|
265
|
+
HOST_ADMIN_SCOPE,
|
|
266
|
+
deps.knownIssuers ?? [deps.hubOrigin],
|
|
267
|
+
);
|
|
253
268
|
} catch (err) {
|
|
254
269
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
255
270
|
}
|
package/src/admin-auth.ts
CHANGED
|
@@ -59,15 +59,24 @@ export function extractBearerToken(req: Request): string {
|
|
|
59
59
|
* and check it carries `requiredScope`. Returns surfaced claims on success;
|
|
60
60
|
* throws `AdminAuthError` (401 or 403) otherwise.
|
|
61
61
|
*
|
|
62
|
-
* `expectedIssuer`
|
|
63
|
-
* tokens we sign.
|
|
64
|
-
*
|
|
62
|
+
* `expectedIssuer` is the hub's own origin(s) — the same value(s) baked into
|
|
63
|
+
* tokens we sign. Pass a single string for a single-origin hub, or the SET of
|
|
64
|
+
* origins the hub legitimately answers on (`buildHubBoundOrigins`: loopback ∪
|
|
65
|
+
* expose-state ∪ platform ∪ per-request issuer) so a credential minted under
|
|
66
|
+
* a still-valid prior origin keeps validating across an origin switch — the
|
|
67
|
+
* same multi-origin posture the OAuth path and `validateHostAdminToken`
|
|
68
|
+
* already use. Defense in depth: even though we can only verify our own keys,
|
|
69
|
+
* the `iss`-∈-set reject keeps cross-issuer confusion impossible. SECURITY:
|
|
70
|
+
* the set is ONLY an additive `iss` membership relaxation — `validateAccessToken`
|
|
71
|
+
* verifies the signature against the hub's own key FIRST, so only tokens this
|
|
72
|
+
* hub minted ever reach the `iss` check; never pass a raw request Host, only a
|
|
73
|
+
* `buildHubBoundOrigins`-derived set.
|
|
65
74
|
*/
|
|
66
75
|
export async function requireScope(
|
|
67
76
|
db: Database,
|
|
68
77
|
req: Request,
|
|
69
78
|
requiredScope: string,
|
|
70
|
-
expectedIssuer: string,
|
|
79
|
+
expectedIssuer: string | readonly string[],
|
|
71
80
|
): Promise<AdminAuthContext> {
|
|
72
81
|
const token = extractBearerToken(req);
|
|
73
82
|
|
package/src/admin-clients.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* without round-tripping through the `/oauth/authorize` flow (whose
|
|
5
5
|
* `POST /oauth/authorize/approve` requires a `return_to` authorize URL).
|
|
6
6
|
*
|
|
7
|
-
* GET
|
|
8
|
-
* POST
|
|
7
|
+
* GET /api/oauth/clients/<client_id> client details
|
|
8
|
+
* POST /api/oauth/clients/<client_id>/approve flip status to approved
|
|
9
|
+
* DELETE /oauth/clients/<client_id> deregister (RFC 7592) — note
|
|
10
|
+
* the TOP-LEVEL prefix, see
|
|
11
|
+
* handleDeleteClient
|
|
9
12
|
*
|
|
10
13
|
* Both gated by `parachute:host:admin` Bearer (same shape as /api/grants,
|
|
11
14
|
* /api/auth/tokens, etc.). The SPA mints one via the session cookie at
|
|
@@ -54,13 +57,22 @@ import {
|
|
|
54
57
|
requireScope,
|
|
55
58
|
} from "./admin-auth.ts";
|
|
56
59
|
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
57
|
-
import { approveClient, getClient } from "./clients.ts";
|
|
60
|
+
import { approveClient, deleteClient, getClient } from "./clients.ts";
|
|
58
61
|
import { isSafeAuthorizeReturnTo } from "./oauth-handlers.ts";
|
|
59
62
|
|
|
60
63
|
export interface AdminClientsDeps {
|
|
61
64
|
db: Database;
|
|
62
65
|
/** Hub origin — passed through to JWT validation as the expected `iss`. */
|
|
63
66
|
issuer: string;
|
|
67
|
+
/**
|
|
68
|
+
* SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
|
|
69
|
+
* per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
|
|
70
|
+
* `iss` is validated against THIS set rather than the single `issuer`, so a
|
|
71
|
+
* credential minted under a still-valid prior origin keeps working across an
|
|
72
|
+
* origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
|
|
73
|
+
* prior strict per-request behavior; tests/non-HTTP callers unaffected).
|
|
74
|
+
*/
|
|
75
|
+
knownIssuers?: readonly string[];
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
export interface AdminClientView {
|
|
@@ -90,7 +102,7 @@ export async function handleGetClient(
|
|
|
90
102
|
return jsonError(405, "method_not_allowed", "use GET");
|
|
91
103
|
}
|
|
92
104
|
try {
|
|
93
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
105
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
94
106
|
} catch (err) {
|
|
95
107
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
96
108
|
}
|
|
@@ -126,7 +138,7 @@ export async function handleApproveClient(
|
|
|
126
138
|
}
|
|
127
139
|
let ctx: AdminAuthContext;
|
|
128
140
|
try {
|
|
129
|
-
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
141
|
+
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
130
142
|
} catch (err) {
|
|
131
143
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
132
144
|
}
|
|
@@ -177,6 +189,55 @@ export async function handleApproveClient(
|
|
|
177
189
|
});
|
|
178
190
|
}
|
|
179
191
|
|
|
192
|
+
/**
|
|
193
|
+
* RFC 7592 Dynamic Client Registration *deletion* (deregistration).
|
|
194
|
+
*
|
|
195
|
+
* DELETE /oauth/clients/<client_id> remove the client + its cascade
|
|
196
|
+
*
|
|
197
|
+
* Mounted at the TOP-LEVEL `/oauth/clients/` prefix (NOT under `/api/...`)
|
|
198
|
+
* because that's the path parachute-surface's remove-flow actually calls
|
|
199
|
+
* (`packages/surface-host/src/dcr.ts` → `DELETE <hub>/oauth/clients/<id>`),
|
|
200
|
+
* carrying the operator token as a Bearer. Before this route existed the
|
|
201
|
+
* hub 404'd every such DELETE, so every Notes/Claude reconnect orphaned a
|
|
202
|
+
* `clients` row in the operator's DB (closes hub#640, 4/5 boxes — the GC
|
|
203
|
+
* reaper for legacy orphans is a separate follow-up).
|
|
204
|
+
*
|
|
205
|
+
* Auth mirrors `handleGetClient`: `parachute:host:admin` Bearer via
|
|
206
|
+
* `requireScope`. Returns 204 (no content) on a successful delete, 404 when
|
|
207
|
+
* the client isn't registered — the same shape the surface already tolerates
|
|
208
|
+
* (`hubDeleteStatus: "ok"` on 200/204, `"not_found"` on a JSON 404).
|
|
209
|
+
*
|
|
210
|
+
* Audit: emits a `client deleted: ...` line in the same `key=value` shape as
|
|
211
|
+
* the `client approved: ...` line, so cross-machine "who removed this client"
|
|
212
|
+
* is greppable in hub.log.
|
|
213
|
+
*/
|
|
214
|
+
export async function handleDeleteClient(
|
|
215
|
+
req: Request,
|
|
216
|
+
clientId: string,
|
|
217
|
+
deps: AdminClientsDeps,
|
|
218
|
+
): Promise<Response> {
|
|
219
|
+
if (req.method !== "DELETE") {
|
|
220
|
+
return jsonError(405, "method_not_allowed", "use DELETE");
|
|
221
|
+
}
|
|
222
|
+
let ctx: AdminAuthContext;
|
|
223
|
+
try {
|
|
224
|
+
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
227
|
+
}
|
|
228
|
+
// Capture the name BEFORE deleting so the audit line can carry it.
|
|
229
|
+
const before = getClient(deps.db, clientId);
|
|
230
|
+
const removed = deleteClient(deps.db, clientId);
|
|
231
|
+
if (!removed) {
|
|
232
|
+
return jsonError(404, "not_found", `no client registered with id ${clientId}`);
|
|
233
|
+
}
|
|
234
|
+
console.log(
|
|
235
|
+
`client deleted: client_id=${clientId} client_name=${before?.clientName ?? ""} remover_sub=${ctx.sub}`,
|
|
236
|
+
);
|
|
237
|
+
// 204 No Content — RFC 7592 §2.3 prescribes 204 for a successful delete.
|
|
238
|
+
return new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
|
|
239
|
+
}
|
|
240
|
+
|
|
180
241
|
interface ApproveClientResponse {
|
|
181
242
|
client_id: string;
|
|
182
243
|
status: "approved";
|
package/src/admin-grants.ts
CHANGED
|
@@ -38,6 +38,15 @@ export interface AdminGrantsDeps {
|
|
|
38
38
|
db: Database;
|
|
39
39
|
/** Hub origin — passed through to JWT validation as the expected `iss`. */
|
|
40
40
|
issuer: string;
|
|
41
|
+
/**
|
|
42
|
+
* SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
|
|
43
|
+
* per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
|
|
44
|
+
* `iss` is validated against THIS set rather than the single `issuer`, so a
|
|
45
|
+
* credential minted under a still-valid prior origin keeps working across an
|
|
46
|
+
* origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
|
|
47
|
+
* prior strict per-request behavior; tests/non-HTTP callers unaffected).
|
|
48
|
+
*/
|
|
49
|
+
knownIssuers?: readonly string[];
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
export interface AdminGrantListing {
|
|
@@ -55,7 +64,7 @@ export async function handleListGrants(req: Request, deps: AdminGrantsDeps): Pro
|
|
|
55
64
|
}
|
|
56
65
|
let ctx: AdminAuthContext;
|
|
57
66
|
try {
|
|
58
|
-
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
67
|
+
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
59
68
|
} catch (err) {
|
|
60
69
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
61
70
|
}
|
|
@@ -111,7 +120,7 @@ export async function handleRevokeGrant(
|
|
|
111
120
|
}
|
|
112
121
|
let ctx: AdminAuthContext;
|
|
113
122
|
try {
|
|
114
|
-
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
123
|
+
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
115
124
|
} catch (err) {
|
|
116
125
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
117
126
|
}
|
package/src/admin-vaults.ts
CHANGED
|
@@ -129,6 +129,15 @@ export interface CreateVaultDeps {
|
|
|
129
129
|
db: Database;
|
|
130
130
|
/** Hub origin used to validate JWT `iss` and to build the response `url`. */
|
|
131
131
|
issuer: string;
|
|
132
|
+
/**
|
|
133
|
+
* SET of origins the hub legitimately answers on (loopback ∪ expose-state ∪
|
|
134
|
+
* platform ∪ per-request `issuer`), built via `buildHubBoundOrigins`. The
|
|
135
|
+
* admin bearer's `iss` is validated against THIS set rather than the single
|
|
136
|
+
* `issuer`, so a host-admin credential minted under a still-valid prior
|
|
137
|
+
* origin keeps working across an origin switch (hub#516 parity). Absent →
|
|
138
|
+
* falls back to `[issuer]` (the prior strict per-request behavior).
|
|
139
|
+
*/
|
|
140
|
+
knownIssuers?: readonly string[];
|
|
132
141
|
/** Override the services.json path. Defaults to `~/.parachute/services.json`. */
|
|
133
142
|
manifestPath?: string;
|
|
134
143
|
/**
|
|
@@ -207,15 +216,11 @@ function findExistingVault(
|
|
|
207
216
|
} catch {
|
|
208
217
|
return null;
|
|
209
218
|
}
|
|
210
|
-
const target = `/vault/${name}`;
|
|
211
219
|
for (const svc of manifest.services) {
|
|
212
220
|
if (!isVaultEntry(svc)) continue;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
221
|
+
// #478: an empty-paths vault row means "installed but no servable vault
|
|
222
|
+
// instance" — skip it entirely so it never resolves to a phantom "default".
|
|
223
|
+
if (svc.paths.length === 0) continue;
|
|
219
224
|
for (const path of svc.paths) {
|
|
220
225
|
if (vaultInstanceNameFor(svc.name, path) === name) {
|
|
221
226
|
return { url: path, version: svc.version, path };
|
|
@@ -446,7 +451,7 @@ export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Pr
|
|
|
446
451
|
// Auth gate: parachute:host:admin scope. Maps an AdminAuthError straight
|
|
447
452
|
// to an RFC 6750 401/403 — the route handler doesn't care which.
|
|
448
453
|
try {
|
|
449
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
454
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
450
455
|
} catch (err) {
|
|
451
456
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
452
457
|
}
|
|
@@ -534,6 +539,15 @@ export interface DeleteVaultDeps {
|
|
|
534
539
|
db: Database;
|
|
535
540
|
/** Hub origin — JWT `iss` validation + cascade mint issuer. */
|
|
536
541
|
issuer: string;
|
|
542
|
+
/**
|
|
543
|
+
* SET of origins the hub legitimately answers on (loopback ∪ expose-state ∪
|
|
544
|
+
* platform ∪ per-request `issuer`), built via `buildHubBoundOrigins`. The
|
|
545
|
+
* admin bearer's `iss` is validated against THIS set rather than the single
|
|
546
|
+
* `issuer`, so a host-admin credential minted under a still-valid prior
|
|
547
|
+
* origin keeps working across an origin switch (hub#516 parity). Absent →
|
|
548
|
+
* falls back to `[issuer]` (the prior strict per-request behavior).
|
|
549
|
+
*/
|
|
550
|
+
knownIssuers?: readonly string[];
|
|
537
551
|
/** Override the services.json path. Defaults to `~/.parachute/services.json`. */
|
|
538
552
|
manifestPath?: string;
|
|
539
553
|
/** Absolute path to `connections.json` in the hub state dir. */
|
|
@@ -607,7 +621,7 @@ function emptyCascadeSummary(): CascadeSummary {
|
|
|
607
621
|
}
|
|
608
622
|
|
|
609
623
|
/** Every vault instance name currently registered in services.json. */
|
|
610
|
-
function listVaultInstanceNames(manifestPath: string): Set<string> {
|
|
624
|
+
export function listVaultInstanceNames(manifestPath: string): Set<string> {
|
|
611
625
|
const names = new Set<string>();
|
|
612
626
|
let manifest: ReturnType<typeof readManifest>;
|
|
613
627
|
try {
|
|
@@ -617,10 +631,9 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
|
|
|
617
631
|
}
|
|
618
632
|
for (const svc of manifest.services) {
|
|
619
633
|
if (!isVaultEntry(svc)) continue;
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
}
|
|
634
|
+
// #478: an empty-paths vault row means "installed but no servable vault
|
|
635
|
+
// instance" — skip it so no phantom "default" is synthesized.
|
|
636
|
+
if (svc.paths.length === 0) continue;
|
|
624
637
|
for (const path of svc.paths) names.add(vaultInstanceNameFor(svc.name, path));
|
|
625
638
|
}
|
|
626
639
|
return names;
|
|
@@ -635,15 +648,22 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
|
|
|
635
648
|
*
|
|
636
649
|
* Refusals:
|
|
637
650
|
* - unknown vault → 404;
|
|
638
|
-
* - LAST remaining vault → 409. Vault's boot auto-creates `default` at
|
|
639
|
-
* zero vaults, so deleting the last one would silently resurrect a fresh
|
|
640
|
-
* `default` (with a fresh global API key) — refusing sidesteps the
|
|
641
|
-
* resurrection class entirely. The CLI (`parachute-vault remove`) is the
|
|
642
|
-
* escape hatch for an operator who really means it.
|
|
643
651
|
* - RESERVED names are deliberately ALLOWED (no reserved-name gate): a
|
|
644
652
|
* squatted `admin`/`new`/`assets` vault created before the B2h
|
|
645
653
|
* reservation must be removable through this endpoint.
|
|
646
654
|
*
|
|
655
|
+
* Last-vault handling (#678): deleting the LAST remaining vault runs the SAME
|
|
656
|
+
* cascade-then-delete as any other vault — it is NOT refused. The old 409 that
|
|
657
|
+
* steered the operator to the raw `parachute-vault remove` CLI was a
|
|
658
|
+
* correctness defect: that escape hatch SKIPS this cascade, orphaning tokens +
|
|
659
|
+
* grants that named the last vault. The resurrection risk the refusal once
|
|
660
|
+
* guarded (vault boot auto-creating a fresh-credentialed first vault at zero
|
|
661
|
+
* vaults) is handled downstream instead: the vault CLI writes an
|
|
662
|
+
* `auto_create: false` marker on last-vault removal and the vault boot gate
|
|
663
|
+
* honors it, so the server won't silently resurrect. Detection stays
|
|
664
|
+
* count-based + name-agnostic (no `name === "default"` special case); the last
|
|
665
|
+
* vault just adds a `last_vault` warning to the 200 response.
|
|
666
|
+
*
|
|
647
667
|
* Cascade, in order (identity first, mechanics last — revocation is the safe
|
|
648
668
|
* direction if a later step fails):
|
|
649
669
|
* 1. tokens-registry sweep (exact scope-segment match — never SQL LIKE);
|
|
@@ -666,6 +686,15 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
|
|
|
666
686
|
* Response: 200 with a structured per-step summary (counts +
|
|
667
687
|
* `orphaned_channels` + warnings). A mechanics failure responds 500 with
|
|
668
688
|
* the partial summary — the identity artifacts already revoked stay revoked.
|
|
689
|
+
*
|
|
690
|
+
* Bounded residual: the cascade revokes every *registered* token row naming
|
|
691
|
+
* the vault, but an UNREGISTERED interactive-mint (a host-admin browser
|
|
692
|
+
* session minting a short-lived vault token at ≤10-min TTL — see
|
|
693
|
+
* REGISTERED_MINT_TTL_THRESHOLD_SECONDS in admin-connections.ts) that was
|
|
694
|
+
* issued just before the delete leaves no registry row to sweep. Such a token
|
|
695
|
+
* stays valid for at most its remaining ≤10-min TTL, against a vault whose
|
|
696
|
+
* daemon is evicted in step 7 anyway. Same bound the auth-codes note below
|
|
697
|
+
* relies on; not eliminated, just bounded.
|
|
669
698
|
*/
|
|
670
699
|
export async function handleDeleteVault(
|
|
671
700
|
req: Request,
|
|
@@ -682,7 +711,12 @@ export async function handleDeleteVault(
|
|
|
682
711
|
// Auth gate: parachute:host:admin — the same gate as POST /vaults.
|
|
683
712
|
let adminSub: string;
|
|
684
713
|
try {
|
|
685
|
-
const auth = await requireScope(
|
|
714
|
+
const auth = await requireScope(
|
|
715
|
+
deps.db,
|
|
716
|
+
req,
|
|
717
|
+
HOST_ADMIN_SCOPE,
|
|
718
|
+
deps.knownIssuers ?? [deps.issuer],
|
|
719
|
+
);
|
|
686
720
|
adminSub = auth.sub;
|
|
687
721
|
} catch (err) {
|
|
688
722
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
@@ -719,16 +753,20 @@ export async function handleDeleteVault(
|
|
|
719
753
|
return jsonError(404, "not_found", `no vault named "${name}" on this hub`);
|
|
720
754
|
}
|
|
721
755
|
|
|
722
|
-
// Last-vault
|
|
756
|
+
// Last-vault detection (count-based, name-agnostic). Deleting the LAST
|
|
757
|
+
// vault used to refuse with 409 and steer the operator to the raw
|
|
758
|
+
// `parachute-vault remove` CLI — but that path SKIPS this whole identity
|
|
759
|
+
// cascade, orphaning tokens + grants that named the vault. The resurrection
|
|
760
|
+
// risk the refusal guarded against is already handled downstream: the vault
|
|
761
|
+
// CLI writes an `auto_create: false` marker when it removes the last vault
|
|
762
|
+
// (vault `cli.ts` cmdRemove) and the vault boot gate honors it
|
|
763
|
+
// (`bootAutoCreateAllowed` in vault `config.ts`), so the server won't
|
|
764
|
+
// silently resurrect a fresh-credentialed first vault. We therefore run the
|
|
765
|
+
// cascade-then-delete for the last vault exactly as for any other — the
|
|
766
|
+
// count is informational only (no refusal).
|
|
723
767
|
const instanceNames = listVaultInstanceNames(manifestPath);
|
|
724
768
|
instanceNames.delete(name);
|
|
725
|
-
|
|
726
|
-
return jsonError(
|
|
727
|
-
409,
|
|
728
|
-
"last_vault",
|
|
729
|
-
`"${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.`,
|
|
730
|
-
);
|
|
731
|
-
}
|
|
769
|
+
const isLastVault = instanceNames.size === 0;
|
|
732
770
|
|
|
733
771
|
const summary = emptyCascadeSummary();
|
|
734
772
|
const warnings: { step: string; detail: string }[] = [];
|
|
@@ -870,6 +908,18 @@ export async function handleDeleteVault(
|
|
|
870
908
|
);
|
|
871
909
|
}
|
|
872
910
|
|
|
911
|
+
// Last-vault heads-up. The vault CLI's remove wrote `auto_create: false`, so
|
|
912
|
+
// the next vault boot won't resurrect a fresh-credentialed first vault — the
|
|
913
|
+
// hub is now deliberately empty. Surface that so the operator knows to create
|
|
914
|
+
// one when they want the hub serving again.
|
|
915
|
+
if (isLastVault) {
|
|
916
|
+
warnings.push({
|
|
917
|
+
step: "last_vault",
|
|
918
|
+
detail:
|
|
919
|
+
"the deleted vault was the last one on this hub — no vaults remain. The vault CLI wrote auto_create: false, so boot won't recreate a default vault. Create one with: parachute-vault create <name>",
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
873
923
|
// --- 7. Daemon eviction: supervisor-restart the vault module. -------------
|
|
874
924
|
if (deps.restartVaultModule) {
|
|
875
925
|
try {
|