@openparachute/hub 0.5.7 → 0.5.10-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 (85) 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-modules-ops.test.ts +658 -0
  8. package/src/__tests__/api-modules.test.ts +426 -0
  9. package/src/__tests__/api-revocation-list.test.ts +198 -0
  10. package/src/__tests__/api-revoke-token.test.ts +320 -0
  11. package/src/__tests__/api-tokens.test.ts +629 -0
  12. package/src/__tests__/auth.test.ts +680 -16
  13. package/src/__tests__/csrf.test.ts +40 -1
  14. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  15. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  16. package/src/__tests__/expose.test.ts +2 -2
  17. package/src/__tests__/hub-server.test.ts +584 -67
  18. package/src/__tests__/hub-settings.test.ts +377 -0
  19. package/src/__tests__/hub.test.ts +123 -53
  20. package/src/__tests__/install-source.test.ts +249 -0
  21. package/src/__tests__/jwt-sign.test.ts +205 -0
  22. package/src/__tests__/module-manifest.test.ts +48 -0
  23. package/src/__tests__/oauth-handlers.test.ts +522 -5
  24. package/src/__tests__/operator-token.test.ts +427 -3
  25. package/src/__tests__/origin-check.test.ts +220 -0
  26. package/src/__tests__/request-protocol.test.ts +54 -0
  27. package/src/__tests__/serve-boot.test.ts +193 -0
  28. package/src/__tests__/serve.test.ts +100 -0
  29. package/src/__tests__/sessions.test.ts +25 -2
  30. package/src/__tests__/setup-gate.test.ts +222 -0
  31. package/src/__tests__/setup-wizard.test.ts +2089 -0
  32. package/src/__tests__/status.test.ts +199 -0
  33. package/src/__tests__/supervisor.test.ts +482 -0
  34. package/src/__tests__/upgrade.test.ts +247 -4
  35. package/src/__tests__/vault-name.test.ts +79 -0
  36. package/src/__tests__/well-known.test.ts +69 -0
  37. package/src/admin-clients.ts +139 -0
  38. package/src/admin-handlers.ts +37 -254
  39. package/src/admin-host-admin-token.ts +25 -10
  40. package/src/admin-login-ui.ts +256 -0
  41. package/src/admin-vault-admin-token.ts +1 -1
  42. package/src/api-me.ts +124 -0
  43. package/src/api-mint-token.ts +239 -0
  44. package/src/api-modules-ops.ts +585 -0
  45. package/src/api-modules.ts +367 -0
  46. package/src/api-revocation-list.ts +59 -0
  47. package/src/api-revoke-token.ts +153 -0
  48. package/src/api-tokens.ts +224 -0
  49. package/src/cli.ts +28 -0
  50. package/src/commands/auth.ts +408 -51
  51. package/src/commands/expose-2fa-warning.ts +6 -6
  52. package/src/commands/serve-boot.ts +133 -0
  53. package/src/commands/serve.ts +214 -0
  54. package/src/commands/status.ts +74 -10
  55. package/src/commands/upgrade.ts +33 -6
  56. package/src/csrf.ts +34 -13
  57. package/src/help.ts +55 -5
  58. package/src/hub-control.ts +1 -0
  59. package/src/hub-db.ts +87 -0
  60. package/src/hub-server.ts +767 -136
  61. package/src/hub-settings.ts +259 -0
  62. package/src/hub.ts +298 -150
  63. package/src/install-source.ts +291 -0
  64. package/src/jwt-sign.ts +265 -5
  65. package/src/module-manifest.ts +48 -10
  66. package/src/oauth-handlers.ts +262 -56
  67. package/src/oauth-ui.ts +23 -2
  68. package/src/operator-token.ts +349 -18
  69. package/src/origin-check.ts +127 -0
  70. package/src/rate-limit.ts +5 -2
  71. package/src/request-protocol.ts +48 -0
  72. package/src/scope-explanations.ts +33 -2
  73. package/src/sessions.ts +30 -18
  74. package/src/setup-wizard.ts +2009 -0
  75. package/src/supervisor.ts +411 -0
  76. package/src/vault-name.ts +71 -0
  77. package/src/well-known.ts +54 -1
  78. package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
  79. package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
  80. package/web/ui/dist/index.html +2 -2
  81. package/src/__tests__/admin-config.test.ts +0 -281
  82. package/src/admin-config-ui.ts +0 -534
  83. package/src/admin-config.ts +0 -226
  84. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  85. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -6,7 +6,13 @@ 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 {
10
+ FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
11
+ getSetting,
12
+ openFirstClientAutoApproveWindow,
13
+ setSetting,
14
+ } from "../hub-settings.ts";
15
+ import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
10
16
  import {
11
17
  authorizationServerMetadata,
12
18
  buildServicesCatalog,
@@ -1034,10 +1040,37 @@ describe("handleToken — full OAuth dance", () => {
1034
1040
 
1035
1041
  // closes #81 — services catalog tells the client where vault lives so
1036
1042
  // notes doesn't have to re-probe /.well-known/parachute.json. A
1037
- // vault:read token only sees the vault entry.
1043
+ // `vault:default:read` token sees both the collapsed `vault` key
1044
+ // (backwards compat) AND the per-vault `vault:default` key (closes
1045
+ // #247 — pre-#247 only the collapsed key was emitted; consumers on
1046
+ // multi-vault hubs were forced to assume `/vault/default` and
1047
+ // collided).
1038
1048
  expect(tokenBody.services).toEqual({
1039
1049
  vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1040
- });
1050
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1051
+ });
1052
+
1053
+ // closes #215 reviewer F2 — Phase 1 code-grant access-token registry
1054
+ // exemption pinning. The access token and refresh token share `jti`
1055
+ // by design (signRefreshToken({ jti: access.jti, ... }) at the mint
1056
+ // site), so the `tokens` row keyed by the access-token jti IS the
1057
+ // shared row — refresh_token_hash is non-null, created_via is
1058
+ // 'oauth_refresh'. We deliberately don't write a separate per-jti
1059
+ // access-token row; revocation acts on the shared jti / family,
1060
+ // bounded by the 15-min access TTL.
1061
+ expect(payload.jti).toBeTruthy();
1062
+ const row = findTokenRowByJti(db, payload.jti as string);
1063
+ expect(row).not.toBeNull();
1064
+ expect(row?.createdVia).toBe("oauth_refresh");
1065
+ expect(row?.familyId).toBeTruthy();
1066
+ // Verify the registry has exactly one row for this code-grant
1067
+ // (not two — no separate access-token row).
1068
+ const rowCount = (
1069
+ db
1070
+ .query<{ n: number }, [string]>("SELECT COUNT(*) as n FROM tokens WHERE jti = ?")
1071
+ .get(payload.jti as string) ?? { n: 0 }
1072
+ ).n;
1073
+ expect(rowCount).toBe(1);
1041
1074
  } finally {
1042
1075
  cleanup();
1043
1076
  }
