@openparachute/hub 0.7.2 → 0.7.3-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 (66) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-agent-grants.test.ts +113 -0
  3. package/src/__tests__/admin-vaults.test.ts +20 -5
  4. package/src/__tests__/api-modules.test.ts +65 -10
  5. package/src/__tests__/api-vault-caps.test.ts +232 -0
  6. package/src/__tests__/cli.test.ts +20 -0
  7. package/src/__tests__/hub-command.test.ts +136 -0
  8. package/src/__tests__/hub-origins-env-set.test.ts +273 -0
  9. package/src/__tests__/init.test.ts +148 -0
  10. package/src/__tests__/jwt-sign.test.ts +79 -0
  11. package/src/__tests__/module-manifest.test.ts +3 -1
  12. package/src/__tests__/oauth-client.test.ts +75 -0
  13. package/src/__tests__/oauth-handlers.test.ts +413 -5
  14. package/src/__tests__/public-signup.test.ts +619 -0
  15. package/src/__tests__/rate-limit.test.ts +86 -0
  16. package/src/__tests__/scribe-config.test.ts +117 -0
  17. package/src/__tests__/serve-boot.test.ts +45 -0
  18. package/src/__tests__/serve.test.ts +67 -1
  19. package/src/__tests__/service-spec-discovery.test.ts +22 -2
  20. package/src/__tests__/setup-wizard.test.ts +18 -0
  21. package/src/__tests__/setup.test.ts +129 -15
  22. package/src/__tests__/two-factor-flow.test.ts +94 -0
  23. package/src/__tests__/users.test.ts +33 -0
  24. package/src/__tests__/vault-caps.test.ts +89 -0
  25. package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
  26. package/src/__tests__/wizard-transcription.test.ts +334 -0
  27. package/src/__tests__/wizard.test.ts +146 -0
  28. package/src/account-setup.ts +118 -32
  29. package/src/admin-agent-grants.ts +51 -3
  30. package/src/admin-handlers.ts +53 -16
  31. package/src/admin-login-ui.ts +30 -4
  32. package/src/admin-vaults.ts +9 -1
  33. package/src/api-invites.ts +163 -3
  34. package/src/api-modules-ops.ts +12 -0
  35. package/src/api-modules.ts +47 -13
  36. package/src/api-users.ts +3 -0
  37. package/src/api-vault-caps.ts +206 -0
  38. package/src/cli.ts +35 -1
  39. package/src/commands/hub.ts +173 -0
  40. package/src/commands/init.ts +73 -2
  41. package/src/commands/serve-boot.ts +16 -2
  42. package/src/commands/serve.ts +39 -3
  43. package/src/commands/setup.ts +31 -6
  44. package/src/commands/wizard-transcription.ts +296 -0
  45. package/src/commands/wizard.ts +73 -3
  46. package/src/help.ts +8 -0
  47. package/src/hub-db.ts +108 -1
  48. package/src/hub-origin.ts +64 -0
  49. package/src/hub-server.ts +27 -0
  50. package/src/invites.ts +155 -31
  51. package/src/jwt-sign.ts +72 -3
  52. package/src/module-manifest.ts +23 -9
  53. package/src/oauth-client.ts +102 -12
  54. package/src/oauth-flows-store.ts +12 -0
  55. package/src/oauth-handlers.ts +145 -20
  56. package/src/rate-limit.ts +111 -20
  57. package/src/scribe-config.ts +145 -0
  58. package/src/service-spec.ts +31 -12
  59. package/src/setup-wizard.ts +37 -4
  60. package/src/users.ts +62 -3
  61. package/src/vault-caps.ts +109 -0
  62. package/src/vault-hub-origin-env.ts +109 -16
  63. package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
  64. package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/web/ui/dist/assets/index-B5AUE359.js +0 -61
@@ -126,6 +126,81 @@ describe("discover", () => {
126
126
  const { fn } = fakeFetch({});
127
127
  await expect(discover("not-a-url", fn)).rejects.toBeInstanceOf(OAuthClientError);
128
128
  });
