@openparachute/hub 0.5.10-rc.9 → 0.5.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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +141 -6
  3. package/src/__tests__/api-account.test.ts +463 -0
  4. package/src/__tests__/api-modules-ops.test.ts +74 -0
  5. package/src/__tests__/api-modules.test.ts +134 -0
  6. package/src/__tests__/api-users.test.ts +522 -0
  7. package/src/__tests__/cors.test.ts +587 -0
  8. package/src/__tests__/hub-db.test.ts +126 -1
  9. package/src/__tests__/hub-settings.test.ts +152 -0
  10. package/src/__tests__/jwt-sign.test.ts +59 -0
  11. package/src/__tests__/oauth-handlers.test.ts +912 -10
  12. package/src/__tests__/oauth-ui.test.ts +210 -0
  13. package/src/__tests__/scope-explanations.test.ts +23 -0
  14. package/src/__tests__/serve.test.ts +8 -1
  15. package/src/__tests__/setup-wizard.test.ts +216 -3
  16. package/src/__tests__/users.test.ts +196 -0
  17. package/src/__tests__/vault-names.test.ts +172 -0
  18. package/src/account-change-password-ui.ts +379 -0
  19. package/src/admin-handlers.ts +68 -2
  20. package/src/admin-host-admin-token.ts +5 -0
  21. package/src/admin-vault-admin-token.ts +7 -0
  22. package/src/api-account.ts +443 -0
  23. package/src/api-mint-token.ts +6 -0
  24. package/src/api-modules-ops.ts +15 -6
  25. package/src/api-modules.ts +101 -0
  26. package/src/api-users.ts +393 -0
  27. package/src/commands/auth.ts +10 -1
  28. package/src/commands/serve.ts +5 -1
  29. package/src/cors.ts +263 -0
  30. package/src/hub-db.ts +30 -0
  31. package/src/hub-server.ts +138 -18
  32. package/src/hub-settings.ts +98 -1
  33. package/src/jwt-sign.ts +17 -1
  34. package/src/oauth-handlers.ts +237 -29
  35. package/src/oauth-ui.ts +451 -38
  36. package/src/operator-token.ts +4 -0
  37. package/src/scope-explanations.ts +26 -1
  38. package/src/setup-wizard.ts +134 -16
  39. package/src/users.ts +210 -3
  40. package/src/vault-names.ts +57 -0
  41. package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
  42. package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
  43. package/web/ui/dist/index.html +2 -2
  44. package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
