@openparachute/hub 0.7.3-rc.1 → 0.7.3-rc.10
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__/admin-agent-grants.test.ts +113 -0
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/api-modules.test.ts +65 -10
- package/src/__tests__/api-vault-caps.test.ts +232 -0
- package/src/__tests__/cli.test.ts +20 -0
- package/src/__tests__/hub-command.test.ts +136 -0
- package/src/__tests__/hub-origins-env-set.test.ts +273 -0
- package/src/__tests__/init.test.ts +148 -0
- package/src/__tests__/jwt-sign.test.ts +79 -0
- package/src/__tests__/module-manifest.test.ts +3 -1
- package/src/__tests__/oauth-handlers.test.ts +413 -5
- package/src/__tests__/public-signup.test.ts +619 -0
- package/src/__tests__/rate-limit.test.ts +86 -0
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/serve.test.ts +67 -1
- package/src/__tests__/service-spec-discovery.test.ts +22 -2
- package/src/__tests__/setup-wizard.test.ts +18 -0
- package/src/__tests__/setup.test.ts +129 -15
- package/src/__tests__/two-factor-flow.test.ts +94 -0
- package/src/__tests__/users.test.ts +33 -0
- package/src/__tests__/vault-caps.test.ts +89 -0
- package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
- package/src/__tests__/wizard-transcription.test.ts +334 -0
- package/src/__tests__/wizard.test.ts +146 -0
- package/src/account-setup.ts +118 -32
- package/src/admin-agent-grants.ts +51 -3
- package/src/admin-handlers.ts +53 -16
- package/src/admin-login-ui.ts +30 -4
- package/src/admin-vaults.ts +9 -1
- package/src/api-invites.ts +163 -3
- package/src/api-modules-ops.ts +12 -0
- package/src/api-modules.ts +47 -13
- package/src/api-users.ts +3 -0
- package/src/api-vault-caps.ts +206 -0
- package/src/cli.ts +35 -1
- package/src/commands/hub.ts +173 -0
- package/src/commands/init.ts +73 -2
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/serve.ts +39 -3
- package/src/commands/setup.ts +31 -6
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/help.ts +8 -0
- package/src/hub-db.ts +108 -1
- package/src/hub-origin.ts +64 -0
- package/src/hub-server.ts +27 -0
- package/src/invites.ts +155 -31
- package/src/jwt-sign.ts +72 -3
- package/src/module-manifest.ts +23 -9
- package/src/oauth-flows-store.ts +12 -0
- package/src/oauth-handlers.ts +145 -20
- package/src/rate-limit.ts +111 -20
- package/src/scribe-config.ts +145 -0
- package/src/service-spec.ts +31 -12
- package/src/setup-wizard.ts +37 -4
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
- package/src/vault-hub-origin-env.ts +109 -16
- package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
- package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-B5AUE359.js +0 -61
package/package.json
CHANGED
|
@@ -1353,6 +1353,119 @@ describe("GET /oauth/agent-grant/callback", () => {
|
|
|
1353
1353
|
});
|
|
1354
1354
|
});
|
|
1355
1355
|
|
|
1356
|
+
// === returnTo: send the operator back to where they started ================
|
|
1357
|
+
//
|
|
1358
|
+
// The OAuth-consent round-trip used to dead-end on a "close this tab" page. A
|
|
1359
|
+
// same-origin (hub-relative) `returnTo` on the approve body is stashed on the
|
|
1360
|
+
// flow + 302'd to on success (with `?mcp_connected=1`). The open-redirect guard
|
|
1361
|
+
// (`isSafeHubReturnTo`, reused) is the load-bearing property: an off-origin /
|
|
1362
|
+
// scheme-relative value is dropped at stash time and never lands in Location.
|
|
1363
|
+
describe("approve(mcp) + callback — returnTo round-trip", () => {
|
|
1364
|
+
async function startFlowWithBody(body: Record<string, unknown>): Promise<string> {
|
|
1365
|
+
const bearer = await moduleBearer();
|
|
1366
|
+
const cookie = await operatorCookie();
|
|
1367
|
+
const created = await json(
|
|
1368
|
+
await dispatch(
|
|
1369
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1370
|
+
agent: "a",
|
|
1371
|
+
connection: { kind: "mcp", target: "https://remote.test/mcp" },
|
|
1372
|
+
}),
|
|
1373
|
+
),
|
|
1374
|
+
);
|
|
1375
|
+
const id = created.id as string;
|
|
1376
|
+
await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie, body));
|
|
1377
|
+
return id;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
test("approve stashes a valid same-origin returnTo on the flow", async () => {
|
|
1381
|
+
currentOAuth = fakeOAuth();
|
|
1382
|
+
await startFlowWithBody({ returnTo: "/admin/grants" });
|
|
1383
|
+
const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
|
|
1384
|
+
expect(flow?.returnTo).toBe("/admin/grants");
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
test("approve drops an off-origin returnTo (absolute URL) — open-redirect guard", async () => {
|
|
1388
|
+
currentOAuth = fakeOAuth();
|
|
1389
|
+
await startFlowWithBody({ returnTo: "https://evil.example/steal" });
|
|
1390
|
+
const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
|
|
1391
|
+
expect(flow?.returnTo).toBeUndefined();
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
test("approve drops a scheme-relative returnTo (//host) — open-redirect guard", async () => {
|
|
1395
|
+
currentOAuth = fakeOAuth();
|
|
1396
|
+
await startFlowWithBody({ returnTo: "//evil.example/steal" });
|
|
1397
|
+
const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
|
|
1398
|
+
expect(flow?.returnTo).toBeUndefined();
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
test("approve drops a `..` path-traversal returnTo — open-redirect guard", async () => {
|
|
1402
|
+
currentOAuth = fakeOAuth();
|
|
1403
|
+
await startFlowWithBody({ returnTo: "/admin/../../etc/passwd" });
|
|
1404
|
+
const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
|
|
1405
|
+
expect(flow?.returnTo).toBeUndefined();
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
test("approve drops a PERCENT-ENCODED `..` returnTo (%2e%2e) — open-redirect guard", async () => {
|
|
1409
|
+
// `new URL()` decodes %2e%2e before emitting the redirect path, so the guard
|
|
1410
|
+
// must reject the encoded form too, not just the literal `..`.
|
|
1411
|
+
currentOAuth = fakeOAuth();
|
|
1412
|
+
await startFlowWithBody({ returnTo: "/%2e%2e/etc/passwd" });
|
|
1413
|
+
const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
|
|
1414
|
+
expect(flow?.returnTo).toBeUndefined();
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
test("callback 302-redirects to a valid returnTo (with mcp_connected=1) on success", async () => {
|
|
1418
|
+
currentOAuth = fakeOAuth();
|
|
1419
|
+
const id = await startFlowWithBody({ returnTo: "/admin/grants?agent=a" });
|
|
1420
|
+
const res = await callback("?code=ok&state=fixed-state");
|
|
1421
|
+
expect(res.status).toBe(302);
|
|
1422
|
+
const location = res.headers.get("location") ?? "";
|
|
1423
|
+
expect(location).toContain("/admin/grants");
|
|
1424
|
+
expect(location).toContain("agent=a");
|
|
1425
|
+
expect(location).toContain("mcp_connected=1");
|
|
1426
|
+
// Same-origin only — never a host/scheme in Location.
|
|
1427
|
+
expect(location.startsWith("/")).toBe(true);
|
|
1428
|
+
expect(location.startsWith("//")).toBe(false);
|
|
1429
|
+
// The grant still flipped to approved (the success side-effects all ran).
|
|
1430
|
+
expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("approved");
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
test("callback overwrites a pre-existing mcp_connected param rather than duplicating it", async () => {
|
|
1434
|
+
currentOAuth = fakeOAuth();
|
|
1435
|
+
await startFlowWithBody({ returnTo: "/admin/grants?mcp_connected=0" });
|
|
1436
|
+
const res = await callback("?code=ok&state=fixed-state");
|
|
1437
|
+
expect(res.status).toBe(302);
|
|
1438
|
+
const location = res.headers.get("location") ?? "";
|
|
1439
|
+
expect(location).toContain("mcp_connected=1");
|
|
1440
|
+
expect(location).not.toContain("mcp_connected=0");
|
|
1441
|
+
// exactly one occurrence (searchParams.set semantics, not append)
|
|
1442
|
+
expect(location.match(/mcp_connected=/g)?.length).toBe(1);
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
test("callback falls back to the close-tab HTML when no returnTo was stashed", async () => {
|
|
1446
|
+
currentOAuth = fakeOAuth();
|
|
1447
|
+
const id = await startFlowWithBody({}); // no returnTo
|
|
1448
|
+
const res = await callback("?code=ok&state=fixed-state");
|
|
1449
|
+
expect(res.status).toBe(200);
|
|
1450
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
1451
|
+
expect(await res.text()).toContain("Connected");
|
|
1452
|
+
expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("approved");
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
test("callback falls back to the close-tab HTML for an unsafe returnTo (never 302s off-origin)", async () => {
|
|
1456
|
+
currentOAuth = fakeOAuth();
|
|
1457
|
+
// The unsafe value was already dropped at stash time, so the callback has no
|
|
1458
|
+
// returnTo to honor — it renders the back-compat page rather than redirecting.
|
|
1459
|
+
const id = await startFlowWithBody({ returnTo: "https://evil.example/steal" });
|
|
1460
|
+
const res = await callback("?code=ok&state=fixed-state");
|
|
1461
|
+
expect(res.status).toBe(200);
|
|
1462
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
1463
|
+
expect(res.headers.get("location")).toBeNull();
|
|
1464
|
+
expect(await res.text()).toContain("Connected");
|
|
1465
|
+
expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("approved");
|
|
1466
|
+
});
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1356
1469
|
describe("material(mcp) — auto-refresh", () => {
|
|
1357
1470
|
// Drive a grant to approved-via-OAuth, with expiry in the (near) past/future.
|
|
1358
1471
|
async function approvedViaOAuth(expiresAt: string): Promise<string> {
|
|
@@ -733,6 +733,7 @@ import { findGrant, recordGrant } from "../grants.ts";
|
|
|
733
733
|
import { findInviteByHash, issueInvite } from "../invites.ts";
|
|
734
734
|
import { findTokenRowByJti, listActiveRevocations, recordTokenMint } from "../jwt-sign.ts";
|
|
735
735
|
import { createUser, setUserVaults } from "../users.ts";
|
|
736
|
+
import { getVaultCapBytes, setVaultCap } from "../vault-caps.ts";
|
|
736
737
|
|
|
737
738
|
const VAULT_ORIGIN = "http://127.0.0.1:19400";
|
|
738
739
|
const AGENT_ORIGIN = "http://127.0.0.1:19410";
|
|
@@ -1026,11 +1027,19 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
|
|
|
1026
1027
|
const pendingWork = issueInvite(db, { createdBy: alice.id, vaultName: "work" });
|
|
1027
1028
|
const pendingDefault = issueInvite(db, { createdBy: alice.id, vaultName: "default" });
|
|
1028
1029
|
const redeemedWork = issueInvite(db, { createdBy: alice.id, vaultName: "work" });
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1030
|
+
// Simulate a fully-redeemed single-use invite exactly as the redeem path
|
|
1031
|
+
// leaves it (v15): used_at stamped AND used_count bumped to max_uses, so
|
|
1032
|
+
// the cascade's `used_count < max_uses` exhaustion guard treats it as
|
|
1033
|
+
// terminal (untouched), same as the pre-v15 `used_at IS NULL` guard did.
|
|
1034
|
+
db.prepare(
|
|
1035
|
+
"UPDATE invites SET used_at = ?, used_count = 1, redeemed_user_id = ? WHERE token = ?",
|
|
1036
|
+
).run(new Date().toISOString(), alice.id, redeemedWork.invite.tokenHash);
|
|
1037
|
+
|
|
1038
|
+
// 4b. vault_caps (v15): one row per vault. The work row is dropped by
|
|
1039
|
+
// the cascade; the default row stays (a re-created same-name vault
|
|
1040
|
+
// must not inherit a stale cap).
|
|
1041
|
+
setVaultCap(db, "work", 1024 * 1024 * 1024);
|
|
1042
|
+
setVaultCap(db, "default", 2 * 1024 * 1024 * 1024);
|
|
1034
1043
|
|
|
1035
1044
|
// 5. Connections: one sourced on work (torn down), one on default (kept).
|
|
1036
1045
|
putConnection(store, {
|
|
@@ -1092,6 +1101,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
|
|
|
1092
1101
|
grants_dropped: number;
|
|
1093
1102
|
user_vaults_removed: number;
|
|
1094
1103
|
invites_invalidated: number;
|
|
1104
|
+
vault_cap_removed: boolean;
|
|
1095
1105
|
connections_torn_down: number;
|
|
1096
1106
|
orphaned_channels: string[];
|
|
1097
1107
|
vault_removed: boolean;
|
|
@@ -1134,6 +1144,11 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
|
|
|
1134
1144
|
expect(findInviteByHash(db, redeemedWork.invite.tokenHash)?.usedAt).not.toBeNull();
|
|
1135
1145
|
expect(findInviteByHash(db, redeemedWork.invite.tokenHash)?.revokedAt).toBeNull();
|
|
1136
1146
|
|
|
1147
|
+
// 4b. vault_caps: the work cap row dropped; the default cap row stays.
|
|
1148
|
+
expect(out.cascade.vault_cap_removed).toBe(true);
|
|
1149
|
+
expect(getVaultCapBytes(db, "work")).toBeNull();
|
|
1150
|
+
expect(getVaultCapBytes(db, "default")).toBe(2 * 1024 * 1024 * 1024);
|
|
1151
|
+
|
|
1137
1152
|
// 5. Connections: work connection torn down (trigger deregistered +
|
|
1138
1153
|
// channel entry deleted + record removed); default connection kept.
|
|
1139
1154
|
expect(out.cascade.connections_torn_down).toBe(1);
|
|
@@ -219,9 +219,11 @@ describe("GET /api/modules", () => {
|
|
|
219
219
|
// yet. Post-2026-06-09 (modular-UI architecture, P2) discovery is driven
|
|
220
220
|
// by the UNION of the bootstrap registries (KNOWN_MODULES ∪
|
|
221
221
|
// FIRST_PARTY_FALLBACKS), NOT a curated whitelist. Every known module
|
|
222
|
-
// surfaces — core (vault/scribe/surface) in the headline tier,
|
|
223
|
-
//
|
|
224
|
-
//
|
|
222
|
+
// surfaces — core (vault/scribe/surface) in the headline tier, agent as
|
|
223
|
+
// `experimental`, and notes/runner as `deprecated` (2026-06-25, still
|
|
224
|
+
// resolvable but not offered for fresh installs) — so the agent-not-installed
|
|
225
|
+
// class (running but invisible) can't recur while deprecated modules stop
|
|
226
|
+
// being pushed on a fresh box.
|
|
225
227
|
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
226
228
|
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
227
229
|
db: h.db,
|
|
@@ -233,8 +235,9 @@ describe("GET /api/modules", () => {
|
|
|
233
235
|
const body = (await res.json()) as {
|
|
234
236
|
modules: Array<{
|
|
235
237
|
short: string;
|
|
236
|
-
focus: "core" | "experimental";
|
|
238
|
+
focus: "core" | "experimental" | "deprecated";
|
|
237
239
|
available: boolean;
|
|
240
|
+
available_to_install: boolean;
|
|
238
241
|
installed: boolean;
|
|
239
242
|
latest_version: string | null;
|
|
240
243
|
}>;
|
|
@@ -242,12 +245,14 @@ describe("GET /api/modules", () => {
|
|
|
242
245
|
};
|
|
243
246
|
const shorts = body.modules.map((m) => m.short);
|
|
244
247
|
// The core tier leads, in the recommended install order (vault → scribe),
|
|
245
|
-
// ahead of every experimental module.
|
|
248
|
+
// ahead of every experimental module, which lead the deprecated ones.
|
|
246
249
|
expect(shorts.indexOf("vault")).toBeLessThan(shorts.indexOf("scribe"));
|
|
247
250
|
expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("agent"));
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
+
// agent (experimental) sorts ahead of notes/runner (deprecated).
|
|
252
|
+
expect(shorts.indexOf("agent")).toBeLessThan(shorts.indexOf("runner"));
|
|
253
|
+
expect(shorts.indexOf("agent")).toBeLessThan(shorts.indexOf("notes"));
|
|
254
|
+
// Every known module is discoverable — vault/scribe/surface (core),
|
|
255
|
+
// agent (experimental), notes/runner (deprecated).
|
|
251
256
|
for (const s of ["vault", "scribe", "surface", "agent", "runner", "notes"]) {
|
|
252
257
|
expect(shorts).toContain(s);
|
|
253
258
|
}
|
|
@@ -257,15 +262,65 @@ describe("GET /api/modules", () => {
|
|
|
257
262
|
expect(byShort.get("scribe")?.focus).toBe("core");
|
|
258
263
|
expect(byShort.get("surface")?.focus).toBe("core");
|
|
259
264
|
expect(byShort.get("agent")?.focus).toBe("experimental");
|
|
260
|
-
expect(byShort.get("runner")?.focus).toBe("
|
|
261
|
-
expect(byShort.get("notes")?.focus).toBe("
|
|
265
|
+
expect(byShort.get("runner")?.focus).toBe("deprecated");
|
|
266
|
+
expect(byShort.get("notes")?.focus).toBe("deprecated");
|
|
267
|
+
// `available` stays true for every known module (re-installable), but the
|
|
268
|
+
// fresh-install OFFER (`available_to_install`) drops the deprecated tier —
|
|
269
|
+
// notes/runner aren't pushed on a fresh box; agent (experimental) still is.
|
|
262
270
|
expect(body.modules.every((m) => m.available)).toBe(true);
|
|
271
|
+
expect(byShort.get("vault")?.available_to_install).toBe(true);
|
|
272
|
+
expect(byShort.get("scribe")?.available_to_install).toBe(true);
|
|
273
|
+
expect(byShort.get("surface")?.available_to_install).toBe(true);
|
|
274
|
+
expect(byShort.get("agent")?.available_to_install).toBe(true);
|
|
275
|
+
expect(byShort.get("runner")?.available_to_install).toBe(false);
|
|
276
|
+
expect(byShort.get("notes")?.available_to_install).toBe(false);
|
|
263
277
|
expect(body.modules.every((m) => !m.installed)).toBe(true);
|
|
264
278
|
expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
|
|
265
279
|
// Supervisor wasn't injected → flag reflects that.
|
|
266
280
|
expect(body.supervisor_available).toBe(false);
|
|
267
281
|
});
|
|
268
282
|
|
|
283
|
+
test("an installed deprecated module (runner) still surfaces for management but is not offered for fresh install (2026-06-25)", async () => {
|
|
284
|
+
// A legacy operator with runner on disk: the row must remain visible
|
|
285
|
+
// (installed: true) + manageable, in the `deprecated` tier — but
|
|
286
|
+
// `available_to_install` is false so the SPA's install catalog won't push
|
|
287
|
+
// it. Mirrors the notes-daemon back-compat posture.
|
|
288
|
+
writeManifest(h.manifestPath, [
|
|
289
|
+
{
|
|
290
|
+
name: "parachute-runner",
|
|
291
|
+
port: 1945,
|
|
292
|
+
paths: ["/runner", "/.parachute"],
|
|
293
|
+
health: "/runner/healthz",
|
|
294
|
+
version: "0.2.0",
|
|
295
|
+
},
|
|
296
|
+
]);
|
|
297
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
298
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
299
|
+
db: h.db,
|
|
300
|
+
issuer: ISSUER,
|
|
301
|
+
manifestPath: h.manifestPath,
|
|
302
|
+
fetchLatestVersion: async () => null,
|
|
303
|
+
});
|
|
304
|
+
const body = (await res.json()) as {
|
|
305
|
+
modules: Array<{
|
|
306
|
+
short: string;
|
|
307
|
+
focus: "core" | "experimental" | "deprecated";
|
|
308
|
+
installed: boolean;
|
|
309
|
+
installed_version: string | null;
|
|
310
|
+
available: boolean;
|
|
311
|
+
available_to_install: boolean;
|
|
312
|
+
}>;
|
|
313
|
+
};
|
|
314
|
+
const runner = body.modules.find((m) => m.short === "runner");
|
|
315
|
+
expect(runner).toBeDefined();
|
|
316
|
+
expect(runner?.installed).toBe(true);
|
|
317
|
+
expect(runner?.installed_version).toBe("0.2.0");
|
|
318
|
+
expect(runner?.focus).toBe("deprecated");
|
|
319
|
+
// Still hub-installable (re-install path) but NOT offered fresh.
|
|
320
|
+
expect(runner?.available).toBe(true);
|
|
321
|
+
expect(runner?.available_to_install).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
269
324
|
test("scribe row carries package + display props from KNOWN_MODULES", async () => {
|
|
270
325
|
// Spot-check the wire shape resolves scribe-specific fields
|
|
271
326
|
// (package, displayName, tagline) from KNOWN_MODULES rather than a
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `/api/vault-caps*` (B5 admin visibility / D-slice).
|
|
3
|
+
* Covers:
|
|
4
|
+
*
|
|
5
|
+
* - Auth boundary: GET + PUT require a bearer carrying `parachute:host:admin`.
|
|
6
|
+
* - GET joins services.json vault names with persisted caps (uncapped =
|
|
7
|
+
* null cap_bytes), ordered by name.
|
|
8
|
+
* - PUT sets/updates a cap (upsert), validates positive cap, refuses a
|
|
9
|
+
* vault not registered in services.json (400 vault_not_found).
|
|
10
|
+
* - 405 on wrong methods.
|
|
11
|
+
*/
|
|
12
|
+
import type { Database } from "bun:sqlite";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { handleListVaultCaps, handleSetVaultCap } from "../api-vault-caps.ts";
|
|
18
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
19
|
+
import { signAccessToken } from "../jwt-sign.ts";
|
|
20
|
+
import { createUser } from "../users.ts";
|
|
21
|
+
import { getVaultCapBytes, setVaultCap } from "../vault-caps.ts";
|
|
22
|
+
|
|
23
|
+
const ISSUER = "https://hub.test";
|
|
24
|
+
const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
25
|
+
|
|
26
|
+
interface Harness {
|
|
27
|
+
db: Database;
|
|
28
|
+
manifestPath: string;
|
|
29
|
+
cleanup: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function manifestWithVaults(...names: string[]): string {
|
|
33
|
+
const paths = names.map((n) => `/vault/${n}`);
|
|
34
|
+
return JSON.stringify({
|
|
35
|
+
services: [
|
|
36
|
+
{ name: "parachute-vault", port: 4101, paths, health: "/health", version: "0.0.0-test" },
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeHarness(servicesJson?: string): Harness {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-api-vault-caps-"));
|
|
43
|
+
const db = openHubDb(hubDbPath(dir));
|
|
44
|
+
const manifestPath = join(dir, "services.json");
|
|
45
|
+
writeFileSync(manifestPath, servicesJson ?? manifestWithVaults("beta", "personal"));
|
|
46
|
+
return {
|
|
47
|
+
db,
|
|
48
|
+
manifestPath,
|
|
49
|
+
cleanup: () => {
|
|
50
|
+
db.close();
|
|
51
|
+
rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let harness: Harness;
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
harness = makeHarness();
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
harness.cleanup();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
async function makeAdminBearer(scopes = [HOST_ADMIN_SCOPE]): Promise<string> {
|
|
65
|
+
const user = await createUser(harness.db, "operator", "any-password", {
|
|
66
|
+
allowMulti: true,
|
|
67
|
+
passwordChanged: true,
|
|
68
|
+
});
|
|
69
|
+
const minted = await signAccessToken(harness.db, {
|
|
70
|
+
sub: user.id,
|
|
71
|
+
scopes,
|
|
72
|
+
audience: "hub",
|
|
73
|
+
clientId: "parachute-hub-spa",
|
|
74
|
+
issuer: ISSUER,
|
|
75
|
+
ttlSeconds: 600,
|
|
76
|
+
});
|
|
77
|
+
return minted.token;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function req(path: string, init: RequestInit = {}): Request {
|
|
81
|
+
return new Request(`${ISSUER}${path}`, init);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function withBearer(path: string, bearer: string, init: RequestInit = {}): Request {
|
|
85
|
+
const headers = new Headers(init.headers ?? {});
|
|
86
|
+
headers.set("authorization", `Bearer ${bearer}`);
|
|
87
|
+
return new Request(`${ISSUER}${path}`, { ...init, headers });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function deps() {
|
|
91
|
+
return { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("handleListVaultCaps", () => {
|
|
95
|
+
test("401 with no Authorization header", async () => {
|
|
96
|
+
const res = await handleListVaultCaps(req("/api/vault-caps"), deps());
|
|
97
|
+
expect(res.status).toBe(401);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("403 when bearer lacks parachute:host:admin", async () => {
|
|
101
|
+
const bearer = await makeAdminBearer(["other:scope"]);
|
|
102
|
+
const res = await handleListVaultCaps(withBearer("/api/vault-caps", bearer), deps());
|
|
103
|
+
expect(res.status).toBe(403);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("405 on POST", async () => {
|
|
107
|
+
const bearer = await makeAdminBearer();
|
|
108
|
+
const res = await handleListVaultCaps(
|
|
109
|
+
withBearer("/api/vault-caps", bearer, { method: "POST" }),
|
|
110
|
+
deps(),
|
|
111
|
+
);
|
|
112
|
+
expect(res.status).toBe(405);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("joins services.json vaults with caps, uncapped = null cap_bytes", async () => {
|
|
116
|
+
const bearer = await makeAdminBearer();
|
|
117
|
+
// Only "beta" has a persisted cap; "personal" stays uncapped.
|
|
118
|
+
setVaultCap(harness.db, "beta", 1024 * 1024 * 1024);
|
|
119
|
+
const res = await handleListVaultCaps(withBearer("/api/vault-caps", bearer), deps());
|
|
120
|
+
expect(res.status).toBe(200);
|
|
121
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
122
|
+
const body = (await res.json()) as {
|
|
123
|
+
vault_caps: Array<{ vault_name: string; cap_bytes: number | null }>;
|
|
124
|
+
};
|
|
125
|
+
// Both vaults present, ordered by name (beta, personal).
|
|
126
|
+
expect(body.vault_caps.map((c) => c.vault_name)).toEqual(["beta", "personal"]);
|
|
127
|
+
const beta = body.vault_caps.find((c) => c.vault_name === "beta");
|
|
128
|
+
const personal = body.vault_caps.find((c) => c.vault_name === "personal");
|
|
129
|
+
expect(beta?.cap_bytes).toBe(1024 * 1024 * 1024);
|
|
130
|
+
expect(personal?.cap_bytes).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("handleSetVaultCap", () => {
|
|
135
|
+
test("401 with no Authorization header", async () => {
|
|
136
|
+
const res = await handleSetVaultCap(
|
|
137
|
+
req("/api/vault-caps/beta", {
|
|
138
|
+
method: "PUT",
|
|
139
|
+
headers: { "content-type": "application/json" },
|
|
140
|
+
body: JSON.stringify({ cap_bytes: 1000 }),
|
|
141
|
+
}),
|
|
142
|
+
"beta",
|
|
143
|
+
deps(),
|
|
144
|
+
);
|
|
145
|
+
expect(res.status).toBe(401);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("403 when bearer lacks parachute:host:admin", async () => {
|
|
149
|
+
const bearer = await makeAdminBearer(["other:scope"]);
|
|
150
|
+
const res = await handleSetVaultCap(
|
|
151
|
+
withBearer("/api/vault-caps/beta", bearer, {
|
|
152
|
+
method: "PUT",
|
|
153
|
+
headers: { "content-type": "application/json" },
|
|
154
|
+
body: JSON.stringify({ cap_bytes: 1000 }),
|
|
155
|
+
}),
|
|
156
|
+
"beta",
|
|
157
|
+
deps(),
|
|
158
|
+
);
|
|
159
|
+
expect(res.status).toBe(403);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("400 on a fractional (non-integer) cap", async () => {
|
|
163
|
+
const bearer = await makeAdminBearer();
|
|
164
|
+
const res = await handleSetVaultCap(
|
|
165
|
+
withBearer("/api/vault-caps/beta", bearer, {
|
|
166
|
+
method: "PUT",
|
|
167
|
+
headers: { "content-type": "application/json" },
|
|
168
|
+
body: JSON.stringify({ cap_bytes: 1.5 }),
|
|
169
|
+
}),
|
|
170
|
+
"beta",
|
|
171
|
+
deps(),
|
|
172
|
+
);
|
|
173
|
+
expect(res.status).toBe(400);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("405 on GET", async () => {
|
|
177
|
+
const bearer = await makeAdminBearer();
|
|
178
|
+
const res = await handleSetVaultCap(withBearer("/api/vault-caps/beta", bearer), "beta", deps());
|
|
179
|
+
expect(res.status).toBe(405);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("sets a cap on a registered vault and persists it (upsert)", async () => {
|
|
183
|
+
const bearer = await makeAdminBearer();
|
|
184
|
+
const res = await handleSetVaultCap(
|
|
185
|
+
withBearer("/api/vault-caps/beta", bearer, {
|
|
186
|
+
method: "PUT",
|
|
187
|
+
headers: { "content-type": "application/json" },
|
|
188
|
+
body: JSON.stringify({ cap_bytes: 2 * 1024 * 1024 * 1024 }),
|
|
189
|
+
}),
|
|
190
|
+
"beta",
|
|
191
|
+
deps(),
|
|
192
|
+
);
|
|
193
|
+
expect(res.status).toBe(200);
|
|
194
|
+
const body = (await res.json()) as { vault_cap: { cap_bytes: number } };
|
|
195
|
+
expect(body.vault_cap.cap_bytes).toBe(2 * 1024 * 1024 * 1024);
|
|
196
|
+
expect(getVaultCapBytes(harness.db, "beta")).toBe(2 * 1024 * 1024 * 1024);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("400 vault_not_found for a vault not in services.json", async () => {
|
|
200
|
+
const bearer = await makeAdminBearer();
|
|
201
|
+
const res = await handleSetVaultCap(
|
|
202
|
+
withBearer("/api/vault-caps/ghost", bearer, {
|
|
203
|
+
method: "PUT",
|
|
204
|
+
headers: { "content-type": "application/json" },
|
|
205
|
+
body: JSON.stringify({ cap_bytes: 1000 }),
|
|
206
|
+
}),
|
|
207
|
+
"ghost",
|
|
208
|
+
deps(),
|
|
209
|
+
);
|
|
210
|
+
expect(res.status).toBe(400);
|
|
211
|
+
const body = (await res.json()) as { error: string };
|
|
212
|
+
expect(body.error).toBe("vault_not_found");
|
|
213
|
+
// Nothing persisted for the rejected name.
|
|
214
|
+
expect(getVaultCapBytes(harness.db, "ghost")).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("400 on non-positive cap", async () => {
|
|
218
|
+
const bearer = await makeAdminBearer();
|
|
219
|
+
const res = await handleSetVaultCap(
|
|
220
|
+
withBearer("/api/vault-caps/beta", bearer, {
|
|
221
|
+
method: "PUT",
|
|
222
|
+
headers: { "content-type": "application/json" },
|
|
223
|
+
body: JSON.stringify({ cap_bytes: 0 }),
|
|
224
|
+
}),
|
|
225
|
+
"beta",
|
|
226
|
+
deps(),
|
|
227
|
+
);
|
|
228
|
+
expect(res.status).toBe(400);
|
|
229
|
+
const body = (await res.json()) as { error: string };
|
|
230
|
+
expect(body.error).toBe("invalid_request");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -83,6 +83,26 @@ describe("cli", () => {
|
|
|
83
83
|
expect(code).toBe(1);
|
|
84
84
|
expect(stderr).toMatch(/unknown command/);
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
// hub#693: --hub-origin is persisted to hub_settings.hub_origin and stamps the
|
|
88
|
+
// OAuth `iss` claim, so the CLI must validate it to the same shape as
|
|
89
|
+
// `hub set-origin` BEFORE it reaches init() / the DB. These reject at the arg
|
|
90
|
+
// layer (exit 1) before any daemon work runs.
|
|
91
|
+
test("init --hub-origin with a path component exits 1", async () => {
|
|
92
|
+
const { code, stderr } = await runCli([
|
|
93
|
+
"init",
|
|
94
|
+
"--hub-origin",
|
|
95
|
+
"https://host.example/some/path",
|
|
96
|
+
]);
|
|
97
|
+
expect(code).toBe(1);
|
|
98
|
+
expect(stderr).toMatch(/invalid --hub-origin/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("init --hub-origin with a non-http(s) scheme exits 1", async () => {
|
|
102
|
+
const { code, stderr } = await runCli(["init", "--hub-origin", "ftp://bad.example"]);
|
|
103
|
+
expect(code).toBe(1);
|
|
104
|
+
expect(stderr).toMatch(/invalid --hub-origin/);
|
|
105
|
+
});
|
|
86
106
|
});
|
|
87
107
|
|
|
88
108
|
describe("cli per-subcommand help", () => {
|