@openparachute/hub 0.5.7 → 0.5.9-rc.6

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 (60) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  12. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  13. package/src/__tests__/expose.test.ts +2 -2
  14. package/src/__tests__/hub-server.test.ts +338 -65
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/jwt-sign.test.ts +205 -0
  18. package/src/__tests__/module-manifest.test.ts +48 -0
  19. package/src/__tests__/oauth-handlers.test.ts +266 -5
  20. package/src/__tests__/operator-token.test.ts +379 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/status.test.ts +199 -0
  23. package/src/__tests__/well-known.test.ts +69 -0
  24. package/src/admin-clients.ts +139 -0
  25. package/src/admin-handlers.ts +32 -254
  26. package/src/admin-host-admin-token.ts +25 -10
  27. package/src/admin-login-ui.ts +256 -0
  28. package/src/admin-vault-admin-token.ts +1 -1
  29. package/src/api-me.ts +124 -0
  30. package/src/api-mint-token.ts +239 -0
  31. package/src/api-revocation-list.ts +59 -0
  32. package/src/api-revoke-token.ts +153 -0
  33. package/src/api-tokens.ts +224 -0
  34. package/src/commands/auth.ts +408 -51
  35. package/src/commands/expose-2fa-warning.ts +6 -6
  36. package/src/commands/status.ts +74 -10
  37. package/src/csrf.ts +6 -3
  38. package/src/help.ts +10 -4
  39. package/src/hub-db.ts +63 -0
  40. package/src/hub-server.ts +426 -97
  41. package/src/hub.ts +272 -149
  42. package/src/install-source.ts +291 -0
  43. package/src/jwt-sign.ts +265 -5
  44. package/src/module-manifest.ts +48 -10
  45. package/src/oauth-handlers.ts +183 -54
  46. package/src/oauth-ui.ts +23 -2
  47. package/src/operator-token.ts +272 -18
  48. package/src/origin-check.ts +127 -0
  49. package/src/rate-limit.ts +5 -2
  50. package/src/scope-explanations.ts +33 -2
  51. package/src/sessions.ts +1 -1
  52. package/src/well-known.ts +54 -1
  53. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  54. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  55. package/web/ui/dist/index.html +2 -2
  56. package/src/__tests__/admin-config.test.ts +0 -281
  57. package/src/admin-config-ui.ts +0 -534
  58. package/src/admin-config.ts +0 -226
  59. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  60. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -126,6 +126,54 @@ describe("validateModuleManifest", () => {
126
126
  ).toThrow(/http:.*https:/);
127
127
  });
128
128
 