@@ -0,0 +1,587 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ CORS_PREFLIGHT_HEADERS,
7
+ CORS_RESPONSE_HEADERS,
8
+ applyCorsHeaders,
9
+ corsPreflightResponse,
10
+ isCorsAllowedRoute,
11
+ } from "../cors.ts";
12
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
13
+ import { hubFetch } from "../hub-server.ts";
14
+ import { writeManifest } from "../services-manifest.ts";
15
+
16
+ const GITCOIN_BRAIN_ORIGIN = "https://unforced-dev.github.io";
17
+ const EXAMPLE_ORIGIN = "https://example.com";
18
+ const ISSUER = "https://parachute.taildf9ce2.ts.net";
19
+
20
+ function preflight(path: string, origin: string | null = GITCOIN_BRAIN_ORIGIN): Request {
21
+ const headers: Record<string, string> = {
22
+ "access-control-request-method": "POST",
23
+ "access-control-request-headers": "content-type",
24
+ };
25
+ if (origin !== null) headers.origin = origin;
26
+ return new Request(`http://127.0.0.1${path}`, { method: "OPTIONS", headers });
27
+ }
28
+
29
+ interface Harness {
30
+ dir: string;
31
+ manifestPath: string;
32
+ cleanup: () => void;
33
+ }
34
+
35
+ function makeHarness(): Harness {
36
+ const dir = mkdtempSync(join(tmpdir(), "phub-cors-"));
37
+ return {
38
+ dir,
39
+ manifestPath: join(dir, "services.json"),
40
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
41
+ };
42
+ }
43
+
44
+ describe("cors helper module", () => {
45
+ test("CORS_RESPONSE_HEADERS exposes WWW-Authenticate (always-on, request-independent)", () => {
46
+ // Expose-Headers surfaces RFC 6750 WWW-Authenticate so cross-origin SPAs
47
+ // can read OAuth error responses. The dynamic Origin/Credentials/Vary
48
+ // triple is no longer static — it's computed per-request in
49
+ // applyCorsHeaders + corsPreflightResponse from the request's Origin
50
+ // header (echo-origin posture, not wildcard).
51
+ expect(CORS_RESPONSE_HEADERS["access-control-expose-headers"]).toContain("WWW-Authenticate");
52
+ });
53
+
54
+ test("CORS_PREFLIGHT_HEADERS announces GET + POST + OPTIONS and standard request headers", () => {
55
+ const methods = CORS_PREFLIGHT_HEADERS["access-control-allow-methods"] ?? "";
56
+ expect(methods).toContain("GET");
57
+ expect(methods).toContain("POST");
58
+ expect(methods).toContain("OPTIONS");
59
+ const headers = CORS_PREFLIGHT_HEADERS["access-control-allow-headers"] ?? "";
60
+ expect(headers).toContain("Authorization");
61
+ expect(headers).toContain("Content-Type");
62
+ expect(CORS_PREFLIGHT_HEADERS["access-control-max-age"]).toBe("86400");
63
+ });
64
+
65
+ test("isCorsAllowedRoute matches /oauth/* and nothing else", () => {
66
+ expect(isCorsAllowedRoute("/oauth/register")).toBe(true);
67
+ expect(isCorsAllowedRoute("/oauth/token")).toBe(true);
68
+ expect(isCorsAllowedRoute("/oauth/authorize")).toBe(true);
69
+ expect(isCorsAllowedRoute("/oauth/authorize/approve")).toBe(true);
70
+ expect(isCorsAllowedRoute("/oauth/revoke")).toBe(true);
71
+ // Out-of-scope surfaces. /.well-known/* handlers carry their own inline
72
+ // CORS posture in hub-server.ts — see the comment in cors.ts on why
73
+ // they're intentionally excluded from this predicate.
74
+ expect(isCorsAllowedRoute("/.well-known/oauth-authorization-server")).toBe(false);
75
+ expect(isCorsAllowedRoute("/.well-known/parachute.json")).toBe(false);
76
+ expect(isCorsAllowedRoute("/.well-known/jwks.json")).toBe(false);
77
+ expect(isCorsAllowedRoute("/api/me")).toBe(false);
78
+ expect(isCorsAllowedRoute("/api/users")).toBe(false);
79
+ expect(isCorsAllowedRoute("/admin/vaults")).toBe(false);
80
+ expect(isCorsAllowedRoute("/admin/host-admin-token")).toBe(false);
81
+ expect(isCorsAllowedRoute("/login")).toBe(false);
82
+ expect(isCorsAllowedRoute("/logout")).toBe(false);
83
+ expect(isCorsAllowedRoute("/account/change-password")).toBe(false);
84
+ expect(isCorsAllowedRoute("/vault/default")).toBe(false);
85
+ expect(isCorsAllowedRoute("/")).toBe(false);
86
+ // Bare /oauth doesn't match — there's no route there and the prefix
87
+ // intentionally requires the trailing slash so it doesn't silently widen.
88
+ expect(isCorsAllowedRoute("/oauth")).toBe(false);
89
+ });
90
+
91
+ test("corsPreflightResponse with Origin echoes origin + credentials:true + Vary:Origin", async () => {
92
+ const res = corsPreflightResponse(
93
+ new Request("http://127.0.0.1/oauth/register", {
94
+ method: "OPTIONS",
95
+ headers: { origin: EXAMPLE_ORIGIN },
96
+ }),
97
+ );
98
+ expect(res.status).toBe(204);
99
+ expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
100
+ expect(res.headers.get("access-control-allow-credentials")).toBe("true");
101
+ // Vary: Origin is critical — without it a browser/CDN can cache a
102
+ // response for one origin and reuse it for a different origin. Pin its
103
+ // presence as a regression guard.
104
+ expect(res.headers.get("vary")).toBe("Origin");
105
+ expect(res.headers.get("access-control-allow-methods")).toContain("POST");
106
+ expect(res.headers.get("access-control-allow-headers")).toContain("Authorization");
107
+ expect(res.headers.get("access-control-max-age")).toBe("86400");
108
+ // 204 = no body. Reading it returns the empty string.
109
+ expect(await res.text()).toBe("");
110
+ });
111
+
112
+ test("corsPreflightResponse without Origin falls back to wildcard + credentials:false", async () => {
113
+ // Non-browser caller (curl, server-side fetch). No Origin → safe wildcard
114
+ // fallback with credentials:false (the only legal pairing per CORS spec
115
+ // when ACAO is `*`).
116
+ const res = corsPreflightResponse(
117
+ new Request("http://127.0.0.1/oauth/register", { method: "OPTIONS" }),
118
+ );
119
+ expect(res.status).toBe(204);
120
+ expect(res.headers.get("access-control-allow-origin")).toBe("*");
121
+ expect(res.headers.get("access-control-allow-credentials")).toBe("false");
122
+ // No Vary needed on a wildcard response — it doesn't vary by origin.
123
+ expect(res.headers.get("vary")).toBeNull();
124
+ expect(res.headers.get("access-control-allow-methods")).toContain("POST");
125
+ });
126
+
127
+ test("applyCorsHeaders with Origin echoes origin + credentials:true + Vary:Origin", async () => {
128
+ const original = Response.json({ ok: true }, { status: 201 });
129
+ const wrapped = applyCorsHeaders(
130
+ new Request("http://127.0.0.1/oauth/register", {
131
+ method: "POST",
132
+ headers: { origin: EXAMPLE_ORIGIN },
133
+ }),
134
+ original,
135
+ );
136
+ expect(wrapped.status).toBe(201);
137
+ expect(wrapped.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
138
+ expect(wrapped.headers.get("access-control-allow-credentials")).toBe("true");
139
+ expect(wrapped.headers.get("vary")).toBe("Origin");
140
+ expect(wrapped.headers.get("content-type")).toBe("application/json;charset=utf-8");
141
+ expect((await wrapped.json()) as { ok: boolean }).toEqual({ ok: true });
142
+ });
143
+
144
+ test("applyCorsHeaders without Origin falls back to wildcard + credentials:false", async () => {
145
+ const original = Response.json({ ok: true }, { status: 201 });
146
+ const wrapped = applyCorsHeaders(
147
+ new Request("http://127.0.0.1/oauth/register", { method: "POST" }),
148
+ original,
149
+ );
150
+ expect(wrapped.headers.get("access-control-allow-origin")).toBe("*");
151
+ expect(wrapped.headers.get("access-control-allow-credentials")).toBe("false");
152
+ expect(wrapped.headers.get("vary")).toBeNull();
153
+ });
154
+
155
+ test("applyCorsHeaders preserves a handler's existing CORS header (no overwrite)", () => {
156
+ // If a handler already set Access-Control-Allow-Origin (e.g. a different
157
+ // posture for a specific route), we don't clobber it. Defensive; no
158
+ // current caller does this, but the contract should be additive.
159
+ const original = new Response("hi", {
160
+ status: 200,
161
+ headers: { "access-control-allow-origin": "https://specific.example" },
162
+ });
163
+ const wrapped = applyCorsHeaders(
164
+ new Request("http://127.0.0.1/oauth/register", {
165
+ method: "POST",
166
+ headers: { origin: EXAMPLE_ORIGIN },
167
+ }),
168
+ original,
169
+ );
170
+ expect(wrapped.headers.get("access-control-allow-origin")).toBe("https://specific.example");
171
+ });
172
+ });
173
+
174
+ describe("hubFetch CORS on /oauth/* — echo origin (credentials:'include' SPAs)", () => {
175
+ // rc.17 used a static `Access-Control-Allow-Origin: *` + Allow-Credentials:
176
+ // false. That works for SPAs that fetch with `credentials: 'omit'`, but the
177
+ // Gitcoin Brain UI (and most SPA frameworks by default) fetches with
178
+ // `credentials: 'include'`, which the browser rejects against a wildcard
179
+ // ACAO. rc.18 echoes the request Origin + sets Allow-Credentials: true so
180
+ // both SPA postures work. These tests pin the echo-origin behavior.
181
+
182
+ test("OPTIONS preflight on /oauth/register from a third-party origin echoes that origin", async () => {
183
+ const h = makeHarness();
184
+ try {
185
+ const db = openHubDb(hubDbPath(h.dir));
186
+ try {
187
+ const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
188
+ preflight("/oauth/register", EXAMPLE_ORIGIN),
189
+ );
190
+ expect(res.status).toBe(204);
191
+ expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
192
+ expect(res.headers.get("access-control-allow-credentials")).toBe("true");
193
+ expect(res.headers.get("vary")).toBe("Origin");
194
+ expect(res.headers.get("access-control-allow-methods")).toContain("POST");
195
+ expect(res.headers.get("access-control-allow-headers")).toContain("Content-Type");
196
+ expect(res.headers.get("access-control-max-age")).toBe("86400");
197
+ } finally {
198
+ db.close();
199
+ }
200
+ } finally {
201
+ h.cleanup();
202
+ }
203
+ });
204
+
205
+ test("OPTIONS preflight on /oauth/register with no Origin falls back to wildcard + credentials:false", async () => {
206
+ // Server-shaped `curl` without `-H Origin: …`. Wildcard + credentials:
207
+ // false is the safe shape — non-browser callers don't enforce CORS, but
208
+ // the response should still be well-formed for diagnostic probes.
209
+ const h = makeHarness();
210
+ try {
211
+ const db = openHubDb(hubDbPath(h.dir));
212
+ try {
213
+ const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
214
+ preflight("/oauth/register", null),
215
+ );
216
+ expect(res.status).toBe(204);
217
+ expect(res.headers.get("access-control-allow-origin")).toBe("*");
218
+ expect(res.headers.get("access-control-allow-credentials")).toBe("false");
219
+ expect(res.headers.get("vary")).toBeNull();
220
+ } finally {
221
+ db.close();
222
+ }
223
+ } finally {
224
+ h.cleanup();
225
+ }
226
+ });
227
+
228
+ test("POST /oauth/register response with Origin echoes that origin + credentials:true (the actual bug)", async () => {
229
+ const h = makeHarness();
230
+ try {
231
+ writeManifest({ services: [] }, h.manifestPath);
232
+ const db = openHubDb(hubDbPath(h.dir));
233
+ try {
234
+ const res = await hubFetch(h.dir, {
235
+ getDb: () => db,
236
+ issuer: ISSUER,
237
+ manifestPath: h.manifestPath,
238
+ })(
239
+ new Request(`${ISSUER}/oauth/register`, {
240
+ method: "POST",
241
+ headers: { "content-type": "application/json", origin: EXAMPLE_ORIGIN },
242
+ body: JSON.stringify({
243
+ client_name: "example-spa",
244
+ redirect_uris: [`${EXAMPLE_ORIGIN}/callback`],
245
+ }),
246
+ }),
247
+ );
248
+ // Status is whatever DCR produces (typically 201 created on the
249
+ // public-DCR path); the CORS headers are the load-bearing assertion.
250
+ expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
251
+ expect(res.headers.get("access-control-allow-credentials")).toBe("true");
252
+ expect(res.headers.get("vary")).toBe("Origin");
253
+ } finally {
254
+ db.close();
255
+ }
256
+ } finally {
257
+ h.cleanup();
258
+ }
259
+ });
260
+
261
+ test("POST /oauth/register response with no Origin falls back to wildcard + credentials:false", async () => {
262
+ const h = makeHarness();
263
+ try {
264
+ writeManifest({ services: [] }, h.manifestPath);
265
+ const db = openHubDb(hubDbPath(h.dir));
266
+ try {
267
+ const res = await hubFetch(h.dir, {
268
+ getDb: () => db,
269
+ issuer: ISSUER,
270
+ manifestPath: h.manifestPath,
271
+ })(
272
+ new Request(`${ISSUER}/oauth/register`, {
273
+ method: "POST",
274
+ headers: { "content-type": "application/json" },
275
+ body: JSON.stringify({
276
+ client_name: "server-side-caller",
277
+ redirect_uris: [`${EXAMPLE_ORIGIN}/callback`],
278
+ }),
279
+ }),
280
+ );
281
+ expect(res.headers.get("access-control-allow-origin")).toBe("*");
282
+ expect(res.headers.get("access-control-allow-credentials")).toBe("false");
283
+ } finally {
284
+ db.close();
285
+ }
286
+ } finally {
287
+ h.cleanup();
288
+ }
289
+ });
290
+
291
+ test("OPTIONS preflight on /oauth/authorize echoes origin", async () => {
292
+ const h = makeHarness();
293
+ try {
294
+ const db = openHubDb(hubDbPath(h.dir));
295
+ try {
296
+ const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
297
+ preflight("/oauth/authorize"),
298
+ );
299
+ expect(res.status).toBe(204);
300
+ expect(res.headers.get("access-control-allow-origin")).toBe(GITCOIN_BRAIN_ORIGIN);
301
+ expect(res.headers.get("access-control-allow-credentials")).toBe("true");
302
+ expect(res.headers.get("vary")).toBe("Origin");
303
+ } finally {
304
+ db.close();
305
+ }
306
+ } finally {
307
+ h.cleanup();
308
+ }
309
+ });
310
+
311
+ test("GET /oauth/authorize response carries echo-origin CORS (the sync-handler branch)", async () => {
312
+ // The other oauth handlers are async (`Promise<Response>`); only
313
+ // `handleAuthorizeGet` is sync. Folding `applyCorsHeaders` over a sync
314
+ // return is exercised here so a future refactor that breaks the
315
+ // sync-vs-async distinction (e.g. dropping the wrapper, double-wrapping,
316
+ // accidentally awaiting a non-Promise into a hang) is caught.
317
+ //
318
+ // 400 branch — missing required PKCE params triggers the htmlError
319
+ // path inside parseAuthorizeFormParams. Cleanest no-DB-seeding fixture
320
+ // since the params fail validation before the client lookup runs.
321
+ const h = makeHarness();
322
+ try {
323
+ const db = openHubDb(hubDbPath(h.dir));
324
+ try {
325
+ const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
326
+ new Request(
327
+ `${ISSUER}/oauth/authorize?client_id=test&redirect_uri=${EXAMPLE_ORIGIN}/cb&response_type=code&state=foo`,
328
+ { method: "GET", headers: { origin: EXAMPLE_ORIGIN } },
329
+ ),
330
+ );
331
+ expect(res.status).toBe(400);
332
+ expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
333
+ expect(res.headers.get("access-control-allow-credentials")).toBe("true");
334
+ expect(res.headers.get("vary")).toBe("Origin");
335
+ } finally {
336
+ db.close();
337
+ }
338
+ } finally {
339
+ h.cleanup();
340
+ }
341
+ });
342
+
343
+ test("OPTIONS preflight on /oauth/token echoes origin", async () => {
344
+ const h = makeHarness();
345
+ try {
346
+ const db = openHubDb(hubDbPath(h.dir));
347
+ try {
348
+ const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
349
+ preflight("/oauth/token"),
350
+ );
351
+ expect(res.status).toBe(204);
352
+ expect(res.headers.get("access-control-allow-origin")).toBe(GITCOIN_BRAIN_ORIGIN);
353
+ expect(res.headers.get("access-control-allow-credentials")).toBe("true");
354
+ expect(res.headers.get("vary")).toBe("Origin");
355
+ expect(res.headers.get("access-control-allow-methods")).toContain("POST");
356
+ } finally {
357
+ db.close();
358
+ }
359
+ } finally {
360
+ h.cleanup();
361
+ }
362
+ });
363
+
364
+ test("OPTIONS preflight on /oauth/revoke echoes origin", async () => {
365
+ const h = makeHarness();
366
+ try {
367
+ const db = openHubDb(hubDbPath(h.dir));
368
+ try {
369
+ const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
370
+ preflight("/oauth/revoke"),
371
+ );
372
+ expect(res.status).toBe(204);
373
+ expect(res.headers.get("access-control-allow-origin")).toBe(GITCOIN_BRAIN_ORIGIN);
374
+ expect(res.headers.get("vary")).toBe("Origin");
375
+ } finally {
376
+ db.close();
377
+ }
378
+ } finally {
379
+ h.cleanup();
380
+ }
381
+ });
382
+
383
+ test("OPTIONS preflight on /oauth/authorize/approve echoes origin", async () => {
384
+ const h = makeHarness();
385
+ try {
386
+ const db = openHubDb(hubDbPath(h.dir));
387
+ try {
388
+ const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
389
+ preflight("/oauth/authorize/approve"),
390
+ );
391
+ expect(res.status).toBe(204);
392
+ expect(res.headers.get("access-control-allow-origin")).toBe(GITCOIN_BRAIN_ORIGIN);
393
+ expect(res.headers.get("vary")).toBe("Origin");
394
+ } finally {
395
+ db.close();
396
+ }
397
+ } finally {
398
+ h.cleanup();
399
+ }
400
+ });
401
+
402
+ test("POST /oauth/token method-not-allowed branch still carries echo-origin CORS", async () => {
403
+ // Bad-method on an in-scope path still has to ship CORS so the SPA can
404
+ // *read* the error response. Without it, the browser drops the response
405
+ // body and the SPA sees an opaque network failure instead of a clear
406
+ // 405.
407
+ const h = makeHarness();
408
+ try {
409
+ const db = openHubDb(hubDbPath(h.dir));
410
+ try {
411
+ const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
412
+ new Request(`${ISSUER}/oauth/token`, {
413
+ method: "GET",
414
+ headers: { origin: EXAMPLE_ORIGIN },
415
+ }),
416
+ );
417
+ expect(res.status).toBe(405);
418
+ expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
419
+ expect(res.headers.get("access-control-allow-credentials")).toBe("true");
420
+ } finally {
421
+ db.close();
422
+ }
423
+ } finally {
424
+ h.cleanup();
425
+ }
426
+ });
427
+
428
+ test("503 dbNotConfigured response on an oauth route still carries echo-origin CORS", async () => {
429
+ // No getDb → service_unavailable. Same as method-not-allowed: the SPA
430
+ // needs to be able to read the error.
431
+ const h = makeHarness();
432
+ try {
433
+ const res = await hubFetch(h.dir, { issuer: ISSUER })(
434
+ new Request(`${ISSUER}/oauth/register`, {
435
+ method: "POST",
436
+ headers: { "content-type": "application/json", origin: EXAMPLE_ORIGIN },
437
+ body: "{}",
438
+ }),
439
+ );
440
+ expect(res.status).toBe(503);
441
+ expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
442
+ expect(res.headers.get("access-control-allow-credentials")).toBe("true");
443
+ } finally {
444
+ h.cleanup();
445
+ }
446
+ });
447
+
448
+ test("the exact bug Aaron hit — preflight from unforced-dev.github.io to /oauth/register echoes that origin", async () => {
449
+ // Reproduces the exact request shape from the browser console error in
450
+ // the rc.17 follow-up PR brief. The Gitcoin Brain UI on
451
+ // https://unforced-dev.github.io fetches with `credentials: 'include'`;
452
+ // the browser preflights and requires the response to specify an
453
+ // explicit origin (not `*`) AND set `Allow-Credentials: true`. This is
454
+ // the canonical regression test for the rc.17→rc.18 fix.
455
+ const h = makeHarness();
456
+ try {
457
+ const db = openHubDb(hubDbPath(h.dir));
458
+ try {
459
+ const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
460
+ new Request(`${ISSUER}/oauth/register`, {
461
+ method: "OPTIONS",
462
+ headers: {
463
+ origin: GITCOIN_BRAIN_ORIGIN,
464
+ "access-control-request-method": "POST",
465
+ "access-control-request-headers": "content-type",
466
+ },
467
+ }),
468
+ );
469
+ expect(res.status).toBe(204);
470
+ expect(res.headers.get("access-control-allow-origin")).toBe(GITCOIN_BRAIN_ORIGIN);
471
+ expect(res.headers.get("access-control-allow-credentials")).toBe("true");
472
+ expect(res.headers.get("vary")).toBe("Origin");
473
+ expect(res.headers.get("access-control-allow-methods")).toContain("POST");
474
+ expect(res.headers.get("access-control-allow-headers")).toContain("Content-Type");
475
+ } finally {
476
+ db.close();
477
+ }
478
+ } finally {
479
+ h.cleanup();
480
+ }
481
+ });
482
+ });
483
+
484
+ describe("hubFetch CORS scope discipline — out-of-scope routes stay same-origin", () => {
485
+ // Sanity: this PR is supposed to be tightly scoped to /oauth/*. Lock in
486
+ // that the admin / API / login / account surfaces still respond same-
487
+ // origin (no wildcard CORS header). Catches any future regression where
488
+ // someone broadens isCorsAllowedRoute to "all /api/*" or similar.
489
+
490
+ test("OPTIONS on /api/me does not return a CORS preflight echo response", async () => {
491
+ const h = makeHarness();
492
+ try {
493
+ const db = openHubDb(hubDbPath(h.dir));
494
+ try {
495
+ const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
496
+ preflight("/api/me"),
497
+ );
498
+ // Whatever the API surface does with OPTIONS, it must not be the
499
+ // CORS preflight echo-origin shape.
500
+ const acao = res.headers.get("access-control-allow-origin");
501
+ expect(acao).not.toBe(GITCOIN_BRAIN_ORIGIN);
502
+ expect(acao).not.toBe("*");
503
+ } finally {
504
+ db.close();
505
+ }
506
+ } finally {
507
+ h.cleanup();
508
+ }
509
+ });
510
+
511
+ test("OPTIONS on /admin/host-admin-token does not return a CORS preflight echo response", async () => {
512
+ const h = makeHarness();
513
+ try {
514
+ const db = openHubDb(hubDbPath(h.dir));
515
+ try {
516
+ const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
517
+ preflight("/admin/host-admin-token"),
518
+ );
519
+ const acao = res.headers.get("access-control-allow-origin");
520
+ expect(acao).not.toBe(GITCOIN_BRAIN_ORIGIN);
521
+ expect(acao).not.toBe("*");
522
+ } finally {
523
+ db.close();
524
+ }
525
+ } finally {
526
+ h.cleanup();
527
+ }
528
+ });
529
+
530
+ test("OPTIONS on /login does not return a CORS preflight echo response", async () => {
531
+ const h = makeHarness();
532
+ try {
533
+ const db = openHubDb(hubDbPath(h.dir));
534
+ try {
535
+ const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(preflight("/login"));
536
+ const acao = res.headers.get("access-control-allow-origin");
537
+ expect(acao).not.toBe(GITCOIN_BRAIN_ORIGIN);
538
+ expect(acao).not.toBe("*");
539
+ } finally {
540
+ db.close();
541
+ }
542
+ } finally {
543
+ h.cleanup();
544
+ }
545
+ });
546
+
547
+ test("OPTIONS on /account/change-password does not return a CORS preflight echo response", async () => {
548
+ const h = makeHarness();
549
+ try {
550
+ const db = openHubDb(hubDbPath(h.dir));
551
+ try {
552
+ const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
553
+ preflight("/account/change-password"),
554
+ );
555
+ const acao = res.headers.get("access-control-allow-origin");
556
+ expect(acao).not.toBe(GITCOIN_BRAIN_ORIGIN);
557
+ expect(acao).not.toBe("*");
558
+ } finally {
559
+ db.close();
560
+ }
561
+ } finally {
562
+ h.cleanup();
563
+ }
564
+ });
565
+
566
+ test("OPTIONS on /vault/default content proxy is not a CORS preflight echo response", async () => {
567
+ const h = makeHarness();
568
+ try {
569
+ writeManifest({ services: [] }, h.manifestPath);
570
+ const db = openHubDb(hubDbPath(h.dir));
571
+ try {
572
+ const res = await hubFetch(h.dir, {
573
+ getDb: () => db,
574
+ issuer: ISSUER,
575
+ manifestPath: h.manifestPath,
576
+ })(preflight("/vault/default"));
577
+ const acao = res.headers.get("access-control-allow-origin");
578
+ expect(acao).not.toBe(GITCOIN_BRAIN_ORIGIN);
579
+ expect(acao).not.toBe("*");
580
+ } finally {
581
+ db.close();
582
+ }
583
+ } finally {
584
+ h.cleanup();
585
+ }
586
+ });
587
+ });