@bevel-software/platform-core-backend 0.30.0 → 0.32.0

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 (210) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/core/catalog-revision.js +3 -3
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +2 -1
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +7 -0
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +16 -3
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +1 -5
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access-model/index.d.ts +14 -0
  19. package/dist/modules/access-model/index.d.ts.map +1 -0
  20. package/dist/modules/access-model/index.js +13 -0
  21. package/dist/modules/access-model/index.js.map +1 -0
  22. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  23. package/dist/modules/audit/agent-audit.service.js +66 -6
  24. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  25. package/dist/modules/audit/audit.contract.d.ts +52 -8
  26. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  27. package/dist/modules/audit/audit.contract.js +37 -1
  28. package/dist/modules/audit/audit.contract.js.map +1 -1
  29. package/dist/modules/audit/audit.routes.d.ts +1 -1
  30. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  31. package/dist/modules/audit/audit.routes.js +15 -13
  32. package/dist/modules/audit/audit.routes.js.map +1 -1
  33. package/dist/modules/audit/request-audit.d.ts +21 -10
  34. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  35. package/dist/modules/audit/request-audit.js +26 -10
  36. package/dist/modules/audit/request-audit.js.map +1 -1
  37. package/dist/modules/auth/account-hooks.d.ts +4 -6
  38. package/dist/modules/auth/account-hooks.d.ts.map +1 -1
  39. package/dist/modules/auth/account-hooks.js +7 -17
  40. package/dist/modules/auth/account-hooks.js.map +1 -1
  41. package/dist/modules/auth/auth.service.d.ts +9 -8
  42. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.service.js +19 -18
  44. package/dist/modules/auth/auth.service.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +43 -4
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +31 -7
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/kb-fs/index.d.ts +1 -0
  50. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  51. package/dist/modules/kb-fs/index.js +1 -0
  52. package/dist/modules/kb-fs/index.js.map +1 -1
  53. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  54. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  55. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  56. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  57. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/mutex.js +14 -0
  59. package/dist/modules/kb-fs/mutex.js.map +1 -1
  60. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  61. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  62. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  63. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  64. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  65. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  66. package/dist/modules/mcp/mcp.routes.js +67 -0
  67. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  68. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  69. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  70. package/dist/modules/mcp/mcp.service.js +257 -50
  71. package/dist/modules/mcp/mcp.service.js.map +1 -1
  72. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
  73. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  74. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
  75. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  76. package/dist/modules/mcp/request-context.d.ts +25 -0
  77. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  78. package/dist/modules/mcp/request-context.js +11 -0
  79. package/dist/modules/mcp/request-context.js.map +1 -0
  80. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  81. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  82. package/dist/modules/mcp/shared-definitions.js +117 -0
  83. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  84. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  85. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  86. package/dist/modules/mcp/surface-cache.js +104 -0
  87. package/dist/modules/mcp/surface-cache.js.map +1 -0
  88. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  89. package/dist/modules/tool-helpers/tool-context.js +33 -12
  90. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  91. package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
  92. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  93. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  94. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  95. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  96. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  97. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
  98. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  99. package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
  100. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  101. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  102. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  103. package/dist/modules/workflow/agent-tools/workflow.tools.js +458 -2
  104. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  105. package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
  106. package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
  107. package/dist/modules/workflow/change-request-hooks.js +15 -0
  108. package/dist/modules/workflow/change-request-hooks.js.map +1 -0
  109. package/dist/modules/workflow/git/git.service.d.ts +113 -4
  110. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  111. package/dist/modules/workflow/git/git.service.js +470 -106
  112. package/dist/modules/workflow/git/git.service.js.map +1 -1
  113. package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
  114. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/git/pull-request.service.js +9 -0
  116. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  117. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  118. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  119. package/dist/modules/workflow/read-on-branch.js +58 -0
  120. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  121. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  122. package/dist/modules/workflow/workflow.routes.js +22 -41
  123. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  124. package/dist/modules/workflow/workflow.service.d.ts +124 -24
  125. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  126. package/dist/modules/workflow/workflow.service.js +326 -48
  127. package/dist/modules/workflow/workflow.service.js.map +1 -1
  128. package/dist/modules/workspace/index.d.ts +2 -0
  129. package/dist/modules/workspace/index.d.ts.map +1 -1
  130. package/dist/modules/workspace/index.js +4 -0
  131. package/dist/modules/workspace/index.js.map +1 -1
  132. package/dist/shared/git.contract.d.ts +7 -0
  133. package/dist/shared/git.contract.d.ts.map +1 -1
  134. package/dist/shared/git.contract.js +9 -0
  135. package/dist/shared/git.contract.js.map +1 -1
  136. package/dist/shared/listener-seam.d.ts +34 -0
  137. package/dist/shared/listener-seam.d.ts.map +1 -0
  138. package/dist/shared/listener-seam.js +42 -0
  139. package/dist/shared/listener-seam.js.map +1 -0
  140. package/dist/shared/no-link-below.d.ts +10 -0
  141. package/dist/shared/no-link-below.d.ts.map +1 -0
  142. package/dist/shared/no-link-below.js +30 -0
  143. package/dist/shared/no-link-below.js.map +1 -0
  144. package/migrations/0018_agent_events_page.sql +11 -0
  145. package/migrations/0019_oauth_token_rotated_at.sql +7 -0
  146. package/migrations/meta/0018_snapshot.json +2429 -0
  147. package/migrations/meta/0019_snapshot.json +2435 -0
  148. package/migrations/meta/_journal.json +15 -1
  149. package/package.json +3 -3
  150. package/src/core/catalog-revision.ts +3 -3
  151. package/src/core/create-core-server.ts +2 -1
  152. package/src/core/create-core-services.ts +21 -2
  153. package/src/index.ts +7 -0
  154. package/src/modules/access/access-control.service.ts +1 -6
  155. package/src/modules/access-model/index.ts +13 -0
  156. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  157. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  158. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  159. package/src/modules/audit/agent-audit.service.ts +66 -6
  160. package/src/modules/audit/audit.contract.ts +62 -10
  161. package/src/modules/audit/audit.routes.ts +14 -12
  162. package/src/modules/audit/request-audit.ts +28 -13
  163. package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
  164. package/src/modules/auth/account-hooks.ts +13 -21
  165. package/src/modules/auth/auth.service.ts +19 -18
  166. package/src/modules/database/core-schema.ts +31 -7
  167. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  168. package/src/modules/kb-fs/index.ts +1 -0
  169. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  170. package/src/modules/kb-fs/mutex.ts +16 -0
  171. package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
  172. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
  173. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  174. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  175. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  176. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  177. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  178. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  179. package/src/modules/mcp/mcp.routes.ts +68 -0
  180. package/src/modules/mcp/mcp.service.ts +315 -51
  181. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
  182. package/src/modules/mcp/request-context.ts +34 -0
  183. package/src/modules/mcp/shared-definitions.ts +121 -0
  184. package/src/modules/mcp/surface-cache.ts +154 -0
  185. package/src/modules/tool-helpers/tool-context.ts +37 -18
  186. package/src/modules/tool-helpers/tool.contract.ts +8 -0
  187. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  188. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
  189. package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
  190. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  191. package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
  192. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
  193. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +508 -5
  194. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +474 -4
  195. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  196. package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
  197. package/src/modules/workflow/agent-tools/workflow.tools.ts +468 -1
  198. package/src/modules/workflow/change-request-hooks.ts +96 -0
  199. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  200. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  201. package/src/modules/workflow/git/git.service.ts +537 -106
  202. package/src/modules/workflow/git/pull-request.service.ts +10 -0
  203. package/src/modules/workflow/read-on-branch.ts +79 -0
  204. package/src/modules/workflow/workflow.routes.ts +21 -39
  205. package/src/modules/workflow/workflow.service.ts +390 -55
  206. package/src/modules/workspace/index.ts +11 -0
  207. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  208. package/src/shared/git.contract.ts +10 -0
  209. package/src/shared/listener-seam.ts +52 -0
  210. package/src/shared/no-link-below.ts +29 -0