129
+ test("uiUrl accepts a leading-slash path (Phase D)", () => {
130
+ const m = validateModuleManifest({ ...VALID, uiUrl: "/notes" }, "x");
131
+ expect(m.uiUrl).toBe("/notes");
132
+ });
133
+
134
+ test("uiUrl accepts an absolute https URL", () => {
135
+ const m = validateModuleManifest({ ...VALID, uiUrl: "https://app.example.com/" }, "x");
136
+ expect(m.uiUrl).toBe("https://app.example.com/");
137
+ });
138
+
139
+ test("uiUrl rejects empty / non-string / non-url-or-path (mirrors managementUrl)", () => {
140
+ expect(() => validateModuleManifest({ ...VALID, uiUrl: "" }, "x")).toThrow(/uiUrl/);
141
+ expect(() => validateModuleManifest({ ...VALID, uiUrl: 7 }, "x")).toThrow(/uiUrl/);
142
+ expect(() => validateModuleManifest({ ...VALID, uiUrl: "no-slash" }, "x")).toThrow(
143
+ /path starting with "\/" or a full http\(s\) URL/,
144
+ );
145
+ expect(() => validateModuleManifest({ ...VALID, uiUrl: "ftp://example.com" }, "x")).toThrow(
146
+ /http:.*https:/,
147
+ );
148
+ });
149
+
150
+ test("uiUrl absent stays absent", () => {
151
+ const m = validateModuleManifest(VALID, "x");
152
+ expect(m.uiUrl).toBeUndefined();
153
+ });
154
+
155
+ // Open-redirect regression: protocol-relative paths like "//evil.com" pass
156
+ // a naive `startsWith("/")` check but `new URL("//evil.com", base)` resolves
157
+ // to the foreign origin. A malicious third-party module could plant such a
158
+ // value in module.json:uiUrl and turn a discovery tile into an off-origin
159
+ // redirect. Both uiUrl and managementUrl are validated by the shared
160
+ // asPathOrUrl helper, so cover both.
161
+ test("uiUrl rejects protocol-relative paths (open-redirect regression)", () => {
162
+ expect(() => validateModuleManifest({ ...VALID, uiUrl: "//evil.com" }, "x")).toThrow(/uiUrl/);
163
+ expect(() => validateModuleManifest({ ...VALID, uiUrl: "//evil.com/path" }, "x")).toThrow(
164
+ /uiUrl/,
165
+ );
166
+ });
167
+
168
+ test("managementUrl rejects protocol-relative paths (open-redirect regression)", () => {
169
+ expect(() => validateModuleManifest({ ...VALID, managementUrl: "//evil.com" }, "x")).toThrow(
170
+ /managementUrl/,
171
+ );
172
+ expect(() =>
173
+ validateModuleManifest({ ...VALID, managementUrl: "//evil.com/admin" }, "x"),
174
+ ).toThrow(/managementUrl/);
175
+ });
176
+
129
177
  test("managementUrl absent stays absent", () => {
130
178
  const m = validateModuleManifest(VALID, "x");
131
179
  expect(m.managementUrl).toBeUndefined();
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import { getClient, registerClient } from "../clients.ts";
7
7
  import { CSRF_COOKIE_NAME } from "../csrf.ts";
8
8
  import { hubDbPath, openHubDb } from "../hub-db.ts";
9
- import { validateAccessToken } from "../jwt-sign.ts";
9
+ import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
10
10
  import {
11
11
  authorizationServerMetadata,
12
12
  buildServicesCatalog,
@@ -1034,10 +1034,37 @@ describe("handleToken — full OAuth dance", () => {
1034
1034
 
1035
1035
  // closes #81 — services catalog tells the client where vault lives so
1036
1036
  // notes doesn't have to re-probe /.well-known/parachute.json. A
1037
- // vault:read token only sees the vault entry.
1037
+ // `vault:default:read` token sees both the collapsed `vault` key
1038
+ // (backwards compat) AND the per-vault `vault:default` key (closes
1039
+ // #247 — pre-#247 only the collapsed key was emitted; consumers on
1040
+ // multi-vault hubs were forced to assume `/vault/default` and
1041
+ // collided).
1038
1042
  expect(tokenBody.services).toEqual({
1039
1043
  vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1040
- });
1044
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1045
+ });
1046
+
1047
+ // closes #215 reviewer F2 — Phase 1 code-grant access-token registry
1048
+ // exemption pinning. The access token and refresh token share `jti`
1049
+ // by design (signRefreshToken({ jti: access.jti, ... }) at the mint
1050
+ // site), so the `tokens` row keyed by the access-token jti IS the
1051
+ // shared row — refresh_token_hash is non-null, created_via is
1052
+ // 'oauth_refresh'. We deliberately don't write a separate per-jti
1053
+ // access-token row; revocation acts on the shared jti / family,
1054
+ // bounded by the 15-min access TTL.
1055
+ expect(payload.jti).toBeTruthy();
1056
+ const row = findTokenRowByJti(db, payload.jti as string);
1057
+ expect(row).not.toBeNull();
1058
+ expect(row?.createdVia).toBe("oauth_refresh");
1059
+ expect(row?.familyId).toBeTruthy();
1060
+ // Verify the registry has exactly one row for this code-grant
1061
+ // (not two — no separate access-token row).
1062
+ const rowCount = (
1063
+ db
1064
+ .query<{ n: number }, [string]>("SELECT COUNT(*) as n FROM tokens WHERE jti = ?")
1065
+ .get(payload.jti as string) ?? { n: 0 }
1066
+ ).n;
1067
+ expect(rowCount).toBe(1);
1041
1068
  } finally {
1042
1069
  cleanup();
1043
1070
  }
@@ -1523,6 +1550,156 @@ describe("handleToken — full OAuth dance", () => {
1523
1550
  vault: { url: `${ISSUER}/vault/work`, version: "0.3.0" },
1524
1551
  });
1525
1552
  });