129
+
130
+ test("RFC 9728 path-inserted PRM + auth server on a SEPARATE host (the Read.ai shape)", async () => {
131
+ // The PRM lives ONLY at the path-inserted location (host root 404s) and
132
+ // points at an auth server on a DIFFERENT host. The pre-fix discover()
133
+ // probed only the host-root PRM, fell back to mcp-origin-as-issuer, and
134
+ // 404'd on the (nonexistent) authorization-server doc — this is the
135
+ // regression that blocked connecting Read.ai.
136
+ const { fn, calls } = fakeFetch({
137
+ "https://api.example.ai/.well-known/oauth-protected-resource/mcp": () =>
138
+ json({
139
+ resource: "https://api.example.ai/mcp",
140
+ authorization_servers: ["https://authn.example.ai/"],
141
+ scopes_supported: ["mcp:execute", "meeting:read"],
142
+ }),
143
+ "https://authn.example.ai/.well-known/oauth-authorization-server": () =>
144
+ json({
145
+ issuer: "https://authn.example.ai/",
146
+ authorization_endpoint: "https://authn.example.ai/oauth2/auth",
147
+ token_endpoint: "https://authn.example.ai/oauth2/token",
148
+ registration_endpoint: "https://api.example.ai/oauth/register",
149
+ }),
150
+ });
151
+ const d = await discover("https://api.example.ai/mcp/", fn);
152
+ expect(d.issuer).toBe("https://authn.example.ai/");
153
+ expect(d.authorizationEndpoint).toBe("https://authn.example.ai/oauth2/auth");
154
+ expect(d.tokenEndpoint).toBe("https://authn.example.ai/oauth2/token");
155
+ expect(d.registrationEndpoint).toBe("https://api.example.ai/oauth/register");
156
+ // 9728 scopes win
157
+ expect(d.scopesSupported).toEqual(["mcp:execute", "meeting:read"]);
158
+ // proves the path-inserted PRM probe was used (host-root PRM isn't defined)
159
+ expect(
160
+ calls.some((c) => c.url === "https://api.example.ai/.well-known/oauth-protected-resource/mcp"),
161
+ ).toBe(true);
162
+ });
163
+
164
+ test("AS discovery falls back to openid-configuration when oauth-authorization-server is absent", async () => {
165
+ const { fn } = fakeFetch({
166
+ "https://api.example.ai/.well-known/oauth-protected-resource/mcp": () =>
167
+ json({ authorization_servers: ["https://authn.example.ai/"] }),
168
+ // No RFC 8414 doc — only OIDC discovery.
169
+ "https://authn.example.ai/.well-known/openid-configuration": () =>
170
+ json({
171
+ issuer: "https://authn.example.ai/",
172
+ authorization_endpoint: "https://authn.example.ai/oauth2/auth",
173
+ token_endpoint: "https://authn.example.ai/oauth2/token",
174
+ }),
175
+ });
176
+ const d = await discover("https://api.example.ai/mcp", fn);
177
+ expect(d.tokenEndpoint).toBe("https://authn.example.ai/oauth2/token");
178
+ });
179
+
180
+ test("malformed authorization_servers issuer throws OAuthClientError (not a raw TypeError)", async () => {
181
+ const { fn } = fakeFetch({
182
+ "https://api.example.ai/.well-known/oauth-protected-resource/mcp": () =>
183
+ json({ authorization_servers: ["not-a-url"] }),
184
+ });
185
+ await expect(discover("https://api.example.ai/mcp", fn)).rejects.toBeInstanceOf(OAuthClientError);
186
+ });
187
+
188
+ test("AS discovery uses the OIDC-append form for a path-ful issuer", async () => {
189
+ const { fn } = fakeFetch({
190
+ "https://api.example.ai/.well-known/oauth-protected-resource/mcp": () =>
191
+ json({ authorization_servers: ["https://idp.example.ai/tenant1"] }),
192
+ // ONLY the OIDC-append URL exists (issuer-path + /.well-known/openid-configuration),
193
+ // not the RFC 8414 insert form — proves the append candidate is generated.
194
+ "https://idp.example.ai/tenant1/.well-known/openid-configuration": () =>
195
+ json({
196
+ issuer: "https://idp.example.ai/tenant1",
197
+ authorization_endpoint: "https://idp.example.ai/tenant1/auth",
198
+ token_endpoint: "https://idp.example.ai/tenant1/token",
199
+ }),
200
+ });
201
+ const d = await discover("https://api.example.ai/mcp", fn);
202
+ expect(d.tokenEndpoint).toBe("https://idp.example.ai/tenant1/token");
203
+ });
129
204
  });
