@openparachute/hub 0.5.13-rc.46 → 0.5.13-rc.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/grants.test.ts +143 -0
- package/src/__tests__/hub-server.test.ts +13 -4
- package/src/__tests__/oauth-handlers.test.ts +196 -0
- package/src/admin-vaults.ts +3 -2
- package/src/api-modules-config.ts +3 -2
- package/src/api-modules.ts +4 -2
- package/src/grants.ts +71 -0
- package/src/hub-server.ts +10 -13
- package/src/oauth-handlers.ts +72 -2
- package/src/services-manifest.ts +9 -5
- package/src/setup-wizard.ts +4 -4
package/package.json
CHANGED
|
@@ -5,7 +5,9 @@ import { join } from "node:path";
|
|
|
5
5
|
import { registerClient } from "../clients.ts";
|
|
6
6
|
import {
|
|
7
7
|
findGrant,
|
|
8
|
+
findGrantByClientName,
|
|
8
9
|
isCoveredByGrant,
|
|
10
|
+
isCoveredByGrantForClientName,
|
|
9
11
|
listGrantsForUser,
|
|
10
12
|
recordGrant,
|
|
11
13
|
revokeGrant,
|
|
@@ -162,3 +164,144 @@ describe("grants module (#75)", () => {
|
|
|
162
164
|
}
|
|
163
165
|
});
|
|
164
166
|
});
|
|
167
|
+
|
|
168
|
+
describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () => {
|
|
169
|
+
test("returns the most recent grant across any client_id with the matching name", async () => {
|
|
170
|
+
// Closes hub#409: CLI MCP clients re-DCR each session, each landing
|
|
171
|
+
// fresh client_ids. Operator approves once by name; future DCRs of
|
|
172
|
+
// the same name should auto-trust.
|
|
173
|
+
const h = await harness();
|
|
174
|
+
try {
|
|
175
|
+
// First DCR: client_name="claude-code", scope a+b
|
|
176
|
+
const reg1 = registerClient(h.db, {
|
|
177
|
+
redirectUris: ["https://app.example/cb"],
|
|
178
|
+
clientName: "claude-code",
|
|
179
|
+
});
|
|
180
|
+
recordGrant(h.db, h.userId, reg1.client.clientId, ["a", "b"], new Date("2026-04-10T00:00:00Z"));
|
|
181
|
+
// Second DCR: same client_name="claude-code", fresh client_id, no grant yet
|
|
182
|
+
const reg2 = registerClient(h.db, {
|
|
183
|
+
redirectUris: ["https://app.example/cb"],
|
|
184
|
+
clientName: "claude-code",
|
|
185
|
+
});
|
|
186
|
+
// findGrantByClientName should return the prior grant
|
|
187
|
+
const grant = findGrantByClientName(h.db, h.userId, "claude-code");
|
|
188
|
+
expect(grant).not.toBeNull();
|
|
189
|
+
expect(grant?.clientId).toBe(reg1.client.clientId);
|
|
190
|
+
expect(grant?.scopes).toEqual(["a", "b"]);
|
|
191
|
+
} finally {
|
|
192
|
+
h.cleanup();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("returns null when no client with that name has any grant", async () => {
|
|
197
|
+
const h = await harness();
|
|
198
|
+
try {
|
|
199
|
+
registerClient(h.db, {
|
|
200
|
+
redirectUris: ["https://app.example/cb"],
|
|
201
|
+
clientName: "claude-code",
|
|
202
|
+
});
|
|
203
|
+
// No grants recorded
|
|
204
|
+
expect(findGrantByClientName(h.db, h.userId, "claude-code")).toBeNull();
|
|
205
|
+
} finally {
|
|
206
|
+
h.cleanup();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("returns null when client_name is empty string", async () => {
|
|
211
|
+
const h = await harness();
|
|
212
|
+
try {
|
|
213
|
+
expect(findGrantByClientName(h.db, h.userId, "")).toBeNull();
|
|
214
|
+
} finally {
|
|
215
|
+
h.cleanup();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("returns null for a different user (per-user isolation)", async () => {
|
|
220
|
+
const h = await harness();
|
|
221
|
+
try {
|
|
222
|
+
const reg = registerClient(h.db, {
|
|
223
|
+
redirectUris: ["https://app.example/cb"],
|
|
224
|
+
clientName: "claude-code",
|
|
225
|
+
});
|
|
226
|
+
recordGrant(h.db, h.userId, reg.client.clientId, ["a"]);
|
|
227
|
+
// Another user — should NOT see the grant. (hub is single-user-by-
|
|
228
|
+
// default; pass allowMulti for the test.)
|
|
229
|
+
const other = await createUser(h.db, "other-user", "pw", { allowMulti: true });
|
|
230
|
+
expect(findGrantByClientName(h.db, other.id, "claude-code")).toBeNull();
|
|
231
|
+
} finally {
|
|
232
|
+
h.cleanup();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("picks the most recent when multiple clients share the name", async () => {
|
|
237
|
+
const h = await harness();
|
|
238
|
+
try {
|
|
239
|
+
const reg1 = registerClient(h.db, {
|
|
240
|
+
redirectUris: ["https://app.example/cb"],
|
|
241
|
+
clientName: "claude-code",
|
|
242
|
+
});
|
|
243
|
+
const reg2 = registerClient(h.db, {
|
|
244
|
+
redirectUris: ["https://app.example/cb"],
|
|
245
|
+
clientName: "claude-code",
|
|
246
|
+
});
|
|
247
|
+
const reg3 = registerClient(h.db, {
|
|
248
|
+
redirectUris: ["https://app.example/cb"],
|
|
249
|
+
clientName: "claude-code",
|
|
250
|
+
});
|
|
251
|
+
recordGrant(h.db, h.userId, reg1.client.clientId, ["a"], new Date("2026-04-01T00:00:00Z"));
|
|
252
|
+
recordGrant(h.db, h.userId, reg3.client.clientId, ["a", "c"], new Date("2026-04-15T00:00:00Z"));
|
|
253
|
+
recordGrant(h.db, h.userId, reg2.client.clientId, ["a", "b"], new Date("2026-04-10T00:00:00Z"));
|
|
254
|
+
const grant = findGrantByClientName(h.db, h.userId, "claude-code");
|
|
255
|
+
// Most recent = reg3's grant (2026-04-15)
|
|
256
|
+
expect(grant?.clientId).toBe(reg3.client.clientId);
|
|
257
|
+
expect(grant?.scopes).toEqual(["a", "c"]);
|
|
258
|
+
} finally {
|
|
259
|
+
h.cleanup();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("isCoveredByGrantForClientName: subset of stored scopes → true", async () => {
|
|
264
|
+
const h = await harness();
|
|
265
|
+
try {
|
|
266
|
+
const reg = registerClient(h.db, {
|
|
267
|
+
redirectUris: ["https://app.example/cb"],
|
|
268
|
+
clientName: "claude-code",
|
|
269
|
+
});
|
|
270
|
+
recordGrant(h.db, h.userId, reg.client.clientId, ["vault:default:read", "vault:default:write"]);
|
|
271
|
+
expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read"])).toBe(true);
|
|
272
|
+
expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read", "vault:default:write"])).toBe(true);
|
|
273
|
+
} finally {
|
|
274
|
+
h.cleanup();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("isCoveredByGrantForClientName: superset of stored scopes → false", async () => {
|
|
279
|
+
const h = await harness();
|
|
280
|
+
try {
|
|
281
|
+
const reg = registerClient(h.db, {
|
|
282
|
+
redirectUris: ["https://app.example/cb"],
|
|
283
|
+
clientName: "claude-code",
|
|
284
|
+
});
|
|
285
|
+
recordGrant(h.db, h.userId, reg.client.clientId, ["vault:default:read"]);
|
|
286
|
+
// Asking for write — not previously granted
|
|
287
|
+
expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:write"])).toBe(false);
|
|
288
|
+
expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read", "vault:default:write"])).toBe(false);
|
|
289
|
+
} finally {
|
|
290
|
+
h.cleanup();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("isCoveredByGrantForClientName: empty scopes → false (matches isCoveredByGrant contract)", async () => {
|
|
295
|
+
const h = await harness();
|
|
296
|
+
try {
|
|
297
|
+
const reg = registerClient(h.db, {
|
|
298
|
+
redirectUris: ["https://app.example/cb"],
|
|
299
|
+
clientName: "claude-code",
|
|
300
|
+
});
|
|
301
|
+
recordGrant(h.db, h.userId, reg.client.clientId, ["a"]);
|
|
302
|
+
expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", [])).toBe(false);
|
|
303
|
+
} finally {
|
|
304
|
+
h.cleanup();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -510,17 +510,26 @@ describe("hubFetch routing", () => {
|
|
|
510
510
|
}
|
|
511
511
|
});
|
|
512
512
|
|
|
513
|
-
test("malformed services.json
|
|
513
|
+
test("malformed services.json yields empty doc (lenient read) + CORS, not a crash (hub#406)", async () => {
|
|
514
|
+
// Pre-#406 behavior: strict readManifest threw → /.well-known/parachute.json
|
|
515
|
+
// returned 500. That cascaded into broken discovery for operators who
|
|
516
|
+
// had any kind of services.json corruption.
|
|
517
|
+
//
|
|
518
|
+
// Post-#406: readManifestLenient catches the parse error + logs +
|
|
519
|
+
// returns {services: []}. Well-known builds successfully with an empty
|
|
520
|
+
// services list, so discovery clients get a valid (empty) doc and the
|
|
521
|
+
// operator sees "no services here" rather than a generic 500.
|
|
514
522
|
const h = makeHarness();
|
|
515
523
|
try {
|
|
516
524
|
writeFileSync(h.manifestPath, "{ not json");
|
|
517
525
|
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
518
526
|
req("/.well-known/parachute.json"),
|
|
519
527
|
);
|
|
520
|
-
expect(res.status).toBe(
|
|
528
|
+
expect(res.status).toBe(200);
|
|
521
529
|
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
522
|
-
const body = (await res.json()) as {
|
|
523
|
-
expect(body.
|
|
530
|
+
const body = (await res.json()) as { vaults: unknown[]; services: unknown[] };
|
|
531
|
+
expect(body.vaults).toEqual([]);
|
|
532
|
+
expect(body.services).toEqual([]);
|
|
524
533
|
} finally {
|
|
525
534
|
h.cleanup();
|
|
526
535
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
setSetting,
|
|
14
14
|
} from "../hub-settings.ts";
|
|
15
15
|
import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
|
|
16
|
+
import { findGrant, recordGrant } from "../grants.ts";
|
|
16
17
|
import {
|
|
17
18
|
authorizationServerMetadata,
|
|
18
19
|
buildServicesCatalog,
|
|
@@ -6392,3 +6393,198 @@ describe("handleAuthorizeGet — stale assignment gates both fast-paths (hub#284
|
|
|
6392
6393
|
}
|
|
6393
6394
|
});
|
|
6394
6395
|
});
|
|
6396
|
+
|
|
6397
|
+
describe("handleAuthorizeGet — trust-by-client_name auto-approve (hub#409)", () => {
|
|
6398
|
+
test("happy path: session + same-origin + prior grant for client_name → 302 to redirect_uri with code", async () => {
|
|
6399
|
+
// The exact scenario hub#409 closes: operator approved "claude-code"
|
|
6400
|
+
// last session; this session, Claude re-DCRs a fresh client_id with
|
|
6401
|
+
// the same client_name; operator should NOT see the approve-pending
|
|
6402
|
+
// screen — the flow goes straight to the authorize-code redirect.
|
|
6403
|
+
const { db, cleanup } = await makeDb();
|
|
6404
|
+
try {
|
|
6405
|
+
const user = await createUser(db, "owner", "pw");
|
|
6406
|
+
const session = createSession(db, { userId: user.id });
|
|
6407
|
+
// 1. Prior client + grant (the "previously approved" state)
|
|
6408
|
+
const prior = registerClient(db, {
|
|
6409
|
+
redirectUris: ["https://app.example/cb"],
|
|
6410
|
+
status: "approved",
|
|
6411
|
+
clientName: "claude-code",
|
|
6412
|
+
});
|
|
6413
|
+
recordGrant(db, user.id, prior.client.clientId, ["vault:default:read"]);
|
|
6414
|
+
// 2. Fresh DCR — same client_name, fresh client_id, status=pending
|
|
6415
|
+
const fresh = registerClient(db, {
|
|
6416
|
+
redirectUris: ["https://app.example/cb"],
|
|
6417
|
+
status: "pending",
|
|
6418
|
+
clientName: "claude-code",
|
|
6419
|
+
});
|
|
6420
|
+
const { challenge } = makePkce();
|
|
6421
|
+
const req = new Request(
|
|
6422
|
+
authorizeUrl({
|
|
6423
|
+
client_id: fresh.client.clientId,
|
|
6424
|
+
redirect_uri: "https://app.example/cb",
|
|
6425
|
+
response_type: "code",
|
|
6426
|
+
code_challenge: challenge,
|
|
6427
|
+
code_challenge_method: "S256",
|
|
6428
|
+
scope: "vault:default:read",
|
|
6429
|
+
state: "trust-by-name",
|
|
6430
|
+
}),
|
|
6431
|
+
{
|
|
6432
|
+
headers: {
|
|
6433
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
6434
|
+
origin: ISSUER,
|
|
6435
|
+
},
|
|
6436
|
+
},
|
|
6437
|
+
);
|
|
6438
|
+
const res = handleAuthorizeGet(db, req, {
|
|
6439
|
+
issuer: ISSUER,
|
|
6440
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
6441
|
+
});
|
|
6442
|
+
// 302 to redirect_uri with code — NOT a 403 with approve-pending HTML.
|
|
6443
|
+
expect(res.status).toBe(302);
|
|
6444
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
6445
|
+
expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
|
|
6446
|
+
expect(loc.searchParams.get("code")?.length).toBeGreaterThan(20);
|
|
6447
|
+
expect(loc.searchParams.get("state")).toBe("trust-by-name");
|
|
6448
|
+
// The fresh client_id is now approved
|
|
6449
|
+
const after = getClient(db, fresh.client.clientId);
|
|
6450
|
+
expect(after?.status).toBe("approved");
|
|
6451
|
+
// A grant was recorded for the new client_id
|
|
6452
|
+
expect(findGrant(db, user.id, fresh.client.clientId)).not.toBeNull();
|
|
6453
|
+
} finally {
|
|
6454
|
+
cleanup();
|
|
6455
|
+
}
|
|
6456
|
+
});
|
|
6457
|
+
|
|
6458
|
+
test("falls through to approve-pending when requested scope is NOT covered by prior grant (superset)", async () => {
|
|
6459
|
+
const { db, cleanup } = await makeDb();
|
|
6460
|
+
try {
|
|
6461
|
+
const user = await createUser(db, "owner", "pw");
|
|
6462
|
+
const session = createSession(db, { userId: user.id });
|
|
6463
|
+
const prior = registerClient(db, {
|
|
6464
|
+
redirectUris: ["https://app.example/cb"],
|
|
6465
|
+
status: "approved",
|
|
6466
|
+
clientName: "claude-code",
|
|
6467
|
+
});
|
|
6468
|
+
// Prior grant covers READ only
|
|
6469
|
+
recordGrant(db, user.id, prior.client.clientId, ["vault:default:read"]);
|
|
6470
|
+
const fresh = registerClient(db, {
|
|
6471
|
+
redirectUris: ["https://app.example/cb"],
|
|
6472
|
+
status: "pending",
|
|
6473
|
+
clientName: "claude-code",
|
|
6474
|
+
});
|
|
6475
|
+
const { challenge } = makePkce();
|
|
6476
|
+
// Asking for WRITE — not in prior grant
|
|
6477
|
+
const req = new Request(
|
|
6478
|
+
authorizeUrl({
|
|
6479
|
+
client_id: fresh.client.clientId,
|
|
6480
|
+
redirect_uri: "https://app.example/cb",
|
|
6481
|
+
response_type: "code",
|
|
6482
|
+
code_challenge: challenge,
|
|
6483
|
+
code_challenge_method: "S256",
|
|
6484
|
+
scope: "vault:default:write",
|
|
6485
|
+
}),
|
|
6486
|
+
{
|
|
6487
|
+
headers: {
|
|
6488
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
6489
|
+
origin: ISSUER,
|
|
6490
|
+
},
|
|
6491
|
+
},
|
|
6492
|
+
);
|
|
6493
|
+
const res = handleAuthorizeGet(db, req, {
|
|
6494
|
+
issuer: ISSUER,
|
|
6495
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
6496
|
+
});
|
|
6497
|
+
// Approve-pending render — 403 — because the new scope wasn't trusted
|
|
6498
|
+
expect(res.status).toBe(403);
|
|
6499
|
+
expect(await res.text()).toContain("App not yet approved");
|
|
6500
|
+
// The fresh client_id stays pending
|
|
6501
|
+
expect(getClient(db, fresh.client.clientId)?.status).toBe("pending");
|
|
6502
|
+
} finally {
|
|
6503
|
+
cleanup();
|
|
6504
|
+
}
|
|
6505
|
+
});
|
|
6506
|
+
|
|
6507
|
+
test("falls through when no session (unauthenticated client re-DCR can't ride a session's trust)", async () => {
|
|
6508
|
+
const { db, cleanup } = await makeDb();
|
|
6509
|
+
try {
|
|
6510
|
+
const user = await createUser(db, "owner", "pw");
|
|
6511
|
+
const prior = registerClient(db, {
|
|
6512
|
+
redirectUris: ["https://app.example/cb"],
|
|
6513
|
+
status: "approved",
|
|
6514
|
+
clientName: "claude-code",
|
|
6515
|
+
});
|
|
6516
|
+
recordGrant(db, user.id, prior.client.clientId, ["vault:default:read"]);
|
|
6517
|
+
const fresh = registerClient(db, {
|
|
6518
|
+
redirectUris: ["https://app.example/cb"],
|
|
6519
|
+
status: "pending",
|
|
6520
|
+
clientName: "claude-code",
|
|
6521
|
+
});
|
|
6522
|
+
const { challenge } = makePkce();
|
|
6523
|
+
// No session cookie
|
|
6524
|
+
const req = new Request(
|
|
6525
|
+
authorizeUrl({
|
|
6526
|
+
client_id: fresh.client.clientId,
|
|
6527
|
+
redirect_uri: "https://app.example/cb",
|
|
6528
|
+
response_type: "code",
|
|
6529
|
+
code_challenge: challenge,
|
|
6530
|
+
code_challenge_method: "S256",
|
|
6531
|
+
scope: "vault:default:read",
|
|
6532
|
+
}),
|
|
6533
|
+
{ headers: { origin: ISSUER } },
|
|
6534
|
+
);
|
|
6535
|
+
const res = handleAuthorizeGet(db, req, {
|
|
6536
|
+
issuer: ISSUER,
|
|
6537
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
6538
|
+
});
|
|
6539
|
+
expect(res.status).toBe(403);
|
|
6540
|
+
expect(await res.text()).toContain("App not yet approved");
|
|
6541
|
+
expect(getClient(db, fresh.client.clientId)?.status).toBe("pending");
|
|
6542
|
+
} finally {
|
|
6543
|
+
cleanup();
|
|
6544
|
+
}
|
|
6545
|
+
});
|
|
6546
|
+
|
|
6547
|
+
test("falls through when client_name is missing/empty (can't match a prior grant)", async () => {
|
|
6548
|
+
const { db, cleanup } = await makeDb();
|
|
6549
|
+
try {
|
|
6550
|
+
const user = await createUser(db, "owner", "pw");
|
|
6551
|
+
const session = createSession(db, { userId: user.id });
|
|
6552
|
+
const prior = registerClient(db, {
|
|
6553
|
+
redirectUris: ["https://app.example/cb"],
|
|
6554
|
+
status: "approved",
|
|
6555
|
+
clientName: "claude-code",
|
|
6556
|
+
});
|
|
6557
|
+
recordGrant(db, user.id, prior.client.clientId, ["vault:default:read"]);
|
|
6558
|
+
// Fresh DCR omits client_name
|
|
6559
|
+
const fresh = registerClient(db, {
|
|
6560
|
+
redirectUris: ["https://app.example/cb"],
|
|
6561
|
+
status: "pending",
|
|
6562
|
+
});
|
|
6563
|
+
const { challenge } = makePkce();
|
|
6564
|
+
const req = new Request(
|
|
6565
|
+
authorizeUrl({
|
|
6566
|
+
client_id: fresh.client.clientId,
|
|
6567
|
+
redirect_uri: "https://app.example/cb",
|
|
6568
|
+
response_type: "code",
|
|
6569
|
+
code_challenge: challenge,
|
|
6570
|
+
code_challenge_method: "S256",
|
|
6571
|
+
scope: "vault:default:read",
|
|
6572
|
+
}),
|
|
6573
|
+
{
|
|
6574
|
+
headers: {
|
|
6575
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
6576
|
+
origin: ISSUER,
|
|
6577
|
+
},
|
|
6578
|
+
},
|
|
6579
|
+
);
|
|
6580
|
+
const res = handleAuthorizeGet(db, req, {
|
|
6581
|
+
issuer: ISSUER,
|
|
6582
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
6583
|
+
});
|
|
6584
|
+
expect(res.status).toBe(403);
|
|
6585
|
+
expect(getClient(db, fresh.client.clientId)?.status).toBe("pending");
|
|
6586
|
+
} finally {
|
|
6587
|
+
cleanup();
|
|
6588
|
+
}
|
|
6589
|
+
});
|
|
6590
|
+
});
|
package/src/admin-vaults.ts
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
import type { Database } from "bun:sqlite";
|
|
51
51
|
import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
52
52
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
53
|
-
import { findService, readManifest } from "./services-manifest.ts";
|
|
53
|
+
import { findService, readManifest, readManifestLenient } from "./services-manifest.ts";
|
|
54
54
|
import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
|
|
55
55
|
|
|
56
56
|
/** Scope required to call POST /vaults. */
|
|
@@ -172,7 +172,8 @@ function findExistingVault(
|
|
|
172
172
|
): { url: string; version: string; path: string } | null {
|
|
173
173
|
let manifest: ReturnType<typeof readManifest>;
|
|
174
174
|
try {
|
|
175
|
-
|
|
175
|
+
// Lenient read — see hub#406.
|
|
176
|
+
manifest = readManifestLenient(manifestPath);
|
|
176
177
|
} catch {
|
|
177
178
|
return null;
|
|
178
179
|
}
|
|
@@ -48,7 +48,7 @@ import type { Database } from "bun:sqlite";
|
|
|
48
48
|
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
49
49
|
import { signAccessToken, validateAccessToken } from "./jwt-sign.ts";
|
|
50
50
|
import { FIRST_PARTY_FALLBACKS, KNOWN_MODULES } from "./service-spec.ts";
|
|
51
|
-
import {
|
|
51
|
+
import { readManifestLenient } from "./services-manifest.ts";
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Resolve a curated short to its services.json `manifestName` key. Consults
|
|
@@ -154,7 +154,8 @@ function resolveUpstream(
|
|
|
154
154
|
| { installed: false } {
|
|
155
155
|
const manifestName = manifestNameForShort(short);
|
|
156
156
|
if (!manifestName) return { installed: false };
|
|
157
|
-
|
|
157
|
+
// Lenient — see hub#406.
|
|
158
|
+
const manifest = readManifestLenient(manifestPath);
|
|
158
159
|
const entry = manifest.services.find((s) => s.name === manifestName);
|
|
159
160
|
if (!entry) return { installed: false };
|
|
160
161
|
// Mount = the first path the service registers (canonical convention
|
package/src/api-modules.ts
CHANGED
|
@@ -40,7 +40,7 @@ import { FIRST_PARTY_FALLBACKS, KNOWN_MODULES } from "./service-spec.ts";
|
|
|
40
40
|
// still required) and the latter for vault/scribe/runner (post-FALLBACK
|
|
41
41
|
// retirement, hub#310). The local helper hides the split from the rest of
|
|
42
42
|
// this file.
|
|
43
|
-
import { type UiSubUnit, type UiSubUnitStatus, readManifest } from "./services-manifest.ts";
|
|
43
|
+
import { type UiSubUnit, type UiSubUnitStatus, readManifest, readManifestLenient } from "./services-manifest.ts";
|
|
44
44
|
import type { ModuleState, Supervisor } from "./supervisor.ts";
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -303,7 +303,9 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
303
303
|
// Load installed state from services.json. Missing file = empty manifest
|
|
304
304
|
// (fresh container), which is the v0.6 hot path — readManifest already
|
|
305
305
|
// returns { services: [] } for a missing file, so no extra branching.
|
|
306
|
-
|
|
306
|
+
// Lenient read so a single bad row written by a buggy module install
|
|
307
|
+
// (e.g. app@0.2.0-rc.4) doesn't take down /api/modules — see hub#406.
|
|
308
|
+
const manifest = readManifestLenient(deps.manifestPath);
|
|
307
309
|
const installedByShort = new Map<
|
|
308
310
|
string,
|
|
309
311
|
{
|
package/src/grants.ts
CHANGED
|
@@ -117,6 +117,77 @@ export function isCoveredByGrant(
|
|
|
117
117
|
return true;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Find the most-recent grant for a user across any client matching the
|
|
122
|
+
* given client_name. Used to support "trust an app by name" — once a
|
|
123
|
+
* user approves a `client_name` like `"claude-code"`, future DCRs with
|
|
124
|
+
* the same name auto-trust without re-asking. Returns null when no
|
|
125
|
+
* grant exists for any client of this name.
|
|
126
|
+
*
|
|
127
|
+
* Why: CLI MCP clients (Claude Code et al.) re-DCR on every `mcp add`
|
|
128
|
+
* (or every session), each landing a fresh `client_id`. Strict
|
|
129
|
+
* (user, client_id) grants force re-approval every time even though
|
|
130
|
+
* the operator has approved the same app many times before. Matching
|
|
131
|
+
* by client_name reflects the operator's actual mental model — "I
|
|
132
|
+
* approved Claude" — not the protocol's mental model — "I approved
|
|
133
|
+
* this specific client_id."
|
|
134
|
+
*
|
|
135
|
+
* Tradeoff: an attacker who can register a client with a known-trusted
|
|
136
|
+
* name (e.g. `"claude-code"`) gets auto-trust on first authorize. The
|
|
137
|
+
* defenses we kept:
|
|
138
|
+
* 1. Admin-scope flows still show consent (handled by the caller,
|
|
139
|
+
* not this helper).
|
|
140
|
+
* 2. The audit log records each auto-trust event with both client_ids
|
|
141
|
+
* (the original trusted one + the freshly auto-trusted one).
|
|
142
|
+
* 3. The Permissions admin SPA shows trusted client_names so the
|
|
143
|
+
* operator can revoke trust by name.
|
|
144
|
+
*
|
|
145
|
+
* Closes hub#409 (Aaron 2026-05-26: "asking for approval every time…
|
|
146
|
+
* once we've approved something like Claude once it should not need
|
|
147
|
+
* admin approval every other time").
|
|
148
|
+
*/
|
|
149
|
+
export function findGrantByClientName(
|
|
150
|
+
db: Database,
|
|
151
|
+
userId: string,
|
|
152
|
+
clientName: string,
|
|
153
|
+
): Grant | null {
|
|
154
|
+
if (!clientName) return null;
|
|
155
|
+
const row = db
|
|
156
|
+
.prepare(
|
|
157
|
+
`SELECT g.user_id, g.client_id, g.scopes, g.granted_at
|
|
158
|
+
FROM grants g
|
|
159
|
+
JOIN clients c ON g.client_id = c.client_id
|
|
160
|
+
WHERE g.user_id = ? AND c.client_name = ?
|
|
161
|
+
ORDER BY g.granted_at DESC
|
|
162
|
+
LIMIT 1`,
|
|
163
|
+
)
|
|
164
|
+
.get(userId, clientName) as GrantRow | undefined;
|
|
165
|
+
return row ? rowToGrant(row) : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Test whether `requestedScopes` is covered by ANY grant for the given
|
|
170
|
+
* client_name + user. The client_name-keyed counterpart to
|
|
171
|
+
* `isCoveredByGrant`. Used by /oauth/authorize to skip BOTH the
|
|
172
|
+
* approve-pending screen + the consent screen when the operator has
|
|
173
|
+
* previously approved a same-named client with sufficient scopes.
|
|
174
|
+
*/
|
|
175
|
+
export function isCoveredByGrantForClientName(
|
|
176
|
+
db: Database,
|
|
177
|
+
userId: string,
|
|
178
|
+
clientName: string,
|
|
179
|
+
requestedScopes: readonly string[],
|
|
180
|
+
): boolean {
|
|
181
|
+
if (requestedScopes.length === 0) return false;
|
|
182
|
+
const grant = findGrantByClientName(db, userId, clientName);
|
|
183
|
+
if (!grant) return false;
|
|
184
|
+
const granted = new Set(grant.scopes);
|
|
185
|
+
for (const s of requestedScopes) {
|
|
186
|
+
if (!granted.has(s)) return false;
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
120
191
|
/** All grants for a user, ordered most-recent first. Used by `parachute auth list-grants`. */
|
|
121
192
|
export function listGrantsForUser(db: Database, userId: string): Grant[] {
|
|
122
193
|
const rows = db
|
package/src/hub-server.ts
CHANGED
|
@@ -327,7 +327,8 @@ export function findVaultUpstream(
|
|
|
327
327
|
*/
|
|
328
328
|
function hasVaultInstalled(manifestPath: string): boolean {
|
|
329
329
|
try {
|
|
330
|
-
|
|
330
|
+
// Lenient — see hub#406.
|
|
331
|
+
const services = readManifestLenient(manifestPath).services;
|
|
331
332
|
return services.some((s) => isVaultEntry(s));
|
|
332
333
|
} catch {
|
|
333
334
|
return false;
|
|
@@ -499,16 +500,10 @@ async function proxyRequest(
|
|
|
499
500
|
* #173 introduced).
|
|
500
501
|
*/
|
|
501
502
|
async function proxyToVault(req: Request, manifestPath: string): Promise<Response | undefined> {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
507
|
-
return new Response(JSON.stringify({ error: `vault routing failed: ${msg}` }), {
|
|
508
|
-
status: 500,
|
|
509
|
-
headers: { "content-type": "application/json" },
|
|
510
|
-
});
|
|
511
|
-
}
|
|
503
|
+
// Lenient — see hub#406. One bad services.json row no longer takes
|
|
504
|
+
// down vault routing the way it used to take down /admin/setup and
|
|
505
|
+
// /api/modules (the symptom Aaron hit 2026-05-26).
|
|
506
|
+
const services = readManifestLenient(manifestPath).services;
|
|
512
507
|
const url = new URL(req.url);
|
|
513
508
|
const match = findVaultUpstream(services, url.pathname);
|
|
514
509
|
if (!match) return undefined;
|
|
@@ -1406,7 +1401,8 @@ export function hubFetch(
|
|
|
1406
1401
|
// configured public origin (set by `--issuer https://<fqdn>`), else
|
|
1407
1402
|
// the request's own origin (fine for direct loopback hits).
|
|
1408
1403
|
try {
|
|
1409
|
-
|
|
1404
|
+
// Lenient — see hub#406.
|
|
1405
|
+
const manifest = readManifestLenient(manifestPath);
|
|
1410
1406
|
// Same precedence as the OAuth issuer (hub#298): hub_settings →
|
|
1411
1407
|
// env → request origin. The well-known doc embeds this origin
|
|
1412
1408
|
// in service URLs + the issuer metadata link, so it must follow
|
|
@@ -1616,7 +1612,8 @@ export function hubFetch(
|
|
|
1616
1612
|
// shape the well-known doc derives. Source from services.json so a
|
|
1617
1613
|
// freshly-created vault is mintable on the next request without a
|
|
1618
1614
|
// restart.
|
|
1619
|
-
|
|
1615
|
+
// Lenient — see hub#406.
|
|
1616
|
+
const manifest = readManifestLenient(manifestPath);
|
|
1620
1617
|
const knownVaultNames = new Set<string>();
|
|
1621
1618
|
for (const s of manifest.services) {
|
|
1622
1619
|
if (!isVaultEntry(s)) continue;
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
verifyClientSecret,
|
|
45
45
|
} from "./clients.ts";
|
|
46
46
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
47
|
-
import { isCoveredByGrant, recordGrant } from "./grants.ts";
|
|
47
|
+
import { isCoveredByGrant, isCoveredByGrantForClientName, recordGrant } from "./grants.ts";
|
|
48
48
|
import { consumeFirstClientAutoApproveWindow } from "./hub-settings.ts";
|
|
49
49
|
import { VAULT_VERBS, inferAudience } from "./jwt-audience.ts";
|
|
50
50
|
import {
|
|
@@ -70,7 +70,10 @@ import { isNonRequestableScope, isRequestableScope, scopeIsAdmin } from "./scope
|
|
|
70
70
|
import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
|
|
71
71
|
import {
|
|
72
72
|
type ServicesManifest,
|
|
73
|
-
|
|
73
|
+
// Hot-path OAuth flows use the lenient reader so a single malformed
|
|
74
|
+
// services.json row (e.g. from a buggy module install) doesn't crash
|
|
75
|
+
// the entire OAuth dispatch. See hub#406.
|
|
76
|
+
readManifestLenient as readServicesManifest,
|
|
74
77
|
} from "./services-manifest.ts";
|
|
75
78
|
import {
|
|
76
79
|
SESSION_TTL_MS,
|
|
@@ -506,6 +509,73 @@ function pendingClientResponse(
|
|
|
506
509
|
const requestedVault = vaultParam && vaultParam.length > 0 ? vaultParam : undefined;
|
|
507
510
|
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
508
511
|
const sameOrigin = isSameOriginRequest(req, resolveBoundOrigins(deps));
|
|
512
|
+
|
|
513
|
+
// Trust-by-client_name auto-approve (closes hub#409). When the requesting
|
|
514
|
+
// user has previously approved a client with the SAME client_name AND the
|
|
515
|
+
// current request's scopes are covered by that prior grant, auto-promote
|
|
516
|
+
// this pending client to approved + carry on as if status had been
|
|
517
|
+
// approved from the start.
|
|
518
|
+
//
|
|
519
|
+
// Motivation: CLI MCP clients (Claude Code et al.) re-DCR each session,
|
|
520
|
+
// each landing a fresh client_id. Strict (user, client_id) approval forces
|
|
521
|
+
// the operator to click Approve every single time even though they
|
|
522
|
+
// already approved the same client by name on every prior session. Aaron
|
|
523
|
+
// 2026-05-26: "once we've approved something like claude once it should
|
|
524
|
+
// not need admin approval every other time."
|
|
525
|
+
//
|
|
526
|
+
// Constraints (security guardrails kept):
|
|
527
|
+
// 1. Requires an active operator session — anonymous DCR can't ride
|
|
528
|
+
// another operator's prior trust.
|
|
529
|
+
// 2. Requires same-origin — defends against an attacker registering a
|
|
530
|
+
// malicious "claude-code" client on a different hub and tricking
|
|
531
|
+
// the operator into authorizing it.
|
|
532
|
+
// 3. Requires a non-empty client_name — DCR allows omitting it, in
|
|
533
|
+
// which case the prior-grant lookup has nothing to match against.
|
|
534
|
+
// 4. Requires scope coverage — a strict superset (the new request asks
|
|
535
|
+
// for scopes the prior grant didn't cover) falls through to the
|
|
536
|
+
// approve-pending screen so the operator explicitly approves the
|
|
537
|
+
// addition.
|
|
538
|
+
// 5. Non-admin scopes only — `*:admin` scopes (hub:admin, vault:*:admin
|
|
539
|
+
// if it ever becomes requestable) require explicit per-session
|
|
540
|
+
// consent. This guard mirrors the same-hub-auto-trust gate's
|
|
541
|
+
// treatment of admin scopes (handleAuthorizeGet ~line 854).
|
|
542
|
+
// NOTE: `scopeIsAdmin` has a documented blind spot for
|
|
543
|
+
// module-declared admin scopes (e.g. a hypothetical `runner:admin`
|
|
544
|
+
// registered via a module manifest's scopes.defines). See
|
|
545
|
+
// `src/scope-explanations.ts:191`. A future module that makes a
|
|
546
|
+
// module-admin scope requestable via public DCR would silently
|
|
547
|
+
// bypass this guard. Worth a tighter scope-classification helper
|
|
548
|
+
// when that becomes a real risk.
|
|
549
|
+
if (
|
|
550
|
+
session &&
|
|
551
|
+
sameOrigin &&
|
|
552
|
+
client.clientName &&
|
|
553
|
+
requestedScopes.length > 0 &&
|
|
554
|
+
!requestedScopes.some(scopeIsAdmin) &&
|
|
555
|
+
isCoveredByGrantForClientName(db, session.userId, client.clientName, requestedScopes)
|
|
556
|
+
) {
|
|
557
|
+
console.log(
|
|
558
|
+
`[oauth] auto-approved pending client by prior client_name trust client_id=${client.clientId} client_name=${JSON.stringify(client.clientName)} user_id=${session.userId} scopes=${requestedScopes.join(" ")} (hub#409)`,
|
|
559
|
+
);
|
|
560
|
+
approveClient(db, client.clientId);
|
|
561
|
+
// Re-record the grant for this fresh client_id so the standard
|
|
562
|
+
// (user, client_id) consent-skip path also fires on the IMMEDIATE
|
|
563
|
+
// continuation below — without this, the very next /oauth/authorize
|
|
564
|
+
// dispatch would re-enter the "is grant covered?" check against the
|
|
565
|
+
// new client_id, find nothing (we matched by name, not id), and
|
|
566
|
+
// render the consent screen anyway.
|
|
567
|
+
recordGrant(db, session.userId, client.clientId, requestedScopes, deps.now?.() ?? new Date());
|
|
568
|
+
// Fall through to the standard approved-client flow: re-fetch the
|
|
569
|
+
// refreshed row + let handleAuthorizeGet continue past the
|
|
570
|
+
// status-check + into the consent-skip / same-hub auto-trust path.
|
|
571
|
+
const refreshed = getClient(db, client.clientId);
|
|
572
|
+
if (refreshed && refreshed.status === "approved") {
|
|
573
|
+
return handleAuthorizeGet(db, req, deps);
|
|
574
|
+
}
|
|
575
|
+
// If for some reason the refresh failed, fall through to render the
|
|
576
|
+
// approve-pending page (defensive — should never happen given the
|
|
577
|
+
// approveClient call just above).
|
|
578
|
+
}
|
|
509
579
|
const csrf = ensureCsrfToken(req);
|
|
510
580
|
const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
|
|
511
581
|
// Hub-relative URL of the original `/oauth/authorize?...` request. Used in
|
package/src/services-manifest.ts
CHANGED
|
@@ -471,17 +471,21 @@ export function readManifestLenient(
|
|
|
471
471
|
}
|
|
472
472
|
}
|
|
473
473
|
// Best-effort duplicate-port detection — log + drop the duplicate
|
|
474
|
-
// rather than throw.
|
|
475
|
-
|
|
474
|
+
// rather than throw. Mirrors the strict `assertNoDuplicatePorts`'s
|
|
475
|
+
// vault-on-vault exception: multiple parachute-vault-<name> rows
|
|
476
|
+
// legitimately share port 1940 because they're all served by the
|
|
477
|
+
// single vault module process.
|
|
478
|
+
const portsSeen = new Map<number, string>();
|
|
476
479
|
const dedup: ServiceEntry[] = [];
|
|
477
480
|
for (const e of valid) {
|
|
478
|
-
|
|
481
|
+
const prev = portsSeen.get(e.port);
|
|
482
|
+
if (prev !== undefined && !(isVaultName(prev) && isVaultName(e.name))) {
|
|
479
483
|
log.warn?.(
|
|
480
|
-
`[services-manifest] dropping duplicate-port entry: name=${JSON.stringify(e.name)} port=${e.port}`,
|
|
484
|
+
`[services-manifest] dropping duplicate-port entry: name=${JSON.stringify(e.name)} port=${e.port} (already claimed by ${JSON.stringify(prev)})`,
|
|
481
485
|
);
|
|
482
486
|
continue;
|
|
483
487
|
}
|
|
484
|
-
|
|
488
|
+
if (prev === undefined) portsSeen.set(e.port, e.name);
|
|
485
489
|
dedup.push(e);
|
|
486
490
|
}
|
|
487
491
|
return { services: dedup };
|
package/src/setup-wizard.ts
CHANGED
|
@@ -65,7 +65,7 @@ import {
|
|
|
65
65
|
import { escapeHtml } from "./oauth-ui.ts";
|
|
66
66
|
import { mintOperatorToken } from "./operator-token.ts";
|
|
67
67
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
68
|
-
import { findService,
|
|
68
|
+
import { findService, readManifestLenient } from "./services-manifest.ts";
|
|
69
69
|
import {
|
|
70
70
|
SESSION_TTL_MS,
|
|
71
71
|
buildSessionCookie,
|
|
@@ -1716,7 +1716,7 @@ const INSTALL_TILE_PROPS: ReadonlyArray<{
|
|
|
1716
1716
|
* (op status snapshot). Pure-ish — only the registry call is impure.
|
|
1717
1717
|
*/
|
|
1718
1718
|
function buildInstallTiles(url: URL, deps: SetupWizardDeps): ModuleInstallTileState[] {
|
|
1719
|
-
const manifest =
|
|
1719
|
+
const manifest = readManifestLenient(deps.manifestPath);
|
|
1720
1720
|
return INSTALL_TILE_PROPS.filter((p) =>
|
|
1721
1721
|
(CURATED_MODULES as readonly string[]).includes(p.short),
|
|
1722
1722
|
).map((p) => {
|
|
@@ -1874,7 +1874,7 @@ function validateAccountFields(input: {
|
|
|
1874
1874
|
* shared with `buildInstallTiles`.
|
|
1875
1875
|
*/
|
|
1876
1876
|
function isModuleInstalled(short: CuratedModuleShort, manifestPath: string): boolean {
|
|
1877
|
-
const manifest =
|
|
1877
|
+
const manifest = readManifestLenient(manifestPath);
|
|
1878
1878
|
const spec = specFor(short);
|
|
1879
1879
|
return manifest.services.some((s) => s.name === spec.manifestName);
|
|
1880
1880
|
}
|
|
@@ -1885,7 +1885,7 @@ function isModuleInstalled(short: CuratedModuleShort, manifestPath: string): boo
|
|
|
1885
1885
|
* entry's metadata isn't present.
|
|
1886
1886
|
*/
|
|
1887
1887
|
function firstVaultName(manifestPath: string): string {
|
|
1888
|
-
const manifest =
|
|
1888
|
+
const manifest = readManifestLenient(manifestPath);
|
|
1889
1889
|
// Match on the canonical vault manifestName from the curated spec.
|
|
1890
1890
|
// (`CURATED_MODULES.includes("vault")` was a dead guard — vault is a
|
|
1891
1891
|
// tuple-literal member, so the conjunct is always true.)
|