@shipfox/api-auth 18.0.0 → 20.0.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 (151) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +24 -9
  4. package/dist/core/administration.d.ts +15 -0
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +44 -2
  7. package/dist/core/administration.js.map +1 -1
  8. package/dist/core/auth.d.ts.map +1 -1
  9. package/dist/core/auth.js +5 -3
  10. package/dist/core/auth.js.map +1 -1
  11. package/dist/core/cimd.d.ts +39 -0
  12. package/dist/core/cimd.d.ts.map +1 -0
  13. package/dist/core/cimd.js +392 -0
  14. package/dist/core/cimd.js.map +1 -0
  15. package/dist/core/entities/agent-access.d.ts +75 -0
  16. package/dist/core/entities/agent-access.d.ts.map +1 -0
  17. package/dist/core/entities/agent-access.js +3 -0
  18. package/dist/core/entities/agent-access.js.map +1 -0
  19. package/dist/core/errors.d.ts +17 -0
  20. package/dist/core/errors.d.ts.map +1 -1
  21. package/dist/core/errors.js +32 -0
  22. package/dist/core/errors.js.map +1 -1
  23. package/dist/core/oauth-client-resolver.d.ts +54 -0
  24. package/dist/core/oauth-client-resolver.d.ts.map +1 -0
  25. package/dist/core/oauth-client-resolver.js +154 -0
  26. package/dist/core/oauth-client-resolver.js.map +1 -0
  27. package/dist/core/oauth-client.d.ts +39 -0
  28. package/dist/core/oauth-client.d.ts.map +1 -0
  29. package/dist/core/oauth-client.js +236 -0
  30. package/dist/core/oauth-client.js.map +1 -0
  31. package/dist/db/admin-user-moderation.d.ts.map +1 -1
  32. package/dist/db/admin-user-moderation.js +30 -26
  33. package/dist/db/admin-user-moderation.js.map +1 -1
  34. package/dist/db/admin-user-summary.d.ts +22 -0
  35. package/dist/db/admin-user-summary.d.ts.map +1 -1
  36. package/dist/db/admin-user-summary.js +75 -1
  37. package/dist/db/admin-user-summary.js.map +1 -1
  38. package/dist/db/admin-users.d.ts +11 -0
  39. package/dist/db/admin-users.d.ts.map +1 -1
  40. package/dist/db/admin-users.js +4 -1
  41. package/dist/db/admin-users.js.map +1 -1
  42. package/dist/db/agent-access-retention.d.ts +10 -0
  43. package/dist/db/agent-access-retention.d.ts.map +1 -0
  44. package/dist/db/agent-access-retention.js +11 -0
  45. package/dist/db/agent-access-retention.js.map +1 -0
  46. package/dist/db/agent-access.d.ts +255 -0
  47. package/dist/db/agent-access.d.ts.map +1 -0
  48. package/dist/db/agent-access.js +705 -0
  49. package/dist/db/agent-access.js.map +1 -0
  50. package/dist/db/db.d.ts +2604 -238
  51. package/dist/db/db.d.ts.map +1 -1
  52. package/dist/db/db.js +7 -0
  53. package/dist/db/db.js.map +1 -1
  54. package/dist/db/schema/agent-access.d.ts +1204 -0
  55. package/dist/db/schema/agent-access.d.ts.map +1 -0
  56. package/dist/db/schema/agent-access.js +267 -0
  57. package/dist/db/schema/agent-access.js.map +1 -0
  58. package/dist/db/schema/users.d.ts.map +1 -1
  59. package/dist/db/schema/users.js +8 -3
  60. package/dist/db/schema/users.js.map +1 -1
  61. package/dist/index.d.ts +11 -2
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +26 -2
  64. package/dist/index.js.map +1 -1
  65. package/dist/metrics/instance.d.ts +1 -1
  66. package/dist/metrics/instance.d.ts.map +1 -1
  67. package/dist/metrics/instance.js.map +1 -1
  68. package/dist/presentation/routes/administration.d.ts.map +1 -1
  69. package/dist/presentation/routes/administration.js +123 -20
  70. package/dist/presentation/routes/administration.js.map +1 -1
  71. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
  72. package/dist/presentation/routes/email-verification/verify-email-resend.js +6 -1
  73. package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
  74. package/dist/presentation/routes/oauth.d.ts +9 -0
  75. package/dist/presentation/routes/oauth.d.ts.map +1 -0
  76. package/dist/presentation/routes/oauth.js +150 -0
  77. package/dist/presentation/routes/oauth.js.map +1 -0
  78. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  79. package/dist/presentation/routes/rate-limit.js +34 -0
  80. package/dist/presentation/routes/rate-limit.js.map +1 -1
  81. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  82. package/dist/presentation/routes/registration/signup.js +59 -50
  83. package/dist/presentation/routes/registration/signup.js.map +1 -1
  84. package/dist/temporal/activities/agent-access-retention.d.ts +13 -0
  85. package/dist/temporal/activities/agent-access-retention.d.ts.map +1 -0
  86. package/dist/temporal/activities/agent-access-retention.js +58 -0
  87. package/dist/temporal/activities/agent-access-retention.js.map +1 -0
  88. package/dist/temporal/activities/index.d.ts +5 -0
  89. package/dist/temporal/activities/index.d.ts.map +1 -0
  90. package/dist/temporal/activities/index.js +8 -0
  91. package/dist/temporal/activities/index.js.map +1 -0
  92. package/dist/temporal/constants.d.ts +2 -0
  93. package/dist/temporal/constants.d.ts.map +1 -0
  94. package/dist/temporal/constants.js +3 -0
  95. package/dist/temporal/constants.js.map +1 -0
  96. package/dist/temporal/workflows/agent-access-retention-cron.d.ts +2 -0
  97. package/dist/temporal/workflows/agent-access-retention-cron.d.ts.map +1 -0
  98. package/dist/temporal/workflows/agent-access-retention-cron.js +22 -0
  99. package/dist/temporal/workflows/agent-access-retention-cron.js.map +1 -0
  100. package/dist/temporal/workflows/index.bundle.js +25227 -0
  101. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  102. package/dist/temporal/workflows/index.d.ts +2 -0
  103. package/dist/temporal/workflows/index.d.ts.map +1 -0
  104. package/dist/temporal/workflows/index.js +3 -0
  105. package/dist/temporal/workflows/index.js.map +1 -0
  106. package/dist/tsconfig.test.tsbuildinfo +1 -1
  107. package/drizzle/0003_brainy_luckman.sql +6 -0
  108. package/drizzle/0004_yummy_runaways.sql +110 -0
  109. package/drizzle/meta/0003_snapshot.json +844 -0
  110. package/drizzle/meta/0004_snapshot.json +1751 -0
  111. package/drizzle/meta/_journal.json +14 -0
  112. package/package.json +11 -8
  113. package/src/core/admin-role.test.ts +70 -0
  114. package/src/core/administration.ts +78 -1
  115. package/src/core/auth.ts +3 -5
  116. package/src/core/cimd.test.ts +213 -0
  117. package/src/core/cimd.ts +532 -0
  118. package/src/core/entities/agent-access.ts +80 -0
  119. package/src/core/errors.ts +48 -0
  120. package/src/core/oauth-client-resolver.test.ts +198 -0
  121. package/src/core/oauth-client-resolver.ts +253 -0
  122. package/src/core/oauth-client.test.ts +157 -0
  123. package/src/core/oauth-client.ts +331 -0
  124. package/src/db/admin-user-moderation.ts +53 -45
  125. package/src/db/admin-user-summary.test.ts +228 -0
  126. package/src/db/admin-user-summary.ts +143 -1
  127. package/src/db/admin-users.ts +21 -1
  128. package/src/db/agent-access-retention.ts +10 -0
  129. package/src/db/agent-access.test.ts +1064 -0
  130. package/src/db/agent-access.ts +1491 -0
  131. package/src/db/db.ts +14 -0
  132. package/src/db/schema/agent-access.ts +273 -0
  133. package/src/db/schema/users.ts +9 -3
  134. package/src/index.test.ts +27 -0
  135. package/src/index.ts +82 -0
  136. package/src/metrics/instance.ts +6 -1
  137. package/src/presentation/routes/administration.test.ts +359 -2
  138. package/src/presentation/routes/administration.ts +142 -20
  139. package/src/presentation/routes/email-verification/verify-email-resend.ts +7 -1
  140. package/src/presentation/routes/oauth.test.ts +133 -0
  141. package/src/presentation/routes/oauth.ts +144 -0
  142. package/src/presentation/routes/rate-limit.ts +16 -0
  143. package/src/presentation/routes/registration/signup.ts +52 -41
  144. package/src/temporal/activities/agent-access-retention.ts +76 -0
  145. package/src/temporal/activities/index.ts +5 -0
  146. package/src/temporal/constants.ts +1 -0
  147. package/src/temporal/workflows/agent-access-retention-cron.ts +23 -0
  148. package/src/temporal/workflows/index.ts +1 -0
  149. package/test/globalSetup.ts +1 -1
  150. package/test/routes.ts +3 -1
  151. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,1064 @@
