@openparachute/hub 0.5.13-rc.45 → 0.5.13-rc.47

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13-rc.45",
3
+ "version": "0.5.13-rc.47",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -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
+ });
@@ -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
+ });
@@ -8,6 +8,7 @@ import {
8
8
  type UiSubUnit,
9
9
  findService,
10
10
  readManifest,
11
+ readManifestLenient,
11
12
  removeService,
12
13
  upsertService,
13
14
  writeManifest,
@@ -1395,3 +1396,95 @@ describe("retired-module row de-dupe (hub#334)", () => {
1395
1396
  }
1396
1397
  });
1397
1398
  });
1399
+
1400
+ describe("readManifestLenient — skips bad entries instead of throwing (hub#406)", () => {
1401
+ test("returns the healthy entries when one row has port=0 (the rc.4 app bug)", () => {
1402
+ // Reproduces what hub saw 2026-05-26: a fresh deploy installed
1403
+ // @openparachute/app@0.2.0-rc.4 which wrote a row with name="app"
1404
+ // (wrong) + port=0 (wrong). Strict readManifest threw on the bad
1405
+ // entry — every request to every service 500'd, not just app.
1406
+ // Lenient reader skips the bad row + keeps routing healthy ones.
1407
+ const { path, cleanup } = makeTempPath();
1408
+ try {
1409
+ writeFileSync(
1410
+ path,
1411
+ JSON.stringify({
1412
+ services: [
1413
+ { name: "parachute-vault", port: 1940, paths: ["/vault/default"], health: "/vault/default/health", version: "0.4.8-rc.10" },
1414
+ { name: "parachute-app", port: 1946, paths: ["/app"], health: "/app/healthz", version: "0.2.0-rc.13" },
1415
+ { name: "app", port: 0, paths: ["/app"], health: "/app/healthz", version: "0.2.0-rc.4" },
1416
+ ],
1417
+ }),
1418
+ );
1419
+ const warnings: string[] = [];
1420
+ const log = { warn: (m: string) => warnings.push(m) };
1421
+ const m = readManifestLenient(path, log);
1422
+ const names = m.services.map((s) => s.name).sort();
1423
+ expect(names).toEqual(["parachute-app", "parachute-vault"]);
1424
+ expect(warnings.some((w) => w.includes("port") && w.includes("integer"))).toBe(true);
1425
+ } finally {
1426
+ cleanup();
1427
+ }
1428
+ });
1429
+
1430
+ test("returns empty services when the file is malformed JSON, logs the parse error", () => {
1431
+ const { path, cleanup } = makeTempPath();
1432
+ try {
1433
+ writeFileSync(path, "{not valid json");
1434
+ const warnings: string[] = [];
1435
+ const m = readManifestLenient(path, { warn: (msg) => warnings.push(msg) });
1436
+ expect(m.services).toEqual([]);
1437
+ expect(warnings.some((w) => w.includes("failed to parse"))).toBe(true);
1438
+ } finally {
1439
+ cleanup();
1440
+ }
1441
+ });
1442
+
1443
+ test("returns empty services when the file is missing", () => {
1444
+ const { path, cleanup } = makeTempPath();
1445
+ try {
1446
+ // path not yet written
1447
+ const m = readManifestLenient(path, { warn: () => {} });
1448
+ expect(m.services).toEqual([]);
1449
+ } finally {
1450
+ cleanup();
1451
+ }
1452
+ });
1453
+
1454
+ test("drops duplicate-port entries with a warning instead of throwing", () => {
1455
+ const { path, cleanup } = makeTempPath();
1456
+ try {
1457
+ writeFileSync(
1458
+ path,
1459
+ JSON.stringify({
1460
+ services: [
1461
+ { name: "first", port: 1940, paths: ["/x"], health: "/x/health", version: "1.0.0" },
1462
+ { name: "second", port: 1940, paths: ["/y"], health: "/y/health", version: "1.0.0" },
1463
+ ],
1464
+ }),
1465
+ );
1466
+ const warnings: string[] = [];
1467
+ const m = readManifestLenient(path, { warn: (msg) => warnings.push(msg) });
1468
+ expect(m.services).toHaveLength(1);
1469
+ expect(m.services[0]?.name).toBe("first");
1470
+ expect(warnings.some((w) => w.includes("duplicate-port"))).toBe(true);
1471
+ } finally {
1472
+ cleanup();
1473
+ }
1474
+ });
1475
+
1476
+ test("strict readManifest still throws on the same bad entry (contract preserved)", () => {
1477
+ const { path, cleanup } = makeTempPath();
1478
+ try {
1479
+ writeFileSync(
1480
+ path,
1481
+ JSON.stringify({
1482
+ services: [{ name: "app", port: 0, paths: ["/app"], health: "/app/healthz", version: "0.2.0-rc.4" }],
1483
+ }),
1484
+ );
1485
+ expect(() => readManifest(path)).toThrow(ServicesManifestError);
1486
+ } finally {
1487
+ cleanup();
1488
+ }
1489
+ });
1490
+ });
@@ -30,6 +30,7 @@ import { writeManifest } from "../services-manifest.ts";
30
30
  import { SESSION_COOKIE_NAME } from "../sessions.ts";
