@openparachute/hub 0.7.2 → 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.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-agent-grants.test.ts +113 -0
  3. package/src/__tests__/admin-vaults.test.ts +20 -5
  4. package/src/__tests__/api-modules.test.ts +65 -10
  5. package/src/__tests__/api-vault-caps.test.ts +232 -0
  6. package/src/__tests__/cli.test.ts +20 -0
  7. package/src/__tests__/hub-command.test.ts +136 -0
  8. package/src/__tests__/hub-origins-env-set.test.ts +273 -0
  9. package/src/__tests__/init.test.ts +148 -0
  10. package/src/__tests__/jwt-sign.test.ts +79 -0
  11. package/src/__tests__/module-manifest.test.ts +3 -1
  12. package/src/__tests__/oauth-client.test.ts +75 -0
  13. package/src/__tests__/oauth-handlers.test.ts +413 -5
  14. package/src/__tests__/public-signup.test.ts +619 -0
  15. package/src/__tests__/rate-limit.test.ts +86 -0
  16. package/src/__tests__/scribe-config.test.ts +117 -0
  17. package/src/__tests__/serve-boot.test.ts +45 -0
  18. package/src/__tests__/serve.test.ts +67 -1
  19. package/src/__tests__/service-spec-discovery.test.ts +22 -2
  20. package/src/__tests__/setup-wizard.test.ts +18 -0
  21. package/src/__tests__/setup.test.ts +129 -15
  22. package/src/__tests__/two-factor-flow.test.ts +94 -0
  23. package/src/__tests__/users.test.ts +33 -0
  24. package/src/__tests__/vault-caps.test.ts +89 -0
  25. package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
  26. package/src/__tests__/wizard-transcription.test.ts +334 -0
  27. package/src/__tests__/wizard.test.ts +146 -0
  28. package/src/account-setup.ts +118 -32
  29. package/src/admin-agent-grants.ts +51 -3
  30. package/src/admin-handlers.ts +53 -16
  31. package/src/admin-login-ui.ts +30 -4
  32. package/src/admin-vaults.ts +9 -1
  33. package/src/api-invites.ts +163 -3
  34. package/src/api-modules-ops.ts +12 -0
  35. package/src/api-modules.ts +47 -13
  36. package/src/api-users.ts +3 -0
  37. package/src/api-vault-caps.ts +206 -0
  38. package/src/cli.ts +35 -1
  39. package/src/commands/hub.ts +173 -0
  40. package/src/commands/init.ts +73 -2
  41. package/src/commands/serve-boot.ts +16 -2
  42. package/src/commands/serve.ts +39 -3
  43. package/src/commands/setup.ts +31 -6
  44. package/src/commands/wizard-transcription.ts +296 -0
  45. package/src/commands/wizard.ts +73 -3
  46. package/src/help.ts +8 -0
  47. package/src/hub-db.ts +108 -1
  48. package/src/hub-origin.ts +64 -0
  49. package/src/hub-server.ts +27 -0
  50. package/src/invites.ts +155 -31
  51. package/src/jwt-sign.ts +72 -3
  52. package/src/module-manifest.ts +23 -9
  53. package/src/oauth-client.ts +102 -12
  54. package/src/oauth-flows-store.ts +12 -0
  55. package/src/oauth-handlers.ts +145 -20
  56. package/src/rate-limit.ts +111 -20
  57. package/src/scribe-config.ts +145 -0
  58. package/src/service-spec.ts +31 -12
  59. package/src/setup-wizard.ts +37 -4
  60. package/src/users.ts +62 -3
  61. package/src/vault-caps.ts +109 -0
  62. package/src/vault-hub-origin-env.ts +109 -16
  63. package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
  64. package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/web/ui/dist/assets/index-B5AUE359.js +0 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.2",
3
+ "version": "0.7.3-rc.10",
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": {
@@ -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
- db.prepare("UPDATE invites SET used_at = ?, redeemed_user_id = ? WHERE token = ?").run(
1030
- new Date().toISOString(),
1031
- alice.id,
1032
- redeemedWork.invite.tokenHash,
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, the rest
223
- // (agent/runner/notes) as `experimental` — so the agent-not-installed
224
- // class (running but invisible) can't recur.
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
- expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("runner"));
249
- // Every known module is discoverable — vault/scribe/surface (core) +
250
- // agent/runner/notes (experimental).
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("experimental");
261
- expect(byShort.get("notes")?.focus).toBe("experimental");
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", () => {