1
+ import {hashOpaqueToken} from '@shipfox/node-tokens';
2
+ import {eq, sql} from 'drizzle-orm';
3
+ import {userFactory} from '#test/index.js';
4
+ import {
5
+ agentRefreshTokenExpiresAt,
6
+ consumeAgentAuthorizationCode,
7
+ consumeAgentAuthorizationRequest,
8
+ createAgentAuthorizationCode,
9
+ createAgentAuthorizationRequest,
10
+ createAgentClient,
11
+ createAgentGrant,
12
+ createAgentPersonalAccessToken,
13
+ createAgentRefreshToken,
14
+ findActiveAgentAuthorizationCodeByHash,
15
+ findActiveAgentPersonalAccessTokenByHash,
16
+ findActiveAgentRefreshTokenByGrantId,
17
+ findActiveAgentRefreshTokenByHash,
18
+ findAgentAuthorizationCodeByHash,
19
+ findAgentClientByClientId,
20
+ findAgentGrant,
21
+ findAgentPersonalAccessTokenByHash,
22
+ findAgentRefreshTokenByHash,
23
+ findPendingAgentAuthorizationRequest,
24
+ isWithinAgentRefreshRotationGrace,
25
+ lockAgentGrant,
26
+ markAgentPersonalAccessTokenUsed,
27
+ pruneAgentAccess,
28
+ pruneAgentAccessBatch,
29
+ resolveAgentRefreshTokenReplay,
30
+ revokeAgentGrant,
31
+ revokeAgentPersonalAccessToken,
32
+ rotateAgentRefreshToken,
33
+ transitionAgentGrantsToTerminal,
34
+ upsertCimdAgentClient,
35
+ } from './agent-access.js';
36
+ import {db} from './db.js';
37
+ import {
38
+ agentClients,
39
+ agentGrants,
40
+ agentPersonalAccessTokens,
41
+ agentRefreshTokens,
42
+ } from './schema/agent-access.js';
43
+ import {users} from './schema/users.js';
44
+
45
+ const GRANT_LOCK_TEST_TIMEOUT_MS = 30_000;
46
+
47
+ async function waitForLockWait(
48
+ waiterPid: number,
49
+ query: () => Promise<{rows: Array<{blockers: number[]}>}>,
50
+ timeoutMs: number,
51
+ ): Promise<void> {
52
+ const deadline = Date.now() + timeoutMs;
53
+ while (Date.now() < deadline) {
54
+ const result = await query();
55
+ if ((result.rows[0]?.blockers.length ?? 0) > 0) return;
56
+ await new Promise<void>((resolve) => setImmediate(resolve));
57
+ }
58
+ throw new Error(`Timed out waiting for PostgreSQL row lock on backend ${waiterPid}`);
59
+ }
60
+
61
+ function deferred<T>() {
62
+ let resolve!: (value: T | PromiseLike<T>) => void;
63
+ let reject!: (reason?: unknown) => void;
64
+ const promise = new Promise<T>((innerResolve, innerReject) => {
65
+ resolve = innerResolve;
66
+ reject = innerReject;
67
+ });
68
+ return {promise, resolve, reject};
69
+ }
70
+
71
+ describe('agent-access db', () => {
72
+ test('does not accept a future rotation timestamp as a grace replay', () => {
73
+ const now = new Date('2026-09-01T00:00:00.000Z');
74
+
75
+ expect(
76
+ isWithinAgentRefreshRotationGrace({
77
+ rotatedAt: new Date(now.getTime() + 1_000),
78
+ now,
79
+ graceSeconds: 30,
80
+ }),
81
+ ).toBe(false);
82
+ });
83
+
84
+ test('consumes a pending authorization request exactly once under concurrency', async () => {
85
+ const client = await createAgentClient({
86
+ clientId: `https://client.example/${crypto.randomUUID()}`,
87
+ name: 'Test client',
88
+ redirectUris: ['https://client.example/callback'],
89
+ kind: 'registered',
90
+ });
91
+ const request = await createAgentAuthorizationRequest({
92
+ clientId: client.id,
93
+ redirectUri: 'https://client.example/callback',
94
+ resource: 'https://api.example/mcp',
95
+ scopes: ['read'],
96
+ codeChallenge: 'challenge',
97
+ state: null,
98
+ expiresAt: new Date(Date.now() + 60_000),
99
+ });
100
+
101
+ expect(request.state).toBeNull();
102
+ expect(await findPendingAgentAuthorizationRequest({id: request.id})).toMatchObject({
103
+ id: request.id,
104
+ state: null,
105
+ });
106
+
107
+ const [first, second] = await Promise.all([
108
+ consumeAgentAuthorizationRequest({id: request.id}),
109
+ consumeAgentAuthorizationRequest({id: request.id}),
110
+ ]);
111
+
112
+ expect([first, second].filter(Boolean)).toHaveLength(1);
113
+ expect([first, second].filter((value) => value === undefined)).toHaveLength(1);
114
+ expect(await findPendingAgentAuthorizationRequest({id: request.id})).toBeUndefined();
115
+ });
116
+
117
+ test('reuses an active grant during reauthorization', async () => {
118
+ const user = await userFactory.create();
119
+ const client = await createAgentClient({
120
+ clientId: `https://client.example/${crypto.randomUUID()}`,
121
+ name: 'Test client',
122
+ redirectUris: ['https://client.example/callback'],
123
+ kind: 'registered',
124
+ });
125
+ const params = {
126
+ userId: user.id,
127
+ workspaceId: crypto.randomUUID(),
128
+ clientId: client.id,
129
+ scopes: ['read'],
130
+ };
131
+
132
+ const duplicateClient = await createAgentClient({
133
+ clientId: client.clientId,
134
+ name: 'Changed name is ignored for an existing client',
135
+ redirectUris: client.redirectUris,
136
+ kind: client.kind,
137
+ });
138
+ expect(duplicateClient.id).toBe(client.id);
139
+
140
+ await db()
141
+ .update(agentClients)
142
+ .set({unreferencedAt: new Date()})
143
+ .where(eq(agentClients.id, client.id));
144
+
145
+ const first = await createAgentGrant(params);
146
+ const refreshToken = await createAgentRefreshToken({
147
+ grantId: first.id,
148
+ hashedToken: hashOpaqueToken(`refresh-${crypto.randomUUID()}`),
149
+ expiresAt: new Date(Date.now() + 60_000),
150
+ });
151
+ const second = await createAgentGrant({...params, scopes: ['read', 'write']});
152
+
153
+ expect(second).toMatchObject({id: first.id, scopes: ['read', 'write']});
154
+ expect(
155
+ await findActiveAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
156
+ ).toBeUndefined();
157
+ expect(await findAgentClientByClientId({clientId: client.clientId})).toMatchObject({
158
+ id: client.id,
159
+ unreferencedAt: null,
160
+ });
161
+ });
162
+
163
+ test('refreshes validated CIMD metadata while preserving the client row', async () => {
164
+ const clientId = `https://client.example/.well-known/${crypto.randomUUID()}`;
165
+ const first = await upsertCimdAgentClient({
166
+ clientId,
167
+ name: 'First name',
168
+ redirectUris: ['https://client.example/first'],
169
+ });
170
+ const second = await upsertCimdAgentClient({
171
+ clientId,
172
+ name: 'Updated name',
173
+ redirectUris: ['https://client.example/updated'],
174
+ });
175
+
176
+ expect(second).toMatchObject({
177
+ id: first.id,
178
+ clientId,
179
+ name: 'Updated name',
180
+ redirectUris: ['https://client.example/updated'],
181
+ kind: 'cimd',
182
+ unreferencedAt: null,
183
+ });
184
+ });
185
+
186
+ test('manages refresh tokens and personal access tokens', async () => {
187
+ const user = await userFactory.create();
188
+ const client = await createAgentClient({
189
+ clientId: `https://client.example/${crypto.randomUUID()}`,
190
+ name: 'Test client',
191
+ redirectUris: ['https://client.example/callback'],
192
+ kind: 'registered',
193
+ });
194
+ const grant = await createAgentGrant({
195
+ userId: user.id,
196
+ workspaceId: crypto.randomUUID(),
197
+ clientId: client.id,
198
+ scopes: ['read'],
199
+ });
200
+ const refreshToken = await createAgentRefreshToken({
201
+ grantId: grant.id,
202
+ hashedToken: hashOpaqueToken('refresh-token'),
203
+ expiresAt: new Date(Date.now() + 60_000),
204
+ });
205
+
206
+ expect(
207
+ await findActiveAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
208
+ ).toMatchObject({id: refreshToken.id});
209
+ const rotated = await rotateAgentRefreshToken({
210
+ hashedToken: refreshToken.hashedToken,
211
+ replacementHashedToken: hashOpaqueToken('replacement-refresh-token'),
212
+ replacementExpiresAt: new Date(Date.now() + 60_000),
213
+ });
214
+ expect(rotated).toMatchObject({
215
+ grantId: grant.id,
216
+ hashedToken: hashOpaqueToken('replacement-refresh-token'),
217
+ });
218
+ expect(
219
+ await findActiveAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
220
+ ).toBeUndefined();
221
+ expect(
222
+ await rotateAgentRefreshToken({
223
+ hashedToken: refreshToken.hashedToken,
224
+ replacementHashedToken: hashOpaqueToken('unused-refresh-token'),
225
+ replacementExpiresAt: new Date(Date.now() + 60_000),
226
+ }),
227
+ ).toBeUndefined();
228
+
229
+ const pat = await createAgentPersonalAccessToken({
230
+ userId: user.id,
231
+ workspaceId: grant.workspaceId,
232
+ hashedToken: hashOpaqueToken('pat-token'),
233
+ prefix: 'sf_pat_test',
234
+ name: 'Test PAT',
235
+ scopes: ['read'],
236
+ expiresAt: new Date(Date.now() + 60_000),
237
+ });
238
+ expect(
239
+ await findActiveAgentPersonalAccessTokenByHash({hashedToken: pat.hashedToken}),
240
+ ).toMatchObject({id: pat.id});
241
+ const firstPatUse = await markAgentPersonalAccessTokenUsed({id: pat.id});
242
+ expect(firstPatUse).toMatchObject({
243
+ id: pat.id,
244
+ lastUsedAt: expect.any(Date),
245
+ });
246
+ const throttledPatUse = await markAgentPersonalAccessTokenUsed({id: pat.id});
247
+ expect(throttledPatUse).toMatchObject({
248
+ id: pat.id,
249
+ lastUsedAt: firstPatUse?.lastUsedAt,
250
+ });
251
+ await db()
252
+ .update(agentPersonalAccessTokens)
253
+ .set({lastUsedAt: new Date(Date.now() - 61_000)})
254
+ .where(eq(agentPersonalAccessTokens.id, pat.id));
255
+ expect(await markAgentPersonalAccessTokenUsed({id: pat.id})).toMatchObject({
256
+ id: pat.id,
257
+ lastUsedAt: expect.any(Date),
258
+ });
259
+ await db().update(users).set({status: 'suspended'}).where(eq(users.id, user.id));
260
+ expect(
261
+ await findActiveAgentPersonalAccessTokenByHash({hashedToken: pat.hashedToken}),
262
+ ).toBeUndefined();
263
+ await revokeAgentPersonalAccessToken({id: pat.id});
264
+ expect(
265
+ await findActiveAgentPersonalAccessTokenByHash({hashedToken: pat.hashedToken}),
266
+ ).toBeUndefined();
267
+
268
+ await pruneAgentAccess({retentionDays: 0, now: new Date(Date.now() + 1_000)});
269
+
270
+ expect(
271
+ await findAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
272
+ ).toBeUndefined();
273
+ expect(
274
+ await findAgentPersonalAccessTokenByHash({hashedToken: pat.hashedToken}),
275
+ ).toBeUndefined();
276
+ });
277
+
278
+ test('serializes concurrent refresh rotation and revokes the complete grant family', async () => {
279
+ const user = await userFactory.create();
280
+ const client = await createAgentClient({
281
+ clientId: `https://client.example/${crypto.randomUUID()}`,
282
+ name: 'Test client',
283
+ redirectUris: ['https://client.example/callback'],
284
+ kind: 'registered',
285
+ });
286
+ const grant = await createAgentGrant({
287
+ userId: user.id,
288
+ workspaceId: crypto.randomUUID(),
289
+ clientId: client.id,
290
+ scopes: ['read'],
291
+ });
292
+ const first = await createAgentRefreshToken({
293
+ grantId: grant.id,
294
+ hashedToken: hashOpaqueToken(`concurrent-refresh-${crypto.randomUUID()}`),
295
+ expiresAt: new Date(Date.now() + 60_000),
296
+ });
297
+ const replacements = [
298
+ hashOpaqueToken(`replacement-a-${crypto.randomUUID()}`),
299
+ hashOpaqueToken(`replacement-b-${crypto.randomUUID()}`),
300
+ ];
301
+
302
+ const rotations = await Promise.all(
303
+ replacements.map((replacementHashedToken) =>
304
+ rotateAgentRefreshToken({
305
+ hashedToken: first.hashedToken,
306
+ replacementHashedToken,
307
+ replacementExpiresAt: new Date(Date.now() + 60_000),
308
+ }),
309
+ ),
310
+ );
311
+
312
+ expect(rotations.filter(Boolean)).toHaveLength(1);
313
+ const refreshRows = await db()
314
+ .select({id: agentRefreshTokens.id, hashedToken: agentRefreshTokens.hashedToken})
315
+ .from(agentRefreshTokens)
316
+ .where(eq(agentRefreshTokens.grantId, grant.id));
317
+ expect(refreshRows).toHaveLength(2);
318
+ expect(await findAgentRefreshTokenByHash({hashedToken: first.hashedToken})).toMatchObject({
319
+ id: first.id,
320
+ rotatedAt: expect.any(Date),
321
+ });
322
+ const live = await findActiveAgentRefreshTokenByGrantId({grantId: grant.id});
323
+ expect(live?.hashedToken).toBeOneOf(replacements);
324
+
325
+ const revoked = await revokeAgentGrant({grantId: grant.id});
326
+ expect(revoked).toMatchObject({
327
+ id: grant.id,
328
+ revokedAt: expect.any(Date),
329
+ terminalAt: expect.any(Date),
330
+ });
331
+ expect(await findAgentRefreshTokenByHash({hashedToken: first.hashedToken})).toBeUndefined();
332
+ expect(await findActiveAgentRefreshTokenByGrantId({grantId: grant.id})).toBeUndefined();
333
+ for (const hashedToken of replacements) {
334
+ expect(await findAgentRefreshTokenByHash({hashedToken})).toBeUndefined();
335
+ }
336
+ });
337
+
338
+ test('serves retained refresh replays during grace and revokes reuse after grace', async () => {
339
+ const user = await userFactory.create();
340
+ const client = await createAgentClient({
341
+ clientId: `https://client.example/${crypto.randomUUID()}`,
342
+ name: 'Test client',
343
+ redirectUris: ['https://client.example/callback'],
344
+ kind: 'registered',
345
+ });
346
+ const grant = await createAgentGrant({
347
+ userId: user.id,
348
+ workspaceId: crypto.randomUUID(),
349
+ clientId: client.id,
350
+ scopes: ['read'],
351
+ });
352
+ const predecessor = await createAgentRefreshToken({
353
+ grantId: grant.id,
354
+ hashedToken: hashOpaqueToken(`grace-refresh-${crypto.randomUUID()}`),
355
+ });
356
+ const replacementHashedToken = hashOpaqueToken(`grace-successor-${crypto.randomUUID()}`);
357
+ const expectedSlidingExpiry = agentRefreshTokenExpiresAt();
358
+ const successor = await rotateAgentRefreshToken({
359
+ hashedToken: predecessor.hashedToken,
360
+ replacementHashedToken,
361
+ });
362
+ if (!successor) throw new Error('Expected refresh rotation to create a successor');
363
+
364
+ expect(successor?.expiresAt.getTime()).toBeGreaterThanOrEqual(
365
+ expectedSlidingExpiry.getTime() - 1_000,
366
+ );
367
+ const rotated = await findAgentRefreshTokenByHash({hashedToken: predecessor.hashedToken});
368
+ expect(rotated?.rotatedAt).toEqual(expect.any(Date));
369
+ if (!rotated?.rotatedAt) throw new Error('Expected the predecessor to be marked rotated');
370
+ const graceReplay = await resolveAgentRefreshTokenReplay({
371
+ hashedToken: predecessor.hashedToken,
372
+ now: rotated.rotatedAt,
373
+ });
374
+ expect(graceReplay).toMatchObject({
375
+ kind: 'grace',
376
+ grant: {id: grant.id, revokedAt: null},
377
+ predecessor: {id: predecessor.id},
378
+ successor: {id: successor.id},
379
+ });
380
+
381
+ await db()
382
+ .update(agentRefreshTokens)
383
+ .set({rotatedAt: new Date(Date.now() - 31_000)})
384
+ .where(eq(agentRefreshTokens.id, predecessor.id));
385
+ const reused = await resolveAgentRefreshTokenReplay({
386
+ hashedToken: predecessor.hashedToken,
387
+ });
388
+ expect(reused).toMatchObject({
389
+ kind: 'reused',
390
+ grant: {
391
+ id: grant.id,
392
+ revokedAt: expect.any(Date),
393
+ terminalAt: expect.any(Date),
394
+ },
395
+ });
396
+ expect(
397
+ await findAgentRefreshTokenByHash({hashedToken: predecessor.hashedToken}),
398
+ ).toBeUndefined();
399
+ expect(
400
+ await findAgentRefreshTokenByHash({hashedToken: replacementHashedToken}),
401
+ ).toBeUndefined();
402
+ });
403
+
404
+ test('rejects code exchange and refresh rotation for suspended users', async () => {
405
+ const user = await userFactory.create();
406
+ const client = await createAgentClient({
407
+ clientId: `https://client.example/${crypto.randomUUID()}`,
408
+ name: 'Test client',
409
+ redirectUris: ['https://client.example/callback'],
410
+ kind: 'registered',
411
+ });
412
+ const grant = await createAgentGrant({
413
+ userId: user.id,
414
+ workspaceId: crypto.randomUUID(),
415
+ clientId: client.id,
416
+ scopes: ['read'],
417
+ });
418
+ const refreshToken = await createAgentRefreshToken({
419
+ grantId: grant.id,
420
+ hashedToken: hashOpaqueToken(`suspended-refresh-${crypto.randomUUID()}`),
421
+ expiresAt: new Date(Date.now() + 60_000),
422
+ });
423
+ const code = await createAgentAuthorizationCode({
424
+ grantId: grant.id,
425
+ hashedCode: hashOpaqueToken(`suspended-code-${crypto.randomUUID()}`),
426
+ codeChallenge: 'challenge',
427
+ redirectUri: 'https://client.example/callback',
428
+ resource: 'https://api.example/mcp',
429
+ expiresAt: new Date(Date.now() + 60_000),
430
+ });
431
+ await db().update(users).set({status: 'suspended'}).where(eq(users.id, user.id));
432
+
433
+ expect(
434
+ await rotateAgentRefreshToken({
435
+ hashedToken: refreshToken.hashedToken,
436
+ replacementHashedToken: hashOpaqueToken(`unused-suspended-${crypto.randomUUID()}`),
437
+ }),
438
+ ).toBeUndefined();
439
+ expect(await consumeAgentAuthorizationCode({hashedCode: code.hashedCode})).toBeUndefined();
440
+ expect(
441
+ await findActiveAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
442
+ ).toMatchObject({id: refreshToken.id});
443
+ expect(
444
+ await findActiveAgentAuthorizationCodeByHash({hashedCode: code.hashedCode}),
445
+ ).toMatchObject({
446
+ id: code.id,
447
+ });
448
+ });
449
+
450
+ test('treats a replay after pruning as an unknown token without revoking its grant', async () => {
451
+ const user = await userFactory.create();
452
+ const client = await createAgentClient({
453
+ clientId: `https://client.example/${crypto.randomUUID()}`,
454
+ name: 'Pruned replay client',
455
+ redirectUris: ['https://client.example/callback'],
456
+ kind: 'registered',
457
+ });
458
+ const grant = await createAgentGrant({
459
+ userId: user.id,
460
+ workspaceId: crypto.randomUUID(),
461
+ clientId: client.id,
462
+ scopes: ['read'],
463
+ });
464
+ const predecessor = await createAgentRefreshToken({
465
+ grantId: grant.id,
466
+ hashedToken: hashOpaqueToken(`pruned-replay-${crypto.randomUUID()}`),
467
+ expiresAt: new Date(Date.now() + 60_000),
468
+ });
469
+ const successorHashedToken = hashOpaqueToken(`pruned-successor-${crypto.randomUUID()}`);
470
+ const successor = await rotateAgentRefreshToken({
471
+ hashedToken: predecessor.hashedToken,
472
+ replacementHashedToken: successorHashedToken,
473
+ replacementExpiresAt: new Date(Date.now() + 60_000),
474
+ });
475
+ if (!successor) throw new Error('Expected refresh rotation to create a successor');
476
+ await db()
477
+ .update(agentRefreshTokens)
478
+ .set({rotatedAt: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000)})
479
+ .where(eq(agentRefreshTokens.id, predecessor.id));
480
+
481
+ await pruneAgentAccess({retentionDays: 0, now: new Date(Date.now() + 1_000)});
482
+
483
+ expect(
484
+ await resolveAgentRefreshTokenReplay({hashedToken: predecessor.hashedToken}),
485
+ ).toBeUndefined();
486
+ expect(
487
+ await findActiveAgentRefreshTokenByHash({hashedToken: successorHashedToken}),
488
+ ).toMatchObject({
489
+ id: successor.id,
490
+ });
491
+ expect(await findAgentGrant({id: grant.id})).toMatchObject({
492
+ id: grant.id,
493
+ revokedAt: null,
494
+ terminalAt: null,
495
+ });
496
+ });
497
+
498
+ test('keeps terminal transitions serialized with authorization-code exchange', async () => {
499
+ const user = await userFactory.create();
500
+ const client = await createAgentClient({
501
+ clientId: `https://client.example/${crypto.randomUUID()}`,
502
+ name: 'Test client',
503
+ redirectUris: ['https://client.example/callback'],
504
+ kind: 'registered',
505
+ });
506
+ const emptyGrant = await createAgentGrant({
507
+ userId: user.id,
508
+ workspaceId: crypto.randomUUID(),
509
+ clientId: client.id,
510
+ scopes: ['read'],
511
+ });
512
+ expect(await transitionAgentGrantsToTerminal()).toBeGreaterThanOrEqual(1);
513
+ expect(await findAgentGrant({id: emptyGrant.id})).toMatchObject({
514
+ terminalAt: expect.any(Date),
515
+ });
516
+
517
+ const grant = await createAgentGrant({
518
+ userId: user.id,
519
+ workspaceId: crypto.randomUUID(),
520
+ clientId: client.id,
521
+ scopes: ['read'],
522
+ });
523
+ const code = await createAgentAuthorizationCode({
524
+ grantId: grant.id,
525
+ hashedCode: hashOpaqueToken(`racing-code-${crypto.randomUUID()}`),
526
+ codeChallenge: 'challenge',
527
+ redirectUri: 'https://client.example/callback',
528
+ resource: 'https://api.example/mcp',
529
+ expiresAt: new Date(Date.now() + 60_000),
530
+ });
531
+ await transitionAgentGrantsToTerminal({limit: 1_000});
532
+ expect(await findAgentGrant({id: grant.id})).toMatchObject({terminalAt: null});
533
+
534
+ const [consumed, transitioned] = await Promise.all([
535
+ consumeAgentAuthorizationCode({hashedCode: code.hashedCode}),
536
+ transitionAgentGrantsToTerminal(),
537
+ ]);
538
+
539
+ expect(consumed).toMatchObject({id: code.id, grantId: grant.id});
540
+ expect(transitioned).toBeLessThanOrEqual(1);
541
+ expect(await consumeAgentAuthorizationCode({hashedCode: code.hashedCode})).toBeUndefined();
542
+ if (transitioned === 0) {
543
+ expect(await transitionAgentGrantsToTerminal({limit: 1_000})).toBe(1);
544
+ }
545
+ expect(await findAgentGrant({id: grant.id})).toMatchObject({
546
+ terminalAt: expect.any(Date),
547
+ });
548
+ });
549
+
550
+ test('applies credential retention windows and retires clients after their grants', async () => {
551
+ const now = new Date('2026-09-01T00:00:00.000Z');
552
+ const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
553
+ const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
554
+ const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
555
+ const user = await userFactory.create();
556
+ const client = await createAgentClient({
557
+ clientId: `https://client.example/${crypto.randomUUID()}`,
558
+ name: 'Retention client',
559
+ redirectUris: ['https://client.example/callback'],
560
+ kind: 'registered',
561
+ });
562
+ const request = await createAgentAuthorizationRequest({
563
+ clientId: client.id,
564
+ redirectUri: 'https://client.example/callback',
565
+ resource: 'https://api.example/mcp',
566
+ scopes: ['read'],
567
+ codeChallenge: 'challenge',
568
+ state: null,
569
+ expiresAt: oneDayAgo,
570
+ });
571
+ const grant = await createAgentGrant({
572
+ userId: user.id,
573
+ workspaceId: crypto.randomUUID(),
574
+ clientId: client.id,
575
+ scopes: ['read'],
576
+ });
577
+ const code = await createAgentAuthorizationCode({
578
+ grantId: grant.id,
579
+ hashedCode: hashOpaqueToken(`retention-code-${crypto.randomUUID()}`),
580
+ codeChallenge: 'challenge',
581
+ redirectUri: 'https://client.example/callback',
582
+ resource: 'https://api.example/mcp',
583
+ expiresAt: oneDayAgo,
584
+ });
585
+ const rotated = await createAgentRefreshToken({
586
+ grantId: grant.id,
587
+ hashedToken: hashOpaqueToken(`retention-rotated-${crypto.randomUUID()}`),
588
+ expiresAt: new Date(now.getTime() + 60_000),
589
+ });
590
+ await db()
591
+ .update(agentRefreshTokens)
592
+ .set({rotatedAt: thirtyDaysAgo})
593
+ .where(eq(agentRefreshTokens.id, rotated.id));
594
+ const revoked = await createAgentRefreshToken({
595
+ grantId: grant.id,
596
+ hashedToken: hashOpaqueToken(`retention-revoked-${crypto.randomUUID()}`),
597
+ expiresAt: new Date(now.getTime() + 60_000),
598
+ });
599
+ await db()
600
+ .update(agentRefreshTokens)
601
+ .set({revokedAt: thirtyDaysAgo})
602
+ .where(eq(agentRefreshTokens.id, revoked.id));
603
+ const expired = await createAgentRefreshToken({
604
+ grantId: grant.id,
605
+ hashedToken: hashOpaqueToken(`retention-expired-${crypto.randomUUID()}`),
606
+ expiresAt: thirtyDaysAgo,
607
+ });
608
+ const expiredPat = await createAgentPersonalAccessToken({
609
+ userId: user.id,
610
+ workspaceId: grant.workspaceId,
611
+ hashedToken: hashOpaqueToken(`retention-expired-pat-${crypto.randomUUID()}`),
612
+ prefix: 'sf_pat_expired',
613
+ name: 'Expired PAT',
614
+ scopes: ['read'],
615
+ expiresAt: ninetyDaysAgo,
616
+ });
617
+ const revokedPat = await createAgentPersonalAccessToken({
618
+ userId: user.id,
619
+ workspaceId: grant.workspaceId,
620
+ hashedToken: hashOpaqueToken(`retention-revoked-pat-${crypto.randomUUID()}`),
621
+ prefix: 'sf_pat_revoked',
622
+ name: 'Revoked PAT',
623
+ scopes: ['read'],
624
+ expiresAt: new Date(now.getTime() + 60_000),
625
+ });
626
+ await db()
627
+ .update(agentPersonalAccessTokens)
628
+ .set({revokedAt: ninetyDaysAgo})
629
+ .where(eq(agentPersonalAccessTokens.id, revokedPat.id));
630
+
631
+ await pruneAgentAccess({now});
632
+
633
+ expect(await findPendingAgentAuthorizationRequest({id: request.id})).toBeUndefined();
634
+ expect(await findAgentAuthorizationCodeByHash({hashedCode: code.hashedCode})).toBeUndefined();
635
+ for (const token of [rotated, revoked, expired]) {
636
+ expect(await findAgentRefreshTokenByHash({hashedToken: token.hashedToken})).toBeUndefined();
637
+ }
638
+ expect(
639
+ await findAgentPersonalAccessTokenByHash({hashedToken: expiredPat.hashedToken}),
640
+ ).toBeUndefined();
641
+ expect(
642
+ await findAgentPersonalAccessTokenByHash({hashedToken: revokedPat.hashedToken}),
643
+ ).toBeUndefined();
644
+ expect(await findAgentGrant({id: grant.id})).toMatchObject({
645
+ terminalAt: expect.any(Date),
646
+ });
647
+ expect(await findAgentClientByClientId({clientId: client.clientId})).toMatchObject({
648
+ id: client.id,
649
+ unreferencedAt: null,
650
+ });
651
+
652
+ await db()
653
+ .update(agentGrants)
654
+ .set({terminalAt: ninetyDaysAgo})
655
+ .where(eq(agentGrants.id, grant.id));
656
+ await pruneAgentAccess({now});
657
+ expect(await findAgentGrant({id: grant.id})).toBeUndefined();
658
+ expect(await findAgentClientByClientId({clientId: client.clientId})).toMatchObject({
659
+ id: client.id,
660
+ unreferencedAt: expect.any(Date),
661
+ });
662
+
663
+ await pruneAgentAccess({now: new Date(now.getTime() + 31 * 24 * 60 * 60 * 1000)});
664
+ expect(await findAgentClientByClientId({clientId: client.clientId})).toBeUndefined();
665
+ });
666
+
667
+ test('retains live credentials and rejects new children after grant revocation', async () => {
668
+ const user = await userFactory.create();
669
+ const client = await createAgentClient({
670
+ clientId: `https://client.example/${crypto.randomUUID()}`,
671
+ name: 'Live client',
672
+ redirectUris: ['https://client.example/callback'],
673
+ kind: 'registered',
674
+ });
675
+ const grant = await createAgentGrant({
676
+ userId: user.id,
677
+ workspaceId: crypto.randomUUID(),
678
+ clientId: client.id,
679
+ scopes: ['read'],
680
+ });
681
+ const refreshToken = await createAgentRefreshToken({
682
+ grantId: grant.id,
683
+ hashedToken: hashOpaqueToken(`live-refresh-${crypto.randomUUID()}`),
684
+ expiresAt: new Date(Date.now() + 60_000),
685
+ });
686
+ const pat = await createAgentPersonalAccessToken({
687
+ userId: user.id,
688
+ workspaceId: grant.workspaceId,
689
+ hashedToken: hashOpaqueToken(`live-pat-${crypto.randomUUID()}`),
690
+ prefix: 'sf_pat_live',
691
+ name: 'Live PAT',
692
+ scopes: ['read'],
693
+ expiresAt: new Date(Date.now() + 60_000),
694
+ });
695
+
696
+ expect(await transitionAgentGrantsToTerminal({limit: 1})).toBe(0);
697
+ await pruneAgentAccess({retentionDays: 0, now: new Date(Date.now() + 1_000)});
698
+ expect(await findAgentGrant({id: grant.id})).toMatchObject({terminalAt: null});
699
+ expect(
700
+ await findActiveAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
701
+ ).toMatchObject({
702
+ id: refreshToken.id,
703
+ });
704
+ expect(
705
+ await findActiveAgentPersonalAccessTokenByHash({hashedToken: pat.hashedToken}),
706
+ ).toMatchObject({
707
+ id: pat.id,
708
+ });
709
+
710
+ await revokeAgentGrant({grantId: grant.id});
711
+ await expect(
712
+ createAgentRefreshToken({
713
+ grantId: grant.id,
714
+ hashedToken: hashOpaqueToken(`rejected-refresh-${crypto.randomUUID()}`),
715
+ expiresAt: new Date(Date.now() + 60_000),
716
+ }),
717
+ ).rejects.toThrow('no longer active');
718
+ await expect(
719
+ createAgentAuthorizationCode({
720
+ grantId: grant.id,
721
+ hashedCode: hashOpaqueToken(`rejected-code-${crypto.randomUUID()}`),
722
+ codeChallenge: 'challenge',
723
+ redirectUri: 'https://client.example/callback',
724
+ resource: 'https://api.example/mcp',
725
+ expiresAt: new Date(Date.now() + 60_000),
726
+ }),
727
+ ).rejects.toThrow('no longer active');
728
+ });
729
+
730
+ test('keeps a never-granted client while it has a live authorization request', async () => {
731
+ const client = await createAgentClient({
732
+ clientId: `https://client.example/${crypto.randomUUID()}`,
733
+ name: 'Pending client',
734
+ redirectUris: ['https://client.example/callback'],
735
+ kind: 'cimd',
736
+ });
737
+ const oldCreatedAt = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
738
+ await db()
739
+ .update(agentClients)
740
+ .set({createdAt: oldCreatedAt})
741
+ .where(eq(agentClients.id, client.id));
742
+ const request = await createAgentAuthorizationRequest({
743
+ clientId: client.id,
744
+ redirectUri: 'https://client.example/callback',
745
+ resource: 'https://api.example/mcp',
746
+ scopes: ['read'],
747
+ codeChallenge: 'challenge',
748
+ state: null,
749
+ expiresAt: new Date(Date.now() + 60_000),
750
+ });
751
+
752
+ await pruneAgentAccess({retentionDays: 0, now: new Date(Date.now() + 1_000)});
753
+ expect(await findAgentClientByClientId({clientId: client.clientId})).toMatchObject({
754
+ id: client.id,
755
+ });
756
+
757
+ expect(await consumeAgentAuthorizationRequest({id: request.id})).toMatchObject({
758
+ id: request.id,
759
+ });
760
+ await pruneAgentAccess({retentionDays: 0, now: new Date(Date.now() + 1_000)});
761
+ expect(await findAgentClientByClientId({clientId: client.clientId})).toBeUndefined();
762
+ });
763
+
764
+ test('retains a child revoked after grant terminalization until its own horizon', async () => {
765
+ const now = new Date('2026-09-01T00:00:00.000Z');
766
+ const user = await userFactory.create();
767
+ const client = await createAgentClient({
768
+ clientId: `https://client.example/${crypto.randomUUID()}`,
769
+ name: 'Child retention client',
770
+ redirectUris: ['https://client.example/callback'],
771
+ kind: 'registered',
772
+ });
773
+ const grant = await createAgentGrant({
774
+ userId: user.id,
775
+ workspaceId: crypto.randomUUID(),
776
+ clientId: client.id,
777
+ scopes: ['read'],
778
+ });
779
+ const refreshToken = await createAgentRefreshToken({
780
+ grantId: grant.id,
781
+ hashedToken: hashOpaqueToken(`late-revoked-refresh-${crypto.randomUUID()}`),
782
+ expiresAt: new Date(now.getTime() + 60_000),
783
+ });
784
+ const terminalAt = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
785
+ await db().update(agentGrants).set({terminalAt}).where(eq(agentGrants.id, grant.id));
786
+ await db()
787
+ .update(agentRefreshTokens)
788
+ .set({revokedAt: new Date(now.getTime() - 24 * 60 * 60 * 1000)})
789
+ .where(eq(agentRefreshTokens.id, refreshToken.id));
790
+
791
+ await pruneAgentAccess({now});
792
+ expect(await findAgentGrant({id: grant.id})).toMatchObject({id: grant.id});
793
+ expect(
794
+ await findAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
795
+ ).toBeUndefined();
796
+ const retainedRefresh = await db()
797
+ .select({id: agentRefreshTokens.id})
798
+ .from(agentRefreshTokens)
799
+ .where(eq(agentRefreshTokens.id, refreshToken.id));
800
+ expect(retainedRefresh).toHaveLength(1);
801
+
802
+ await pruneAgentAccess({now: new Date(now.getTime() + 31 * 24 * 60 * 60 * 1000)});
803
+ expect(await findAgentGrant({id: grant.id})).toBeUndefined();
804
+ });
805
+
806
+ test('does not let retained children starve newer terminal grants', async () => {
807
+ const now = new Date('2026-09-01T00:00:00.000Z');
808
+ const user = await userFactory.create();
809
+ const blockedClient = await createAgentClient({
810
+ clientId: `https://client.example/${crypto.randomUUID()}`,
811
+ name: 'Blocked terminal client',
812
+ redirectUris: ['https://client.example/callback'],
813
+ kind: 'registered',
814
+ });
815
+ const deletableClient = await createAgentClient({
816
+ clientId: `https://client.example/${crypto.randomUUID()}`,
817
+ name: 'Deletable terminal client',
818
+ redirectUris: ['https://client.example/callback'],
819
+ kind: 'registered',
820
+ });
821
+ const blockedGrant = await createAgentGrant({
822
+ userId: user.id,
823
+ workspaceId: crypto.randomUUID(),
824
+ clientId: blockedClient.id,
825
+ scopes: ['read'],
826
+ });
827
+ const deletableGrant = await createAgentGrant({
828
+ userId: user.id,
829
+ workspaceId: crypto.randomUUID(),
830
+ clientId: deletableClient.id,
831
+ scopes: ['read'],
832
+ });
833
+ const retainedChild = await createAgentRefreshToken({
834
+ grantId: blockedGrant.id,
835
+ hashedToken: hashOpaqueToken(`starving-child-${crypto.randomUUID()}`),
836
+ expiresAt: new Date(now.getTime() + 60_000),
837
+ });
838
+ const terminalAt = new Date(now.getTime() - 91 * 24 * 60 * 60 * 1000);
839
+ await db().update(agentGrants).set({terminalAt}).where(eq(agentGrants.id, blockedGrant.id));
840
+ await db().update(agentGrants).set({terminalAt}).where(eq(agentGrants.id, deletableGrant.id));
841
+ await db()
842
+ .update(agentRefreshTokens)
843
+ .set({revokedAt: new Date(now.getTime() - 24 * 60 * 60 * 1000)})
844
+ .where(eq(agentRefreshTokens.id, retainedChild.id));
845
+
846
+ await pruneAgentAccess({now, limit: 1});
847
+
848
+ expect(await findAgentGrant({id: blockedGrant.id})).toMatchObject({id: blockedGrant.id});
849
+ expect(await findAgentGrant({id: deletableGrant.id})).toBeUndefined();
850
+ });
851
+
852
+ test('consumes an authorization code exactly once and preserves its bindings', async () => {
853
+ const user = await userFactory.create();
854
+ const client = await createAgentClient({
855
+ clientId: `https://client.example/${crypto.randomUUID()}`,
856
+ name: 'Test client',
857
+ redirectUris: ['https://client.example/callback'],
858
+ kind: 'registered',
859
+ });
860
+ const grant = await createAgentGrant({
861
+ userId: user.id,
862
+ workspaceId: crypto.randomUUID(),
863
+ clientId: client.id,
864
+ scopes: ['read'],
865
+ });
866
+ const rawCode = `code-${crypto.randomUUID()}`;
867
+ const code = await createAgentAuthorizationCode({
868
+ grantId: grant.id,
869
+ hashedCode: hashOpaqueToken(rawCode),
870
+ codeChallenge: 'challenge',
871
+ redirectUri: 'https://client.example/callback',
872
+ resource: 'https://api.example/mcp',
873
+ expiresAt: new Date(Date.now() + 60_000),
874
+ });
875
+
876
+ expect(
877
+ await findActiveAgentAuthorizationCodeByHash({hashedCode: code.hashedCode}),
878
+ ).toMatchObject({
879
+ id: code.id,
880
+ });
881
+
882
+ const [first, second] = await Promise.all([
883
+ consumeAgentAuthorizationCode({hashedCode: code.hashedCode}),
884
+ consumeAgentAuthorizationCode({hashedCode: code.hashedCode}),
885
+ ]);
886
+ const consumed = [first, second].find(Boolean);
887
+
888
+ expect(
889
+ await findActiveAgentAuthorizationCodeByHash({hashedCode: code.hashedCode}),
890
+ ).toBeUndefined();
891
+ expect([first, second].filter(Boolean)).toHaveLength(1);
892
+ expect(consumed).toMatchObject({
893
+ grantId: grant.id,
894
+ codeChallenge: 'challenge',
895
+ redirectUri: 'https://client.example/callback',
896
+ resource: 'https://api.example/mcp',
897
+ });
898
+ });
899
+
900
+ test(
901
+ 'serializes grant lifecycle work on the grant row lock',
902
+ async () => {
903
+ const user = await userFactory.create();
904
+ const client = await createAgentClient({
905
+ clientId: `https://client.example/${crypto.randomUUID()}`,
906
+ name: 'Test client',
907
+ redirectUris: ['https://client.example/callback'],
908
+ kind: 'registered',
909
+ });
910
+ const grant = await createAgentGrant({
911
+ userId: user.id,
912
+ workspaceId: crypto.randomUUID(),
913
+ clientId: client.id,
914
+ scopes: ['read'],
915
+ });
916
+
917
+ let releaseHolder!: () => void;
918
+ const holderReleased = new Promise<void>((resolve) => {
919
+ releaseHolder = resolve;
920
+ });
921
+ let holderReady!: () => void;
922
+ const lockAcquired = new Promise<void>((resolve) => {
923
+ holderReady = resolve;
924
+ });
925
+ const waiterReady = deferred<number>();
926
+ const lockObserved = deferred<void>();
927
+ const holder = db().transaction(async (tx) => {
928
+ try {
929
+ await lockAgentGrant(tx, {grantId: grant.id});
930
+ holderReady();
931
+ const waiterPid = await waiterReady.promise;
932
+ await waitForLockWait(
933
+ waiterPid,
934
+ () => tx.execute(sql`select pg_blocking_pids(${waiterPid}) as blockers`),
935
+ GRANT_LOCK_TEST_TIMEOUT_MS,
936
+ );
937
+ lockObserved.resolve();
938
+ await holderReleased;
939
+ } catch (error) {
940
+ lockObserved.reject(error);
941
+ throw error;
942
+ }
943
+ });
944
+ await lockAcquired;
945
+
946
+ const waiter = db().transaction(async (tx) => {
947
+ const result = await tx.execute(sql`select pg_backend_pid() as pid`);
948
+ const waiterPid = result.rows[0]?.pid;
949
+ if (typeof waiterPid !== 'number') throw new Error('Expected waiter backend pid');
950
+ waiterReady.resolve(waiterPid);
951
+ return await lockAgentGrant(tx, {grantId: grant.id});
952
+ });
953
+ waiter.catch(waiterReady.reject);
954
+ try {
955
+ await lockObserved.promise;
956
+ releaseHolder();
957
+ const lockedGrant = await waiter;
958
+
959
+ expect(lockedGrant?.id).toBe(grant.id);
960
+ } finally {
961
+ releaseHolder();
962
+ await Promise.all([holder, waiter.catch(() => undefined)]);
963
+ }
964
+ },
965
+ GRANT_LOCK_TEST_TIMEOUT_MS,
966
+ );
967
+
968
+ test(
969
+ 'bounds retention waits on a locked terminal grant',
970
+ async () => {
971
+ const user = await userFactory.create();
972
+ const client = await createAgentClient({
973
+ clientId: `https://client.example/${crypto.randomUUID()}`,
974
+ name: 'Retention lock client',
975
+ redirectUris: ['https://client.example/callback'],
976
+ kind: 'registered',
977
+ });
978
+ const grant = await createAgentGrant({
979
+ userId: user.id,
980
+ workspaceId: crypto.randomUUID(),
981
+ clientId: client.id,
982
+ scopes: ['read'],
983
+ });
984
+ await db()
985
+ .update(agentGrants)
986
+ .set({terminalAt: new Date('2000-01-01T00:00:00.000Z')})
987
+ .where(eq(agentGrants.id, grant.id));
988
+
989
+ const holderReady = deferred<void>();
990
+ const releaseHolder = deferred<void>();
991
+ const holder = db().transaction(async (tx) => {
992
+ await tx
993
+ .update(agentGrants)
994
+ .set({updatedAt: sql`now()`})
995
+ .where(eq(agentGrants.id, grant.id));
996
+ holderReady.resolve();
997
+ await releaseHolder.promise;
998
+ });
999
+
1000
+ try {
1001
+ await holderReady.promise;
1002
+ await expect(
1003
+ pruneAgentAccessBatch({
1004
+ now: new Date('2026-09-01T00:00:00.000Z'),
1005
+ limit: 1,
1006
+ deadlineMs: Date.now() + 1_500,
1007
+ }),
1008
+ ).rejects.toMatchObject({
1009
+ cause: {code: expect.stringMatching('^(55P03|57014)$')},
1010
+ });
1011
+ } finally {
1012
+ releaseHolder.resolve();
1013
+ await holder;
1014
+ }
1015
+ },
1016
+ GRANT_LOCK_TEST_TIMEOUT_MS,
1017
+ );
1018
+
1019
+ test('does not consume an expired request or code', async () => {
1020
+ const client = await createAgentClient({
1021
+ clientId: `https://client.example/${crypto.randomUUID()}`,
1022
+ name: 'Test client',
1023
+ redirectUris: ['https://client.example/callback'],
1024
+ kind: 'registered',
1025
+ });
1026
+ const request = await createAgentAuthorizationRequest({
1027
+ clientId: client.id,
1028
+ redirectUri: 'https://client.example/callback',
1029
+ resource: 'https://api.example/mcp',
1030
+ scopes: ['read'],
1031
+ codeChallenge: 'challenge',
1032
+ state: 'state',
1033
+ expiresAt: new Date(Date.now() - 1_000),
1034
+ });
1035
+ const user = await userFactory.create();
1036
+ const grant = await createAgentGrant({
1037
+ userId: user.id,
1038
+ workspaceId: crypto.randomUUID(),
1039
+ clientId: client.id,
1040
+ scopes: ['read'],
1041
+ });
1042
+ const code = await createAgentAuthorizationCode({
1043
+ grantId: grant.id,
1044
+ hashedCode: hashOpaqueToken(`expired-${crypto.randomUUID()}`),
1045
+ codeChallenge: 'challenge',
1046
+ redirectUri: 'https://client.example/callback',
1047
+ resource: 'https://api.example/mcp',
1048
+ expiresAt: new Date(Date.now() - 1_000),
1049
+ });
1050
+
1051
+ const pendingRequest = await findPendingAgentAuthorizationRequest({id: request.id});
1052
+ const activeCode = await findActiveAgentAuthorizationCodeByHash({hashedCode: code.hashedCode});
1053
+ const consumedRequest = await consumeAgentAuthorizationRequest({id: request.id});
1054
+ const consumedCode = await consumeAgentAuthorizationCode({hashedCode: code.hashedCode});
1055
+
1056
+ expect(pendingRequest).toBeUndefined();
1057
+ expect(activeCode).toBeUndefined();
1058
+ expect(consumedRequest).toBeUndefined();
1059
+ expect(consumedCode).toBeUndefined();
1060
+ expect(
1061
+ await pruneAgentAccess({retentionDays: 0, now: new Date(Date.now() + 1_000)}),
1062
+ ).toBeGreaterThanOrEqual(2);
1063
+ });
1064
+ });