31
31
  import {
32
32
  deriveWizardState,
33
+ detectAutoExposeMode,
33
34
  handleSetupAccountPost,
34
35
  handleSetupExposePost,
35
36
  handleSetupGet,
@@ -172,6 +173,62 @@ describe("deriveWizardState", () => {
172
173
  }
173
174
  });
174
175
 
176
+ test("auto-skips expose step when RENDER_EXTERNAL_URL is set (hub#406 follow-up)", async () => {
177
+ // Aaron's UX concern: on Render the "How will this hub be reached?"
178
+ // step asks the operator to pick between localhost / tailnet /
179
+ // public-with-custom-domain — none of which describe the actual
180
+ // setup. The platform owns the public URL via RENDER_EXTERNAL_URL.
181
+ // deriveWizardState now auto-seeds `setup_expose_mode = "public"`
182
+ // when that env var is present, so the wizard skips straight to
183
+ // the done screen instead of surfacing an irrelevant choice.
184
+ const db = openHubDb(hubDbPath(h.dir));
185
+ try {
186
+ await createUser(db, "owner", "pw");
187
+ writeManifest(
188
+ {
189
+ services: [
190
+ {
191
+ name: "parachute-vault",
192
+ version: "0.1.0",
193
+ port: 1940,
194
+ paths: ["/vault/default"],
195
+ health: "/health",
196
+ },
197
+ ],
198
+ },
199
+ h.manifestPath,
200
+ );
201
+ // Simulate Render env. detectAutoExposeMode reads RENDER_EXTERNAL_URL.
202
+ const renderEnv = { RENDER_EXTERNAL_URL: "https://parachute-hub.onrender.com" };
203
+ const s = deriveWizardState({ db, manifestPath: h.manifestPath, env: renderEnv });
204
+ expect(s.step).toBe("done");
205
+ expect(s.hasExposeMode).toBe(true);
206
+ } finally {
207
+ db.close();
208
+ }
209
+ });
210
+
211
+ test("does NOT auto-skip expose when RENDER_EXTERNAL_URL is unset (local install path)", async () => {
212
+ const db = openHubDb(hubDbPath(h.dir));
213
+ try {
214
+ await createUser(db, "owner", "pw");
215
+ writeManifest(
216
+ {
217
+ services: [
218
+ { name: "parachute-vault", version: "0.1.0", port: 1940, paths: ["/vault/default"], health: "/health" },
219
+ ],
220
+ },
221
+ h.manifestPath,
222
+ );
223
+ const s = deriveWizardState({ db, manifestPath: h.manifestPath, env: {} });
224
+ // Local install path — the operator still gets to choose
225
+ expect(s.step).toBe("expose");
226
+ expect(s.hasExposeMode).toBe(false);
227
+ } finally {
228
+ db.close();
229
+ }
230
+ });
231
+
175
232
  test("done step once admin + vault + expose mode all exist", async () => {
176
233
  const db = openHubDb(hubDbPath(h.dir));
177
234
  try {
@@ -2944,3 +3001,31 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
2944
3001
  }
2945
3002
  });
2946
3003
  });