1553
+
1554
+ // closes #247 — multi-vault correctness. Pre-#247 every vault collapsed
1555
+ // under the single `vault` key, so Notes' OAuthCallback always wrote
1556
+ // VaultRecord URL = paths[0] of the first vault row regardless of which
1557
+ // vault the token actually granted. Per-vault `vault:<name>` keys let
1558
+ // consumers route each grant to the correct vault URL.
1559
+ describe("services catalog — multi-vault per-vault keys (#247)", () => {
1560
+ // Real shape from a multi-vault hub: one `parachute-vault` row with N
1561
+ // paths, each path naming an instance. Aaron's setup verbatim (4
1562
+ // vaults: default, boulder, gitcoin, techne).
1563
+ const multiVaultManifest: ServicesManifest = {
1564
+ services: [
1565
+ {
1566
+ name: "parachute-vault",
1567
+ port: 1940,
1568
+ paths: ["/vault/default", "/vault/boulder", "/vault/gitcoin", "/vault/techne"],
1569
+ health: "/health",
1570
+ version: "0.4.4",
1571
+ },
1572
+ ],
1573
+ };
1574
+
1575
+ test("single-vault hub with broad scope: only collapsed `vault` key (unchanged)", () => {
1576
+ // Per-vault keys are noise on single-vault hubs — no disambiguation
1577
+ // is needed. Backwards compat for pre-popover clients matters here.
1578
+ expect(buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, ["vault:read"])).toEqual({
1579
+ vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1580
+ });
1581
+ });
1582
+
1583
+ test("single-vault hub with per-vault-narrowed scope: emits per-vault key too", () => {
1584
+ // A `vault:default:read` token is an explicit consumer signal that
1585
+ // the per-vault key matters — emit it even on a single-vault hub so
1586
+ // the consumer's `services["vault:default"]` lookup works uniformly
1587
+ // regardless of how many vaults the hub has.
1588
+ expect(buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, ["vault:default:read"])).toEqual({
1589
+ vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1590
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1591
+ });
1592
+ });
1593
+
1594
+ test("multi-vault hub with broad scope: emits every per-vault key + collapsed `vault`", () => {
1595
+ // Broad `vault:read` admits every vault on the hub. Per-#247
1596
+ // guidance: emit per-vault keys for all admitted vaults so the
1597
+ // consumer (Notes popover) can pick its target by name without
1598
+ // re-probing /.well-known/parachute.json.
1599
+ expect(buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:read"])).toEqual({
1600
+ // Collapsed key still emitted (first admitted path); backwards compat.
1601
+ vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1602
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1603
+ "vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1604
+ "vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
1605
+ "vault:techne": { url: `${ISSUER}/vault/techne`, version: "0.4.4" },
1606
+ });
1607
+ });
1608
+
1609
+ test("multi-vault hub with per-vault scope: only that vault's per-vault key", () => {
1610
+ // Aaron's "Connect boulder" flow: token has `vault:boulder:write`,
1611
+ // scope admits only boulder. Pre-#247 the catalog said `vault.url =
1612
+ // /vault/default` (WRONG), so Notes stored a /vault/default record
1613
+ // with scope `vault:boulder:write` — collision city as more vaults
1614
+ // got connected. Post-#247 the consumer reads
1615
+ // `services["vault:boulder"].url` which correctly says /vault/boulder.
1616
+ expect(buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:boulder:write"])).toEqual({
1617
+ // Collapsed `vault` points at boulder too — the only admitted
1618
+ // vault — so legacy consumers happen to land on the right URL even
1619
+ // though they have no per-vault awareness.
1620
+ vault: { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1621
+ "vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1622
+ });
1623
+ });
1624
+
1625
+ test("multi-vault hub with mixed scopes: per-vault keys for each narrowed vault", () => {
1626
+ // A token granting both `vault:boulder:read` and `vault:gitcoin:write`
1627
+ // admits exactly those two vaults; default and techne aren't reachable.
1628
+ expect(
1629
+ buildServicesCatalog(multiVaultManifest, ISSUER, [
1630
+ "vault:boulder:read",
1631
+ "vault:gitcoin:write",
1632
+ ]),
1633
+ ).toEqual({
1634
+ vault: { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1635
+ "vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1636
+ "vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
1637
+ });
1638
+ });
1639
+
1640
+ test("multi-vault hub: broad + per-vault scopes coexist; broad opens all vaults", () => {
1641
+ // A token that carries BOTH `vault:read` (broad) AND
1642
+ // `vault:boulder:write` (narrow) should land in the broad bucket
1643
+ // because the broad scope is more permissive — narrowing one verb
1644
+ // can't take away access the unnamed scope already granted.
1645
+ expect(
1646
+ buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:read", "vault:boulder:write"]),
1647
+ ).toEqual({
1648
+ vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1649
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1650
+ "vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1651
+ "vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
1652
+ "vault:techne": { url: `${ISSUER}/vault/techne`, version: "0.4.4" },
1653
+ });
1654
+ });
1655
+
1656
+ test("legacy per-vault rows (parachute-vault-<name>) also produce per-vault keys", () => {
1657
+ // Older multi-vault layout — one row per vault — should produce the
1658
+ // same catalog shape as the single-row-multi-path layout. The
1659
+ // `vaultInstanceNameFor` helper handles both via its
1660
+ // manifest-suffix fallback.
1661
+ const legacyManifest: ServicesManifest = {
1662
+ services: [
1663
+ {
1664
+ name: "parachute-vault",
1665
+ port: 1940,
1666
+ paths: ["/vault/default"],
1667
+ health: "/health",
1668
+ version: "0.4.4",
1669
+ },
1670
+ {
1671
+ name: "parachute-vault-work",
1672
+ port: 1941,
1673
+ paths: ["/vault/work"],
1674
+ health: "/health",
1675
+ version: "0.4.4",
1676
+ },
1677
+ ],
1678
+ };
1679
+ expect(buildServicesCatalog(legacyManifest, ISSUER, ["vault:read"])).toEqual({
1680
+ vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1681
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1682
+ "vault:work": { url: `${ISSUER}/vault/work`, version: "0.4.4" },
1683
+ });
1684
+ });
1685
+
1686
+ test("non-vault services unaffected — only one key per service, no per-instance variant", () => {
1687
+ // The per-vault-key expansion is vault-specific. scribe / notes /
1688
+ // third-party rows still emit one key per service.
1689
+ expect(
1690
+ buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, [
1691
+ "vault:default:read",
1692
+ "scribe:transcribe",
1693
+ "notes:read",
1694
+ ]),
1695
+ ).toEqual({
1696
+ vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1697
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1698
+ scribe: { url: `${ISSUER}/scribe`, version: "0.3.0-rc.1" },
1699
+ notes: { url: `${ISSUER}/notes`, version: "0.3.0" },
1700
+ });
1701
+ });
1702
+ });
1526
1703
  });
