@openparachute/hub 0.6.3 → 0.6.4-rc.1

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 (72) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +609 -0
  3. package/src/__tests__/account-usage.test.ts +137 -0
  4. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  5. package/src/__tests__/account-vault-token.test.ts +53 -1
  6. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  7. package/src/__tests__/admin-vaults.test.ts +20 -0
  8. package/src/__tests__/api-account.test.ts +125 -4
  9. package/src/__tests__/api-invites.test.ts +180 -0
  10. package/src/__tests__/api-mint-token.test.ts +259 -10
  11. package/src/__tests__/api-modules-ops.test.ts +187 -1
  12. package/src/__tests__/api-modules.test.ts +40 -4
  13. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  14. package/src/__tests__/auto-wire.test.ts +101 -1
  15. package/src/__tests__/cli.test.ts +188 -2
  16. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  17. package/src/__tests__/expose-cloudflare.test.ts +5 -4
  18. package/src/__tests__/expose.test.ts +10 -5
  19. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  20. package/src/__tests__/hub-server.test.ts +628 -13
  21. package/src/__tests__/hub-unit.test.ts +4 -0
  22. package/src/__tests__/invites.test.ts +220 -0
  23. package/src/__tests__/launchctl-guard.test.ts +185 -0
  24. package/src/__tests__/migrate-cutover.test.ts +32 -0
  25. package/src/__tests__/module-ops-client.test.ts +68 -0
  26. package/src/__tests__/scope-explanations.test.ts +16 -0
  27. package/src/__tests__/serve-boot.test.ts +74 -1
  28. package/src/__tests__/serve.test.ts +121 -7
  29. package/src/__tests__/spawn-path.test.ts +191 -0
  30. package/src/__tests__/status.test.ts +64 -0
  31. package/src/__tests__/supervisor.test.ts +177 -0
  32. package/src/__tests__/users.test.ts +27 -0
  33. package/src/account-home-ui.ts +82 -9
  34. package/src/account-setup.ts +342 -0
  35. package/src/account-usage.ts +118 -0
  36. package/src/account-vault-admin-token.ts +242 -0
  37. package/src/account-vault-token.ts +27 -2
  38. package/src/admin-login-ui.ts +94 -0
  39. package/src/admin-vault-admin-token.ts +8 -2
  40. package/src/admin-vaults.ts +137 -29
  41. package/src/api-account.ts +54 -1
  42. package/src/api-invites.ts +347 -0
  43. package/src/api-mint-token.ts +81 -0
  44. package/src/api-modules-ops.ts +168 -53
  45. package/src/api-modules.ts +36 -0
  46. package/src/auto-wire.ts +87 -0
  47. package/src/cli.ts +122 -32
  48. package/src/commands/expose-2fa-warning.ts +17 -13
  49. package/src/commands/migrate-cutover.ts +12 -5
  50. package/src/commands/serve-boot.ts +33 -3
  51. package/src/commands/serve.ts +158 -37
  52. package/src/commands/status.ts +9 -1
  53. package/src/hub-db.ts +70 -2
  54. package/src/hub-server.ts +399 -41
  55. package/src/hub-unit.ts +4 -9
  56. package/src/invites.ts +291 -0
  57. package/src/launchctl-guard.ts +131 -0
  58. package/src/managed-unit.ts +13 -3
  59. package/src/migrate-offer.ts +15 -6
  60. package/src/module-ops-client.ts +47 -22
  61. package/src/scope-attenuation.ts +19 -0
  62. package/src/scope-explanations.ts +9 -1
  63. package/src/service-spec.ts +8 -3
  64. package/src/spawn-path.ts +148 -0
  65. package/src/supervisor.ts +84 -7
  66. package/src/users.ts +42 -4
  67. package/src/vault-hub-origin-env.ts +28 -0
  68. package/src/vault-name.ts +13 -1
  69. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  70. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -87,13 +87,16 @@ describe("printPublic2FAWarning", () => {
87
87
  });
88
88
  expect(fired).toBe(true);
89
89
  const joined = logs.join("\n");
90
- // hub#473: real hub-login 2FA. The warning now recommends the real
91
- // `parachute auth 2fa enroll` path (+ the /account/2fa browser path) and
92
- // still nudges a strong owner password.
93
- expect(joined).toContain("/login is now reachable on the public internet");
90
+ // hub#473: real hub-login 2FA. The recommendation now leads with the
91
+ // friendly "strongly recommended" framing, points at both the /account/2fa
92
+ // browser path and the `parachute auth 2fa enroll` CLI path, makes clear
93
+ // it's not a requirement, and still nudges a strong owner password.
94
+ expect(joined).toContain("Strongly recommended: turn on two-factor authentication");
95
+ expect(joined).toContain("reachable from the public internet");
94
96
  expect(joined).toContain("https://vault.example.com/login");