130
205
 
131
206
  // === DCR ====================================================================
@@ -3,7 +3,7 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { handleAdminLoginTotpPost } from "../admin-handlers.ts";
6
+ import { handleAdminLoginPost, handleAdminLoginTotpPost } from "../admin-handlers.ts";
7
7
  import { approveClient, getClient, registerClient } from "../clients.ts";
8
8
  import { CSRF_COOKIE_NAME } from "../csrf.ts";
9
9
  import { findGrant, recordGrant } from "../grants.ts";
@@ -14,7 +14,12 @@ import {
14
14
  openFirstClientAutoApproveWindow,
15
15
  setSetting,
16
16
  } from "../hub-settings.ts";
17
- import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
17
+ import {
18
+ REFRESH_GRACE_MS,
19
+ findTokenRowByJti,
20
+ signRefreshToken,
21
+ validateAccessToken,
22
+ } from "../jwt-sign.ts";
18
23
  import {
19
24
  authorizationServerMetadata,
20
25
  buildServicesCatalog,
@@ -1386,6 +1391,7 @@ describe("handleAuthorizePost — vault picker", () => {
1386
1391
 
1387
1392
  describe("handleAuthorizePost — login submit", () => {
1388
1393
  test("sets session cookie and redirects to GET on valid credentials", async () => {
1394
+ resetRateLimit();
1389
1395
  const { db, cleanup } = await makeDb();
1390
1396
  try {
1391
1397
  await createUser(db, "owner", "hunter2");
@@ -1423,6 +1429,7 @@ describe("handleAuthorizePost — login submit", () => {
1423
1429
  });
1424
1430
 
1425
1431
  test("rejects bad password with 401, no cookie", async () => {
1432
+ resetRateLimit();
1426
1433
  const { db, cleanup } = await makeDb();
1427
1434
  try {
1428
1435
  await createUser(db, "owner", "hunter2");
@@ -1456,6 +1463,125 @@ describe("handleAuthorizePost — login submit", () => {
1456
1463
  });
1457
1464
  });
1458
1465
 
1466
+ // --- Shared per-account login bucket across BOTH password doors -------------
1467
+ //
1468
+ // The shared-egress-IP fix re-keyed the login floor to (ip,username) AND wired
1469
+ // the same `loginRateLimiter` instance into the previously-ungated
1470
+ // `/oauth/authorize` password door. Both doors must share ONE per-account
1471
+ // bucket, so an attacker can't get 5 tries at `/login` PLUS another 5 at
1472
+ // `/oauth/authorize` for the same (ip,username).
1473
+ describe("login floor is shared across /login and /oauth/authorize (same per-account bucket)", () => {
1474
+ const ATTACK_IP = "203.0.113.200";
1475
+
1476
+ function adminLoginReq(username: string, password: string): Request {
1477
+ const body = new URLSearchParams({
1478
+ __csrf: TEST_CSRF,
1479
+ username,
1480
+ password,
1481
+ next: "/admin/vaults",
1482
+ });
1483
+ return new Request("http://hub.test/login", {
1484
+ method: "POST",
1485
+ headers: {
1486
+ "content-type": "application/x-www-form-urlencoded",
1487
+ cookie: CSRF_COOKIE,
1488
+ "cf-connecting-ip": ATTACK_IP,
1489
+ },
1490
+ body,
1491
+ });
1492
+ }
1493
+
1494
+ function oauthLoginReq(
1495
+ username: string,
1496
+ password: string,
1497
+ clientId: string,
1498
+ challenge: string,
1499
+ ): Request {
1500
+ const body = new URLSearchParams({
1501
+ __action: "login",
1502
+ __csrf: TEST_CSRF,
1503
+ username,
1504
+ password,
1505
+ client_id: clientId,
1506
+ redirect_uri: "https://app.example/cb",
1507
+ response_type: "code",
1508
+ scope: "vault:read",
1509
+ code_challenge: challenge,
1510
+ code_challenge_method: "S256",
1511
+ });
1512
+ return new Request(`${ISSUER}/oauth/authorize`, {
1513
+ method: "POST",
1514
+ headers: {
1515
+ "content-type": "application/x-www-form-urlencoded",
1516
+ cookie: CSRF_COOKIE,
1517
+ "cf-connecting-ip": ATTACK_IP,
1518
+ },
1519
+ body,
1520
+ });
1521
+ }
1522
+
1523
+ // (e) 5 at /login + 1 at /oauth/authorize for the same (ip,username) → the
1524
+ // 6th (the /oauth/authorize attempt) is denied regardless of which door it
1525
+ // came through.
1526
+ test("(e) 5 /login attempts then 1 /oauth/authorize attempt → the 6th door is 429", async () => {
1527
+ const { db, cleanup } = await makeDb();
1528
+ resetRateLimit();
1529
+ try {
1530
+ await createUser(db, "owner", "hunter2", { passwordChanged: true });
1531
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1532
+ const { challenge } = makePkce();
1533
+
1534
+ // Burn the full 5-slot floor at the /login door (wrong password → 401).
1535
+ for (let i = 0; i < 5; i++) {
1536
+ const r = await handleAdminLoginPost(db, adminLoginReq("owner", "wrong"));
1537
+ expect(r.status).toBe(401);
1538
+ }
1539
+ // 6th attempt — at the OTHER door (/oauth/authorize). Must be denied
1540
+ // because both doors share ONE (ip,username) bucket.
1541
+ const denied = await handleAuthorizePost(
1542
+ db,
1543
+ oauthLoginReq("owner", "hunter2", reg.client.clientId, challenge),
1544
+ { issuer: ISSUER },
1545
+ );
1546
+ expect(denied.status).toBe(429);
1547
+ expect(denied.headers.get("retry-after")).not.toBeNull();
1548
+ // No session minted even though the password was correct — the floor
1549
+ // fired before the credential check.
1550
+ expect(cookieValueFrom(denied, "parachute_hub_session")).toBeNull();
1551
+ } finally {
1552
+ cleanup();
1553
+ }
1554
+ });
1555
+
1556
+ test("the shared bucket is per-account: a DIFFERENT username at /oauth/authorize is unaffected", async () => {
1557
+ const { db, cleanup } = await makeDb();
1558
+ resetRateLimit();
1559
+ try {
1560
+ await createUser(db, "alice", "alice-pw", { passwordChanged: true });
1561
+ await createUser(db, "bob", "bob-pw", { passwordChanged: true, allowMulti: true });
1562
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1563
+ const { challenge } = makePkce();
1564
+
1565
+ // Exhaust alice's floor at /login.
1566
+ for (let i = 0; i < 5; i++) {
1567
+ await handleAdminLoginPost(db, adminLoginReq("alice", "wrong"));
1568
+ }
1569
+ expect((await handleAdminLoginPost(db, adminLoginReq("alice", "wrong"))).status).toBe(429);
1570
+
1571
+ // bob (same IP, different username) still signs in at /oauth/authorize.
1572
+ const bobRes = await handleAuthorizePost(
1573
+ db,
1574
+ oauthLoginReq("bob", "bob-pw", reg.client.clientId, challenge),
1575
+ { issuer: ISSUER },
1576
+ );
1577
+ expect(bobRes.status).toBe(302);
1578
+ expect(bobRes.headers.get("location")).toContain("/oauth/authorize?");
1579
+ } finally {
1580
+ cleanup();
1581
+ }
1582
+ });
1583
+ });
1584
+
1459
1585
  // --- OAuth-path TOTP gate (hub#473 P0 bypass regression) -------------------
1460
1586
  //
1461
1587
  // The OAuth login POST (`__action=login`) is the more-common sign-in path
@@ -2262,6 +2388,7 @@ describe("handleToken — full OAuth dance", () => {
2262
2388
  refresh_token: initial.refresh_token,
2263
2389
  client_id: reg.client.clientId,
2264
2390
  });
2391
+ const rotateAt = new Date("2026-06-24T00:00:00Z");
2265
2392
  const refreshRes = await handleToken(
2266
2393
  db,
2267
2394
  new Request(`${ISSUER}/oauth/token`, {
@@ -2269,13 +2396,16 @@ describe("handleToken — full OAuth dance", () => {
2269
2396
  body: refreshForm,
2270
2397
  headers: { "content-type": "application/x-www-form-urlencoded" },
2271
2398
  }),
2272
- { issuer: ISSUER },
2399
+ { issuer: ISSUER, now: () => rotateAt },
2273
2400
  );
2274
2401
  expect(refreshRes.status).toBe(200);
2275
2402
  const rotated = (await refreshRes.json()) as { refresh_token: string };
2276
2403
  expect(rotated.refresh_token).not.toBe(initial.refresh_token);
2277
2404
 
2278
- // Old refresh token should now fail (revoked).
2405
+ // Old refresh token replayed PAST the one-generation grace window
2406
+ // should fail (revoked) — the immediate-predecessor grace (hub#685)
2407
+ // only tolerates a replay within REFRESH_GRACE_MS. Replay an hour
2408
+ // later: genuine zero-tolerance rejection.
2279
2409
  const replayRes = await handleToken(
2280
2410
  db,
2281
2411
  new Request(`${ISSUER}/oauth/token`, {
@@ -2283,7 +2413,7 @@ describe("handleToken — full OAuth dance", () => {
2283
2413
  body: refreshForm,
2284
2414
  headers: { "content-type": "application/x-www-form-urlencoded" },
2285
2415
  }),
2286
- { issuer: ISSUER },
2416
+ { issuer: ISSUER, now: () => new Date(rotateAt.getTime() + 60 * 60 * 1000) },
2287
2417
  );
2288
2418
  expect(replayRes.status).toBe(400);
2289
2419
  const err = (await replayRes.json()) as Record<string, unknown>;
@@ -4125,6 +4255,284 @@ describe("refresh-token rotation + /oauth/revoke (#73)", () => {
4125
4255
  }
4126
4256
  });
4127
4257
 
4258
+ // hub#685 — one-generation rotation grace window. A benign concurrent /
4259
+ // retried refresh of the *immediately-previous* (just-rotated) token within
4260
+ // REFRESH_GRACE_MS must NOT revoke the family (multi-tab SPA, bfcache/
4261
+ // stale-tab resume, network retry). Genuine theft — an older ancestor, or
4262
+ // any replay past the window — MUST still revoke the family.
4263
+ describe("rotation grace window (hub#685)", () => {
4264
+ function liveRefreshCount(
4265
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
4266
+ familyId: string,
4267
+ ): number {
4268
+ const r = db
4269
+ .query<{ n: number }, [string]>(
4270
+ "SELECT COUNT(*) AS n FROM tokens WHERE family_id = ? AND revoked_at IS NULL AND refresh_token_hash IS NOT NULL",
4271
+ )
4272
+ .get(familyId);
4273
+ return r?.n ?? -1;
4274
+ }
4275
+
4276
+ async function refreshAt(
4277
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
4278
+ clientId: string,
4279
+ refreshToken: string,
4280
+ at: Date,
4281
+ ): Promise<Response> {
4282
+ return handleToken(
4283
+ db,
4284
+ tokenRequest(
4285
+ new URLSearchParams({
4286
+ grant_type: "refresh_token",
4287
+ refresh_token: refreshToken,
4288
+ client_id: clientId,
4289
+ }),
4290
+ ),
4291
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest, now: () => at },
4292
+ );
4293
+ }
4294
+
4295
+ test("benign concurrent refresh of the immediate predecessor within the window succeeds, no family revocation", async () => {
4296
+ const { db, cleanup } = await makeDb();
4297
+ try {
4298
+ const user = await createUser(db, "owner", "pw");
4299
+ const session = createSession(db, { userId: user.id });
4300
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4301
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4302
+ const family = familyIdFor(db, initial.refresh_token);
4303
+
4304
+ const t0 = new Date("2026-06-24T00:00:00Z");
4305
+ // First (legitimate) rotation at t0 — `initial` becomes the immediate
4306
+ // predecessor; rotated1 is the single live tip.
4307
+ const r1 = await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4308
+ expect(r1.status).toBe(200);
4309
+ const rotated1 = (await r1.json()) as { refresh_token: string };
4310
+ expect(liveRefreshCount(db, family)).toBe(1);
4311
+
4312
+ // A moment later (5s, well within REFRESH_GRACE_MS) a stale tab /
4313
+ // retry replays `initial`. Benign: succeeds, and converges the client
4314
+ // onto the lineage rather than revoking the family.
4315
+ const replay = await refreshAt(
4316
+ db,
4317
+ reg.client.clientId,
4318
+ initial.refresh_token,
4319
+ new Date(t0.getTime() + 5_000),
4320
+ );
4321
+ expect(replay.status).toBe(200);
4322
+ const replayed = (await replay.json()) as { refresh_token: string; access_token: string };
4323
+ expect(replayed.refresh_token).toBeTruthy();
4324
+ expect(replayed.access_token).toBeTruthy();
4325
+
4326
+ // No family-wide revocation: exactly one live refresh row remains
4327
+ // (the grace path rotated the tip → its successor is the new tip).
4328
+ expect(liveRefreshCount(db, family)).toBe(1);
4329
+
4330
+ // The token handed back is usable for a normal subsequent refresh.
4331
+ const next = await refreshAt(
4332
+ db,
4333
+ reg.client.clientId,
4334
+ replayed.refresh_token,
4335
+ new Date(t0.getTime() + 10_000),
4336
+ );
4337
+ expect(next.status).toBe(200);
4338
+ } finally {
4339
+ cleanup();
4340
+ }
4341
+ });
4342
+
4343
+ test("genuine reuse of an OLDER ancestor (two generations back) revokes the family even within the window", async () => {
4344
+ const { db, cleanup } = await makeDb();
4345
+ try {
4346
+ const user = await createUser(db, "owner", "pw");
4347
+ const session = createSession(db, { userId: user.id });
4348
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4349
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4350
+ const family = familyIdFor(db, initial.refresh_token);
4351
+
4352
+ const t0 = new Date("2026-06-24T00:00:00Z");
4353
+ // Two legitimate rotations: initial → rotated1 → rotated2. `initial`
4354
+ // is now an ancestor (its successor rotated1 is itself revoked).
4355
+ const r1 = await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4356
+ const rotated1 = (await r1.json()) as { refresh_token: string };
4357
+ const r2 = await refreshAt(
4358
+ db,
4359
+ reg.client.clientId,
4360
+ rotated1.refresh_token,
4361
+ new Date(t0.getTime() + 1_000),
4362
+ );
4363
+ expect(r2.status).toBe(200);
4364
+
4365
+ // Replay the ANCESTOR `initial` still within the window. Condition (b)
4366
+ // fails — its successor (rotated1) is not the live tip — so this is
4367
+ // treated as theft: family revoked.
4368
+ const replay = await refreshAt(
4369
+ db,
4370
+ reg.client.clientId,
4371
+ initial.refresh_token,
4372
+ new Date(t0.getTime() + 2_000),
4373
+ );
4374
+ expect(replay.status).toBe(400);
4375
+ expect(((await replay.json()) as Record<string, unknown>).error).toBe("invalid_grant");
4376
+ expect(liveRefreshCount(db, family)).toBe(0);
4377
+ } finally {
4378
+ cleanup();
4379
+ }
4380
+ });
4381
+
4382
+ test("replay of the immediate predecessor AFTER the window revokes the family", async () => {
4383
+ const { db, cleanup } = await makeDb();
4384
+ try {
4385
+ const user = await createUser(db, "owner", "pw");
4386
+ const session = createSession(db, { userId: user.id });
4387
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4388
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4389
+ const family = familyIdFor(db, initial.refresh_token);
4390
+
4391
+ const t0 = new Date("2026-06-24T00:00:00Z");
4392
+ const r1 = await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4393
+ expect(r1.status).toBe(200);
4394
+
4395
+ // Replay the immediate predecessor 1ms PAST the window. Condition (a)
4396
+ // fails: zero-tolerance theft handling applies.
4397
+ const replay = await refreshAt(
4398
+ db,
4399
+ reg.client.clientId,
4400
+ initial.refresh_token,
4401
+ new Date(t0.getTime() + REFRESH_GRACE_MS + 1),
4402
+ );
4403
+ expect(replay.status).toBe(400);
4404
+ expect(((await replay.json()) as Record<string, unknown>).error).toBe("invalid_grant");
4405
+ expect(liveRefreshCount(db, family)).toBe(0);
4406
+ } finally {
4407
+ cleanup();
4408
+ }
4409
+ });
4410
+
4411
+ test("window boundary: exactly at the edge succeeds, one ms past fails", async () => {
4412
+ const { db, cleanup } = await makeDb();
4413
+ try {
4414
+ const user = await createUser(db, "owner", "pw");
4415
+ const session = createSession(db, { userId: user.id });
4416
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4417
+
4418
+ // Just inside (=== REFRESH_GRACE_MS): benign success, family intact.
4419
+ const aPair = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4420
+ const aFamily = familyIdFor(db, aPair.refresh_token);
4421
+ const a0 = new Date("2026-06-24T00:00:00Z");
4422
+ await refreshAt(db, reg.client.clientId, aPair.refresh_token, a0);
4423
+ const inEdge = await refreshAt(
4424
+ db,
4425
+ reg.client.clientId,
4426
+ aPair.refresh_token,
4427
+ new Date(a0.getTime() + REFRESH_GRACE_MS),
4428
+ );
4429
+ expect(inEdge.status).toBe(200);
4430
+ expect(liveRefreshCount(db, aFamily)).toBe(1);
4431
+
4432
+ // One ms past the edge: theft handling, family revoked.
4433
+ const bPair = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4434
+ const bFamily = familyIdFor(db, bPair.refresh_token);
4435
+ const b0 = new Date("2026-06-24T01:00:00Z");
4436
+ await refreshAt(db, reg.client.clientId, bPair.refresh_token, b0);
4437
+ const outEdge = await refreshAt(
4438
+ db,
4439
+ reg.client.clientId,
4440
+ bPair.refresh_token,
4441
+ new Date(b0.getTime() + REFRESH_GRACE_MS + 1),
4442
+ );
4443
+ expect(outEdge.status).toBe(400);
4444
+ expect(liveRefreshCount(db, bFamily)).toBe(0);
4445
+ } finally {
4446
+ cleanup();
4447
+ }
4448
+ });
4449
+
4450
+ test("repeated benign replays of the same predecessor each converge on a live tip (idempotent-equivalent)", async () => {
4451
+ const { db, cleanup } = await makeDb();
4452
+ try {
4453
+ const user = await createUser(db, "owner", "pw");
4454
+ const session = createSession(db, { userId: user.id });
4455
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4456
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4457
+ const family = familyIdFor(db, initial.refresh_token);
4458
+
4459
+ const t0 = new Date("2026-06-24T00:00:00Z");
4460
+ await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4461
+
4462
+ // First benign replay of `initial`: succeeds, rotates the tip.
4463
+ const replay1 = await refreshAt(
4464
+ db,
4465
+ reg.client.clientId,
4466
+ initial.refresh_token,
4467
+ new Date(t0.getTime() + 1_000),
4468
+ );
4469
+ expect(replay1.status).toBe(200);
4470
+ expect(liveRefreshCount(db, family)).toBe(1);
4471
+
4472
+ // A SECOND replay of the SAME predecessor `initial`. Its successor is
4473
+ // now revoked (the first replay rotated the tip), so `initial` is no
4474
+ // longer the direct predecessor of the single live tip → theft. The
4475
+ // grace window protects exactly ONE generation, not unbounded replay.
4476
+ const replay2 = await refreshAt(
4477
+ db,
4478
+ reg.client.clientId,
4479
+ initial.refresh_token,
4480
+ new Date(t0.getTime() + 2_000),
4481
+ );
4482
+ expect(replay2.status).toBe(400);
4483
+ expect(liveRefreshCount(db, family)).toBe(0);
4484
+ } finally {
4485
+ cleanup();
4486
+ }
4487
+ });
4488
+
4489
+ test("already-forked family (multiple live tips) does NOT take the grace path — family revoked", async () => {
4490
+ const { db, cleanup } = await makeDb();
4491
+ try {
4492
+ const user = await createUser(db, "owner", "pw");
4493
+ const session = createSession(db, { userId: user.id });
4494
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4495
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4496
+ const family = familyIdFor(db, initial.refresh_token);
4497
+
4498
+ const t0 = new Date("2026-06-24T00:00:00Z");
4499
+ // One legitimate rotation → `initial` is the immediate predecessor of
4500
+ // the single live tip (live count == 1, the benign precondition).
4501
+ const r1 = await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4502
+ expect(r1.status).toBe(200);
4503
+ expect(liveRefreshCount(db, family)).toBe(1);
4504
+
4505
+ // Inject a SECOND live refresh row into the same family — simulating a
4506
+ // family that has already forked into multiple live lineages (an
4507
+ // already-compromised state). Now live count != 1, so `tip` is null.
4508
+ signRefreshToken(db, {
4509
+ jti: "injected-fork-jti",
4510
+ userId: user.id,
4511
+ clientId: reg.client.clientId,
4512
+ scopes: ["vault:default:read"],
4513
+ familyId: family,
4514
+ now: () => t0,
4515
+ });
4516
+ expect(liveRefreshCount(db, family)).toBe(2);
4517
+
4518
+ // Replay the immediate predecessor WITHIN the window. Even though (a)
4519
+ // holds and `initial.rotatedTo` is set, the single-live-tip check
4520
+ // fails (2 live rows) → no grace → zero-tolerance family revocation.
4521
+ const replay = await refreshAt(
4522
+ db,
4523
+ reg.client.clientId,
4524
+ initial.refresh_token,
4525
+ new Date(t0.getTime() + 1_000),
4526
+ );
4527
+ expect(replay.status).toBe(400);
4528
+ expect(((await replay.json()) as Record<string, unknown>).error).toBe("invalid_grant");
4529
+ expect(liveRefreshCount(db, family)).toBe(0);
4530
+ } finally {
4531
+ cleanup();
4532
+ }
4533
+ });
4534
+ });
4535
+
4128
4536
  test("/oauth/revoke refresh_token: revokes the row, second use rejected", async () => {
4129
4537
  const { db, cleanup } = await makeDb();
4130
4538
  try {