@@ -1523,6 +1556,156 @@ describe("handleToken — full OAuth dance", () => {
1523
1556
  vault: { url: `${ISSUER}/vault/work`, version: "0.3.0" },
1524
1557
  });
1525
1558
  });
1559
+
1560
+ // closes #247 — multi-vault correctness. Pre-#247 every vault collapsed
1561
+ // under the single `vault` key, so Notes' OAuthCallback always wrote
1562
+ // VaultRecord URL = paths[0] of the first vault row regardless of which
1563
+ // vault the token actually granted. Per-vault `vault:<name>` keys let
1564
+ // consumers route each grant to the correct vault URL.
1565
+ describe("services catalog — multi-vault per-vault keys (#247)", () => {
1566
+ // Real shape from a multi-vault hub: one `parachute-vault` row with N
1567
+ // paths, each path naming an instance. Aaron's setup verbatim (4
1568
+ // vaults: default, boulder, gitcoin, techne).
1569
+ const multiVaultManifest: ServicesManifest = {
1570
+ services: [
1571
+ {
1572
+ name: "parachute-vault",
1573
+ port: 1940,
1574
+ paths: ["/vault/default", "/vault/boulder", "/vault/gitcoin", "/vault/techne"],
1575
+ health: "/health",
1576
+ version: "0.4.4",
1577
+ },
1578
+ ],
1579
+ };
1580
+
1581
+ test("single-vault hub with broad scope: only collapsed `vault` key (unchanged)", () => {
1582
+ // Per-vault keys are noise on single-vault hubs — no disambiguation
1583
+ // is needed. Backwards compat for pre-popover clients matters here.
1584
+ expect(buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, ["vault:read"])).toEqual({
1585
+ vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1586
+ });
1587
+ });
1588
+
1589
+ test("single-vault hub with per-vault-narrowed scope: emits per-vault key too", () => {
1590
+ // A `vault:default:read` token is an explicit consumer signal that
1591
+ // the per-vault key matters — emit it even on a single-vault hub so
1592
+ // the consumer's `services["vault:default"]` lookup works uniformly
1593
+ // regardless of how many vaults the hub has.
1594
+ expect(buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, ["vault:default:read"])).toEqual({
1595
+ vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1596
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1597
+ });
1598
+ });
1599
+
1600
+ test("multi-vault hub with broad scope: emits every per-vault key + collapsed `vault`", () => {
1601
+ // Broad `vault:read` admits every vault on the hub. Per-#247
1602
+ // guidance: emit per-vault keys for all admitted vaults so the
1603
+ // consumer (Notes popover) can pick its target by name without
1604
+ // re-probing /.well-known/parachute.json.
1605
+ expect(buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:read"])).toEqual({
1606
+ // Collapsed key still emitted (first admitted path); backwards compat.
1607
+ vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1608
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1609
+ "vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1610
+ "vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
1611
+ "vault:techne": { url: `${ISSUER}/vault/techne`, version: "0.4.4" },
1612
+ });
1613
+ });
1614
+
1615
+ test("multi-vault hub with per-vault scope: only that vault's per-vault key", () => {
1616
+ // Aaron's "Connect boulder" flow: token has `vault:boulder:write`,
1617
+ // scope admits only boulder. Pre-#247 the catalog said `vault.url =
1618
+ // /vault/default` (WRONG), so Notes stored a /vault/default record
1619
+ // with scope `vault:boulder:write` — collision city as more vaults
1620
+ // got connected. Post-#247 the consumer reads
1621
+ // `services["vault:boulder"].url` which correctly says /vault/boulder.
1622
+ expect(buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:boulder:write"])).toEqual({
1623
+ // Collapsed `vault` points at boulder too — the only admitted
1624
+ // vault — so legacy consumers happen to land on the right URL even
1625
+ // though they have no per-vault awareness.
1626
+ vault: { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1627
+ "vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1628
+ });
1629
+ });
1630
+
1631
+ test("multi-vault hub with mixed scopes: per-vault keys for each narrowed vault", () => {
1632
+ // A token granting both `vault:boulder:read` and `vault:gitcoin:write`
1633
+ // admits exactly those two vaults; default and techne aren't reachable.
1634
+ expect(
1635
+ buildServicesCatalog(multiVaultManifest, ISSUER, [
1636
+ "vault:boulder:read",
1637
+ "vault:gitcoin:write",
1638
+ ]),
1639
+ ).toEqual({
1640
+ vault: { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1641
+ "vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1642
+ "vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
1643
+ });
1644
+ });
1645
+
1646
+ test("multi-vault hub: broad + per-vault scopes coexist; broad opens all vaults", () => {
1647
+ // A token that carries BOTH `vault:read` (broad) AND
1648
+ // `vault:boulder:write` (narrow) should land in the broad bucket
1649
+ // because the broad scope is more permissive — narrowing one verb
1650
+ // can't take away access the unnamed scope already granted.
1651
+ expect(
1652
+ buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:read", "vault:boulder:write"]),
1653
+ ).toEqual({
1654
+ vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1655
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1656
+ "vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1657
+ "vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
1658
+ "vault:techne": { url: `${ISSUER}/vault/techne`, version: "0.4.4" },
1659
+ });
1660
+ });
1661
+
1662
+ test("legacy per-vault rows (parachute-vault-<name>) also produce per-vault keys", () => {
1663
+ // Older multi-vault layout — one row per vault — should produce the
1664
+ // same catalog shape as the single-row-multi-path layout. The
1665
+ // `vaultInstanceNameFor` helper handles both via its
1666
+ // manifest-suffix fallback.
1667
+ const legacyManifest: ServicesManifest = {
1668
+ services: [
1669
+ {
1670
+ name: "parachute-vault",
1671
+ port: 1940,
1672
+ paths: ["/vault/default"],
1673
+ health: "/health",
1674
+ version: "0.4.4",
1675
+ },
1676
+ {
1677
+ name: "parachute-vault-work",
1678
+ port: 1941,
1679
+ paths: ["/vault/work"],
1680
+ health: "/health",
1681
+ version: "0.4.4",
1682
+ },
1683
+ ],
1684
+ };
1685
+ expect(buildServicesCatalog(legacyManifest, ISSUER, ["vault:read"])).toEqual({
1686
+ vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1687
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1688
+ "vault:work": { url: `${ISSUER}/vault/work`, version: "0.4.4" },
1689
+ });
1690
+ });
1691
+
1692
+ test("non-vault services unaffected — only one key per service, no per-instance variant", () => {
1693
+ // The per-vault-key expansion is vault-specific. scribe / notes /
1694
+ // third-party rows still emit one key per service.
1695
+ expect(
1696
+ buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, [
1697
+ "vault:default:read",
1698
+ "scribe:transcribe",
1699
+ "notes:read",
1700
+ ]),
1701
+ ).toEqual({
1702
+ vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1703
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1704
+ scribe: { url: `${ISSUER}/scribe`, version: "0.3.0-rc.1" },
1705
+ notes: { url: `${ISSUER}/notes`, version: "0.3.0" },
1706
+ });
1707
+ });
1708
+ });
1526
1709
  });