1527
1704
 
1528
1705
  // closes #72 — RFC 6749 §3.2.1 + §2.3.1: confidential clients must
@@ -2054,6 +2231,76 @@ describe("DCR approval gate (#74)", () => {
2054
2231
  const html = await res.text();
2055
2232
  expect(html).toContain("App not yet approved");
2056
2233
  expect(html).toContain("approve-client");
2234
+ // No vault hint → no vault row in approve-meta. Single-vault hubs +
2235
+ // pre-vault-popover clients leave the section omitted (#244).
2236
+ expect(html).not.toContain('approve-meta-label">vault');
2237
+ } finally {
2238
+ cleanup();
2239
+ }
2240
+ });
2241
+
2242
+ // closes #244 — vault hint surfaced in approve-pending UI. Notes#115
2243
+ // passes `vault=<name>` on `/oauth/authorize` for per-vault grants; hub's
2244
+ // approve page now displays it alongside the other client metadata so a
2245
+ // multi-vault operator can tell which vault they're approving for.
2246
+ test("authorize: pending client with vault hint → approve UI renders 'vault: <name>'", async () => {
2247
+ const { db, cleanup } = await makeDb();
2248
+ try {
2249
+ const reg = registerClient(db, {
2250
+ redirectUris: ["https://app.example/cb"],
2251
+ status: "pending",
2252
+ });
2253
+ const { challenge } = makePkce();
2254
+ const req = new Request(
2255
+ authorizeUrl({
2256
+ client_id: reg.client.clientId,
2257
+ redirect_uri: "https://app.example/cb",
2258
+ response_type: "code",
2259
+ code_challenge: challenge,
2260
+ code_challenge_method: "S256",
2261
+ scope: "vault:read",
2262
+ vault: "boulder",
2263
+ }),
2264
+ );
2265
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
2266
+ expect(res.status).toBe(403);
2267
+ const html = await res.text();
2268
+ expect(html).toContain("App not yet approved");
2269
+ // The vault hint surfaces as a labeled row in the approve-meta block.
2270
+ expect(html).toContain('approve-meta-label">vault');
2271
+ expect(html).toContain("boulder");
2272
+ } finally {
2273
+ cleanup();
2274
+ }
2275
+ });
2276
+
2277
+ test("authorize: pending client with empty vault param → no vault row", async () => {
2278
+ // Defensive: `vault=` with empty value normalizes to undefined so the
2279
+ // UI doesn't render a blank vault label. Easy to hit if a client builds
2280
+ // the URL via URLSearchParams.set("vault", someMaybeEmptyVar).
2281
+ const { db, cleanup } = await makeDb();
2282
+ try {
2283
+ const reg = registerClient(db, {
2284
+ redirectUris: ["https://app.example/cb"],
2285
+ status: "pending",
2286
+ });
2287
+ const { challenge } = makePkce();
2288
+ const req = new Request(
2289
+ authorizeUrl({
2290
+ client_id: reg.client.clientId,
2291
+ redirect_uri: "https://app.example/cb",
2292
+ response_type: "code",
2293
+ code_challenge: challenge,
2294
+ code_challenge_method: "S256",
2295
+ scope: "vault:read",
2296
+ vault: "",
2297
+ }),
2298
+ );
2299
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
2300
+ expect(res.status).toBe(403);
2301
+ const html = await res.text();
2302
+ expect(html).toContain("App not yet approved");
2303
+ expect(html).not.toContain('approve-meta-label">vault');
2057
2304
  } finally {
2058
2305
  cleanup();
2059
2306
  }
@@ -2103,6 +2350,13 @@ describe("DCR approval gate (#74)", () => {
2103
2350
  const body = (await res.json()) as Record<string, unknown>;
2104
2351
  expect(body.error).toBe("invalid_client");
2105
2352
  expect(body.error_description).toContain("not been approved");
2353
+ // Surface the inline-approval affordances so consumers (Notes, future
2354
+ // cross-origin SPAs) can deep-link the operator to a browser-based
2355
+ // approve flow without dropping to a terminal.
2356
+ expect(body.approve_url).toBe(
2357
+ `${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
2358
+ );
2359
+ expect(body.cli_alternative).toBe(`parachute auth approve-client ${reg.client.clientId}`);
2106
2360
  } finally {
2107
2361
  cleanup();
2108
2362
  }
@@ -2132,6 +2386,13 @@ describe("DCR approval gate (#74)", () => {
2132
2386
  expect(res.status).toBe(401);
2133
2387
  const body = (await res.json()) as Record<string, unknown>;
2134
2388
  expect(body.error).toBe("invalid_client");
2389
+ // Same pending-affordance shape on the refresh path: a long-lived
2390
+ // OAuth client whose row was unapproved between issuance and refresh
2391
+ // hits this branch and surfaces the same approve_url + cli_alternative.
2392
+ expect(body.approve_url).toBe(
2393
+ `${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
2394
+ );
2395
+ expect(body.cli_alternative).toBe(`parachute auth approve-client ${reg.client.clientId}`);
2135
2396
  } finally {
2136
2397
  cleanup();
2137
2398
  }
@@ -2309,7 +2570,7 @@ describe("DCR auto-approve via session cookie (#199)", () => {
2309
2570
  // Sandbox iframes (`<iframe sandbox>` without `allow-same-origin`),
2310
2571
  // `data:`/`file:` documents, and some privacy contexts send the literal
2311
2572
  // string `Origin: null` rather than omitting the header. `new URL("null")`
2312
- // throws → originMatchesIssuer's try/catch returns false → DCR stays
2573
+ // throws → isSameOriginRequest's try/catch returns false → DCR stays
2313
2574
  // pending. This test pins that invariant: an opaque-origin caller does
2314
2575
  // NOT ride the cookie path even with a valid session, because we can't
2315
2576
  // prove the request came from the issuer's own origin.
@@ -3556,7 +3817,7 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
3556
3817
  // Opaque-origin contexts (sandboxed iframes, some `data:` and `file:`
3557
3818
  // pages) send the literal string "null" as the Origin header. The DCR
3558
3819
  // /register path covers this; the inline-approve endpoint must reject it
3559
- // too. originMatchesIssuer() handles this correctly because new URL("null")
3820
+ // too. isSameOriginRequest() handles this correctly because new URL("null")
3560
3821
  // throws → returns false; this test pins that contract.
3561
3822
  const { db, cleanup } = await makeDb();
3562
3823
  try {