@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.7.4-rc.
|
|
3
|
+
"version": "0.7.4-rc.21",
|
|
4
4
|
"description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"publishConfig": {
|
|
@@ -11,15 +11,8 @@
|
|
|
11
11
|
"bin": {
|
|
12
12
|
"parachute": "src/cli.ts"
|
|
13
13
|
},
|
|
14
|
-
"workspaces": [
|
|
15
|
-
|
|
16
|
-
],
|
|
17
|
-
"files": [
|
|
18
|
-
"src",
|
|
19
|
-
"web/ui/dist",
|
|
20
|
-
"README.md",
|
|
21
|
-
"LICENSE"
|
|
22
|
-
],
|
|
14
|
+
"workspaces": ["packages/*"],
|
|
15
|
+
"files": ["src", "web/ui/dist", "README.md", "LICENSE"],
|
|
23
16
|
"repository": {
|
|
24
17
|
"type": "git",
|
|
25
18
|
"url": "https://github.com/ParachuteComputer/parachute-hub.git"
|
|
@@ -47,7 +40,7 @@
|
|
|
47
40
|
},
|
|
48
41
|
"dependencies": {
|
|
49
42
|
"@node-rs/argon2": "^2.0.2",
|
|
50
|
-
"@openparachute/depcheck": "0.1.
|
|
43
|
+
"@openparachute/depcheck": "0.1.1",
|
|
51
44
|
"jose": "^6.2.2",
|
|
52
45
|
"otpauth": "^9.5.0",
|
|
53
46
|
"qrcode": "^1.5.4"
|
|
@@ -172,6 +172,134 @@ describe("requireScope", () => {
|
|
|
172
172
|
});
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
describe("requireScope multi-origin issuer set (hub#516 parity)", () => {
|
|
176
|
+
// The hub answers on several legitimate origins after `parachute hub
|
|
177
|
+
// set-origin` / `expose` (loopback ∪ tunnel ∪ public). A credential minted
|
|
178
|
+
// under a still-valid prior origin must keep validating at admin-auth even
|
|
179
|
+
// when the per-request canonical issuer is now a different member of the set.
|
|
180
|
+
const LOOPBACK = "http://127.0.0.1:1939";
|
|
181
|
+
const TUNNEL = "https://brain.gitcoin.co";
|
|
182
|
+
const FOREIGN = "https://evil.example.com";
|
|
183
|
+
|
|
184
|
+
test("accepts a token whose iss is in the set but ≠ the per-request canonical", async () => {
|
|
185
|
+
const h = makeHarness();
|
|
186
|
+
try {
|
|
187
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
188
|
+
try {
|
|
189
|
+
rotateSigningKey(db);
|
|
190
|
+
// Token minted under the TUNNEL origin (e.g. before `set-origin`)…
|
|
191
|
+
const token = await mintToken(db, ["parachute:host:admin"], { issuer: TUNNEL });
|
|
192
|
+
// …presented to admin-auth where the canonical per-request issuer is now
|
|
193
|
+
// LOOPBACK, but the bound-origin set still includes TUNNEL.
|
|
194
|
+
const ctx = await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
|
|
195
|
+
LOOPBACK,
|
|
196
|
+
TUNNEL,
|
|
197
|
+
]);
|
|
198
|
+
expect(ctx.sub).toBe("user-test");
|
|
199
|
+
expect(ctx.scopes).toContain("parachute:host:admin");
|
|
200
|
+
} finally {
|
|
201
|
+
db.close();
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
h.cleanup();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("rejects 401 when iss is OUTSIDE the set", async () => {
|
|
209
|
+
const h = makeHarness();
|
|
210
|
+
try {
|
|
211
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
212
|
+
try {
|
|
213
|
+
rotateSigningKey(db);
|
|
214
|
+
const token = await mintToken(db, ["parachute:host:admin"], { issuer: FOREIGN });
|
|
215
|
+
let caught: AdminAuthError | null = null;
|
|
216
|
+
try {
|
|
217
|
+
await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
|
|
218
|
+
LOOPBACK,
|
|
219
|
+
TUNNEL,
|
|
220
|
+
]);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
caught = err as AdminAuthError;
|
|
223
|
+
}
|
|
224
|
+
expect(caught?.status).toBe(401);
|
|
225
|
+
} finally {
|
|
226
|
+
db.close();
|
|
227
|
+
}
|
|
228
|
+
} finally {
|
|
229
|
+
h.cleanup();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("rejects 401 on signature failure regardless of the set (signature-first)", async () => {
|
|
234
|
+
const h = makeHarness();
|
|
235
|
+
try {
|
|
236
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
237
|
+
try {
|
|
238
|
+
rotateSigningKey(db);
|
|
239
|
+
// A hub-signed token, then rotate keys + retire so the original kid no
|
|
240
|
+
// longer verifies — the JWKS signature gate must fire before any iss
|
|
241
|
+
// membership check, so an in-set iss can't rescue an unverifiable token.
|
|
242
|
+
let caught: AdminAuthError | null = null;
|
|
243
|
+
try {
|
|
244
|
+
await requireScope(db, reqWithAuth("Bearer not-a-real-jwt"), "parachute:host:admin", [
|
|
245
|
+
LOOPBACK,
|
|
246
|
+
TUNNEL,
|
|
247
|
+
]);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
caught = err as AdminAuthError;
|
|
250
|
+
}
|
|
251
|
+
expect(caught?.status).toBe(401);
|
|
252
|
+
} finally {
|
|
253
|
+
db.close();
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
h.cleanup();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("single-string back-compat: in-set iss as a lone string still validates", async () => {
|
|
261
|
+
const h = makeHarness();
|
|
262
|
+
try {
|
|
263
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
264
|
+
try {
|
|
265
|
+
rotateSigningKey(db);
|
|
266
|
+
const token = await mintToken(db, ["parachute:host:admin"], { issuer: ISSUER });
|
|
267
|
+
// A single-element set behaves exactly like the prior single-string form.
|
|
268
|
+
const ctx = await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
|
|
269
|
+
ISSUER,
|
|
270
|
+
]);
|
|
271
|
+
expect(ctx.sub).toBe("user-test");
|
|
272
|
+
} finally {
|
|
273
|
+
db.close();
|
|
274
|
+
}
|
|
275
|
+
} finally {
|
|
276
|
+
h.cleanup();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("single-element set rejects a non-matching iss (no accidental widening)", async () => {
|
|
281
|
+
const h = makeHarness();
|
|
282
|
+
try {
|
|
283
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
284
|
+
try {
|
|
285
|
+
rotateSigningKey(db);
|
|
286
|
+
const token = await mintToken(db, ["parachute:host:admin"], { issuer: TUNNEL });
|
|
287
|
+
let caught: AdminAuthError | null = null;
|
|
288
|
+
try {
|
|
289
|
+
await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [ISSUER]);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
caught = err as AdminAuthError;
|
|
292
|
+
}
|
|
293
|
+
expect(caught?.status).toBe(401);
|
|
294
|
+
} finally {
|
|
295
|
+
db.close();
|
|
296
|
+
}
|
|
297
|
+
} finally {
|
|
298
|
+
h.cleanup();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
175
303
|
describe("adminAuthErrorResponse", () => {
|
|
176
304
|
test("403 → insufficient_scope with WWW-Authenticate", async () => {
|
|
177
305
|
const res = adminAuthErrorResponse(new AdminAuthError(403, "needs admin"));
|
|
@@ -12,8 +12,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
12
12
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { handleApproveClient, handleGetClient } from "../admin-clients.ts";
|
|
15
|
+
import { handleApproveClient, handleDeleteClient, handleGetClient } from "../admin-clients.ts";
|
|
16
16
|
import { approveClient, getClient, registerClient } from "../clients.ts";
|
|
17
|
+
import { findGrant, recordGrant } from "../grants.ts";
|
|
17
18
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
18
19
|
import { signAccessToken } from "../jwt-sign.ts";
|
|
19
20
|
import { createUser } from "../users.ts";
|
|
@@ -83,6 +84,23 @@ function approveReq(clientId: string, bearer?: string, method = "POST"): Request
|
|
|
83
84
|
return new Request(`${ISSUER}/api/oauth/clients/${encodeURIComponent(clientId)}/approve`, init);
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
// DELETE hits the TOP-LEVEL /oauth/clients/<id> prefix (the path the surface
|
|
88
|
+
// remove-flow calls), not the /api/... admin prefix the GET/approve use.
|
|
89
|
+
function deleteReq(clientId: string, bearer?: string, method = "DELETE"): Request {
|
|
90
|
+
const init: RequestInit = { method };
|
|
91
|
+
if (bearer) init.headers = { authorization: `Bearer ${bearer}` };
|
|
92
|
+
return new Request(`${ISSUER}/oauth/clients/${encodeURIComponent(clientId)}`, init);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function regApproved(name?: string): string {
|
|
96
|
+
const r = registerClient(harness.db, {
|
|
97
|
+
redirectUris: ["https://app.example/cb"],
|
|
98
|
+
scopes: ["vault:work:read"],
|
|
99
|
+
...(name !== undefined ? { clientName: name } : {}),
|
|
100
|
+
});
|
|
101
|
+
return r.client.clientId;
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
describe("handleGetClient", () => {
|
|
87
105
|
test("401 without Bearer", async () => {
|
|
88
106
|
const id = regPending("App");
|
|
@@ -460,3 +478,87 @@ describe("handleApproveClient", () => {
|
|
|
460
478
|
});
|
|
461
479
|
});
|
|
462
480
|
});
|
|
481
|
+
|
|
482
|
+
// hub#640 — RFC 7592 client deregistration. The surface fires DELETE on the
|
|
483
|
+
// top-level /oauth/clients/<id> path; the handler is operator-bearer-gated,
|
|
484
|
+
// returns 204 on delete (cascading grants + auth_codes), 404 when absent.
|
|
485
|
+
describe("handleDeleteClient", () => {
|
|
486
|
+
test("401 without Bearer (row survives)", async () => {
|
|
487
|
+
const id = regApproved("App");
|
|
488
|
+
const res = await handleDeleteClient(deleteReq(id), id, {
|
|
489
|
+
db: harness.db,
|
|
490
|
+
issuer: ISSUER,
|
|
491
|
+
});
|
|
492
|
+
expect(res.status).toBe(401);
|
|
493
|
+
expect(getClient(harness.db, id)).not.toBeNull();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("403 with the wrong scope (row survives)", async () => {
|
|
497
|
+
const { bearer } = await makeOperatorBearer(["parachute:host:auth"]);
|
|
498
|
+
const id = regApproved("App");
|
|
499
|
+
const res = await handleDeleteClient(deleteReq(id, bearer), id, {
|
|
500
|
+
db: harness.db,
|
|
501
|
+
issuer: ISSUER,
|
|
502
|
+
});
|
|
503
|
+
expect(res.status).toBe(403);
|
|
504
|
+
expect(getClient(harness.db, id)).not.toBeNull();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("204 on an existing client + cascade gone + audit line", async () => {
|
|
508
|
+
const { bearer, userId } = await makeOperatorBearer();
|
|
509
|
+
const id = regApproved("Notes");
|
|
510
|
+
// Plant a grant so the cascade has something to remove.
|
|
511
|
+
recordGrant(harness.db, userId, id, ["vault:work:read"]);
|
|
512
|
+
expect(findGrant(harness.db, userId, id)).not.toBeNull();
|
|
513
|
+
|
|
514
|
+
const logs: string[] = [];
|
|
515
|
+
const originalLog = console.log;
|
|
516
|
+
console.log = (...args: unknown[]) => {
|
|
517
|
+
logs.push(args.map(String).join(" "));
|
|
518
|
+
};
|
|
519
|
+
let res: Response;
|
|
520
|
+
try {
|
|
521
|
+
res = await handleDeleteClient(deleteReq(id, bearer), id, {
|
|
522
|
+
db: harness.db,
|
|
523
|
+
issuer: ISSUER,
|
|
524
|
+
});
|
|
525
|
+
} finally {
|
|
526
|
+
console.log = originalLog;
|
|
527
|
+
}
|
|
528
|
+
expect(res.status).toBe(204);
|
|
529
|
+
// 204 carries no body.
|
|
530
|
+
expect(await res.text()).toBe("");
|
|
531
|
+
// Client + its grant are gone.
|
|
532
|
+
expect(getClient(harness.db, id)).toBeNull();
|
|
533
|
+
expect(findGrant(harness.db, userId, id)).toBeNull();
|
|
534
|
+
|
|
535
|
+
const line = logs.find((l) => l.startsWith("client deleted:"));
|
|
536
|
+
expect(line).toBeDefined();
|
|
537
|
+
expect(line).toContain(`client_id=${id}`);
|
|
538
|
+
expect(line).toContain("client_name=Notes");
|
|
539
|
+
expect(line).toContain(`remover_sub=${userId}`);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("404 on an absent client_id", async () => {
|
|
543
|
+
const { bearer } = await makeOperatorBearer();
|
|
544
|
+
const res = await handleDeleteClient(deleteReq("nope", bearer), "nope", {
|
|
545
|
+
db: harness.db,
|
|
546
|
+
issuer: ISSUER,
|
|
547
|
+
});
|
|
548
|
+
expect(res.status).toBe(404);
|
|
549
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
550
|
+
expect(body.error).toBe("not_found");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("405 on a non-DELETE method", async () => {
|
|
554
|
+
const { bearer } = await makeOperatorBearer();
|
|
555
|
+
const id = regApproved();
|
|
556
|
+
const res = await handleDeleteClient(deleteReq(id, bearer, "GET"), id, {
|
|
557
|
+
db: harness.db,
|
|
558
|
+
issuer: ISSUER,
|
|
559
|
+
});
|
|
560
|
+
expect(res.status).toBe(405);
|
|
561
|
+
// Row untouched.
|
|
562
|
+
expect(getClient(harness.db, id)).not.toBeNull();
|
|
563
|
+
});
|
|
564
|
+
});
|
|
@@ -527,7 +527,13 @@ describe("admin-lock management API", () => {
|
|
|
527
527
|
const r2 = await handleAdminLock(lockReq("/heartbeat", cookie, {}), "/heartbeat", {
|
|
528
528
|
db: harness.db,
|
|
529
529
|
});
|
|
530
|
-
|
|
530
|
+
const body2 = (await r2.json()) as { locked: boolean; idle_seconds?: number };
|
|
531
|
+
expect(body2.locked).toBe(false);
|
|
532
|
+
// The heartbeat MUST carry idle_seconds — the client re-anchors its local
|
|
533
|
+
// idle timer from it on every heartbeat. Omitting it poisoned the timer
|
|
534
|
+
// (undefined → NaN → instant re-lock). Regression guard for the PIN
|
|
535
|
+
// re-prompt loop.
|
|
536
|
+
expect(body2.idle_seconds).toBe(getIdleSeconds(harness.db));
|
|
531
537
|
});
|
|
532
538
|
|
|
533
539
|
test("unlock brute-force limiter: 6th attempt is 429", async () => {
|
|
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
HOST_ADMIN_SCOPE,
|
|
7
|
+
type RunResult,
|
|
8
|
+
handleCreateVault,
|
|
9
|
+
listVaultInstanceNames,
|
|
10
|
+
provisionVault,
|
|
11
|
+
} from "../admin-vaults.ts";
|
|
6
12
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
7
13
|
import { signAccessToken } from "../jwt-sign.ts";
|
|
8
14
|
import { upsertService, writeManifest } from "../services-manifest.ts";
|
|
@@ -934,27 +940,70 @@ describe("DELETE /vaults/<name> — gates", () => {
|
|
|
934
940
|
}
|
|
935
941
|
});
|
|
936
942
|
|
|
937
|
-
test("
|
|
943
|
+
test("#678: deleting the LAST vault cascades + deletes (no 409), with a last_vault warning", async () => {
|
|
938
944
|
const h = makeHarness();
|
|
939
945
|
try {
|
|
940
946
|
const db = openHubDb(hubDbPath(h.dir));
|
|
941
947
|
try {
|
|
942
948
|
rotateSigningKey(db);
|
|
943
|
-
|
|
949
|
+
// Only one vault on the hub — the previously-refused last-vault case.
|
|
950
|
+
writeVaults(h.manifestPath, ["solo"]);
|
|
951
|
+
|
|
952
|
+
// Seed identity artifacts naming the soon-to-be-deleted last vault.
|
|
953
|
+
registryRow(db, "jti-solo", ["vault:solo:write"]);
|
|
954
|
+
const carol = await createUser(db, "carol", "carol-passphrase-123");
|
|
955
|
+
const client = registerClient(db, { redirectUris: ["https://d.example/cb"] }).client
|
|
956
|
+
.clientId;
|
|
957
|
+
recordGrant(db, carol.id, client, ["vault:solo:admin"]);
|
|
958
|
+
setUserVaults(db, carol.id, ["solo"]);
|
|
959
|
+
|
|
944
960
|
const runner = stubRun();
|
|
945
961
|
const res = await callDelete({
|
|
946
|
-
name: "
|
|
962
|
+
name: "solo",
|
|
947
963
|
db,
|
|
948
964
|
manifestPath: h.manifestPath,
|
|
949
965
|
connectionsStorePath: join(h.dir, "connections.json"),
|
|
950
966
|
runCommand: runner.run,
|
|
951
967
|
});
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
expect(
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
968
|
+
|
|
969
|
+
// No 409 — the delete completes.
|
|
970
|
+
expect(res.status).toBe(200);
|
|
971
|
+
const out = (await res.json()) as {
|
|
972
|
+
ok: boolean;
|
|
973
|
+
cascade: {
|
|
974
|
+
tokens_revoked: number;
|
|
975
|
+
grants_dropped: number;
|
|
976
|
+
user_vaults_removed: number;
|
|
977
|
+
vault_removed: boolean;
|
|
978
|
+
};
|
|
979
|
+
warnings?: { step: string; detail: string }[];
|
|
980
|
+
};
|
|
981
|
+
expect(out.ok).toBe(true);
|
|
982
|
+
|
|
983
|
+
// The cascade ran for the last vault: tokens/grants/assignments gone.
|
|
984
|
+
expect(out.cascade.tokens_revoked).toBe(1);
|
|
985
|
+
expect(findTokenRowByJti(db, "jti-solo")?.revokedAt).not.toBeNull();
|
|
986
|
+
expect(out.cascade.grants_dropped).toBe(1);
|
|
987
|
+
expect(findGrant(db, carol.id, client)).toBeNull();
|
|
988
|
+
expect(out.cascade.user_vaults_removed).toBe(1);
|
|
989
|
+
expect(
|
|
990
|
+
db
|
|
991
|
+
.query<{ vault_name: string }, [string]>(
|
|
992
|
+
"SELECT vault_name FROM user_vaults WHERE user_id = ?",
|
|
993
|
+
)
|
|
994
|
+
.all(carol.id),
|
|
995
|
+
).toEqual([]);
|
|
996
|
+
|
|
997
|
+
// The underlying vault remove ran (the cascade no longer skips it).
|
|
998
|
+
expect(runner.calls).toContainEqual(["parachute-vault", "remove", "solo", "--yes"]);
|
|
999
|
+
expect(out.cascade.vault_removed).toBe(true);
|
|
1000
|
+
|
|
1001
|
+
// A last_vault heads-up warning is surfaced (name-agnostic — does not
|
|
1002
|
+
// assume "default"); the auto_create:false marker prevents resurrection.
|
|
1003
|
+
const lastVaultWarning = out.warnings?.find((w) => w.step === "last_vault");
|
|
1004
|
+
expect(lastVaultWarning).toBeDefined();
|
|
1005
|
+
expect(lastVaultWarning?.detail).toContain("auto_create: false");
|
|
1006
|
+
expect(lastVaultWarning?.detail).not.toContain('"default"');
|
|
958
1007
|
} finally {
|
|
959
1008
|
db.close();
|
|
960
1009
|
}
|
|
@@ -1304,3 +1353,160 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
|
|
|
1304
1353
|
}
|
|
1305
1354
|
});
|
|
1306
1355
|
});
|
|
1356
|
+
|
|
1357
|
+
// ===========================================================================
|
|
1358
|
+
// #478 — empty-paths vault rows must not resolve to phantom "default"
|
|
1359
|
+
// ===========================================================================
|
|
1360
|
+
|
|
1361
|
+
describe("#478 — empty-paths vault row tolerance", () => {
|
|
1362
|
+
test("findExistingVault: empty-paths vault row does NOT match 'default'", () => {
|
|
1363
|
+
// A vault module registered in services.json with paths:[] is "installed
|
|
1364
|
+
// but no servable vault instance". Hub must skip it — never synthesize a
|
|
1365
|
+
// phantom "default" — so provisionVault can proceed to a real create.
|
|
1366
|
+
const h = makeHarness();
|
|
1367
|
+
try {
|
|
1368
|
+
// Write a services.json with a parachute-vault entry carrying paths:[].
|
|
1369
|
+
writeManifest(
|
|
1370
|
+
{
|
|
1371
|
+
services: [
|
|
1372
|
+
{
|
|
1373
|
+
name: "parachute-vault",
|
|
1374
|
+
port: 1940,
|
|
1375
|
+
paths: [],
|
|
1376
|
+
health: "/health",
|
|
1377
|
+
version: "0.5.0",
|
|
1378
|
+
},
|
|
1379
|
+
],
|
|
1380
|
+
},
|
|
1381
|
+
h.manifestPath,
|
|
1382
|
+
);
|
|
1383
|
+
// Calling provisionVault("default") internally calls findExistingVault.
|
|
1384
|
+
// We verify the behaviour indirectly via listVaultInstanceNames (exported
|
|
1385
|
+
// for this test) and via provisionVault's created:true path below.
|
|
1386
|
+
const names = listVaultInstanceNames(h.manifestPath);
|
|
1387
|
+
expect(names.has("default")).toBe(false);
|
|
1388
|
+
} finally {
|
|
1389
|
+
h.cleanup();
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
test("listVaultInstanceNames: empty-paths vault row is omitted from the Set", () => {
|
|
1394
|
+
const h = makeHarness();
|
|
1395
|
+
try {
|
|
1396
|
+
writeManifest(
|
|
1397
|
+
{
|
|
1398
|
+
services: [
|
|
1399
|
+
{
|
|
1400
|
+
name: "parachute-vault",
|
|
1401
|
+
port: 1940,
|
|
1402
|
+
paths: [],
|
|
1403
|
+
health: "/health",
|
|
1404
|
+
version: "0.5.0",
|
|
1405
|
+
},
|
|
1406
|
+
],
|
|
1407
|
+
},
|
|
1408
|
+
h.manifestPath,
|
|
1409
|
+
);
|
|
1410
|
+
const names = listVaultInstanceNames(h.manifestPath);
|
|
1411
|
+
expect(names.size).toBe(0);
|
|
1412
|
+
} finally {
|
|
1413
|
+
h.cleanup();
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
test("provisionVault: empty-paths row → created:true (proceeds to orchestrate, not false 'already exists')", async () => {
|
|
1418
|
+
// Core regression test for #478: before the fix, an empty-paths row
|
|
1419
|
+
// resolved to phantom "default" → findExistingVault returned non-null →
|
|
1420
|
+
// provisionVault short-circuited to created:false with "already exists".
|
|
1421
|
+
// After the fix: findExistingVault returns null → orchestrate runs →
|
|
1422
|
+
// created:true.
|
|
1423
|
+
const h = makeHarness();
|
|
1424
|
+
try {
|
|
1425
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1426
|
+
try {
|
|
1427
|
+
rotateSigningKey(db);
|
|
1428
|
+
// Seed an empty-paths vault row (what vault's self-register emits at
|
|
1429
|
+
// zero vaults, per the #478 contract).
|
|
1430
|
+
writeManifest(
|
|
1431
|
+
{
|
|
1432
|
+
services: [
|
|
1433
|
+
{
|
|
1434
|
+
name: "parachute-vault",
|
|
1435
|
+
port: 1940,
|
|
1436
|
+
paths: [],
|
|
1437
|
+
health: "/health",
|
|
1438
|
+
version: "0.5.0",
|
|
1439
|
+
},
|
|
1440
|
+
],
|
|
1441
|
+
},
|
|
1442
|
+
h.manifestPath,
|
|
1443
|
+
);
|
|
1444
|
+
|
|
1445
|
+
const calls: Array<readonly string[]> = [];
|
|
1446
|
+
const runCommand = async (cmd: readonly string[]): Promise<RunResult> => {
|
|
1447
|
+
calls.push(cmd);
|
|
1448
|
+
// Simulate vault CLI writing the real path into services.json after
|
|
1449
|
+
// a successful create. Because vault IS already registered (paths:[]),
|
|
1450
|
+
// orchestrate picks the `parachute-vault create --json` branch and
|
|
1451
|
+
// expects JSON stdout.
|
|
1452
|
+
upsertService(
|
|
1453
|
+
{
|
|
1454
|
+
name: "parachute-vault",
|
|
1455
|
+
port: 1940,
|
|
1456
|
+
paths: ["/vault/default"],
|
|
1457
|
+
health: "/health",
|
|
1458
|
+
version: "0.5.0",
|
|
1459
|
+
},
|
|
1460
|
+
h.manifestPath,
|
|
1461
|
+
);
|
|
1462
|
+
return { exitCode: 0, stdout: vaultCreateJson("default"), stderr: "" };
|
|
1463
|
+
};
|
|
1464
|
+
|
|
1465
|
+
const result = await provisionVault("default", {
|
|
1466
|
+
issuer: ISSUER,
|
|
1467
|
+
manifestPath: h.manifestPath,
|
|
1468
|
+
runCommand,
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
// Must have proceeded to orchestrate and returned created:true.
|
|
1472
|
+
expect(result.ok).toBe(true);
|
|
1473
|
+
if (!result.ok) return; // narrow for TS
|
|
1474
|
+
expect(result.created).toBe(true);
|
|
1475
|
+
// The orchestration command ran (not short-circuited).
|
|
1476
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
1477
|
+
} finally {
|
|
1478
|
+
db.close();
|
|
1479
|
+
}
|
|
1480
|
+
} finally {
|
|
1481
|
+
h.cleanup();
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
test("listVaultInstanceNames: real paths still enumerate correctly (empty-paths does not break them)", () => {
|
|
1486
|
+
// Sanity: mixing an empty-paths row with a real-paths row — the real
|
|
1487
|
+
// paths are still found, the empty one is still skipped.
|
|
1488
|
+
const h = makeHarness();
|
|
1489
|
+
try {
|
|
1490
|
+
writeManifest(
|
|
1491
|
+
{
|
|
1492
|
+
services: [
|
|
1493
|
+
{
|
|
1494
|
+
name: "parachute-vault",
|
|
1495
|
+
port: 1940,
|
|
1496
|
+
paths: ["/vault/default", "/vault/work"],
|
|
1497
|
+
health: "/health",
|
|
1498
|
+
version: "0.5.0",
|
|
1499
|
+
},
|
|
1500
|
+
],
|
|
1501
|
+
},
|
|
1502
|
+
h.manifestPath,
|
|
1503
|
+
);
|
|
1504
|
+
const names = listVaultInstanceNames(h.manifestPath);
|
|
1505
|
+
expect(names.has("default")).toBe(true);
|
|
1506
|
+
expect(names.has("work")).toBe(true);
|
|
1507
|
+
expect(names.size).toBe(2);
|
|
1508
|
+
} finally {
|
|
1509
|
+
h.cleanup();
|
|
1510
|
+
}
|
|
1511
|
+
});
|
|
1512
|
+
});
|