@@ -0,0 +1,279 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { eq } from 'drizzle-orm';
4
+ import { migrate } from 'drizzle-orm/node-postgres/migrator';
5
+ import { InvalidGrantError, InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
6
+ import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js';
7
+ import { BevelOAuthProvider } from '../oauth/bevel-oauth-provider.js';
8
+ import { closeDb, createDb, type Database } from '../../database/connection.js';
9
+ import { agentConnections, oauthClients, oauthTokens, users } from '../../database/core-schema.js';
10
+ import { coreMigrationsDir } from '../../../assets.js';
11
+ import { PG_ADMIN_URL, PG_TEST_TIMEOUT, dropScratchDatabases, scratchDatabaseUrl } from '../../../__tests__/pg-scratch.js';
12
+
13
+ /**
14
+ * The provider mints its tokens from `randomBytes`. One case below needs a
15
+ * mint to FAIL after the consume, with the database as the one saying no:
16
+ * while `forced` is set, every random draw is this buffer, so the minted
17
+ * access token's hash is one a seeded row already holds and the insert hits
18
+ * the unique index. Null the rest of the time, when the real generator runs.
19
+ */
20
+ const { forcedRandom } = vi.hoisted(() => ({ forcedRandom: { bytes: null as Buffer | null } }));
21
+ vi.mock('node:crypto', async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import('node:crypto')>();
23
+ return { ...actual, randomBytes: (size: number) => forcedRandom.bytes ?? actual.randomBytes(size) };
24
+ });
25
+
26
+ /**
27
+ * Refresh-token rotation as the DATABASE ends up holding it — on a real
28
+ * Postgres, through the provider's public surface alone (a sign-in's pair
29
+ * seeded as the token endpoint leaves one, then `exchangeRefreshToken`,
30
+ * `verifyAccessToken` and `revokeToken`), with the clock moved by hand.
31
+ *
32
+ * The fake-database suite beside this pins which statements run and what
33
+ * their predicates say; this one pins what those statements DO to a row over
34
+ * time: a retired pair's access token verifying for the rest of its hour and
35
+ * not after, the one replay a retired refresh token gets, a revoke ending a
36
+ * retired pair at once, and the prune keeping a retired row for exactly as
37
+ * long as it can still authenticate. Skipped without `TEST_DATABASE_URL`, as
38
+ * every `*.pg.test.ts` is; CI provides one.
39
+ */
40
+
41
+ const PREFIX = 'bevel-mcp_';
42
+ const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
43
+ const T0 = new Date('2026-10-19T10:00:00Z');
44
+ const HOUR = 60 * 60_000;
45
+
46
+ describe.skipIf(!PG_ADMIN_URL)('refresh-token rotation, on a real Postgres', () => {
47
+ let db: Database;
48
+ let provider: BevelOAuthProvider;
49
+
50
+ beforeAll(async () => {
51
+ // A handle that holds a personal-data key, as the application's does:
52
+ // the seeded person's email and name are encrypted columns.
53
+ db = createDb(await scratchDatabaseUrl('hexis_oauth'), { piiKey: randomBytes(32).toString('base64') });
54
+ await migrate(db, { migrationsFolder: coreMigrationsDir() });
55
+ provider = new BevelOAuthProvider({
56
+ db,
57
+ crypto: null,
58
+ stateSecret: 'test-state-secret',
59
+ publicFrontendUrl: 'https://app.example.com',
60
+ tokenPrefix: PREFIX,
61
+ });
62
+ }, PG_TEST_TIMEOUT);
63
+
64
+ afterAll(async () => {
65
+ await closeDb(db);
66
+ await dropScratchDatabases();
67
+ }, PG_TEST_TIMEOUT);
68
+
69
+ beforeEach(() => {
70
+ // Only the clock is faked: the driver's own timers keep running.
71
+ vi.useFakeTimers({ toFake: ['Date'] });
72
+ vi.setSystemTime(T0);
73
+ vi.spyOn(console, 'log').mockImplementation(() => undefined);
74
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined);
75
+ });
76
+ afterEach(() => {
77
+ vi.useRealTimers();
78
+ vi.restoreAllMocks();
79
+ });
80
+
81
+ /** Move the clock `n` minutes on. */
82
+ const minutes = (n: number) => vi.setSystemTime(new Date(Date.now() + n * 60_000));
83
+
84
+ /**
85
+ * The mint refused by the unique index on `access_token_hash`, and by
86
+ * nothing else: Drizzle's wrapper around the insert, with Postgres's
87
+ * unique-violation code (23505) as the cause.
88
+ */
89
+ async function expectRefusedByUniqueIndex(call: Promise<unknown>): Promise<void> {
90
+ const err = await call.then(
91
+ () => {
92
+ throw new Error('the mint was not refused');
93
+ },
94
+ (e: unknown) => e as Error & { cause?: { code?: string } },
95
+ );
96
+ expect(err.message).toMatch(/Failed query: insert into "oauth_tokens"/);
97
+ expect(err.cause?.code).toBe('23505');
98
+ }
99
+
100
+ /** The row a refresh token's hash resolves to, or undefined once pruned. */
101
+ const rowOf = async (refreshToken: string) =>
102
+ (await db.select().from(oauthTokens).where(eq(oauthTokens.refreshTokenHash, sha256(refreshToken))))[0];
103
+
104
+ /**
105
+ * A person, a registered client, the agent connection their sign-in made,
106
+ * and the pair that sign-in minted — one of each per case, named by `tag`.
107
+ */
108
+ async function seed(tag: string) {
109
+ const [user] = await db
110
+ .insert(users)
111
+ .values({ email: `${tag}@example.com`, emailBidx: `${tag}@example.com`, name: tag })
112
+ .returning({ id: users.id });
113
+ const clientId = `client-${tag}`;
114
+ await db.insert(oauthClients).values({ clientId, redirectUris: ['https://agent.example.com/callback'] });
115
+ const [connection] = await db
116
+ .insert(agentConnections)
117
+ .values({ userId: user!.id, clientId, clientName: 'Claude', agentKey: 'claude' })
118
+ .returning({ id: agentConnections.id });
119
+ const access = `${PREFIX}a-${tag}`;
120
+ const refresh = `${PREFIX}r_${tag}`;
121
+ await db.insert(oauthTokens).values({
122
+ accessTokenHash: sha256(access),
123
+ refreshTokenHash: sha256(refresh),
124
+ clientId,
125
+ userId: user!.id,
126
+ connectionId: connection!.id,
127
+ scope: 'mcp',
128
+ expiresAt: new Date(Date.now() + HOUR),
129
+ refreshExpiresAt: new Date(Date.now() + 30 * 24 * HOUR),
130
+ });
131
+ const client: OAuthClientInformationFull = {
132
+ client_id: clientId,
133
+ redirect_uris: ['https://agent.example.com/callback'],
134
+ token_endpoint_auth_method: 'none',
135
+ };
136
+ return { client, access, refresh };
137
+ }
138
+
139
+ it("a retired pair's access token verifies for the rest of its hour, and not after", async () => {
140
+ const { client, access, refresh } = await seed('hour');
141
+ await expect(provider.verifyAccessToken(access)).resolves.toMatchObject({ clientId: client.client_id });
142
+
143
+ const next = await provider.exchangeRefreshToken(client, refresh);
144
+ const retired = await rowOf(refresh);
145
+ expect(retired?.rotatedAt).toBeInstanceOf(Date);
146
+ expect(retired?.revokedAt).toBeNull();
147
+ // Retired, not revoked: the old access token is still served…
148
+ await expect(provider.verifyAccessToken(access)).resolves.toMatchObject({ clientId: client.client_id });
149
+ minutes(59);
150
+ await expect(provider.verifyAccessToken(access)).resolves.toMatchObject({ clientId: client.client_id });
151
+ // …until its own expiry, like any access token.
152
+ minutes(2);
153
+ await expect(provider.verifyAccessToken(access)).rejects.toBeInstanceOf(InvalidTokenError);
154
+ // The pair the rotation minted carries on: its refresh token is good for 30 days.
155
+ await expect(provider.exchangeRefreshToken(client, next.refresh_token!)).resolves.toMatchObject({ token_type: 'Bearer' });
156
+ }, PG_TEST_TIMEOUT);
157
+
158
+ it('a retired refresh token is honoured once more within the grace — which ends the retired pair — and refused after that, or later', async () => {
159
+ const { client, access, refresh } = await seed('replay');
160
+ const winner = await provider.exchangeRefreshToken(client, refresh);
161
+ // The loser of a double refresh, a moment later: a fresh pair of its own.
162
+ const loser = await provider.exchangeRefreshToken(client, refresh);
163
+ expect(loser.refresh_token).not.toBe(winner.refresh_token);
164
+ // The replay revoked the retired row, access token included.
165
+ expect((await rowOf(refresh))?.revokedAt).toBeInstanceOf(Date);
166
+ await expect(provider.verifyAccessToken(access)).rejects.toBeInstanceOf(InvalidTokenError);
167
+ // Once: a third presentation is refused.
168
+ await expect(provider.exchangeRefreshToken(client, refresh)).rejects.toBeInstanceOf(InvalidGrantError);
169
+ // Both pairs are live and refresh on their own.
170
+ const winnerNext = await provider.exchangeRefreshToken(client, winner.refresh_token!);
171
+ await expect(provider.exchangeRefreshToken(client, loser.refresh_token!)).resolves.toMatchObject({ token_type: 'Bearer' });
172
+ // Past the grace, a retired token is not honoured again at all.
173
+ minutes(2);
174
+ await expect(provider.exchangeRefreshToken(client, winner.refresh_token!)).rejects.toBeInstanceOf(InvalidGrantError);
175
+ await expect(provider.exchangeRefreshToken(client, winnerNext.refresh_token!)).resolves.toMatchObject({ token_type: 'Bearer' });
176
+ }, PG_TEST_TIMEOUT);
177
+
178
+ it('three refreshes of one token at the same instant: exactly two pairs, the third refused — the row lock decides', async () => {
179
+ // What a client refreshing from several places at once actually sends.
180
+ // All three read the token fresh; the row lock serialises their retires,
181
+ // so one wins and the other two find it rotated under them; of those,
182
+ // the replay statement admits exactly one, and the last finds the row
183
+ // revoked. Nothing here is mocked: the database's own locking is the
184
+ // mechanism, and this is the one place it is exercised.
185
+ const { client, refresh } = await seed('race');
186
+ const outcomes = await Promise.allSettled([
187
+ provider.exchangeRefreshToken(client, refresh),
188
+ provider.exchangeRefreshToken(client, refresh),
189
+ provider.exchangeRefreshToken(client, refresh),
190
+ ]);
191
+ const fulfilled = outcomes.filter((o): o is PromiseFulfilledResult<Awaited<ReturnType<typeof provider.exchangeRefreshToken>>> => o.status === 'fulfilled');
192
+ const rejected = outcomes.filter((o): o is PromiseRejectedResult => o.status === 'rejected');
193
+ expect(fulfilled).toHaveLength(2);
194
+ expect(rejected).toHaveLength(1);
195
+ expect(rejected[0]!.reason).toBeInstanceOf(InvalidGrantError);
196
+ // Two distinct pairs, both live.
197
+ const [a, b] = fulfilled.map((o) => o.value.refresh_token!);
198
+ expect(a).not.toBe(b);
199
+ await expect(provider.exchangeRefreshToken(client, a)).resolves.toMatchObject({ token_type: 'Bearer' });
200
+ await expect(provider.exchangeRefreshToken(client, b)).resolves.toMatchObject({ token_type: 'Bearer' });
201
+ // The original is spent for good: retired by the winner, revoked by the replay.
202
+ const row = await rowOf(refresh);
203
+ expect(row?.rotatedAt).toBeInstanceOf(Date);
204
+ expect(row?.revokedAt).toBeInstanceOf(Date);
205
+ await expect(provider.exchangeRefreshToken(client, refresh)).rejects.toBeInstanceOf(InvalidGrantError);
206
+ }, PG_TEST_TIMEOUT);
207
+
208
+ it('a mint that fails after the consume rolls the consume back: the token is exactly as it was, and still refreshes', async () => {
209
+ // The consume and the mint are one transaction, and this is what that
210
+ // buys: the database refuses the insert (the minted access token's hash
211
+ // is one a seeded row already holds), the retire — then, on a second
212
+ // try, the replay — is rolled back with it, and the token that was
213
+ // presented is untouched rather than spent for nothing.
214
+ const { client, refresh } = await seed('rollback');
215
+ const collider = Buffer.alloc(32, 7);
216
+ const { client: other } = await seed('collider');
217
+ await db
218
+ .insert(oauthTokens)
219
+ .values({
220
+ accessTokenHash: sha256(PREFIX + collider.toString('base64url')),
221
+ refreshTokenHash: sha256(`${PREFIX}r_collider-pair`),
222
+ clientId: other.client_id,
223
+ userId: (await db.select({ id: users.id }).from(users).limit(1))[0]!.id,
224
+ scope: 'mcp',
225
+ expiresAt: new Date(Date.now() + HOUR),
226
+ });
227
+
228
+ // A fresh token: the retire is rolled back with the failed mint.
229
+ forcedRandom.bytes = collider;
230
+ try {
231
+ await expectRefusedByUniqueIndex(provider.exchangeRefreshToken(client, refresh));
232
+ } finally {
233
+ forcedRandom.bytes = null;
234
+ }
235
+ const untouched = await rowOf(refresh);
236
+ expect(untouched?.rotatedAt).toBeNull();
237
+ expect(untouched?.revokedAt).toBeNull();
238
+ const winner = await provider.exchangeRefreshToken(client, refresh);
239
+
240
+ // A retired token within the grace: the replay is rolled back with the failed mint too.
241
+ forcedRandom.bytes = collider;
242
+ try {
243
+ await expectRefusedByUniqueIndex(provider.exchangeRefreshToken(client, refresh));
244
+ } finally {
245
+ forcedRandom.bytes = null;
246
+ }
247
+ const stillRetired = await rowOf(refresh);
248
+ expect(stillRetired?.rotatedAt).toBeInstanceOf(Date);
249
+ expect(stillRetired?.revokedAt).toBeNull();
250
+ // Its one replay is still owed.
251
+ const loser = await provider.exchangeRefreshToken(client, refresh);
252
+ expect(loser.refresh_token).not.toBe(winner.refresh_token);
253
+ }, PG_TEST_TIMEOUT);
254
+
255
+ it("a revoke ends a retired pair's access token at once, hour or no hour", async () => {
256
+ const { client, access, refresh } = await seed('revoke');
257
+ await provider.exchangeRefreshToken(client, refresh);
258
+ await expect(provider.verifyAccessToken(access)).resolves.toMatchObject({ clientId: client.client_id });
259
+ await provider.revokeToken(client, { token: access, token_type_hint: 'access_token' });
260
+ await expect(provider.verifyAccessToken(access)).rejects.toBeInstanceOf(InvalidTokenError);
261
+ await expect(provider.exchangeRefreshToken(client, refresh)).rejects.toBeInstanceOf(InvalidGrantError);
262
+ }, PG_TEST_TIMEOUT);
263
+
264
+ it('the prune keeps a retired row for its hour and the grace, then removes it', async () => {
265
+ const { client, refresh } = await seed('prune');
266
+ const next = await provider.exchangeRefreshToken(client, refresh);
267
+ // The mint prunes, and the row it just retired is kept: it can still verify and be replayed.
268
+ expect(await rowOf(refresh)).toBeDefined();
269
+ minutes(30);
270
+ const later = await provider.exchangeRefreshToken(client, next.refresh_token!);
271
+ expect(await rowOf(refresh)).toBeDefined();
272
+ // 62 minutes after its rotation it can do neither, and the next mint's prune drops it.
273
+ minutes(32);
274
+ await provider.exchangeRefreshToken(client, later.refresh_token!);
275
+ expect(await rowOf(refresh)).toBeUndefined();
276
+ // The row retired 32 minutes ago is still there.
277
+ expect(await rowOf(next.refresh_token!)).toBeDefined();
278
+ }, PG_TEST_TIMEOUT);
279
+ });
@@ -60,6 +60,10 @@ function makeFakeDb(queue: any[]) {
60
60
  // The mint-time prune of expired token rows; nothing here queues for it.
61
61
  delete: vi.fn(() => nextChain()),
62
62
  } as unknown as Database;