97
+ expect(joined).toContain("https://vault.example.com/account/2fa");
95
98
  expect(joined).toContain("parachute auth 2fa enroll");
96
- expect(joined).toContain("/account/2fa");
99
+ expect(joined).toContain("It's a recommendation, not a requirement");
97
100
  expect(joined).toContain("parachute auth set-password");
98
101
  });
99
102
 
@@ -120,9 +123,9 @@ describe("printPublic2FAWarning", () => {
120
123
  publicUrl: "https://vault.example.com",
121
124
  });
122
125
  expect(fired).toBe(true);
123
- expect(logs.some((l) => l.includes("/login is now reachable on the public internet"))).toBe(
124
- true,
125
- );
126
+ expect(
127
+ logs.some((l) => l.includes("Strongly recommended: turn on two-factor authentication")),
128
+ ).toBe(true);
126
129
  });
127
130
 
128
131
  test("embeds the supplied publicUrl into the /login pointer", () => {
@@ -1230,9 +1230,10 @@ describe("exposeCloudflareUp", () => {
1230
1230
 
1231
1231
  expect(code).toBe(0);
1232
1232
  const joined = logs.join("\n");
1233
- // hub#473: real hub-login 2FA — the warning now recommends the real
1234
- // `parachute auth 2fa enroll` path.
1235
- expect(joined).toContain("/login is now reachable on the public internet");
1233
+ // hub#473: real hub-login 2FA — the recommendation now leads with the
1234
+ // friendly "strongly recommended" framing and the real `parachute auth
1235
+ // 2fa enroll` / `/account/2fa` paths.
1236
+ expect(joined).toContain("Strongly recommended: turn on two-factor authentication");
1236
1237
  expect(joined).toContain("https://vault.example.com/login");
1237
1238
  expect(joined).toContain("parachute auth 2fa enroll");
1238
1239
  } finally {
@@ -1281,7 +1282,7 @@ describe("exposeCloudflareUp", () => {
1281
1282
 
1282
1283
  expect(code).toBe(0);
1283
1284
  const joined = logs.join("\n");
1284
- expect(joined).not.toContain("/login is now reachable on the public internet");
1285
+ expect(joined).not.toContain("Strongly recommended: turn on two-factor authentication");
1285
1286
  // The contextual 2FA warning is suppressed (2FA already enrolled); the
1286
1287
  // always-shown owner-password guidance from `printAuthGuidance` still
1287
1288
  // appears, and it now (hub#473) also surfaces the real `2fa enroll`
@@ -558,7 +558,9 @@ describe("expose tailnet up", () => {
558
558
  },
559
559
  });
560
560
  expect(code).toBe(0);
561
- expect(logs.join("\n")).not.toContain("2FA is not enrolled");
561
+ expect(logs.join("\n")).not.toContain(
562
+ "Strongly recommended: turn on two-factor authentication",
563
+ );
562
564
  } finally {
563
565
  h.cleanup();
564
566
  }
@@ -931,9 +933,10 @@ describe("expose public up", () => {
931
933
  });
932
934
  expect(code).toBe(0);
933
935
  const joined = logs.join("\n");
934
- // hub#473: real hub-login 2FA. The warning now recommends the real
935
- // `parachute auth 2fa enroll` path.
936
- expect(joined).toContain("/login is now reachable on the public internet");
936
+ // hub#473: real hub-login 2FA. The recommendation now leads with the
937
+ // friendly "strongly recommended" framing and the real `parachute auth
938
+ // 2fa enroll` path.
939
+ expect(joined).toContain("Strongly recommended: turn on two-factor authentication");
937
940
  expect(joined).toContain("parachute auth 2fa enroll");
938
941
  // /login pointer uses the canonical https://<fqdn> origin.
939
942
  expect(joined).toContain("https://parachute.taildf9ce2.ts.net/login");
@@ -966,7 +969,9 @@ describe("expose public up", () => {
966
969
  },
967
970
  });
968
971
  expect(code).toBe(0);
969
- expect(logs.join("\n")).not.toContain("2FA is not enrolled");
972
+ expect(logs.join("\n")).not.toContain(
973
+ "Strongly recommended: turn on two-factor authentication",
974
+ );
970
975
  } finally {
971
976
  h.cleanup();
972
977
  }
@@ -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
  });