3004
+
3005
+ describe("detectAutoExposeMode — Render env detection edge cases (hub#407 nit)", () => {
3006
+ test("returns 'public' for a real https Render URL", () => {
3007
+ expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: "https://parachute-hub.onrender.com" })).toBe("public");
3008
+ });
3009
+
3010
+ test("returns 'public' for an http:// URL (defensive — if Render ever emits one)", () => {
3011
+ expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: "http://local.test:1939" })).toBe("public");
3012
+ });
3013
+
3014
+ test("returns undefined when RENDER_EXTERNAL_URL is absent", () => {
3015
+ expect(detectAutoExposeMode({})).toBeUndefined();
3016
+ });
3017
+
3018
+ test("returns undefined when RENDER_EXTERNAL_URL is empty", () => {
3019
+ expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: "" })).toBeUndefined();
3020
+ });
3021
+
3022
+ test("returns undefined for a non-http scheme (httpx://, ftp://, etc.)", () => {
3023
+ expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: "httpx://foo.example" })).toBeUndefined();
3024
+ expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: "ftp://foo.example" })).toBeUndefined();
3025
+ expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: "javascript:alert(1)" })).toBeUndefined();
3026
+ });
3027
+
3028
+ test("returns undefined when value is non-string (defensive)", () => {
3029
+ expect(detectAutoExposeMode({ RENDER_EXTERNAL_URL: undefined })).toBeUndefined();
3030
+ });
3031
+ });
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
@@ -177,7 +177,7 @@ import {
177
177
  effectivePublicExposure,
178
178
  shortNameForManifest,
179
179
  } from "./service-spec.ts";
180
- import { type ServiceEntry, readManifest } from "./services-manifest.ts";
180
+ import { type ServiceEntry, readManifest, readManifestLenient } from "./services-manifest.ts";
181
181
  import { findActiveSession } from "./sessions.ts";