1527
1710
 
1528
1711
  // closes #72 — RFC 6749 §3.2.1 + §2.3.1: confidential clients must
@@ -2054,6 +2237,76 @@ describe("DCR approval gate (#74)", () => {
2054
2237
  const html = await res.text();
2055
2238
  expect(html).toContain("App not yet approved");
2056
2239
  expect(html).toContain("approve-client");
2240
+ // No vault hint → no vault row in approve-meta. Single-vault hubs +
2241
+ // pre-vault-popover clients leave the section omitted (#244).
2242
+ expect(html).not.toContain('approve-meta-label">vault');
2243
+ } finally {
2244
+ cleanup();
2245
+ }
2246
+ });
2247
+
2248
+ // closes #244 — vault hint surfaced in approve-pending UI. Notes#115
2249
+ // passes `vault=<name>` on `/oauth/authorize` for per-vault grants; hub's
2250
+ // approve page now displays it alongside the other client metadata so a
2251
+ // multi-vault operator can tell which vault they're approving for.
2252
+ test("authorize: pending client with vault hint → approve UI renders 'vault: <name>'", async () => {
2253
+ const { db, cleanup } = await makeDb();
2254
+ try {
2255
+ const reg = registerClient(db, {
2256
+ redirectUris: ["https://app.example/cb"],
2257
+ status: "pending",
2258
+ });
2259
+ const { challenge } = makePkce();
2260
+ const req = new Request(
2261
+ authorizeUrl({
2262
+ client_id: reg.client.clientId,
2263
+ redirect_uri: "https://app.example/cb",
2264
+ response_type: "code",
2265
+ code_challenge: challenge,
2266
+ code_challenge_method: "S256",
2267
+ scope: "vault:read",
2268
+ vault: "boulder",
2269
+ }),
2270
+ );
2271
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
2272
+ expect(res.status).toBe(403);
2273
+ const html = await res.text();
2274
+ expect(html).toContain("App not yet approved");
2275
+ // The vault hint surfaces as a labeled row in the approve-meta block.
2276
+ expect(html).toContain('approve-meta-label">vault');
2277
+ expect(html).toContain("boulder");
2278
+ } finally {
2279
+ cleanup();
2280
+ }
2281
+ });
2282
+
2283
+ test("authorize: pending client with empty vault param → no vault row", async () => {
2284
+ // Defensive: `vault=` with empty value normalizes to undefined so the
2285
+ // UI doesn't render a blank vault label. Easy to hit if a client builds
2286
+ // the URL via URLSearchParams.set("vault", someMaybeEmptyVar).
2287
+ const { db, cleanup } = await makeDb();
2288
+ try {
2289
+ const reg = registerClient(db, {
2290
+ redirectUris: ["https://app.example/cb"],
2291
+ status: "pending",
2292
+ });
2293
+ const { challenge } = makePkce();
2294
+ const req = new Request(
2295
+ authorizeUrl({
2296
+ client_id: reg.client.clientId,
2297
+ redirect_uri: "https://app.example/cb",
2298
+ response_type: "code",
2299
+ code_challenge: challenge,
2300
+ code_challenge_method: "S256",
2301
+ scope: "vault:read",
2302
+ vault: "",
2303
+ }),
2304
+ );
2305
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
2306
+ expect(res.status).toBe(403);
2307
+ const html = await res.text();
2308
+ expect(html).toContain("App not yet approved");
2309
+ expect(html).not.toContain('approve-meta-label">vault');
2057
2310
  } finally {
2058
2311
  cleanup();
2059
2312
  }
@@ -2103,6 +2356,13 @@ describe("DCR approval gate (#74)", () => {
2103
2356
  const body = (await res.json()) as Record<string, unknown>;
2104
2357
  expect(body.error).toBe("invalid_client");
2105
2358
  expect(body.error_description).toContain("not been approved");
2359
+ // Surface the inline-approval affordances so consumers (Notes, future
2360
+ // cross-origin SPAs) can deep-link the operator to a browser-based
2361
+ // approve flow without dropping to a terminal.
2362
+ expect(body.approve_url).toBe(
2363
+ `${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
2364
+ );
2365
+ expect(body.cli_alternative).toBe(`parachute auth approve-client ${reg.client.clientId}`);
2106
2366
  } finally {
2107
2367
  cleanup();
2108
2368
  }
@@ -2132,6 +2392,13 @@ describe("DCR approval gate (#74)", () => {
2132
2392
  expect(res.status).toBe(401);
2133
2393
  const body = (await res.json()) as Record<string, unknown>;
2134
2394
  expect(body.error).toBe("invalid_client");
2395
+ // Same pending-affordance shape on the refresh path: a long-lived
2396
+ // OAuth client whose row was unapproved between issuance and refresh
2397
+ // hits this branch and surfaces the same approve_url + cli_alternative.
2398
+ expect(body.approve_url).toBe(
2399
+ `${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
2400
+ );
2401
+ expect(body.cli_alternative).toBe(`parachute auth approve-client ${reg.client.clientId}`);
2135
2402
  } finally {
2136
2403
  cleanup();
2137
2404
  }
@@ -2309,7 +2576,7 @@ describe("DCR auto-approve via session cookie (#199)", () => {
2309
2576
  // Sandbox iframes (`<iframe sandbox>` without `allow-same-origin`),
2310
2577
  // `data:`/`file:` documents, and some privacy contexts send the literal
2311
2578
  // string `Origin: null` rather than omitting the header. `new URL("null")`
2312
- // throws → originMatchesIssuer's try/catch returns false → DCR stays
2579
+ // throws → isSameOriginRequest's try/catch returns false → DCR stays
2313
2580
  // pending. This test pins that invariant: an opaque-origin caller does
2314
2581
  // NOT ride the cookie path even with a valid session, because we can't
2315
2582
  // prove the request came from the issuer's own origin.
@@ -2944,6 +3211,115 @@ describe("refresh-token rotation + /oauth/revoke (#73)", () => {
2944
3211
  // to the auth-code redirect. Strict superset (incremental scope) and
2945
3212
  // revoked grants still show consent.
2946
3213
  describe("handleAuthorizeGet — skip consent when scope already granted (#75)", () => {
3214
+ // hub#236 — pin the full silent-approve flow end-to-end in one test.
3215
+ // The per-branch tests below this one cover individual branches (subset,
3216
+ // superset, revoke, unnamed-vault, re-registered-client); this test
3217
+ // walks the operator-visible state machine in a single body so a
3218
+ // regression at any step surfaces immediately, and the JSDoc on
3219
+ // handleAuthorizeGet's silent-approve flow (1-5) has a single load-
3220
+ // bearing test to point at.
3221
+ test("first-use consent → silent-approve → novel-scope re-prompts (full silent-approve flow, #236)", async () => {
3222
+ const { db, cleanup } = await makeDb();
3223
+ try {
3224
+ const user = await createUser(db, "owner", "pw");
3225
+ const session = createSession(db, { userId: user.id });
3226
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
3227
+ const { challenge } = makePkce();
3228
+ const sessionCookie = buildSessionCookie(session.id, 86400);
3229
+
3230
+ // Step 1: first use — no grant exists; consent screen renders.
3231
+ const firstReq = new Request(
3232
+ authorizeUrl({
3233
+ client_id: reg.client.clientId,
3234
+ redirect_uri: "https://app.example/cb",
3235
+ response_type: "code",
3236
+ scope: "vault:default:read",
3237
+ code_challenge: challenge,
3238
+ code_challenge_method: "S256",
3239
+ state: "step1",
3240
+ }),
3241
+ { headers: { cookie: sessionCookie } },
3242
+ );
3243
+ const firstRes = handleAuthorizeGet(db, firstReq, { issuer: ISSUER });
3244
+ expect(firstRes.status).toBe(200);
3245
+ expect(firstRes.headers.get("content-type")).toContain("text/html");
3246
+
3247
+ // Step 1b: user approves via the consent form — grant gets recorded.
3248
+ const consentRes = await handleAuthorizePost(
3249
+ db,
3250
+ new Request(`${ISSUER}/oauth/authorize`, {
3251
+ method: "POST",
3252
+ body: new URLSearchParams({
3253
+ __action: "consent",
3254
+ __csrf: TEST_CSRF,
3255
+ approve: "yes",
3256
+ client_id: reg.client.clientId,
3257
+ redirect_uri: "https://app.example/cb",
3258
+ response_type: "code",
3259
+ scope: "vault:default:read",
3260
+ code_challenge: challenge,
3261
+ code_challenge_method: "S256",
3262
+ }),
3263
+ headers: {
3264
+ "content-type": "application/x-www-form-urlencoded",
3265
+ cookie: `${CSRF_COOKIE}; ${sessionCookie}`,
3266
+ },
3267
+ }),
3268
+ { issuer: ISSUER },
3269
+ );
3270
+ expect(consentRes.status).toBe(302);
3271
+
3272
+ // Step 2: subsequent use, same scopes — silent-approve fires.
3273
+ // Authoritative assertion: 302 redirect with auth code, NOT a 200
3274
+ // HTML consent screen. This is the operator-visible payoff.
3275
+ const secondReq = new Request(
3276
+ authorizeUrl({
3277
+ client_id: reg.client.clientId,
3278
+ redirect_uri: "https://app.example/cb",
3279
+ response_type: "code",
3280
+ scope: "vault:default:read",
3281
+ code_challenge: challenge,
3282
+ code_challenge_method: "S256",
3283
+ state: "step2",
3284
+ }),
3285
+ { headers: { cookie: sessionCookie } },
3286
+ );
3287
+ const secondRes = handleAuthorizeGet(db, secondReq, { issuer: ISSUER });
3288
+ expect(secondRes.status).toBe(302);
3289
+ const secondLoc = new URL(secondRes.headers.get("location") ?? "");
3290
+ expect(secondLoc.origin + secondLoc.pathname).toBe("https://app.example/cb");
3291
+ expect(secondLoc.searchParams.get("code")?.length).toBeGreaterThan(20);
3292
+ expect(secondLoc.searchParams.get("state")).toBe("step2");
3293
+
3294
+ // Step 3: subsequent use, novel scope NOT in the grant — gate must
3295
+ // NOT fire; consent re-renders with the new scope explicit. This is
3296
+ // the load-bearing security property: silent-approve must not
3297
+ // silently approve scopes the user never consented to.
3298
+ const novelReq = new Request(
3299
+ authorizeUrl({
3300
+ client_id: reg.client.clientId,
3301
+ redirect_uri: "https://app.example/cb",
3302
+ response_type: "code",
3303
+ // Adds scribe:transcribe to the original vault:default:read.
3304
+ scope: "vault:default:read scribe:transcribe",
3305
+ code_challenge: challenge,
3306
+ code_challenge_method: "S256",
3307
+ state: "step3",
3308
+ }),
3309
+ { headers: { cookie: sessionCookie } },
3310
+ );
3311
+ const novelRes = handleAuthorizeGet(db, novelReq, { issuer: ISSUER });
3312
+ expect(novelRes.status).toBe(200);
3313
+ expect(novelRes.headers.get("content-type")).toContain("text/html");
3314
+ const novelBody = await novelRes.text();
3315
+ // The new scope appears on the consent page — the user must approve
3316
+ // it explicitly.
3317
+ expect(novelBody).toContain("scribe:transcribe");
3318
+ } finally {
3319
+ cleanup();
3320
+ }
3321
+ });
3322
+
2947
3323
  test("first approval records grant; second flow with same scopes skips consent", async () => {
2948
3324
  const { db, cleanup } = await makeDb();
2949
3325
  try {
@@ -3556,7 +3932,7 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
3556
3932
  // Opaque-origin contexts (sandboxed iframes, some `data:` and `file:`
3557
3933
  // pages) send the literal string "null" as the Origin header. The DCR
3558
3934
  // /register path covers this; the inline-approve endpoint must reject it
3559
- // too. originMatchesIssuer() handles this correctly because new URL("null")
3935
+ // too. isSameOriginRequest() handles this correctly because new URL("null")
3560
3936
  // throws → returns false; this test pins that contract.
3561
3937
  const { db, cleanup } = await makeDb();
3562
3938
  try {
@@ -3846,3 +4222,144 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
3846
4222
  }
3847
4223
  });
3848
4224
  });
4225
+
4226
+ // DCR first-client auto-approve window (hub#268 Item 3). The wizard's
4227
+ // expose-step POST opens a 60-minute window where the very next
4228
+ // `/oauth/register` registration is auto-approved + the window cleared.
4229
+ // Single-use: client #2 within the same window falls through to the
4230
+ // standard pending-approval flow.
4231
+ describe("DCR first-client auto-approve window (hub#268 Item 3)", () => {
4232
+ function registerRequest(): Request {
4233
+ return new Request(`${ISSUER}/oauth/register`, {
4234
+ method: "POST",
4235
+ body: JSON.stringify({
4236
+ redirect_uris: ["https://app.example/cb"],
4237
+ client_name: "first-client",
4238
+ }),
4239
+ headers: { "content-type": "application/json" },
4240
+ });
4241
+ }
4242
+
4243
+ test("client registered within the open window → status approved + window cleared", async () => {
4244
+ const { db, cleanup } = await makeDb();
4245
+ try {
4246
+ const t0 = new Date("2026-05-19T00:00:00.000Z");
4247
+ openFirstClientAutoApproveWindow(db, () => t0);
4248
+ const res = await handleRegister(db, registerRequest(), {
4249
+ issuer: ISSUER,
4250
+ now: () => t0,
4251
+ });
4252
+ expect(res.status).toBe(201);
4253
+ const body = (await res.json()) as Record<string, unknown>;
4254
+ expect(body.status).toBe("approved");
4255
+ // Persisted, not just response-shaped.
4256
+ const row = getClient(db, body.client_id as string);
4257
+ expect(row?.status).toBe("approved");
4258
+ // Window cleared on consume (single-use).
4259
+ expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
4260
+ } finally {
4261
+ cleanup();
4262
+ }
4263
+ });
4264
+
4265
+ test("client registered AFTER the window has expired → status pending", async () => {
4266
+ const { db, cleanup } = await makeDb();
4267
+ try {
4268
+ const t0 = new Date("2026-05-19T00:00:00.000Z");
4269
+ openFirstClientAutoApproveWindow(db, () => t0);
4270
+ const past = new Date(t0.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS + 1);
4271
+ const res = await handleRegister(db, registerRequest(), {
4272
+ issuer: ISSUER,
4273
+ now: () => past,
4274
+ });
4275
+ expect(res.status).toBe(201);
4276
+ const body = (await res.json()) as Record<string, unknown>;
4277
+ expect(body.status).toBe("pending");
4278
+ } finally {
4279
+ cleanup();
4280
+ }
4281
+ });
4282
+
4283
+ test("second client within window after first auto-approved → status pending (single-use)", async () => {
4284
+ const { db, cleanup } = await makeDb();
4285
+ try {
4286
+ const t0 = new Date("2026-05-19T00:00:00.000Z");
4287
+ openFirstClientAutoApproveWindow(db, () => t0);
4288
+ // Client #1: approved.
4289
+ const res1 = await handleRegister(db, registerRequest(), {
4290
+ issuer: ISSUER,
4291
+ now: () => t0,
4292
+ });
4293
+ const body1 = (await res1.json()) as Record<string, unknown>;
4294
+ expect(body1.status).toBe("approved");
4295
+ // Client #2 within the (still-not-expired) window: pending.
4296
+ const stillWithinWindow = new Date(t0.getTime() + 30 * 60 * 1000);
4297
+ const res2 = await handleRegister(db, registerRequest(), {
4298
+ issuer: ISSUER,
4299
+ now: () => stillWithinWindow,
4300
+ });
4301
+ const body2 = (await res2.json()) as Record<string, unknown>;
4302
+ expect(body2.status).toBe("pending");
4303
+ } finally {
4304
+ cleanup();
4305
+ }
4306
+ });
4307
+
4308
+ test("no window set → status pending (default public-DCR flow)", async () => {
4309
+ const { db, cleanup } = await makeDb();
4310
+ try {
4311
+ const res = await handleRegister(db, registerRequest(), { issuer: ISSUER });
4312
+ const body = (await res.json()) as Record<string, unknown>;
4313
+ expect(body.status).toBe("pending");
4314
+ // Settings row untouched.
4315
+ expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
4316
+ } finally {
4317
+ cleanup();
4318
+ }
4319
+ });
4320
+
4321
+ test("operator-bearer auto-approve still takes precedence over the window (no double-consume)", async () => {
4322
+ // Bearer-authenticated registration approves directly; the
4323
+ // auto-approve window should NOT be consumed in that case — it's
4324
+ // still available for the first un-authenticated client.
4325
+ const { db, cleanup } = await makeDb();
4326
+ try {
4327
+ const t0 = new Date("2026-05-19T00:00:00.000Z");
4328
+ openFirstClientAutoApproveWindow(db, () => t0);
4329
+ // We can't easily mint an operator bearer in this test layer, so
4330
+ // simulate by using the session-cookie path (issuer-trusted) which
4331
+ // also auto-approves before falling through to the window check.
4332
+ const user = await createUser(db, "owner", "pw");
4333
+ const session = createSession(db, { userId: user.id });
4334
+ const req = new Request(`${ISSUER}/oauth/register`, {
4335
+ method: "POST",
4336
+ body: JSON.stringify({ redirect_uris: ["https://app.example/cb"] }),
4337
+ headers: {
4338
+ "content-type": "application/json",
4339
+ cookie: buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)),
4340
+ origin: ISSUER,
4341
+ },
4342
+ });
4343
+ const res = await handleRegister(db, req, { issuer: ISSUER, now: () => t0 });
4344
+ const body = (await res.json()) as Record<string, unknown>;
4345
+ expect(body.status).toBe("approved");
4346
+ // Window NOT consumed — still set, still open. The session-cookie
4347
+ // path approved first, never reaching the window-consume code.
4348
+ expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeDefined();
4349
+ } finally {
4350
+ cleanup();
4351
+ }
4352
+ });
4353
+
4354
+ test("malformed timestamp in the setting → treated as no-window, status pending", async () => {
4355
+ const { db, cleanup } = await makeDb();
4356
+ try {
4357
+ setSetting(db, "pending_first_client_auto_approve_until", "not-a-real-iso-string");
4358
+ const res = await handleRegister(db, registerRequest(), { issuer: ISSUER });
4359
+ const body = (await res.json()) as Record<string, unknown>;
4360
+ expect(body.status).toBe("pending");
4361
+ } finally {
4362
+ cleanup();
4363
+ }
4364
+ });
4365
+ });