@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.
Files changed (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. 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(h.db, h.userId, reg1.client.clientId, ["a", "b"], new Date("2026-04-10T00:00:00Z"));
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(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"));
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, ["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);
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(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);
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("http://parachute-hub.onrender.com/.well-known/oauth-authorization-server", {
128
- method: "GET",
129
- headers: { "X-Forwarded-Proto": "https" },
130
- });
131
- expect(resolveIssuer(r, db, undefined)).toBe("https://parachute-hub.onrender.com");
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("http://configured.example");
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("https://hub.example.com");
193
- expect(resolveIssuerSource(db, "https://hub.from-env.example")).toBe("settings");
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
- expect(resolveIssuer(r1, db, undefined)).toBe("http://127.0.0.1:1939");
202
- expect(resolveIssuerSource(db, undefined)).toBe("request");
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
  });