182
182
  import {
183
183
  type SetupWizardDeps,
@@ -582,16 +582,19 @@ export function findServiceUpstream(
582
582
  * Returns `undefined` when no service claims the pathname; caller 404s.
583
583
  */
584
584
  async function proxyToService(req: Request, manifestPath: string): Promise<Response | undefined> {
585
- let services: readonly ServiceEntry[];
586
- try {
587
- services = readManifest(manifestPath).services;
588
- } catch (err) {
589
- const msg = err instanceof Error ? err.message : String(err);
590
- return new Response(JSON.stringify({ error: `service routing failed: ${msg}` }), {
591
- status: 500,
592
- headers: { "content-type": "application/json" },
593
- });
594
- }
585
+ // Lenient read on the hot-path — a single malformed services.json
586
+ // entry (e.g. a module installed at a buggy version that wrote
587
+ // `port: 0`) used to cascade into 500s for every route on this hub
588
+ // because the strict throw bailed BEFORE we could dispatch to the
589
+ // healthy entries. `readManifestLenient` skips + logs bad rows so
590
+ // unrelated services keep working. The strict `readManifest` is
591
+ // still used by write paths + admin surfaces that want errors
592
+ // surfaced immediately. See hub#406.
593
+ //
594
+ // The default `log` is `console`, which under Render's container
595
+ // routing surfaces in the Logs panel — operators see the warning
596
+ // about the skipped entry.
597
+ const services = readManifestLenient(manifestPath).services;
595
598
  const url = new URL(req.url);
596
599
  const match = findServiceUpstream(services, url.pathname);
597
600
  if (!match) return undefined;
@@ -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 {
@@ -506,6 +506,73 @@ function pendingClientResponse(
506
506
  const requestedVault = vaultParam && vaultParam.length > 0 ? vaultParam : undefined;
507
507
  const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
508
508
  const sameOrigin = isSameOriginRequest(req, resolveBoundOrigins(deps));
509
+
510
+ // Trust-by-client_name auto-approve (closes hub#409). When the requesting
511
+ // user has previously approved a client with the SAME client_name AND the
512
+ // current request's scopes are covered by that prior grant, auto-promote
513
+ // this pending client to approved + carry on as if status had been
514
+ // approved from the start.
515
+ //
516
+ // Motivation: CLI MCP clients (Claude Code et al.) re-DCR each session,
517
+ // each landing a fresh client_id. Strict (user, client_id) approval forces
518
+ // the operator to click Approve every single time even though they
519
+ // already approved the same client by name on every prior session. Aaron
520
+ // 2026-05-26: "once we've approved something like claude once it should
521
+ // not need admin approval every other time."
522
+ //
523
+ // Constraints (security guardrails kept):
524
+ // 1. Requires an active operator session — anonymous DCR can't ride
525
+ // another operator's prior trust.
526
+ // 2. Requires same-origin — defends against an attacker registering a
527
+ // malicious "claude-code" client on a different hub and tricking
528
+ // the operator into authorizing it.
529
+ // 3. Requires a non-empty client_name — DCR allows omitting it, in
530
+ // which case the prior-grant lookup has nothing to match against.
531
+ // 4. Requires scope coverage — a strict superset (the new request asks
532
+ // for scopes the prior grant didn't cover) falls through to the
533
+ // approve-pending screen so the operator explicitly approves the
534
+ // addition.
535
+ // 5. Non-admin scopes only — `*:admin` scopes (hub:admin, vault:*:admin
536
+ // if it ever becomes requestable) require explicit per-session
537
+ // consent. This guard mirrors the same-hub-auto-trust gate's
538
+ // treatment of admin scopes (handleAuthorizeGet ~line 854).
539
+ // NOTE: `scopeIsAdmin` has a documented blind spot for
540
+ // module-declared admin scopes (e.g. a hypothetical `runner:admin`
541
+ // registered via a module manifest's scopes.defines). See
542
+ // `src/scope-explanations.ts:191`. A future module that makes a
543
+ // module-admin scope requestable via public DCR would silently
544
+ // bypass this guard. Worth a tighter scope-classification helper
545
+ // when that becomes a real risk.
546
+ if (
547
+ session &&
548
+ sameOrigin &&
549
+ client.clientName &&
550
+ requestedScopes.length > 0 &&
551
+ !requestedScopes.some(scopeIsAdmin) &&
552
+ isCoveredByGrantForClientName(db, session.userId, client.clientName, requestedScopes)
553
+ ) {
554
+ console.log(
555
+ `[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)`,
556
+ );
557
+ approveClient(db, client.clientId);
558
+ // Re-record the grant for this fresh client_id so the standard
559
+ // (user, client_id) consent-skip path also fires on the IMMEDIATE
560
+ // continuation below — without this, the very next /oauth/authorize
561
+ // dispatch would re-enter the "is grant covered?" check against the
562
+ // new client_id, find nothing (we matched by name, not id), and
563
+ // render the consent screen anyway.
564
+ recordGrant(db, session.userId, client.clientId, requestedScopes, deps.now?.() ?? new Date());
565
+ // Fall through to the standard approved-client flow: re-fetch the
566
+ // refreshed row + let handleAuthorizeGet continue past the
567
+ // status-check + into the consent-skip / same-hub auto-trust path.
568
+ const refreshed = getClient(db, client.clientId);
569
+ if (refreshed && refreshed.status === "approved") {
570
+ return handleAuthorizeGet(db, req, deps);
571
+ }
572
+ // If for some reason the refresh failed, fall through to render the
573
+ // approve-pending page (defensive — should never happen given the
574
+ // approveClient call just above).
575
+ }
509
576
  const csrf = ensureCsrfToken(req);
510
577
  const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
511
578
  // Hub-relative URL of the original `/oauth/authorize?...` request. Used in
@@ -405,6 +405,88 @@ function validateManifest(raw: unknown, where: string): ServicesManifest {
405
405
  return { services: entries };
406
406
  }
407
407
 
408
+ /**
409
+ * Lenient counterpart to `readManifest` — used by hub's hot-path service
410
+ * routing (`proxyToService`). The strict `readManifest` throws when ANY
411
+ * entry fails validation; an entire bad row (e.g. an installed module
412
+ * that wrote `port: 0` to its services.json row before the module's
413
+ * own selfRegister gained validation) takes down ALL routing because
414
+ * the routing call site catches the throw and returns 500.
415
+ *
416
+ * This lenient reader:
417
+ * - parses the file the same way (JSON parse + cleanup passes)
418
+ * - validates each entry independently
419
+ * - skips entries that fail validation, logging a warning per skip
420
+ * - returns the validated remainder
421
+ *
422
+ * The trade-off: strict callers (admin SPA write paths, init flows,
423
+ * tests) keep the throw — they want bugs surfaced immediately. The
424
+ * routing path uses this so a single bad row doesn't cascade into
425
+ * "the whole hub appears broken to users." Operators see the rest
426
+ * of their services keep working + a warning in the logs pointing at
427
+ * the offending entry.
428
+ *
429
+ * Caught 2026-05-26 (hub#406) when @openparachute/app@0.2.0-rc.4 wrote
430
+ * a row with `name: "app"` (instead of `parachute-app`) + `port: 0`
431
+ * (instead of bound port). Hub's routing throw on services.json read
432
+ * meant every request to every service 500'd — not just app — because
433
+ * one row's bad shape took out the whole manifest read.
434
+ *
435
+ * One behavioral difference from strict `readManifest`: this function
436
+ * does NOT write cleanup mutations back to disk. The bad row persists
437
+ * on disk until a write-path call (upsertService, etc.) exercises the
438
+ * strict path. That's intentional — a hot-path read should not mutate
439
+ * state — but worth knowing: a fix upstream (e.g. app@rc.13 overwriting
440
+ * the bad row on its next selfRegister) is what finally clears it.
441
+ */
442
+ export function readManifestLenient(
443
+ path: string = SERVICES_MANIFEST_PATH,
444
+ log: { warn?: (msg: string) => void } = console,
445
+ ): ServicesManifest {
446
+ if (!existsSync(path)) return { services: [] };
447
+ let raw: unknown;
448
+ try {
449
+ raw = JSON.parse(readFileSync(path, "utf8"));
450
+ } catch (err) {
451
+ log.warn?.(
452
+ `[services-manifest] failed to parse ${path}: ${err instanceof Error ? err.message : String(err)} — treating as empty`,
453
+ );
454
+ return { services: [] };
455
+ }
456
+ const afterRetired = dropRetiredModuleRows(raw, path);
457
+ const cleaned = dropLegacyShortNameRows(afterRetired.raw, path);
458
+ // `typeof null === "object"` in JS, so the `!cleaned.raw` part of this
459
+ // guard is load-bearing for the null case — not a typo or redundancy.
460
+ if (!cleaned.raw || typeof cleaned.raw !== "object") return { services: [] };
461
+ const services = (cleaned.raw as Record<string, unknown>).services;
462
+ if (!Array.isArray(services)) return { services: [] };
463
+ const valid: ServiceEntry[] = [];
464
+ for (let i = 0; i < services.length; i++) {
465
+ try {
466
+ valid.push(validateEntry(services[i], `${path} services[${i}]`));
467
+ } catch (err) {
468
+ log.warn?.(
469
+ `[services-manifest] skipping bad entry: ${err instanceof Error ? err.message : String(err)}`,
470
+ );
471
+ }
472
+ }
473
+ // Best-effort duplicate-port detection — log + drop the duplicate
474
+ // rather than throw.
475
+ const seenPorts = new Set<number>();
476
+ const dedup: ServiceEntry[] = [];
477
+ for (const e of valid) {
478
+ if (seenPorts.has(e.port)) {
479
+ log.warn?.(
480
+ `[services-manifest] dropping duplicate-port entry: name=${JSON.stringify(e.name)} port=${e.port}`,
481
+ );
482
+ continue;
483
+ }
484
+ seenPorts.add(e.port);
485
+ dedup.push(e);
486
+ }
487
+ return { services: dedup };
488
+ }
489
+
408
490
  export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesManifest {
409
491
  if (!existsSync(path)) return { services: [] };
410
492
  let raw: unknown;
@@ -141,12 +141,21 @@ export const FIRST_VAULT_SHORT: CuratedModuleShort = "vault";
141
141
 
142
142
  /**
143
143
  * Read DB + services.json to decide which step the wizard should render.
144
- * Pure, idempotent — re-running the wizard after partial setup picks up
145
- * where it left off.
144
+ * Idempotent — re-running after partial setup picks up where it left
145
+ * off. Mostly read-only, with one specific write: on Render (or any
146
+ * platform `detectAutoExposeMode` recognizes), the first call auto-
147
+ * seeds `setup_expose_mode = "public"` so the wizard skips the expose
148
+ * step. Subsequent calls find the setting present and are read-only.
146
149
  */
147
150
  export function deriveWizardState(deps: {
148
151
  db: Database;
149
152
  manifestPath: string;
153
+ /**
154
+ * Optional env-override. When undefined, falls through to `process.env`.
155
+ * Used by tests + by handleSetupGet which threads through the full
156
+ * SetupWizardDeps.env.
157
+ */
158
+ env?: Record<string, string | undefined>;
150
159
  }): DerivedWizardState {
151
160
  const hasAdmin = userCount(deps.db) > 0;
152
161
  // The wizard's first-vault provisioning uses the curated `vault` short,
@@ -156,7 +165,19 @@ export function deriveWizardState(deps: {
156
165
  const hasVault = vaultEntry !== undefined;
157
166
  // Expose-mode is the operator's "how will this hub be reached?" answer
158
167
  // (hub#268 Item 2). Stored as a hub_setting; the wizard's expose step
159
- // sets it; absence means we should still ask.
168
+ // sets it; absence means we should still ask. EXCEPT — if we're
169
+ // running on a platform where the answer is pre-determined (e.g.
170
+ // Render exposes the service at $RENDER_EXTERNAL_URL automatically),
171
+ // auto-seed `setup_expose_mode = "public"` so the wizard skips the
172
+ // expose step entirely. The operator landed here through a deploy
173
+ // path that already answered the question; asking again wastes a
174
+ // click and surfaces irrelevant options (localhost, tailnet).
175
+ if (
176
+ getSetting(deps.db, "setup_expose_mode") === undefined &&
177
+ detectAutoExposeMode(deps.env ?? process.env) === "public"
178
+ ) {
179
+ setSetting(deps.db, "setup_expose_mode", "public");
180
+ }
160
181
  const hasExposeMode = getSetting(deps.db, "setup_expose_mode") !== undefined;
161
182
  let step: WizardStep;
162
183
  // Note: `"account"` is a visual-only step in the progress header —
@@ -200,6 +221,43 @@ export interface SetupWizardDeps {
200
221
  registry?: OperationsRegistry;
201
222
  /** Test seam: stub `bun add` / `bun remove` runner. */
202
223
  run?: (cmd: readonly string[]) => Promise<number>;
224
+ /**
225
+ * Test seam: override the process env that `detectAutoExposeMode`
226
+ * consults. Production omits this and the helper reads `process.env`
227
+ * directly. Setting in tests lets the auto-skip branch be exercised
228
+ * without mutating the real process env.
229
+ */
230
+ env?: Record<string, string | undefined>;
231
+ }
232
+
233
+ /**
234
+ * Returns `"public"` when the runtime env indicates the hub is deployed
235
+ * on a platform where the "how will this hub be reached?" answer is
236
+ * pre-determined by the platform. Today: Render (sets RENDER_EXTERNAL_URL
237
+ * for any web service). Returns `undefined` otherwise — the wizard's
238
+ * expose step asks the operator.
239
+ *
240
+ * Why this matters: on Render, none of the three radio options
241
+ * (localhost, tailnet, public-with-custom-domain) match the actual
242
+ * setup. The hub is reached at `*.onrender.com` automatically. Asking
243
+ * the operator wastes a click and surfaces three options that don't
244
+ * speak to their situation. Auto-pinning `public` skips the step.
245
+ *
246
+ * Add more platforms here when we encounter them — e.g. Fly.io
247
+ * (FLY_APP_NAME), Railway (RAILWAY_ENVIRONMENT), etc. Each only auto-
248
+ * detects when the platform clearly owns the public URL.
249
+ */
250
+ export function detectAutoExposeMode(env: Record<string, string | undefined>): "public" | undefined {
251
+ // Render always sets `RENDER_EXTERNAL_URL` to a real `https://` URL on
252
+ // any web service. `startsWith("https://")` is the precise shape; we
253
+ // also accept `http://` as a defensive fallback in case Render ever
254
+ // changes the scheme on some plan tier. Anything else (empty, weird,
255
+ // not a URL) → don't auto-skip; let the operator choose.
256
+ const url = env.RENDER_EXTERNAL_URL;
257
+ if (typeof url === "string" && (url.startsWith("https://") || url.startsWith("http://"))) {
258
+ return "public";
259
+ }
260
+ return undefined;
203
261
  }
204
262
 
205
263
  // --- rendering -----------------------------------------------------------