@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.
- package/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +275 -5
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +980 -61
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +21 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +35 -5
- package/src/grants-store.ts +272 -0
- package/src/help.ts +17 -7
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +710 -39
- package/src/hub-settings.ts +23 -8
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +11 -1
- package/src/module-manifest.ts +109 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +13 -5
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +56 -240
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- 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-
|
|
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", "
|
|
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-
|
|
287
|
+
test("first-party row resolves through the manifest-name map (parachute-agent ↔ agent)", async () => {
|
|
288
288
|
const { cookie } = await withSession();
|
|
289
289
|
rotateSigningKey(harness.db);
|
|
290
|
-
const installDir = writeManifestDir("
|
|
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: ["/
|
|
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("
|
|
303
|
-
const res = await handleModuleToken(req, "
|
|
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(["
|
|
334
|
+
expect(body.scopes).toEqual(["agent:admin"]);
|
|
307
335
|
} finally {
|
|
308
336
|
rmSync(installDir, { recursive: true, force: true });
|
|
309
337
|
}
|