63
+ // A transaction is the same fake, so the queue is consumed in statement
64
+ // order whether or not a statement ran inside one; what ran inside one is
65
+ // pinned by the call count.
66
+ (db as unknown as { transaction: unknown }).transaction = vi.fn(async (fn: (tx: Database) => Promise<unknown>) => fn(db));
63
67
 
64
68
  return { db, captured };
65
69
  }
@@ -288,28 +292,39 @@ describe('BevelOAuthProvider', () => {
288
292
  ).rejects.toBeInstanceOf(InvalidGrantError);
289
293
  });
290
294
 
291
- it('exchangeRefreshToken rotates (revokes old row, mints new pair) and only narrows scope', async () => {
295
+ it('exchangeRefreshToken rotates (retires the old row, mints a new pair) and only narrows scope', async () => {
292
296
  const oldRow = {
293
297
  clientId: 'client-1', userId: 'user-1', connectionId: 'conn-1', scope: 'mcp extra', resource: null,
294
298
  refreshExpiresAt: new Date(Date.now() + 60_000),
295
299
  };
296
300
  const { provider, captured, db } = makeProvider([
297
- [oldRow] /* revoke.returning */,
301
+ [oldRow] /* the row behind the hash, for this client */,
298
302
  [{ id: 'conn-1' }] /* the token's connection, still live */,
299
- [] /* the (throttled) use stamp's first-use claim: stamped before */,
300
- undefined, undefined, /* prunes */
303
+ undefined, undefined, /* prunes — on the pool, before the transaction */
304
+ [oldRow] /* retire.returning — inside the transaction with the mint */,
301
305
  undefined /* token insert */,
306
+ [] /* the (throttled) use stamp's first-use claim, after the commit: stamped before */,
302
307
  undefined /* the ordinary use stamp */,
303
308
  ]);
304
309
  const tokens = await provider.exchangeRefreshToken(CLIENT, 'bevel-mcp_r_old', ['mcp']);
310
+ // The consume and the mint landed in one transaction.
311
+ expect((db as unknown as { transaction: ReturnType<typeof vi.fn> }).transaction).toHaveBeenCalledTimes(1);
305
312
  expect(tokens.scope).toBe('mcp');
313
+ // Read by hash AND client first, judged, and only then consumed.
314
+ expect(render(captured.where[0]).sql).toMatch(/"oauth_tokens"."refresh_token_hash" = .* and "oauth_tokens"."client_id" = /);
315
+ // The old row is RETIRED, not revoked: its access token keeps working for
316
+ // its own hour, so a runner still holding it is not cut off by a refresh
317
+ // another runner made. Only a fresh row is retired — one already rotated
318
+ // or revoked is the replay path's business.
319
+ expect(Object.keys(captured.set[0])).toEqual(['rotatedAt']);
320
+ expect(render(captured.where[4]).sql).toMatch(/"oauth_tokens"."revoked_at" is null and "oauth_tokens"."rotated_at" is null/);
306
321
  // A refresh STAYS on its token's connection: one connection spans every
307
322
  // token the agent holds, nothing is looked up by (user, agent) and no
308
323
  // second row is inserted for it.
309
324
  expect(captured.values).toHaveLength(1);
310
325
  expect(captured.values[0]).toMatchObject({ connectionId: 'conn-1' });
311
326
  // The liveness check is a read of THAT connection, live rows only…
312
- expect((db as any).select).toHaveBeenCalledTimes(1);
327
+ expect((db as any).select).toHaveBeenCalledTimes(2);
313
328
  expect(render(captured.where[1])).toMatchObject({ params: ['conn-1'] });
314
329
  expect(render(captured.where[1]).sql).toMatch(/"agent_connections"."revoked_at" is null/);
315
330
  // …and the use is stamped separately, off the refresh's own path: the
@@ -317,16 +332,170 @@ describe('BevelOAuthProvider', () => {
317
332
  await new Promise((r) => setTimeout(r, 0));
318
333
  expect(captured.updateTargets).toEqual([oauthTokens, agentConnections, agentConnections]);
319
334
  expect(Object.keys(captured.set[1])).toEqual(['lastUsedAt']);
335
+ // The refresh's prune — on the pool, before the transaction opened —
336
+ // keeps a retired row for its access hour plus the reuse grace (it can
337
+ // still verify and be replayed until then) and removes it after: the
338
+ // third term of the delete, cut off 61 minutes ago.
339
+ const prune = captured.where.map(render).find((w) => /"oauth_tokens"."rotated_at" < /.test(w.sql));
340
+ expect(prune, 'the refresh prunes retired rows').toBeDefined();
341
+ // Third statement: after the read and the liveness check, before the retire.
342
+ expect(render(captured.where[2]).sql).toMatch(/"oauth_tokens"."rotated_at" < /);
343
+ // The dialect renders a Date parameter as its ISO string.
344
+ const rotatedCutoff = new Date(prune!.params.at(-1) as string | Date);
345
+ expect(Date.now() - rotatedCutoff.getTime()).toBeGreaterThanOrEqual(61 * 60_000);
346
+ expect(Date.now() - rotatedCutoff.getTime()).toBeLessThan(61 * 60_000 + 5_000);
347
+
348
+ // Widening is refused — invalid_scope to the client, in the log like
349
+ // every other refusal, and BEFORE anything is consumed: the same token
350
+ // still refreshes with the scope it was granted.
351
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
352
+ try {
353
+ const widening = makeProvider([[oldRow]]);
354
+ await expect(
355
+ widening.provider.exchangeRefreshToken(CLIENT, 'bevel-mcp_r_old', ['mcp', 'admin']),
356
+ ).rejects.toBeInstanceOf(InvalidScopeError);
357
+ expect(warn).toHaveBeenCalledWith(expect.stringMatching(/refresh refused: requested scope exceeds the originally granted scope/), expect.anything());
358
+ expect(widening.captured.updateTargets).toEqual([]);
359
+ } finally {
360
+ warn.mockRestore();
361
+ }
362
+ });
320
363
 
321
- // Widening is refused.
322
- await expect(
323
- makeProvider([[oldRow]]).provider.exchangeRefreshToken(CLIENT, 'bevel-mcp_r_old', ['mcp', 'admin']),
324
- ).rejects.toBeInstanceOf(InvalidScopeError);
364
+ it('exchangeRefreshToken honours the loser of a rotation race once: read fresh, retired under it, replayed', async () => {
365
+ // Two refreshes of one fresh token at the same instant. Both read it
366
+ // fresh; one retire lands, the other matches nothing — and is then the
367
+ // loser of a double refresh, replayable within the grace like any other.
368
+ const oldRow = {
369
+ clientId: 'client-1', userId: 'user-1', connectionId: 'conn-1', scope: 'mcp', resource: null,
370
+ refreshExpiresAt: new Date(Date.now() + 60_000), revokedAt: null, rotatedAt: null,
371
+ };
372
+ const info = vi.spyOn(console, 'log').mockImplementation(() => undefined);
373
+ const { provider, captured } = makeProvider([
374
+ [oldRow] /* read fresh */,
375
+ [{ id: 'conn-1' }] /* its connection, live */,
376
+ undefined, undefined, /* prunes — before the transaction */
377
+ [] /* the retire matches nothing: rotated under this request */,
378
+ [{ ...oldRow, rotatedAt: new Date() }] /* the replay consumes it */,
379
+ undefined /* token insert */,
380
+ [] /* first-use claim, after the commit */,
381
+ undefined /* the ordinary use stamp */,
382
+ ]);
383
+ try {
384
+ const tokens = await provider.exchangeRefreshToken(CLIENT, 'bevel-mcp_r_old');
385
+ expect(tokens.refresh_token).toMatch(/^bevel-mcp_r_/);
386
+ expect(captured.values).toHaveLength(1);
387
+ expect(Object.keys(captured.set[0])).toEqual(['rotatedAt']);
388
+ expect(Object.keys(captured.set[1])).toEqual(['revokedAt']);
389
+ expect(info).toHaveBeenCalledWith(expect.stringMatching(/refresh replayed 0 s after its rotation/), expect.anything());
390
+ } finally {
391
+ info.mockRestore();
392
+ }
393
+ });
325
394
 
326
- // Unknown/already-rotated refresh token.
327
- await expect(
328
- makeProvider([[]]).provider.exchangeRefreshToken(CLIENT, 'bevel-mcp_r_gone'),
329
- ).rejects.toBeInstanceOf(InvalidGrantError);
395
+ it('a retired pair\'s access token verifies until its own expiry, and dies with a revoke like any other', async () => {
396
+ // Rotation sets `rotated_at` and leaves `revoked_at` null, and the
397
+ // verification predicate asks about `revoked_at` and `expires_at` only:
398
+ // so a runner still holding the old pair is served for the rest of its
399
+ // hour. The same predicate is what a revoke, a sign-out or a connection
400
+ // revoke then falsify, since all of those set `revoked_at` (or the
401
+ // connection's), none of which this change touched.
402
+ const row = {
403
+ id: 't-1', clientId: 'client-1', connectionId: 'conn-1', scope: 'mcp', resource: null,
404
+ expiresAt: new Date(Date.now() + 30 * 60_000), userId: 'user-1', userEmail: 'u@x.io',
405
+ };
406
+ const { provider, captured } = makeProvider([[row], undefined /* touch */, [] /* claim lost */, undefined /* stamp */]);
407
+ const info = await provider.verifyAccessToken('bevel-mcp_live');
408
+ expect(info.clientId).toBe('client-1');
409
+ const verify = render(captured.where[0]).sql;
410
+ expect(verify).toMatch(/"oauth_tokens"."revoked_at" is null/);
411
+ expect(verify).toMatch(/"oauth_tokens"."expires_at" > /);
412
+ expect(verify).toMatch(/"agent_connections"."revoked_at" is null/);
413
+ expect(verify).not.toMatch(/rotated_at/);
414
+ });
415
+
416
+ /** A row the rotation did not match, as the replay path then reads it. */
417
+ const rotatedRow = (ago: number) => ({
418
+ clientId: 'client-1', userId: 'user-1', connectionId: 'conn-1', scope: 'mcp', resource: null,
419
+ refreshExpiresAt: new Date(Date.now() + 60_000),
420
+ revokedAt: null,
421
+ rotatedAt: new Date(Date.now() - ago),
422
+ });
423
+
424
+ it('exchangeRefreshToken honours a refresh token rotated within the reuse grace once more — the loser of a double refresh gets a pair too', async () => {
425
+ // Claude's hosted apps refresh proactively and reactively, and a
426
+ // scheduled task refreshes beside the chat that shares its connector:
427
+ // the same token arrives twice. The second arrival, 10 s after the first
428
+ // rotated it, is answered with a fresh pair on the same connection rather
429
+ // than the invalid_grant a client reads as "re-authorize".
430
+ const info = vi.spyOn(console, 'log').mockImplementation(() => undefined);
431
+ const { provider, captured } = makeProvider([
432
+ [rotatedRow(10_000)] /* the row behind the hash: retired 10 s ago */,
433
+ [{ id: 'conn-1' }] /* its connection, live */,
434
+ undefined, undefined, /* prunes — before the transaction */
435
+ [rotatedRow(10_000)] /* the replay consumes it — inside the transaction with the mint */,
436
+ undefined /* token insert */,
437
+ [] /* first-use claim, after the commit */,
438
+ undefined /* the ordinary use stamp */,
439
+ ]);
440
+ try {
441
+ const tokens = await provider.exchangeRefreshToken(CLIENT, 'bevel-mcp_r_old');
442
+ expect(tokens.refresh_token).toMatch(/^bevel-mcp_r_/);
443
+ expect(captured.values).toHaveLength(1);
444
+ expect(captured.values[0]).toMatchObject({ connectionId: 'conn-1', userId: 'user-1' });
445
+ // Honoured ONCE: the replay revokes the retired row in the statement
446
+ // that admits it — by hash AND client, unrevoked, rotated within the
447
+ // grace — so a third presentation finds nothing, and another client's
448
+ // token is never honoured. Never retired again: no `rotatedAt` write.
449
+ expect(Object.keys(captured.set[0])).toEqual(['revokedAt']);
450
+ expect(captured.set.flatMap((s) => Object.keys(s))).not.toContain('rotatedAt');
451
+ expect(render(captured.where[4]).sql).toMatch(
452
+ /"oauth_tokens"."refresh_token_hash" = .* and "oauth_tokens"."client_id" = .* and "oauth_tokens"."revoked_at" is null and "oauth_tokens"."rotated_at" > /,
453
+ );
454
+ // Said once the pair is minted, not before the checks that could still refuse.
455
+ expect(info).toHaveBeenCalledWith(expect.stringMatching(/refresh replayed 10 s after its rotation, within the reuse grace; a fresh pair is minted/), expect.anything());
456
+ } finally {
457
+ info.mockRestore();
458
+ }
459
+ });
460
+
461
+ it.each([
462
+ ['unknown to this client', [[]], /refresh refused: unknown refresh token for this client/],
463
+ ['revoked', [[{ ...rotatedRow(10_000), rotatedAt: null, revokedAt: new Date() }]], /refresh refused: refresh token revoked/],
464
+ ['rotated past the grace', [[rotatedRow(120_000)]], /refresh refused: refresh token rotated 120 s ago, past the 60 s reuse grace/],
465
+ ['already replayed once', [[{ ...rotatedRow(10_000), revokedAt: new Date() }]], /refresh refused: refresh token rotated 10 s ago and already replayed once, or revoked since/],
466
+ [
467
+ 'rotated and replayed by concurrent requests between the read and the consume',
468
+ [[{ ...rotatedRow(0), rotatedAt: null }], [{ id: 'conn-1' }], undefined, undefined, /* prunes */ [] /* retire misses */, [] /* replay misses too */],
469
+ /refresh refused: refresh token rotated or revoked by a concurrent request/,
470
+ ],
471
+ ])('exchangeRefreshToken refuses a refresh token that is %s, mints nothing, and says why in the log', async (_what, queue, said) => {
472
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
473
+ try {
474
+ const { provider, captured } = makeProvider([...queue]);
475
+ await expect(provider.exchangeRefreshToken(CLIENT, 'bevel-mcp_r_old')).rejects.toBeInstanceOf(InvalidGrantError);
476
+ expect(captured.values).toHaveLength(0);
477
+ // The reason is in the log, never the token.
478
+ expect(warn).toHaveBeenCalledWith(expect.stringMatching(said), expect.anything());
479
+ expect(JSON.stringify(warn.mock.calls)).not.toContain('bevel-mcp_r_old');
480
+ } finally {
481
+ warn.mockRestore();
482
+ }
483
+ });
484
+
485
+ it('exchangeRefreshToken logs the two refusals that come after a token was accepted: expired, and its agent revoked', async () => {
486
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
487
+ try {
488
+ const expired = { ...rotatedRow(0), rotatedAt: null, refreshExpiresAt: new Date(Date.now() - 1_000) };
489
+ await expect(makeProvider([[expired]]).provider.exchangeRefreshToken(CLIENT, 'bevel-mcp_r_old')).rejects.toBeInstanceOf(InvalidGrantError);
490
+ expect(warn).toHaveBeenLastCalledWith(expect.stringMatching(/refresh refused: refresh token expired/), expect.anything());
491
+
492
+ const live = { ...rotatedRow(0), rotatedAt: null };
493
+ await expect(makeProvider([[live], [] /* its connection: revoked or gone */]).provider.exchangeRefreshToken(CLIENT, 'bevel-mcp_r_old')).rejects.toBeInstanceOf(InvalidGrantError);
494
+ expect(warn).toHaveBeenLastCalledWith(expect.stringMatching(/refresh refused: access for this agent was revoked/), expect.anything());
495
+ expect(JSON.stringify(warn.mock.calls)).not.toContain('bevel-mcp_r_old');
496
+ } finally {
497
+ warn.mockRestore();
498
+ }
330
499
  });
331
500
 
332
501
  it('exchangeRefreshToken refuses a token whose agent connection was revoked, minting nothing', async () => {
@@ -350,11 +519,13 @@ describe('BevelOAuthProvider', () => {
350
519
  refreshExpiresAt: new Date(Date.now() + 60_000),
351
520
  };
352
521
  const { provider, captured } = makeProvider([
353
- [oldRow],
354
- undefined, undefined, /* prunes */
522
+ [oldRow] /* the row behind the hash: no connection to check */,
523
+ // Placed under a connection BEFORE the transaction, as a first mint would place it.
355
524
  [{ clientName: 'Claude' }],
356
525
  [] /* no live connection */,
357
526
  [{ id: 'conn-new' }] /* connection insert.returning */,
527
+ undefined, undefined, /* prunes — before the transaction */
528
+ [oldRow] /* retire.returning */,
358
529
  undefined /* token insert */,
359
530
  ]);
360
531
  await provider.exchangeRefreshToken(CLIENT, 'bevel-mcp_r_old');
@@ -458,6 +629,10 @@ describe('BevelOAuthProvider', () => {
458
629
  ]);
459
630
  await provider.revokeToken(CLIENT, { token: 'bevel-mcp_r_old', token_type_hint: 'refresh_token' });
460
631
  expect(captured.updateTargets).toEqual([oauthTokens, agentConnections]);
632
+ // "Live" excludes a retired row: its successor is the live token, and a
633
+ // sign-out must not keep the connection open on a refresh token nobody
634
+ // can use any more.
635
+ expect(render(captured.where[1]).sql).toMatch(/"oauth_tokens"."revoked_at" is null and "oauth_tokens"."rotated_at" is null/);
461
636
  expect(captured.set[1]).toMatchObject({ revokedBy: 'owner' });
462
637
  expect(captured.set[1].revokedAt).toBeInstanceOf(Date);
463
638
  expect(render(captured.where[2])).toMatchObject({ params: ['conn-1'] });
@@ -2,6 +2,7 @@ import type { Server as HttpServer } from 'node:http';
2
2
  import type { RequestHandler } from 'express';
3
3
  import express from 'express';
4
4
  import { createMcpRoutes } from '../mcp.routes.js';
5
+ import type { McpCallerKind } from '../mcp-auth.middleware.js';
5
6
 
6
7
  /**
7
8
  * The shared mount for every `createMcpRoutes` suite.
@@ -19,7 +20,8 @@ import { createMcpRoutes } from '../mcp.routes.js';
19
20
  */
20
21
 
21
22
  /**
22
- * Auth stand-in binding the user named by `x-test-user`, defaulting to user-A.
23
+ * Auth stand-in binding the user named by `x-test-user`, defaulting to user-A,
24
+ * and the credential kind named by `x-test-kind`, when one is.
23
25
  *
24
26
  * The header lets a suite act as a second user (the cross-user checks need
25
27
  * one); suites that never send it see the single fixed user their assertions
@@ -27,6 +29,10 @@ import { createMcpRoutes } from '../mcp.routes.js';
27
29
  */
28
30
  export const fakeAuth: RequestHandler = (req, _res, next) => {
29
31
  req.userId = (req.headers['x-test-user'] as string | undefined) ?? 'user-A';
32
+ // The credential kind the real middleware would have recognised, when a
33
+ // suite needs one (the page header is a session's alone).
34
+ const kind = req.headers['x-test-kind'] as McpCallerKind | undefined;
35
+ if (kind) req.mcpCallerKind = kind;
30
36
  next();
31
37
  };
32
38