@openparachute/hub 0.6.3 → 0.6.4-rc.10
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 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +236 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +195 -3
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +36 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +118 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
|
@@ -8,9 +8,12 @@ import {
|
|
|
8
8
|
findGrantByClientName,
|
|
9
9
|
isCoveredByGrant,
|
|
10
10
|
isCoveredByGrantForClientName,
|
|
11
|
+
isFirstPartyBrowserClient,
|
|
11
12
|
listGrantsForUser,
|
|
12
13
|
recordGrant,
|
|
13
14
|
revokeGrant,
|
|
15
|
+
userHasExternalAiGrant,
|
|
16
|
+
userHasVaultGrant,
|
|
14
17
|
} from "../grants.ts";
|
|
15
18
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
16
19
|
import { createUser } from "../users.ts";
|
|
@@ -177,7 +180,13 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
|
|
|
177
180
|
redirectUris: ["https://app.example/cb"],
|
|
178
181
|
clientName: "claude-code",
|
|
179
182
|
});
|
|
180
|
-
recordGrant(
|
|
183
|
+
recordGrant(
|
|
184
|
+
h.db,
|
|
185
|
+
h.userId,
|
|
186
|
+
reg1.client.clientId,
|
|
187
|
+
["a", "b"],
|
|
188
|
+
new Date("2026-04-10T00:00:00Z"),
|
|
189
|
+
);
|
|
181
190
|
// Second DCR: same client_name="claude-code", fresh client_id, no grant yet
|
|
182
191
|
const reg2 = registerClient(h.db, {
|
|
183
192
|
redirectUris: ["https://app.example/cb"],
|
|
@@ -249,8 +258,20 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
|
|
|
249
258
|
clientName: "claude-code",
|
|
250
259
|
});
|
|
251
260
|
recordGrant(h.db, h.userId, reg1.client.clientId, ["a"], new Date("2026-04-01T00:00:00Z"));
|
|
252
|
-
recordGrant(
|
|
253
|
-
|
|
261
|
+
recordGrant(
|
|
262
|
+
h.db,
|
|
263
|
+
h.userId,
|
|
264
|
+
reg3.client.clientId,
|
|
265
|
+
["a", "c"],
|
|
266
|
+
new Date("2026-04-15T00:00:00Z"),
|
|
267
|
+
);
|
|
268
|
+
recordGrant(
|
|
269
|
+
h.db,
|
|
270
|
+
h.userId,
|
|
271
|
+
reg2.client.clientId,
|
|
272
|
+
["a", "b"],
|
|
273
|
+
new Date("2026-04-10T00:00:00Z"),
|
|
274
|
+
);
|
|
254
275
|
const grant = findGrantByClientName(h.db, h.userId, "claude-code");
|
|
255
276
|
// Most recent = reg3's grant (2026-04-15)
|
|
256
277
|
expect(grant?.clientId).toBe(reg3.client.clientId);
|
|
@@ -267,9 +288,19 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
|
|
|
267
288
|
redirectUris: ["https://app.example/cb"],
|
|
268
289
|
clientName: "claude-code",
|
|
269
290
|
});
|
|
270
|
-
recordGrant(h.db, h.userId, reg.client.clientId, [
|
|
271
|
-
|
|
272
|
-
|
|
291
|
+
recordGrant(h.db, h.userId, reg.client.clientId, [
|
|
292
|
+
"vault:default:read",
|
|
293
|
+
"vault:default:write",
|
|
294
|
+
]);
|
|
295
|
+
expect(
|
|
296
|
+
isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read"]),
|
|
297
|
+
).toBe(true);
|
|
298
|
+
expect(
|
|
299
|
+
isCoveredByGrantForClientName(h.db, h.userId, "claude-code", [
|
|
300
|
+
"vault:default:read",
|
|
301
|
+
"vault:default:write",
|
|
302
|
+
]),
|
|
303
|
+
).toBe(true);
|
|
273
304
|
} finally {
|
|
274
305
|
h.cleanup();
|
|
275
306
|
}
|
|
@@ -284,8 +315,15 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
|
|
|
284
315
|
});
|
|
285
316
|
recordGrant(h.db, h.userId, reg.client.clientId, ["vault:default:read"]);
|
|
286
317
|
// Asking for write — not previously granted
|
|
287
|
-
expect(
|
|
288
|
-
|
|
318
|
+
expect(
|
|
319
|
+
isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:write"]),
|
|
320
|
+
).toBe(false);
|
|
321
|
+
expect(
|
|
322
|
+
isCoveredByGrantForClientName(h.db, h.userId, "claude-code", [
|
|
323
|
+
"vault:default:read",
|
|
324
|
+
"vault:default:write",
|
|
325
|
+
]),
|
|
326
|
+
).toBe(false);
|
|
289
327
|
} finally {
|
|
290
328
|
h.cleanup();
|
|
291
329
|
}
|
|
@@ -304,4 +342,155 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
|
|
|
304
342
|
h.cleanup();
|
|
305
343
|
}
|
|
306
344
|
});
|
|
345
|
+
|
|
346
|
+
// --- userHasVaultGrant (onboarding "has connected an AI?" signal) --------
|
|
347
|
+
|
|
348
|
+
test("userHasVaultGrant: false when the user has no grants at all", async () => {
|
|
349
|
+
const h = await harness();
|
|
350
|
+
try {
|
|
351
|
+
expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(false);
|
|
352
|
+
} finally {
|
|
353
|
+
h.cleanup();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("userHasVaultGrant: true when a grant's scopes touch the vault", async () => {
|
|
358
|
+
const h = await harness();
|
|
359
|
+
try {
|
|
360
|
+
recordGrant(h.db, h.userId, h.clientId, ["vault:default:read", "vault:default:write"]);
|
|
361
|
+
expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(true);
|
|
362
|
+
} finally {
|
|
363
|
+
h.cleanup();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("userHasVaultGrant: false when the grant touches a DIFFERENT vault", async () => {
|
|
368
|
+
const h = await harness();
|
|
369
|
+
try {
|
|
370
|
+
recordGrant(h.db, h.userId, h.clientId, ["vault:work:read"]);
|
|
371
|
+
expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(false);
|
|
372
|
+
expect(userHasVaultGrant(h.db, h.userId, "work")).toBe(true);
|
|
373
|
+
} finally {
|
|
374
|
+
h.cleanup();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("userHasVaultGrant: non-vault scopes don't count as a connection", async () => {
|
|
379
|
+
const h = await harness();
|
|
380
|
+
try {
|
|
381
|
+
recordGrant(h.db, h.userId, h.clientId, ["parachute:host:auth", "vault:read"]);
|
|
382
|
+
// `vault:read` (no name segment) is a generic scope, not vault:<name>:.
|
|
383
|
+
expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(false);
|
|
384
|
+
} finally {
|
|
385
|
+
h.cleanup();
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("userHasVaultGrant: prefix isn't substring-fooled (vault:default-2 ≠ default)", async () => {
|
|
390
|
+
const h = await harness();
|
|
391
|
+
try {
|
|
392
|
+
recordGrant(h.db, h.userId, h.clientId, ["vault:default-2:read"]);
|
|
393
|
+
expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(false);
|
|
394
|
+
expect(userHasVaultGrant(h.db, h.userId, "default-2")).toBe(true);
|
|
395
|
+
} finally {
|
|
396
|
+
h.cleanup();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("userHasExternalAiGrant / isFirstPartyBrowserClient (hub#583)", () => {
|
|
402
|
+
test("isFirstPartyBrowserClient matches fixed first-party client_ids", () => {
|
|
403
|
+
expect(isFirstPartyBrowserClient("parachute-hub-spa", null)).toBe(true);
|
|
404
|
+
expect(isFirstPartyBrowserClient("parachute-account", null)).toBe(true);
|
|
405
|
+
expect(isFirstPartyBrowserClient("some-random-dcr-id", null)).toBe(false);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("isFirstPartyBrowserClient matches Notes by client_name (case-insensitive)", () => {
|
|
409
|
+
expect(isFirstPartyBrowserClient("dcr-generated-id", "Notes")).toBe(true);
|
|
410
|
+
expect(isFirstPartyBrowserClient("dcr-generated-id", "notes")).toBe(true);
|
|
411
|
+
expect(isFirstPartyBrowserClient("dcr-generated-id", "Claude")).toBe(false);
|
|
412
|
+
expect(isFirstPartyBrowserClient("dcr-generated-id", null)).toBe(false);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("a first-party browser grant does NOT count as a connected AI", async () => {
|
|
416
|
+
const h = await harness();
|
|
417
|
+
try {
|
|
418
|
+
// Notes signs in via DCR (generated client_id, client_name "Notes") and
|
|
419
|
+
// writes a vault-scoped grant — the exact false-positive in hub#583.
|
|
420
|
+
const notes = registerClient(h.db, {
|
|
421
|
+
redirectUris: ["https://app.example/cb"],
|
|
422
|
+
clientName: "Notes",
|
|
423
|
+
});
|
|
424
|
+
recordGrant(h.db, h.userId, notes.client.clientId, ["vault:default:read"]);
|
|
425
|
+
// The coarse signal lights up...
|
|
426
|
+
expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(true);
|
|
427
|
+
// ...but the AI-connection signal does NOT.
|
|
428
|
+
expect(userHasExternalAiGrant(h.db, h.userId, "default")).toBe(false);
|
|
429
|
+
} finally {
|
|
430
|
+
h.cleanup();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("the fixed first-party SPA client_id does NOT count as a connected AI", async () => {
|
|
435
|
+
const h = await harness();
|
|
436
|
+
try {
|
|
437
|
+
registerClient(h.db, {
|
|
438
|
+
redirectUris: ["https://app.example/cb"],
|
|
439
|
+
clientId: "parachute-hub-spa",
|
|
440
|
+
});
|
|
441
|
+
recordGrant(h.db, h.userId, "parachute-hub-spa", ["vault:default:read"]);
|
|
442
|
+
expect(userHasExternalAiGrant(h.db, h.userId, "default")).toBe(false);
|
|
443
|
+
} finally {
|
|
444
|
+
h.cleanup();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("an external AI/MCP client grant DOES count as connected", async () => {
|
|
449
|
+
const h = await harness();
|
|
450
|
+
try {
|
|
451
|
+
// Claude Code: DCR-registered, ordinary client_name, vault scope.
|
|
452
|
+
const claude = registerClient(h.db, {
|
|
453
|
+
redirectUris: ["https://claude.ai/cb"],
|
|
454
|
+
clientName: "Claude",
|
|
455
|
+
});
|
|
456
|
+
recordGrant(h.db, h.userId, claude.client.clientId, ["vault:default:read"]);
|
|
457
|
+
expect(userHasExternalAiGrant(h.db, h.userId, "default")).toBe(true);
|
|
458
|
+
} finally {
|
|
459
|
+
h.cleanup();
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("external grant is scoped to the named vault", async () => {
|
|
464
|
+
const h = await harness();
|
|
465
|
+
try {
|
|
466
|
+
const claude = registerClient(h.db, {
|
|
467
|
+
redirectUris: ["https://claude.ai/cb"],
|
|
468
|
+
clientName: "Claude",
|
|
469
|
+
});
|
|
470
|
+
recordGrant(h.db, h.userId, claude.client.clientId, ["vault:other:read"]);
|
|
471
|
+
expect(userHasExternalAiGrant(h.db, h.userId, "default")).toBe(false);
|
|
472
|
+
expect(userHasExternalAiGrant(h.db, h.userId, "other")).toBe(true);
|
|
473
|
+
} finally {
|
|
474
|
+
h.cleanup();
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("Notes + Claude both granted: still counts (the external one wins)", async () => {
|
|
479
|
+
const h = await harness();
|
|
480
|
+
try {
|
|
481
|
+
const notes = registerClient(h.db, {
|
|
482
|
+
redirectUris: ["https://app.example/cb"],
|
|
483
|
+
clientName: "Notes",
|
|
484
|
+
});
|
|
485
|
+
const claude = registerClient(h.db, {
|
|
486
|
+
redirectUris: ["https://claude.ai/cb"],
|
|
487
|
+
clientName: "Claude",
|
|
488
|
+
});
|
|
489
|
+
recordGrant(h.db, h.userId, notes.client.clientId, ["vault:default:read"]);
|
|
490
|
+
recordGrant(h.db, h.userId, claude.client.clientId, ["vault:default:read"]);
|
|
491
|
+
expect(userHasExternalAiGrant(h.db, h.userId, "default")).toBe(true);
|
|
492
|
+
} finally {
|
|
493
|
+
h.cleanup();
|
|
494
|
+
}
|
|
495
|
+
});
|
|
307
496
|
});
|
|
@@ -31,9 +31,20 @@ function req(url: string): Request {
|
|
|
31
31
|
return new Request(url, { method: "GET" });
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Stub the expose-state reader to "no exposure recorded" so these
|
|
36
|
+
* settings/env/request-tier tests are isolated from the host's real
|
|
37
|
+
* `~/.parachute/expose-state.json`. Without this, the default reader picks
|
|
38
|
+
* up a live exposure on the dev box and the expose tier shadows the
|
|
39
|
+
* request-origin fallback these tests assert. (The expose tier itself is
|
|
40
|
+
* exercised in the dedicated describe blocks below with its own injected
|
|
41
|
+
* origins.)
|
|
42
|
+
*/
|
|
43
|
+
const noExpose = (): string | undefined => undefined;
|
|
44
|
+
|
|
34
45
|
describe("resolveIssuer — precedence chain", () => {
|
|
35
46
|
test("falls back to request origin when no settings + no env", () => {
|
|
36
|
-
const got = resolveIssuer(req("http://127.0.0.1:1939/oauth/token"), db, undefined);
|
|
47
|
+
const got = resolveIssuer(req("http://127.0.0.1:1939/oauth/token"), db, undefined, noExpose);
|
|
37
48
|
expect(got).toBe("http://127.0.0.1:1939");
|
|
38
49
|
});
|
|
39
50
|
|
|
@@ -42,6 +53,7 @@ describe("resolveIssuer — precedence chain", () => {
|
|
|
42
53
|
req("http://127.0.0.1:1939/oauth/token"),
|
|
43
54
|
db,
|
|
44
55
|
"https://hub.from-env.example",
|
|
56
|
+
noExpose,
|
|
45
57
|
);
|
|
46
58
|
expect(got).toBe("https://hub.from-env.example");
|
|
47
59
|
});
|
|
@@ -52,13 +64,14 @@ describe("resolveIssuer — precedence chain", () => {
|
|
|
52
64
|
req("http://127.0.0.1:1939/oauth/token"),
|
|
53
65
|
db,
|
|
54
66
|
"https://hub.from-env.example",
|
|
67
|
+
noExpose,
|
|
55
68
|
);
|
|
56
69
|
expect(got).toBe("https://hub.from-settings.example");
|
|
57
70
|
});
|
|
58
71
|
|
|
59
72
|
test("hub_settings wins over request origin (no env)", () => {
|
|
60
73
|
setHubOrigin(db, "https://hub.from-settings.example");
|
|
61
|
-
const got = resolveIssuer(req("http://127.0.0.1:1939/oauth/token"), db, undefined);
|
|
74
|
+
const got = resolveIssuer(req("http://127.0.0.1:1939/oauth/token"), db, undefined, noExpose);
|
|
62
75
|
expect(got).toBe("https://hub.from-settings.example");
|
|
63
76
|
});
|
|
64
77
|
|
|
@@ -69,6 +82,7 @@ describe("resolveIssuer — precedence chain", () => {
|
|
|
69
82
|
req("http://127.0.0.1:1939/oauth/token"),
|
|
70
83
|
db,
|
|
71
84
|
"https://hub.from-env.example",
|
|
85
|
+
noExpose,
|
|
72
86
|
);
|
|
73
87
|
expect(got).toBe("https://hub.from-env.example");
|
|
74
88
|
});
|
|
@@ -76,7 +90,7 @@ describe("resolveIssuer — precedence chain", () => {
|
|
|
76
90
|
test("clearing hub_settings + no env reverts to request origin", () => {
|
|
77
91
|
setHubOrigin(db, "https://hub.from-settings.example");
|
|
78
92
|
setHubOrigin(db, null);
|
|
79
|
-
const got = resolveIssuer(req("http://127.0.0.1:1939/oauth/token"), db, undefined);
|
|
93
|
+
const got = resolveIssuer(req("http://127.0.0.1:1939/oauth/token"), db, undefined, noExpose);
|
|
80
94
|
expect(got).toBe("http://127.0.0.1:1939");
|
|
81
95
|
});
|
|
82
96
|
|
|
@@ -84,13 +98,14 @@ describe("resolveIssuer — precedence chain", () => {
|
|
|
84
98
|
// The wellknown / discovery surfaces may hit oauthDeps before a DB
|
|
85
99
|
// is wired; resolveIssuer must not throw — just skip the settings
|
|
86
100
|
// layer.
|
|
87
|
-
const got = resolveIssuer(req("http://127.0.0.1:1939/"), undefined, undefined);
|
|
101
|
+
const got = resolveIssuer(req("http://127.0.0.1:1939/"), undefined, undefined, noExpose);
|
|
88
102
|
expect(got).toBe("http://127.0.0.1:1939");
|
|
89
103
|
|
|
90
104
|
const gotEnv = resolveIssuer(
|
|
91
105
|
req("http://127.0.0.1:1939/"),
|
|
92
106
|
undefined,
|
|
93
107
|
"https://hub.from-env.example",
|
|
108
|
+
noExpose,
|
|
94
109
|
);
|
|
95
110
|
expect(gotEnv).toBe("https://hub.from-env.example");
|
|
96
111
|
});
|
|
@@ -103,19 +118,19 @@ describe("resolveIssuer — precedence chain", () => {
|
|
|
103
118
|
const baseUrl = "http://127.0.0.1:1939/oauth/token";
|
|
104
119
|
|
|
105
120
|
// Pass 1 — no settings, no env → request origin.
|
|
106
|
-
expect(resolveIssuer(req(baseUrl), db, undefined)).toBe("http://127.0.0.1:1939");
|
|
121
|
+
expect(resolveIssuer(req(baseUrl), db, undefined, noExpose)).toBe("http://127.0.0.1:1939");
|
|
107
122
|
|
|
108
123
|
// Mid-flight write.
|
|
109
124
|
setHubOrigin(db, "https://hub.example.com");
|
|
110
125
|
|
|
111
126
|
// Pass 2 — settings wins immediately.
|
|
112
|
-
expect(resolveIssuer(req(baseUrl), db, undefined)).toBe("https://hub.example.com");
|
|
127
|
+
expect(resolveIssuer(req(baseUrl), db, undefined, noExpose)).toBe("https://hub.example.com");
|
|
113
128
|
|
|
114
129
|
// Mid-flight clear.
|
|
115
130
|
setHubOrigin(db, null);
|
|
116
131
|
|
|
117
132
|
// Pass 3 — back to request origin.
|
|
118
|
-
expect(resolveIssuer(req(baseUrl), db, undefined)).toBe("http://127.0.0.1:1939");
|
|
133
|
+
expect(resolveIssuer(req(baseUrl), db, undefined, noExpose)).toBe("http://127.0.0.1:1939");
|
|
119
134
|
});
|
|
120
135
|
|
|
121
136
|
test("X-Forwarded-Proto: https upgrades the request-origin fallback", () => {
|
|
@@ -124,11 +139,14 @@ describe("resolveIssuer — precedence chain", () => {
|
|
|
124
139
|
// `http://...` in OAuth discovery — mixed-content blocked when the
|
|
125
140
|
// page loaded over https://. See hub#355 (the notes app's
|
|
126
141
|
// /oauth/register call surfaced this).
|
|
127
|
-
const r = new Request(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
142
|
+
const r = new Request(
|
|
143
|
+
"http://parachute-hub.onrender.com/.well-known/oauth-authorization-server",
|
|
144
|
+
{
|
|
145
|
+
method: "GET",
|
|
146
|
+
headers: { "X-Forwarded-Proto": "https" },
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
expect(resolveIssuer(r, db, undefined, noExpose)).toBe("https://parachute-hub.onrender.com");
|
|
132
150
|
});
|
|
133
151
|
|
|
134
152
|
test("X-Forwarded-Proto with comma-separated values takes the first", () => {
|
|
@@ -138,14 +156,14 @@ describe("resolveIssuer — precedence chain", () => {
|
|
|
138
156
|
method: "GET",
|
|
139
157
|
headers: { "X-Forwarded-Proto": "https, http" },
|
|
140
158
|
});
|
|
141
|
-
expect(resolveIssuer(r, db, undefined)).toBe("https://hub.internal");
|
|
159
|
+
expect(resolveIssuer(r, db, undefined, noExpose)).toBe("https://hub.internal");
|
|
142
160
|
});
|
|
143
161
|
|
|
144
162
|
test("missing X-Forwarded-Proto leaves the URL scheme as-is (localhost dev)", () => {
|
|
145
163
|
// No reverse proxy → no header → keep http for the local-dev shape.
|
|
146
164
|
// Operators on plain HTTP localhost depend on this.
|
|
147
165
|
const r = new Request("http://127.0.0.1:1939/oauth/token", { method: "GET" });
|
|
148
|
-
expect(resolveIssuer(r, db, undefined)).toBe("http://127.0.0.1:1939");
|
|
166
|
+
expect(resolveIssuer(r, db, undefined, noExpose)).toBe("http://127.0.0.1:1939");
|
|
149
167
|
});
|
|
150
168
|
|
|
151
169
|
test("X-Forwarded-Proto is IGNORED when hub_settings or env wins", () => {
|
|
@@ -161,26 +179,28 @@ describe("resolveIssuer — precedence chain", () => {
|
|
|
161
179
|
|
|
162
180
|
// Env layer wins, even though the header says https — the env value
|
|
163
181
|
// is returned verbatim (preserving whatever scheme the operator set).
|
|
164
|
-
expect(resolveIssuer(r, db, "http://configured.example")).toBe(
|
|
182
|
+
expect(resolveIssuer(r, db, "http://configured.example", noExpose)).toBe(
|
|
183
|
+
"http://configured.example",
|
|
184
|
+
);
|
|
165
185
|
|
|
166
186
|
// Settings layer wins above env, also verbatim.
|
|
167
187
|
setHubOrigin(db, "http://settings.example");
|
|
168
|
-
expect(resolveIssuer(r, db, "https://env.example")).toBe("http://settings.example");
|
|
188
|
+
expect(resolveIssuer(r, db, "https://env.example", noExpose)).toBe("http://settings.example");
|
|
169
189
|
});
|
|
170
190
|
});
|
|
171
191
|
|
|
172
192
|
describe("resolveIssuerSource — attribution for SPA", () => {
|
|
173
193
|
test('"request" when nothing is configured', () => {
|
|
174
|
-
expect(resolveIssuerSource(db, undefined)).toBe("request");
|
|
194
|
+
expect(resolveIssuerSource(db, undefined, noExpose)).toBe("request");
|
|
175
195
|
});
|
|
176
196
|
|
|
177
197
|
test('"env" when configuredIssuer is set + no settings row', () => {
|
|
178
|
-
expect(resolveIssuerSource(db, "https://hub.from-env.example")).toBe("env");
|
|
198
|
+
expect(resolveIssuerSource(db, "https://hub.from-env.example", noExpose)).toBe("env");
|
|
179
199
|
});
|
|
180
200
|
|
|
181
201
|
test('"settings" when hub_settings row is set, even if env is also set', () => {
|
|
182
202
|
setHubOrigin(db, "https://hub.from-settings.example");
|
|
183
|
-
expect(resolveIssuerSource(db, "https://hub.from-env.example")).toBe("settings");
|
|
203
|
+
expect(resolveIssuerSource(db, "https://hub.from-env.example", noExpose)).toBe("settings");
|
|
184
204
|
});
|
|
185
205
|
|
|
186
206
|
test("attribution matches resolved value across the chain", () => {
|
|
@@ -189,16 +209,150 @@ describe("resolveIssuerSource — attribution for SPA", () => {
|
|
|
189
209
|
// settings layer is what got returned.
|
|
190
210
|
setHubOrigin(db, "https://hub.example.com");
|
|
191
211
|
const r1 = req("http://127.0.0.1:1939/oauth/token");
|
|
192
|
-
expect(resolveIssuer(r1, db, "https://hub.from-env.example")).toBe(
|
|
193
|
-
|
|
212
|
+
expect(resolveIssuer(r1, db, "https://hub.from-env.example", noExpose)).toBe(
|
|
213
|
+
"https://hub.example.com",
|
|
214
|
+
);
|
|
215
|
+
expect(resolveIssuerSource(db, "https://hub.from-env.example", noExpose)).toBe("settings");
|
|
194
216
|
|
|
195
217
|
setHubOrigin(db, null);
|
|
196
|
-
expect(resolveIssuer(r1, db, "https://hub.from-env.example")).toBe(
|
|
218
|
+
expect(resolveIssuer(r1, db, "https://hub.from-env.example", noExpose)).toBe(
|
|
197
219
|
"https://hub.from-env.example",
|
|
198
220
|
);
|
|
199
|
-
expect(resolveIssuerSource(db, "https://hub.from-env.example")).toBe("env");
|
|
221
|
+
expect(resolveIssuerSource(db, "https://hub.from-env.example", noExpose)).toBe("env");
|
|
222
|
+
|
|
223
|
+
expect(resolveIssuer(r1, db, undefined, noExpose)).toBe("http://127.0.0.1:1939");
|
|
224
|
+
expect(resolveIssuerSource(db, undefined, noExpose)).toBe("request");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The expose-state tier (#531). On the reboot-persistent owner-operated
|
|
230
|
+
* path the launchd plist / systemd unit carries no PARACHUTE_HUB_ORIGIN, so
|
|
231
|
+
* the hub boots with no `configuredIssuer`. Without this tier it would stamp
|
|
232
|
+
* `iss` from the per-request origin (loopback) and exposed resource servers
|
|
233
|
+
* (vault) reject the token with `unexpected "iss" claim value`. The exposed
|
|
234
|
+
* origin recorded in expose-state.json's hubOrigin is consulted between the
|
|
235
|
+
* env tier and the request-origin fallback. The `readExpose` seam (4th /
|
|
236
|
+
* 3rd param) drives this without touching the real ~/.parachute.
|
|
237
|
+
*/
|
|
238
|
+
describe("resolveIssuer — expose-state tier (#531)", () => {
|
|
239
|
+
const EXPOSED = "https://parachute.taildf9ce2.ts.net";
|
|
240
|
+
// Simulates the reported bug: token minted under loopback, request arrives
|
|
241
|
+
// at loopback, but the canonical exposed origin lives in expose-state.
|
|
242
|
+
const loopbackReq = () => req("http://127.0.0.1:1939/oauth/token");
|
|
243
|
+
|
|
244
|
+
test("REGRESSION: expose origin used (NOT request origin) when settings+env both absent", () => {
|
|
245
|
+
const got = resolveIssuer(loopbackReq(), db, undefined, () => EXPOSED);
|
|
246
|
+
expect(got).toBe(EXPOSED);
|
|
247
|
+
expect(got).not.toBe("http://127.0.0.1:1939");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("settings wins over expose", () => {
|
|
251
|
+
setHubOrigin(db, "https://hub.from-settings.example");
|
|
252
|
+
const got = resolveIssuer(loopbackReq(), db, undefined, () => EXPOSED);
|
|
253
|
+
expect(got).toBe("https://hub.from-settings.example");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("env wins over expose", () => {
|
|
257
|
+
const got = resolveIssuer(loopbackReq(), db, "https://hub.from-env.example", () => EXPOSED);
|
|
258
|
+
expect(got).toBe("https://hub.from-env.example");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("expose wins over request origin", () => {
|
|
262
|
+
// settings + env both absent → expose beats the per-request loopback origin.
|
|
263
|
+
const got = resolveIssuer(loopbackReq(), db, undefined, () => EXPOSED);
|
|
264
|
+
expect(got).toBe(EXPOSED);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("full precedence: settings > env > expose > request", () => {
|
|
268
|
+
// request-only
|
|
269
|
+
expect(resolveIssuer(loopbackReq(), db, undefined, () => undefined)).toBe(
|
|
270
|
+
"http://127.0.0.1:1939",
|
|
271
|
+
);
|
|
272
|
+
// expose beats request
|
|
273
|
+
expect(resolveIssuer(loopbackReq(), db, undefined, () => EXPOSED)).toBe(EXPOSED);
|
|
274
|
+
// env beats expose
|
|
275
|
+
expect(resolveIssuer(loopbackReq(), db, "https://env.example", () => EXPOSED)).toBe(
|
|
276
|
+
"https://env.example",
|
|
277
|
+
);
|
|
278
|
+
// settings beats env (and expose)
|
|
279
|
+
setHubOrigin(db, "https://settings.example");
|
|
280
|
+
expect(resolveIssuer(loopbackReq(), db, "https://env.example", () => EXPOSED)).toBe(
|
|
281
|
+
"https://settings.example",
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("malformed expose-state falls through to request without throwing", () => {
|
|
286
|
+
// A reader that throws simulates a corrupt expose-state.json. The
|
|
287
|
+
// `exposeIssuerOrigin` wrapper guards the `readExpose()` call itself in
|
|
288
|
+
// try/catch, so even an injected non-swallowing reader can NEVER
|
|
289
|
+
// propagate into the request path — resolveIssuer falls through to the
|
|
290
|
+
// request origin instead of 500ing the hub.
|
|
291
|
+
const throwing = () => {
|
|
292
|
+
throw new Error("malformed expose-state.json");
|
|
293
|
+
};
|
|
294
|
+
expect(() => resolveIssuer(loopbackReq(), db, undefined, throwing)).not.toThrow();
|
|
295
|
+
expect(resolveIssuer(loopbackReq(), db, undefined, throwing)).toBe("http://127.0.0.1:1939");
|
|
296
|
+
// A reader that returns undefined (the default's post-swallow shape) also
|
|
297
|
+
// yields the request origin.
|
|
298
|
+
expect(resolveIssuer(loopbackReq(), db, undefined, () => undefined)).toBe(
|
|
299
|
+
"http://127.0.0.1:1939",
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("loopback expose origin ignored (never re-pin the degraded mode)", () => {
|
|
304
|
+
expect(resolveIssuer(loopbackReq(), db, undefined, () => "http://127.0.0.1:1939")).toBe(
|
|
305
|
+
"http://127.0.0.1:1939",
|
|
306
|
+
);
|
|
307
|
+
expect(resolveIssuer(loopbackReq(), db, undefined, () => "http://localhost:1939")).toBe(
|
|
308
|
+
"http://127.0.0.1:1939",
|
|
309
|
+
);
|
|
310
|
+
expect(resolveIssuer(loopbackReq(), db, undefined, () => "http://0.0.0.0:1939")).toBe(
|
|
311
|
+
"http://127.0.0.1:1939",
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("non-http(s) / empty expose origin ignored", () => {
|
|
316
|
+
expect(resolveIssuer(loopbackReq(), db, undefined, () => "ftp://x.example")).toBe(
|
|
317
|
+
"http://127.0.0.1:1939",
|
|
318
|
+
);
|
|
319
|
+
expect(resolveIssuer(loopbackReq(), db, undefined, () => "")).toBe("http://127.0.0.1:1939");
|
|
320
|
+
expect(resolveIssuer(loopbackReq(), db, undefined, () => "not-a-url")).toBe(
|
|
321
|
+
"http://127.0.0.1:1939",
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("undefined db (pre-config gate) still consults expose before request", () => {
|
|
326
|
+
const got = resolveIssuer(loopbackReq(), undefined, undefined, () => EXPOSED);
|
|
327
|
+
expect(got).toBe(EXPOSED);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("resolveIssuerSource — expose attribution (#531)", () => {
|
|
332
|
+
const EXPOSED = "https://parachute.taildf9ce2.ts.net";
|
|
333
|
+
|
|
334
|
+
test('"expose" when resolved from expose-state (settings+env absent)', () => {
|
|
335
|
+
expect(resolveIssuerSource(db, undefined, () => EXPOSED)).toBe("expose");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('"settings" wins over expose', () => {
|
|
339
|
+
setHubOrigin(db, "https://settings.example");
|
|
340
|
+
expect(resolveIssuerSource(db, undefined, () => EXPOSED)).toBe("settings");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('"env" wins over expose', () => {
|
|
344
|
+
expect(resolveIssuerSource(db, "https://env.example", () => EXPOSED)).toBe("env");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('"request" when no settings/env and no (valid) expose origin', () => {
|
|
348
|
+
expect(resolveIssuerSource(db, undefined, () => undefined)).toBe("request");
|
|
349
|
+
expect(resolveIssuerSource(db, undefined, () => "http://127.0.0.1:1939")).toBe("request");
|
|
350
|
+
});
|
|
200
351
|
|
|
201
|
-
|
|
202
|
-
|
|
352
|
+
test("attribution matches resolved value for the expose tier", () => {
|
|
353
|
+
// Pair the source label with the resolved value so they can't drift.
|
|
354
|
+
const r = req("http://127.0.0.1:1939/oauth/token");
|
|
355
|
+
expect(resolveIssuer(r, db, undefined, () => EXPOSED)).toBe(EXPOSED);
|
|
356
|
+
expect(resolveIssuerSource(db, undefined, () => EXPOSED)).toBe("expose");
|
|
203
357
|
});
|
|
204
358
|
});
|