@openparachute/hub 0.7.0 → 0.7.2-rc.1

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 (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
@@ -0,0 +1,600 @@
1
+ /**
2
+ * Tests for the optional admin-UI screen-lock (admin-lock.ts +
3
+ * api-admin-lock.ts) and the lock gate's cascade through the four
4
+ * admin-token mint chokepoints.
5
+ *
6
+ * Coverage map (from the feature brief):
7
+ * - PIN set / verify / change / remove.
8
+ * - The gate: locked → host-admin-token mint refused (423); unlocked → mint
9
+ * works; idle-expired → locked again; cascade to agent/vault/module mints.
10
+ * - **OAuth path works while the admin session is locked** (the critical
11
+ * boundary — a surface OAuth'ing in never hits the lock).
12
+ * - Optional: no PIN → no lock, the mint works exactly as today.
13
+ * - argon2 hash is never stored plaintext / never in the cookie.
14
+ * - Brute-force limiter on unlock.
15
+ * - "Lock now" + heartbeat (idle window slide).
16
+ * - First-PIN chicken-and-egg + change/remove authorization.
17
+ */
18
+ import type { Database } from "bun:sqlite";
19
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
20
+ import { createHash, randomBytes } from "node:crypto";
21
+ import { mkdtempSync, rmSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { handleAgentToken } from "../admin-agent-token.ts";
25
+ import { handleHostAdminToken } from "../admin-host-admin-token.ts";
26
+ import {
27
+ DEFAULT_ADMIN_LOCK_IDLE_SECONDS,
28
+ _resetUnlockStateForTest,
29
+ clearPin,
30
+ getIdleSeconds,
31
+ isLockConfigured,
32
+ isSessionUnlocked,
33
+ recordUnlock,
34
+ refreshActivity,
35
+ requireUnlocked,
36
+ setIdleSeconds,
37
+ setPin,
38
+ unlockLimiter,
39
+ validatePin,
40
+ verifyPin,
41
+ } from "../admin-lock.ts";
42
+ import { handleModuleToken } from "../admin-module-token.ts";
43
+ import { handleVaultAdminToken } from "../admin-vault-admin-token.ts";
44
+ import { handleAdminLock } from "../api-admin-lock.ts";
45
+ import { registerClient } from "../clients.ts";
46
+ import { CSRF_COOKIE_NAME } from "../csrf.ts";
47
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
48
+ import { getSetting } from "../hub-settings.ts";
49
+ import { validateAccessToken } from "../jwt-sign.ts";
50
+ import { handleAuthorizePost, handleToken } from "../oauth-handlers.ts";
51
+ import type { ServicesManifest } from "../services-manifest.ts";
52
+ import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
53
+ import { rotateSigningKey } from "../signing-keys.ts";
54
+ import { createUser } from "../users.ts";
55
+
56
+ const ISSUER = "https://hub.test";
57
+ const TEST_CSRF = "csrf-test-token";
58
+ const CSRF_COOKIE = `${CSRF_COOKIE_NAME}=${TEST_CSRF}`;
59
+ const ORIGIN_HEADERS = { "content-type": "application/json", origin: ISSUER };
60
+
61
+ interface Harness {
62
+ db: Database;
63
+ cleanup: () => void;
64
+ }
65
+
66
+ function makeHarness(): Harness {
67
+ const dir = mkdtempSync(join(tmpdir(), "phub-admin-lock-"));
68
+ const db = openHubDb(hubDbPath(dir));
69
+ return {
70
+ db,
71
+ cleanup: () => {
72
+ db.close();
73
+ rmSync(dir, { recursive: true, force: true });
74
+ },
75
+ };
76
+ }
77
+
78
+ let harness: Harness;
79
+ beforeEach(() => {
80
+ harness = makeHarness();
81
+ _resetUnlockStateForTest();
82
+ unlockLimiter.reset();
83
+ });
84
+ afterEach(() => {
85
+ harness.cleanup();
86
+ _resetUnlockStateForTest();
87
+ unlockLimiter.reset();
88
+ });
89
+
90
+ /** Create the first admin + a session, return { cookie, sessionId, userId }. */
91
+ async function withAdmin(): Promise<{ cookie: string; sessionId: string; userId: string }> {
92
+ const user = await createUser(harness.db, "operator", "admin-passphrase");
93
+ const session = createSession(harness.db, { userId: user.id });
94
+ const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`;
95
+ return { cookie, sessionId: session.id, userId: user.id };
96
+ }
97
+
98
+ function lockReq(subpath: string, cookie: string, body: Record<string, unknown>): Request {
99
+ return new Request(`${ISSUER}/api/admin-lock${subpath}`, {
100
+ method: "POST",
101
+ headers: { ...ORIGIN_HEADERS, cookie },
102
+ body: JSON.stringify({ __csrf: TEST_CSRF, ...body }),
103
+ });
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Pure module: validate / hash / verify / idle.
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe("admin-lock pure helpers", () => {
111
+ test("validatePin accepts 4–12 digits, rejects others", () => {
112
+ expect(validatePin("1234").valid).toBe(true);
113
+ expect(validatePin("123456789012").valid).toBe(true);
114
+ expect(validatePin("123").valid).toBe(false); // too short
115
+ expect(validatePin("1234567890123").valid).toBe(false); // too long
116
+ expect(validatePin("12a4").valid).toBe(false); // non-digit
117
+ expect(validatePin("").valid).toBe(false);
118
+ });
119
+
120
+ test("setPin stores an argon2id hash — NOT plaintext", async () => {
121
+ await setPin(harness.db, "4827");
122
+ const stored = getSetting(harness.db, "admin_lock_pin_hash");
123
+ expect(stored).toBeDefined();
124
+ expect(stored).not.toBe("4827");
125
+ expect(stored).not.toContain("4827");
126
+ // argon2id hashes carry the canonical $argon2id$ prefix.
127
+ expect(stored?.startsWith("$argon2id$")).toBe(true);
128
+ });
129
+
130
+ test("verifyPin matches the set PIN and rejects wrong ones", async () => {
131
+ await setPin(harness.db, "4827");
132
+ expect(await verifyPin(harness.db, "4827")).toBe(true);
133
+ expect(await verifyPin(harness.db, "0000")).toBe(false);
134
+ });
135
+
136
+ test("verifyPin returns false when no PIN configured", async () => {
137
+ expect(await verifyPin(harness.db, "anything")).toBe(false);
138
+ });
139
+
140
+ test("isLockConfigured tracks set/clear", async () => {
141
+ expect(isLockConfigured(harness.db)).toBe(false);
142
+ await setPin(harness.db, "4827");
143
+ expect(isLockConfigured(harness.db)).toBe(true);
144
+ clearPin(harness.db);
145
+ expect(isLockConfigured(harness.db)).toBe(false);
146
+ });
147
+
148
+ test("idle seconds default + clamp", () => {
149
+ expect(getIdleSeconds(harness.db)).toBe(DEFAULT_ADMIN_LOCK_IDLE_SECONDS);
150
+ setIdleSeconds(harness.db, 5); // below min → clamped to 60
151
+ expect(getIdleSeconds(harness.db)).toBe(60);
152
+ setIdleSeconds(harness.db, 999_999); // above max → clamped to 24h
153
+ expect(getIdleSeconds(harness.db)).toBe(24 * 60 * 60);
154
+ setIdleSeconds(harness.db, 1200);
155
+ expect(getIdleSeconds(harness.db)).toBe(1200);
156
+ });
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // requireUnlocked — the gate logic itself (clock-injected).
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe("requireUnlocked gate", () => {
164
+ test("feature OFF (no PIN) → always allowed", () => {
165
+ expect(requireUnlocked(harness.db, "sid-1", 1000).ok).toBe(true);
166
+ });
167
+
168
+ test("PIN set + no unlock → locked", async () => {
169
+ await setPin(harness.db, "4827");
170
+ expect(requireUnlocked(harness.db, "sid-1", 1000).ok).toBe(false);
171
+ });
172
+
173
+ test("PIN set + fresh unlock → allowed; the mint is a PURE CHECK (does NOT slide)", async () => {
174
+ await setPin(harness.db, "4827");
175
+ setIdleSeconds(harness.db, 60);
176
+ const t0 = 1_000_000;
177
+ recordUnlock("sid-1", 60, t0);
178
+ const at30s = t0 + 30_000;
179
+ // A mint at 30s is allowed but must NOT extend the window — only genuine
180
+ // USER activity (the heartbeat) slides it; a background poll's mint shouldn't
181
+ // keep an idle tab alive forever.
182
+ expect(requireUnlocked(harness.db, "sid-1", at30s).ok).toBe(true);
183
+ // Original window was t0 + 60s. The mint didn't move it, so at t0 + 61s it's
184
+ // expired/locked.
185
+ expect(isSessionUnlocked("sid-1", t0 + 61_000)).toBe(false);
186
+ });
187
+
188
+ test("refreshActivity (heartbeat) DOES slide the window for an unlocked session", async () => {
189
+ await setPin(harness.db, "4827");
190
+ setIdleSeconds(harness.db, 60);
191
+ const t0 = 1_000_000;
192
+ recordUnlock("sid-1", 60, t0);
193
+ // A heartbeat at 30s re-anchors to 30s + 60s = t0 + 90s.
194
+ refreshActivity("sid-1", 60, t0 + 30_000);
195
+ expect(isSessionUnlocked("sid-1", t0 + 80_000)).toBe(true);
196
+ });
197
+
198
+ test("refreshActivity does NOT unlock a locked session (activity ≠ unlock)", async () => {
199
+ await setPin(harness.db, "4827");
200
+ // No unlock recorded → locked. A heartbeat must not create a window.
201
+ refreshActivity("sid-1", 60, 1_000_000);
202
+ expect(isSessionUnlocked("sid-1", 1_000_000)).toBe(false);
203
+ });
204
+
205
+ test("PIN set + idle-expired → locked again", async () => {
206
+ await setPin(harness.db, "4827");
207
+ setIdleSeconds(harness.db, 60);
208
+ const t0 = 1_000_000;
209
+ recordUnlock("sid-1", 60, t0);
210
+ // 61s later, with no intervening activity → expired → locked.
211
+ expect(requireUnlocked(harness.db, "sid-1", t0 + 61_000).ok).toBe(false);
212
+ });
213
+ });
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // The cascade: a locked session refuses ALL four admin-token mints.
217
+ // ---------------------------------------------------------------------------
218
+
219
+ describe("lock cascade through the four admin-token mints", () => {
220
+ test("host-admin-token: 200 when no PIN, 423 when locked, 200 after unlock", async () => {
221
+ const { cookie, sessionId } = await withAdmin();
222
+ rotateSigningKey(harness.db);
223
+ const mk = () => new Request(`${ISSUER}/admin/host-admin-token`, { headers: { cookie } });
224
+
225
+ // No PIN → today's behavior (200).
226
+ expect((await handleHostAdminToken(mk(), { db: harness.db, issuer: ISSUER })).status).toBe(200);
227
+
228
+ // Set a PIN → locked → 423.
229
+ await setPin(harness.db, "4827");
230
+ const lockedRes = await handleHostAdminToken(mk(), { db: harness.db, issuer: ISSUER });
231
+ expect(lockedRes.status).toBe(423);
232
+ expect(((await lockedRes.json()) as { error: string }).error).toBe("locked");
233
+
234
+ // Unlock → 200.
235
+ recordUnlock(sessionId, getIdleSeconds(harness.db));
236
+ expect((await handleHostAdminToken(mk(), { db: harness.db, issuer: ISSUER })).status).toBe(200);
237
+ });
238
+
239
+ test("agent-token cascades (423 when locked)", async () => {
240
+ const { cookie } = await withAdmin();
241
+ rotateSigningKey(harness.db);
242
+ await setPin(harness.db, "4827");
243
+ const res = await handleAgentToken(
244
+ new Request(`${ISSUER}/admin/agent-token`, { headers: { cookie } }),
245
+ { db: harness.db, issuer: ISSUER },
246
+ );
247
+ expect(res.status).toBe(423);
248
+ });
249
+
250
+ test("vault-admin-token cascades (423 when locked)", async () => {
251
+ const { cookie } = await withAdmin();
252
+ rotateSigningKey(harness.db);
253
+ await setPin(harness.db, "4827");
254
+ const res = await handleVaultAdminToken(
255
+ new Request(`${ISSUER}/admin/vault-admin-token/default`, { headers: { cookie } }),
256
+ "default",
257
+ { db: harness.db, issuer: ISSUER, knownVaultNames: new Set(["default"]) },
258
+ );
259
+ expect(res.status).toBe(423);
260
+ });
261
+
262
+ test("module-token cascades (423 when locked)", async () => {
263
+ const { cookie } = await withAdmin();
264
+ rotateSigningKey(harness.db);
265
+ await setPin(harness.db, "4827");
266
+ const res = await handleModuleToken(
267
+ new Request(`${ISSUER}/admin/module-token/scribe`, { headers: { cookie } }),
268
+ "scribe",
269
+ {
270
+ db: harness.db,
271
+ issuer: ISSUER,
272
+ // scribe is a KNOWN bootstrap short — no module.json read needed.
273
+ readServices: () => [],
274
+ },
275
+ );
276
+ expect(res.status).toBe(423);
277
+ });
278
+ });
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // THE CRITICAL BOUNDARY: OAuth issuance works while the admin UI is locked.
282
+ // ---------------------------------------------------------------------------
283
+
284
+ const FIXTURE_MANIFEST: ServicesManifest = {
285
+ services: [
286
+ {
287
+ name: "parachute-vault",
288
+ port: 1940,
289
+ paths: ["/vault/default"],
290
+ health: "/health",
291
+ version: "0.3.0",
292
+ },
293
+ ],
294
+ };
295
+ function fixtureLoadServicesManifest(): ServicesManifest {
296
+ return FIXTURE_MANIFEST;
297
+ }
298
+ function makePkce() {
299
+ const verifier = randomBytes(32).toString("base64url");
300
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
301
+ return { verifier, challenge };
302
+ }
303
+
304
+ describe("OAuth issuer is unaffected by the admin lock", () => {
305
+ test("a third-party client completes authorize→token while the admin session is locked", async () => {
306
+ const { db } = harness;
307
+ rotateSigningKey(db);
308
+ const user = await createUser(db, "owner", "owner-passphrase");
309
+ const session = createSession(db, { userId: user.id });
310
+
311
+ // Lock the admin surface: set a PIN, leave the admin session locked.
312
+ await setPin(db, "4827");
313
+ // Sanity: the admin token mint IS locked right now.
314
+ const adminMint = await handleHostAdminToken(
315
+ new Request(`${ISSUER}/admin/host-admin-token`, {
316
+ headers: { cookie: buildSessionCookie(session.id, 86400) },
317
+ }),
318
+ { db, issuer: ISSUER },
319
+ );
320
+ expect(adminMint.status).toBe(423);
321
+
322
+ // Now run a full OAuth authorization-code + PKCE flow. This must succeed —
323
+ // the lock lives only on the `/admin/*-token` mints, never on `/oauth/*`.
324
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
325
+ const { verifier, challenge } = makePkce();
326
+ const consentForm = new URLSearchParams({
327
+ __action: "consent",
328
+ __csrf: TEST_CSRF,
329
+ approve: "yes",
330
+ client_id: reg.client.clientId,
331
+ redirect_uri: "https://app.example/cb",
332
+ response_type: "code",
333
+ scope: "vault:read",
334
+ code_challenge: challenge,
335
+ code_challenge_method: "S256",
336
+ vault_pick: "default",
337
+ });
338
+ const consentRes = await handleAuthorizePost(
339
+ db,
340
+ new Request(`${ISSUER}/oauth/authorize`, {
341
+ method: "POST",
342
+ body: consentForm,
343
+ headers: {
344
+ "content-type": "application/x-www-form-urlencoded",
345
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
346
+ },
347
+ }),
348
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
349
+ );
350
+ expect(consentRes.status).toBe(302);
351
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
352
+ expect(code).toBeTruthy();
353
+
354
+ const tokenForm = new URLSearchParams({
355
+ grant_type: "authorization_code",
356
+ code: code ?? "",
357
+ client_id: reg.client.clientId,
358
+ redirect_uri: "https://app.example/cb",
359
+ code_verifier: verifier,
360
+ });
361
+ const tokenRes = await handleToken(
362
+ db,
363
+ new Request(`${ISSUER}/oauth/token`, {
364
+ method: "POST",
365
+ body: tokenForm,
366
+ headers: { "content-type": "application/x-www-form-urlencoded" },
367
+ }),
368
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
369
+ );
370
+ // The OAuth token endpoint issues normally despite the locked admin UI.
371
+ expect(tokenRes.status).toBe(200);
372
+ const body = (await tokenRes.json()) as { access_token: string; scope: string };
373
+ expect(body.scope).toBe("vault:default:read");
374
+ const { payload } = await validateAccessToken(db, body.access_token, ISSUER);
375
+ expect(payload.aud).toBe("vault.default");
376
+ });
377
+ });
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // The management API: status, set/change/remove/unlock/lock/heartbeat.
381
+ // ---------------------------------------------------------------------------
382
+
383
+ describe("admin-lock management API", () => {
384
+ test("GET status reflects unconfigured → configured → locked/unlocked", async () => {
385
+ const { cookie, sessionId } = await withAdmin();
386
+ const statusReq = () =>
387
+ new Request(`${ISSUER}/api/admin-lock`, { method: "GET", headers: { cookie } });
388
+
389
+ let res = await handleAdminLock(statusReq(), "", { db: harness.db });
390
+ expect(res.status).toBe(200);
391
+ let body = (await res.json()) as { configured: boolean; locked: boolean };
392
+ expect(body).toMatchObject({ configured: false, locked: false });
393
+
394
+ await setPin(harness.db, "4827");
395
+ res = await handleAdminLock(statusReq(), "", { db: harness.db });
396
+ body = (await res.json()) as { configured: boolean; locked: boolean };
397
+ expect(body).toMatchObject({ configured: true, locked: true });
398
+
399
+ recordUnlock(sessionId, getIdleSeconds(harness.db));
400
+ res = await handleAdminLock(statusReq(), "", { db: harness.db });
401
+ body = (await res.json()) as { configured: boolean; locked: boolean };
402
+ expect(body).toMatchObject({ configured: true, locked: false });
403
+ });
404
+
405
+ test("set first PIN → 201; setting again → 409", async () => {
406
+ const { cookie } = await withAdmin();
407
+ const ok = await handleAdminLock(lockReq("/set", cookie, { pin: "4827" }), "/set", {
408
+ db: harness.db,
409
+ });
410
+ expect(ok.status).toBe(201);
411
+ expect(isLockConfigured(harness.db)).toBe(true);
412
+ // hash is not the plaintext.
413
+ expect(getSetting(harness.db, "admin_lock_pin_hash")).not.toContain("4827");
414
+
415
+ const again = await handleAdminLock(lockReq("/set", cookie, { pin: "1111" }), "/set", {
416
+ db: harness.db,
417
+ });
418
+ expect(again.status).toBe(409);
419
+ });
420
+
421
+ test("set rejects a bad PIN format with 400", async () => {
422
+ const { cookie } = await withAdmin();
423
+ const res = await handleAdminLock(lockReq("/set", cookie, { pin: "ab" }), "/set", {
424
+ db: harness.db,
425
+ });
426
+ expect(res.status).toBe(400);
427
+ });
428
+
429
+ test("unlock with correct PIN opens the window; wrong PIN → 401", async () => {
430
+ const { cookie, sessionId } = await withAdmin();
431
+ await setPin(harness.db, "4827");
432
+
433
+ const bad = await handleAdminLock(lockReq("/unlock", cookie, { pin: "0000" }), "/unlock", {
434
+ db: harness.db,
435
+ });
436
+ expect(bad.status).toBe(401);
437
+ expect(isSessionUnlocked(sessionId)).toBe(false);
438
+
439
+ const good = await handleAdminLock(lockReq("/unlock", cookie, { pin: "4827" }), "/unlock", {
440
+ db: harness.db,
441
+ });
442
+ expect(good.status).toBe(200);
443
+ expect(isSessionUnlocked(sessionId)).toBe(true);
444
+ });
445
+
446
+ test("change requires current PIN (or unlocked session)", async () => {
447
+ const { cookie, sessionId } = await withAdmin();
448
+ await setPin(harness.db, "4827");
449
+
450
+ // Locked + wrong current_pin → 401.
451
+ const wrong = await handleAdminLock(
452
+ lockReq("/change", cookie, { current_pin: "0000", new_pin: "1111" }),
453
+ "/change",
454
+ { db: harness.db },
455
+ );
456
+ expect(wrong.status).toBe(401);
457
+
458
+ // Correct current_pin → 200, and the new PIN verifies.
459
+ const ok = await handleAdminLock(
460
+ lockReq("/change", cookie, { current_pin: "4827", new_pin: "1111" }),
461
+ "/change",
462
+ { db: harness.db },
463
+ );
464
+ expect(ok.status).toBe(200);
465
+ expect(await verifyPin(harness.db, "1111")).toBe(true);
466
+ expect(await verifyPin(harness.db, "4827")).toBe(false);
467
+ // Changing re-opens the window for this session.
468
+ expect(isSessionUnlocked(sessionId)).toBe(true);
469
+ });
470
+
471
+ test("change works from an already-unlocked session without current_pin", async () => {
472
+ const { cookie, sessionId } = await withAdmin();
473
+ await setPin(harness.db, "4827");
474
+ recordUnlock(sessionId, getIdleSeconds(harness.db));
475
+ const ok = await handleAdminLock(lockReq("/change", cookie, { new_pin: "1111" }), "/change", {
476
+ db: harness.db,
477
+ });
478
+ expect(ok.status).toBe(200);
479
+ expect(await verifyPin(harness.db, "1111")).toBe(true);
480
+ });
481
+
482
+ test("remove requires current PIN; turns the feature OFF", async () => {
483
+ const { cookie } = await withAdmin();
484
+ await setPin(harness.db, "4827");
485
+
486
+ const wrong = await handleAdminLock(
487
+ lockReq("/remove", cookie, { current_pin: "0000" }),
488
+ "/remove",
489
+ { db: harness.db },
490
+ );
491
+ expect(wrong.status).toBe(401);
492
+ expect(isLockConfigured(harness.db)).toBe(true);
493
+
494
+ const ok = await handleAdminLock(
495
+ lockReq("/remove", cookie, { current_pin: "4827" }),
496
+ "/remove",
497
+ { db: harness.db },
498
+ );
499
+ expect(ok.status).toBe(200);
500
+ expect(isLockConfigured(harness.db)).toBe(false);
501
+ });
502
+
503
+ test("lock-now drops the unlock window", async () => {
504
+ const { cookie, sessionId } = await withAdmin();
505
+ await setPin(harness.db, "4827");
506
+ recordUnlock(sessionId, getIdleSeconds(harness.db));
507
+ expect(isSessionUnlocked(sessionId)).toBe(true);
508
+
509
+ const res = await handleAdminLock(lockReq("/lock", cookie, {}), "/lock", { db: harness.db });
510
+ expect(res.status).toBe(200);
511
+ expect(isSessionUnlocked(sessionId)).toBe(false);
512
+ });
513
+
514
+ test("heartbeat slides the window but does NOT unlock a locked session", async () => {
515
+ const { cookie, sessionId } = await withAdmin();
516
+ await setPin(harness.db, "4827");
517
+ // Locked: heartbeat must not unlock.
518
+ const r1 = await handleAdminLock(lockReq("/heartbeat", cookie, {}), "/heartbeat", {
519
+ db: harness.db,
520
+ });
521
+ expect(r1.status).toBe(200);
522
+ expect(((await r1.json()) as { locked: boolean }).locked).toBe(true);
523
+ expect(isSessionUnlocked(sessionId)).toBe(false);
524
+
525
+ // Unlocked: heartbeat keeps it alive.
526
+ recordUnlock(sessionId, getIdleSeconds(harness.db));
527
+ const r2 = await handleAdminLock(lockReq("/heartbeat", cookie, {}), "/heartbeat", {
528
+ db: harness.db,
529
+ });
530
+ expect(((await r2.json()) as { locked: boolean }).locked).toBe(false);
531
+ });
532
+
533
+ test("unlock brute-force limiter: 6th attempt is 429", async () => {
534
+ const { cookie } = await withAdmin();
535
+ await setPin(harness.db, "4827");
536
+ // 5 wrong attempts are allowed (and fail 401), the 6th is rate-limited.
537
+ for (let i = 0; i < 5; i++) {
538
+ const res = await handleAdminLock(lockReq("/unlock", cookie, { pin: "0000" }), "/unlock", {
539
+ db: harness.db,
540
+ });
541
+ expect(res.status).toBe(401);
542
+ }
543
+ const limited = await handleAdminLock(lockReq("/unlock", cookie, { pin: "0000" }), "/unlock", {
544
+ db: harness.db,
545
+ });
546
+ expect(limited.status).toBe(429);
547
+ expect(limited.headers.get("retry-after")).toBeTruthy();
548
+ });
549
+
550
+ test("management endpoints require an admin session (401) and the first-admin (403)", async () => {
551
+ // No session.
552
+ const noSession = await handleAdminLock(
553
+ new Request(`${ISSUER}/api/admin-lock`, { method: "GET" }),
554
+ "",
555
+ { db: harness.db },
556
+ );
557
+ expect(noSession.status).toBe(401);
558
+
559
+ // A non-first-admin friend is 403.
560
+ await createUser(harness.db, "operator", "admin-passphrase");
561
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", { allowMulti: true });
562
+ const friendSession = createSession(harness.db, { userId: friend.id });
563
+ const friendCookie = `${CSRF_COOKIE}; ${buildSessionCookie(friendSession.id, 86400)}`;
564
+ const res = await handleAdminLock(
565
+ new Request(`${ISSUER}/api/admin-lock`, { method: "GET", headers: { cookie: friendCookie } }),
566
+ "",
567
+ { db: harness.db },
568
+ );
569
+ expect(res.status).toBe(403);
570
+ });
571
+
572
+ test("POST without a CSRF token is rejected (403)", async () => {
573
+ const { cookie } = await withAdmin();
574
+ const res = await handleAdminLock(
575
+ new Request(`${ISSUER}/api/admin-lock/set`, {
576
+ method: "POST",
577
+ headers: { ...ORIGIN_HEADERS, cookie },
578
+ body: JSON.stringify({ pin: "4827" }), // no __csrf
579
+ }),
580
+ "/set",
581
+ { db: harness.db },
582
+ );
583
+ expect(res.status).toBe(403);
584
+ });
585
+
586
+ test("the PIN hash never appears in any Set-Cookie header", async () => {
587
+ const { cookie } = await withAdmin();
588
+ const setRes = await handleAdminLock(lockReq("/set", cookie, { pin: "4827" }), "/set", {
589
+ db: harness.db,
590
+ });
591
+ // No cookie is set by lock management at all, and certainly not the hash.
592
+ const hash = getSetting(harness.db, "admin_lock_pin_hash") ?? "";
593
+ expect(setRes.headers.get("set-cookie")).toBeNull();
594
+ expect(JSON.stringify([...setRes.headers.entries()])).not.toContain(hash);
595
+ // And the response body never echoes the hash or the PIN.
596
+ const text = await setRes.text();
597
+ expect(text).not.toContain("4827");
598
+ expect(text).not.toContain(hash);
599
+ });
600
+ });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Tests for the GENERIC per-module config-UI session→bearer mint endpoint
3
3
  * (`GET /admin/module-token/<short>`, 2026-06-09 modular-UI architecture P3).
4
- * Mirrors `admin-channel-token.test.ts` shape (single bare audience per
4
+ * Mirrors `admin-agent-token.test.ts` shape (single bare audience per
5
5
  * module). Covers:
6
6
  * - 401 when no admin session cookie is present.
7
7
  * - 401 when the cookie names a deleted session.
@@ -112,7 +112,7 @@ describe("handleModuleToken", () => {
112
112
 
113
113
  // The known single-audience modules the generic mint serves. Each gets
114
114
  // `<short>:admin` with `aud: <short>`.
115
- for (const short of ["scribe", "runner", "surface", "channel"]) {
115
+ for (const short of ["scribe", "runner", "surface", "agent"]) {
116
116
  test(`200 mints a JWT carrying aud:${short} + ${short}:admin`, async () => {
117
117
  const { cookie, userId } = await withSession();
118
118
  rotateSigningKey(harness.db);
@@ -284,26 +284,54 @@ describe("handleModuleToken", () => {
284
284
  expect(body.error).toBe("use_vault_admin_token");
285
285
  });
286
286
 
287
- test("first-party row resolves through the manifest-name map (parachute-channelchannel)", async () => {
287
+ test("first-party row resolves through the manifest-name map (parachute-agentagent)", async () => {
288
288
  const { cookie } = await withSession();
289
289
  rotateSigningKey(harness.db);
290
- const installDir = writeManifestDir("channel");
290
+ const installDir = writeManifestDir("agent");
291
+ try {
292
+ const services: ServiceEntry[] = [
293
+ {
294
+ name: "parachute-agent",
295
+ port: 1941,
296
+ paths: ["/agent"],
297
+ health: "/health",
298
+ version: "0.1.0",
299
+ installDir,
300
+ },
301
+ ];
302
+ const req = new Request(urlFor("agent"), { headers: { cookie } });
303
+ const res = await handleModuleToken(req, "agent", depsWith(services));
304
+ expect(res.status).toBe(200);
305
+ const body = (await res.json()) as { scopes: string[] };
306
+ expect(body.scopes).toEqual(["agent:admin"]);
307
+ } finally {
308
+ rmSync(installDir, { recursive: true, force: true });
309
+ }
310
+ });
311
+
312
+ test("a legacy parachute-channel row still resolves to short `agent` (rename back-compat)", async () => {
313
+ // Un-upgraded operators carry a `parachute-channel` services.json row;
314
+ // LEGACY_MANIFEST_ALIASES maps it to short `agent` so the agent config UI
315
+ // can still mint its admin Bearer until the daemon re-registers.
316
+ const { cookie } = await withSession();
317
+ rotateSigningKey(harness.db);
318
+ const installDir = writeManifestDir("agent");
291
319
  try {
292
320
  const services: ServiceEntry[] = [
293
321
  {
294
322
  name: "parachute-channel",
295
323
  port: 1941,
296
- paths: ["/channel"],
324
+ paths: ["/agent"],
297
325
  health: "/health",
298
326
  version: "0.1.0",
299
327
  installDir,
300
328
  },
301
329
  ];
302
- const req = new Request(urlFor("channel"), { headers: { cookie } });
303
- const res = await handleModuleToken(req, "channel", depsWith(services));
330
+ const req = new Request(urlFor("agent"), { headers: { cookie } });
331
+ const res = await handleModuleToken(req, "agent", depsWith(services));
304
332
  expect(res.status).toBe(200);
305
333
  const body = (await res.json()) as { scopes: string[] };
306
- expect(body.scopes).toEqual(["channel:admin"]);
334
+ expect(body.scopes).toEqual(["agent:admin"]);
307
335
  } finally {
308
336
  rmSync(installDir, { recursive: true, force: true });
309
337
  }