@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 +1 -1
- package/src/__tests__/grants.test.ts +143 -0
- package/src/__tests__/oauth-handlers.test.ts +196 -0
- package/src/__tests__/services-manifest.test.ts +93 -0
- package/src/__tests__/setup-wizard.test.ts +85 -0
- package/src/grants.ts +71 -0
- package/src/hub-server.ts +14 -11
- package/src/oauth-handlers.ts +68 -1
- package/src/services-manifest.ts +82 -0
- package/src/setup-wizard.ts +61 -3
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
|
+
});
|
|
@@ -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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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;
|
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 {
|
|
@@ -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
|
package/src/services-manifest.ts
CHANGED
|
@@ -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;
|
package/src/setup-wizard.ts
CHANGED
|
@@ -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
|
-
*
|
|
145
|
-
*
|
|
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 -----------------------------------------------------------
|