@shipfox/api-auth 19.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 (86) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +13 -0
  3. package/dist/core/cimd.d.ts +39 -0
  4. package/dist/core/cimd.d.ts.map +1 -0
  5. package/dist/core/cimd.js +392 -0
  6. package/dist/core/cimd.js.map +1 -0
  7. package/dist/core/errors.d.ts +14 -0
  8. package/dist/core/errors.d.ts.map +1 -1
  9. package/dist/core/errors.js +26 -0
  10. package/dist/core/errors.js.map +1 -1
  11. package/dist/core/oauth-client-resolver.d.ts +54 -0
  12. package/dist/core/oauth-client-resolver.d.ts.map +1 -0
  13. package/dist/core/oauth-client-resolver.js +154 -0
  14. package/dist/core/oauth-client-resolver.js.map +1 -0
  15. package/dist/core/oauth-client.d.ts +39 -0
  16. package/dist/core/oauth-client.d.ts.map +1 -0
  17. package/dist/core/oauth-client.js +236 -0
  18. package/dist/core/oauth-client.js.map +1 -0
  19. package/dist/db/agent-access-retention.d.ts +10 -0
  20. package/dist/db/agent-access-retention.d.ts.map +1 -0
  21. package/dist/db/agent-access-retention.js +11 -0
  22. package/dist/db/agent-access-retention.js.map +1 -0
  23. package/dist/db/agent-access.d.ts +100 -4
  24. package/dist/db/agent-access.d.ts.map +1 -1
  25. package/dist/db/agent-access.js +483 -45
  26. package/dist/db/agent-access.js.map +1 -1
  27. package/dist/index.d.ts +9 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +25 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/metrics/instance.d.ts +1 -1
  32. package/dist/metrics/instance.d.ts.map +1 -1
  33. package/dist/metrics/instance.js.map +1 -1
  34. package/dist/presentation/routes/oauth.d.ts +9 -0
  35. package/dist/presentation/routes/oauth.d.ts.map +1 -0
  36. package/dist/presentation/routes/oauth.js +150 -0
  37. package/dist/presentation/routes/oauth.js.map +1 -0
  38. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  39. package/dist/presentation/routes/rate-limit.js +24 -0
  40. package/dist/presentation/routes/rate-limit.js.map +1 -1
  41. package/dist/temporal/activities/agent-access-retention.d.ts +13 -0
  42. package/dist/temporal/activities/agent-access-retention.d.ts.map +1 -0
  43. package/dist/temporal/activities/agent-access-retention.js +58 -0
  44. package/dist/temporal/activities/agent-access-retention.js.map +1 -0
  45. package/dist/temporal/activities/index.d.ts +5 -0
  46. package/dist/temporal/activities/index.d.ts.map +1 -0
  47. package/dist/temporal/activities/index.js +8 -0
  48. package/dist/temporal/activities/index.js.map +1 -0
  49. package/dist/temporal/constants.d.ts +2 -0
  50. package/dist/temporal/constants.d.ts.map +1 -0
  51. package/dist/temporal/constants.js +3 -0
  52. package/dist/temporal/constants.js.map +1 -0
  53. package/dist/temporal/workflows/agent-access-retention-cron.d.ts +2 -0
  54. package/dist/temporal/workflows/agent-access-retention-cron.d.ts.map +1 -0
  55. package/dist/temporal/workflows/agent-access-retention-cron.js +22 -0
  56. package/dist/temporal/workflows/agent-access-retention-cron.js.map +1 -0
  57. package/dist/temporal/workflows/index.bundle.js +25227 -0
  58. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  59. package/dist/temporal/workflows/index.d.ts +2 -0
  60. package/dist/temporal/workflows/index.d.ts.map +1 -0
  61. package/dist/temporal/workflows/index.js +3 -0
  62. package/dist/temporal/workflows/index.js.map +1 -0
  63. package/dist/tsconfig.test.tsbuildinfo +1 -1
  64. package/package.json +7 -4
  65. package/src/core/cimd.test.ts +213 -0
  66. package/src/core/cimd.ts +532 -0
  67. package/src/core/errors.ts +41 -0
  68. package/src/core/oauth-client-resolver.test.ts +198 -0
  69. package/src/core/oauth-client-resolver.ts +253 -0
  70. package/src/core/oauth-client.test.ts +157 -0
  71. package/src/core/oauth-client.ts +331 -0
  72. package/src/db/agent-access-retention.ts +10 -0
  73. package/src/db/agent-access.test.ts +697 -3
  74. package/src/db/agent-access.ts +953 -91
  75. package/src/index.test.ts +27 -0
  76. package/src/index.ts +76 -0
  77. package/src/metrics/instance.ts +5 -1
  78. package/src/presentation/routes/oauth.test.ts +133 -0
  79. package/src/presentation/routes/oauth.ts +144 -0
  80. package/src/presentation/routes/rate-limit.ts +12 -0
  81. package/src/temporal/activities/agent-access-retention.ts +76 -0
  82. package/src/temporal/activities/index.ts +5 -0
  83. package/src/temporal/constants.ts +1 -0
  84. package/src/temporal/workflows/agent-access-retention-cron.ts +23 -0
  85. package/src/temporal/workflows/index.ts +1 -0
  86. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,6 +1,84 @@
1
- import { and, asc, eq, gt, inArray, isNull, lt, or, sql } from 'drizzle-orm';
1
+ import { and, asc, eq, gt, inArray, isNotNull, isNull, lte, notExists, or, sql } from 'drizzle-orm';
2
+ import { config } from '#config.js';
3
+ import { AGENT_ACCESS_RETENTION_TIMEOUT_MARGIN_MS, AGENT_AUTHORIZATION_RETENTION_DAYS, AGENT_CLIENT_RETENTION_DAYS, AGENT_GRANT_RETENTION_DAYS, AGENT_PAT_RETENTION_DAYS, AGENT_REFRESH_TOKEN_RETENTION_DAYS } from './agent-access-retention.js';
2
4
  import { db } from './db.js';
3
5
  import { agentAuthorizationCodes, agentAuthorizationRequests, agentClients, agentGrants, agentPersonalAccessTokens, agentRefreshTokens, toAgentAuthorizationCode, toAgentAuthorizationRequest, toAgentClient, toAgentGrant, toAgentPersonalAccessToken, toAgentRefreshToken } from './schema/agent-access.js';
6
+ import { users } from './schema/users.js';
7
+ const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
8
+ const MILLISECONDS_PER_SECOND = 1000;
9
+ function retentionCutoff(now, days) {
10
+ if (!Number.isFinite(days) || days < 0) {
11
+ throw new Error('Agent-access retention must be a non-negative finite number of days');
12
+ }
13
+ return new Date(now.getTime() - days * MILLISECONDS_PER_DAY);
14
+ }
15
+ function batchLimit(limit) {
16
+ const value = limit ?? 1000;
17
+ if (!Number.isInteger(value) || value < 1) {
18
+ throw new Error('Agent-access batch limit must be a positive integer');
19
+ }
20
+ return value;
21
+ }
22
+ let AgentAccessRetentionDeadlineExceededError = class AgentAccessRetentionDeadlineExceededError extends Error {
23
+ constructor(){
24
+ super('Agent-access retention deadline exceeded'), this.code = '57014';
25
+ this.name = 'AgentAccessRetentionDeadlineExceededError';
26
+ }
27
+ };
28
+ function retentionStatementTimeoutMs(deadlineMs) {
29
+ if (!Number.isFinite(deadlineMs)) {
30
+ throw new Error('Agent-access retention deadline must be a finite timestamp');
31
+ }
32
+ const remainingMs = Math.floor(deadlineMs - Date.now() - AGENT_ACCESS_RETENTION_TIMEOUT_MARGIN_MS);
33
+ if (remainingMs < 1) throw new AgentAccessRetentionDeadlineExceededError();
34
+ return remainingMs;
35
+ }
36
+ async function prepareAgentAccessRetentionStatement(tx, deadlineMs) {
37
+ if (deadlineMs === undefined) return;
38
+ const timeout = `${retentionStatementTimeoutMs(deadlineMs)}ms`;
39
+ await tx.execute(sql`select set_config('statement_timeout', ${timeout}, true), set_config('lock_timeout', ${timeout}, true)`);
40
+ }
41
+ async function requireActiveAgentGrant(tx, grantId) {
42
+ const grant = await lockAgentGrant(tx, {
43
+ grantId
44
+ });
45
+ if (!grant) throw new Error(`Agent grant ${grantId} was not found`);
46
+ if (grant.revokedAt || grant.terminalAt) {
47
+ throw new Error(`Agent grant ${grantId} is no longer active`);
48
+ }
49
+ return grant;
50
+ }
51
+ async function isActiveUser(tx, userId) {
52
+ const rows = await tx.select({
53
+ id: users.id
54
+ }).from(users).where(and(eq(users.id, userId), eq(users.status, 'active'))).limit(1);
55
+ return rows.length > 0;
56
+ }
57
+ async function lockAgentClient(tx, clientId) {
58
+ const rows = await tx.select().from(agentClients).where(eq(agentClients.id, clientId)).for('update').limit(1);
59
+ const row = rows[0];
60
+ return row ? toAgentClient(row) : undefined;
61
+ }
62
+ function validateRotationGraceSeconds(graceSeconds) {
63
+ if (!Number.isFinite(graceSeconds) || graceSeconds < 0) {
64
+ throw new Error('Agent refresh rotation grace must be a non-negative finite number of seconds');
65
+ }
66
+ return graceSeconds;
67
+ }
68
+ /** Returns the default sliding lifetime for a newly issued agent refresh token. */ export function agentRefreshTokenExpiresAt(now = new Date()) {
69
+ return new Date(now.getTime() + config.AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS * MILLISECONDS_PER_DAY);
70
+ }
71
+ /**
72
+ * A rotated token remains usable for an access-token-only grace response. The
73
+ * row must still exist; pruning deliberately turns later replays into plain
74
+ * unknown-token responses.
75
+ */ export function isWithinAgentRefreshRotationGrace(params) {
76
+ if (!params.rotatedAt) return false;
77
+ const now = params.now ?? new Date();
78
+ const graceSeconds = validateRotationGraceSeconds(params.graceSeconds ?? config.AUTH_REFRESH_ROTATION_GRACE_SECONDS);
79
+ const elapsedMs = now.getTime() - params.rotatedAt.getTime();
80
+ return elapsedMs >= 0 && elapsedMs <= graceSeconds * MILLISECONDS_PER_SECOND;
81
+ }
4
82
  export async function createAgentClient(params) {
5
83
  return await db().transaction((tx)=>createAgentClientTx(tx, params));
6
84
  }
@@ -17,6 +95,31 @@ export async function createAgentClientTx(tx, params) {
17
95
  if (!row) throw new Error('Upsert returned no rows');
18
96
  return toAgentClient(row);
19
97
  }
98
+ /**
99
+ * Stores the latest validated CIMD identity. The generic client upsert above
100
+ * intentionally preserves registration metadata; CIMD documents are the
101
+ * source of truth and therefore use this explicit update path.
102
+ */ export async function upsertCimdAgentClient(params) {
103
+ return await db().transaction(async (tx)=>{
104
+ const rows = await tx.insert(agentClients).values({
105
+ ...params,
106
+ kind: 'cimd'
107
+ }).onConflictDoUpdate({
108
+ target: agentClients.clientId,
109
+ set: {
110
+ name: params.name,
111
+ redirectUris: params.redirectUris,
112
+ kind: 'cimd',
113
+ lastSeenAt: sql`now()`,
114
+ unreferencedAt: null,
115
+ updatedAt: sql`now()`
116
+ }
117
+ }).returning();
118
+ const row = rows[0];
119
+ if (!row) throw new Error('CIMD client upsert returned no row');
120
+ return toAgentClient(row);
121
+ });
122
+ }
20
123
  export async function findAgentClientByClientId(params) {
21
124
  const executor = params.executor ?? db();
22
125
  const rows = await executor.select().from(agentClients).where(eq(agentClients.clientId, params.clientId)).limit(1);
@@ -34,12 +137,17 @@ export async function markAgentClientUnreferenced(tx, params) {
34
137
  await tx.update(agentClients).set({
35
138
  unreferencedAt: sql`now()`,
36
139
  updatedAt: sql`now()`
37
- }).where(eq(agentClients.id, params.clientUuid));
140
+ }).where(and(eq(agentClients.id, params.clientUuid), notExists(tx.select({
141
+ id: agentGrants.id
142
+ }).from(agentGrants).where(eq(agentGrants.clientId, params.clientUuid)))));
38
143
  }
39
144
  export async function createAgentAuthorizationRequest(params) {
40
145
  return await db().transaction((tx)=>createAgentAuthorizationRequestTx(tx, params));
41
146
  }
42
147
  export async function createAgentAuthorizationRequestTx(tx, params) {
148
+ if (!await lockAgentClient(tx, params.clientId)) {
149
+ throw new Error(`Agent client ${params.clientId} was not found`);
150
+ }
43
151
  const rows = await tx.insert(agentAuthorizationRequests).values(params).returning();
44
152
  const row = rows[0];
45
153
  if (!row) throw new Error('Insert returned no rows');
@@ -69,7 +177,15 @@ export async function consumeAgentAuthorizationRequest(params) {
69
177
  export async function createAgentGrant(params) {
70
178
  return await db().transaction((tx)=>createAgentGrantTx(tx, params));
71
179
  }
72
- export async function createAgentGrantTx(tx, params) {
180
+ /**
181
+ * Creates or reauthorizes a grant while holding its client row lock. A newly
182
+ * created grant has no live child until its first authorization code or refresh
183
+ * token is issued, so approval callers must compose that first child creation
184
+ * with this Tx helper in one database transaction.
185
+ */ export async function createAgentGrantTx(tx, params) {
186
+ if (!await lockAgentClient(tx, params.clientId)) {
187
+ throw new Error(`Agent client ${params.clientId} was not found`);
188
+ }
73
189
  const rows = await tx.insert(agentGrants).values(params).onConflictDoUpdate({
74
190
  target: [
75
191
  agentGrants.userId,
@@ -115,6 +231,7 @@ export async function createAgentAuthorizationCode(params) {
115
231
  return await db().transaction((tx)=>createAgentAuthorizationCodeTx(tx, params));
116
232
  }
117
233
  export async function createAgentAuthorizationCodeTx(tx, params) {
234
+ await requireActiveAgentGrant(tx, params.grantId);
118
235
  const rows = await tx.insert(agentAuthorizationCodes).values(params).returning();
119
236
  const row = rows[0];
120
237
  if (!row) throw new Error('Insert returned no rows');
@@ -136,10 +253,21 @@ export async function consumeAgentAuthorizationCode(params) {
136
253
  return await db().transaction((tx)=>consumeAgentAuthorizationCodeTx(tx, params));
137
254
  }
138
255
  /** Claims an authorization code exactly once, including its 60-second TTL. */ export async function consumeAgentAuthorizationCodeTx(tx, params) {
256
+ // Read the binding before taking the grant lock. The conditional update below
257
+ // re-checks consumed_at and expiry after the lock, so concurrent exchanges
258
+ // still have exactly one winner even when both read the code first.
259
+ const existingRows = await tx.select().from(agentAuthorizationCodes).where(eq(agentAuthorizationCodes.hashedCode, params.hashedCode)).limit(1);
260
+ const existing = existingRows[0];
261
+ if (!existing) return undefined;
262
+ const grant = await lockAgentGrant(tx, {
263
+ grantId: existing.grantId
264
+ });
265
+ if (!grant || grant.revokedAt || grant.terminalAt) return undefined;
266
+ if (!await isActiveUser(tx, grant.userId)) return undefined;
139
267
  const rows = await tx.update(agentAuthorizationCodes).set({
140
268
  consumedAt: sql`now()`,
141
269
  updatedAt: sql`now()`
142
- }).where(and(eq(agentAuthorizationCodes.hashedCode, params.hashedCode), isNull(agentAuthorizationCodes.consumedAt), gt(agentAuthorizationCodes.expiresAt, sql`now()`))).returning();
270
+ }).where(and(eq(agentAuthorizationCodes.id, existing.id), eq(agentAuthorizationCodes.hashedCode, params.hashedCode), isNull(agentAuthorizationCodes.consumedAt), gt(agentAuthorizationCodes.expiresAt, sql`now()`))).returning();
143
271
  const row = rows[0];
144
272
  return row ? toAgentAuthorizationCode(row) : undefined;
145
273
  }
@@ -147,14 +275,18 @@ export async function createAgentRefreshToken(params) {
147
275
  return await db().transaction((tx)=>createAgentRefreshTokenTx(tx, params));
148
276
  }
149
277
  export async function createAgentRefreshTokenTx(tx, params) {
150
- const rows = await tx.insert(agentRefreshTokens).values(params).returning();
278
+ await requireActiveAgentGrant(tx, params.grantId);
279
+ const rows = await tx.insert(agentRefreshTokens).values({
280
+ ...params,
281
+ expiresAt: params.expiresAt ?? agentRefreshTokenExpiresAt()
282
+ }).returning();
151
283
  const row = rows[0];
152
284
  if (!row) throw new Error('Insert returned no rows');
153
285
  return toAgentRefreshToken(row);
154
286
  }
155
287
  export async function findAgentRefreshTokenByHash(params) {
156
288
  const executor = params.executor ?? db();
157
- const rows = await executor.select().from(agentRefreshTokens).where(eq(agentRefreshTokens.hashedToken, params.hashedToken)).limit(1);
289
+ const rows = await executor.select().from(agentRefreshTokens).where(and(eq(agentRefreshTokens.hashedToken, params.hashedToken), isNull(agentRefreshTokens.revokedAt))).limit(1);
158
290
  const row = rows[0];
159
291
  return row ? toAgentRefreshToken(row) : undefined;
160
292
  }
@@ -164,25 +296,100 @@ export async function findActiveAgentRefreshTokenByHash(params) {
164
296
  const row = rows[0];
165
297
  return row ? toAgentRefreshToken(row) : undefined;
166
298
  }
299
+ /** Finds the one live refresh token currently attached to a grant. */ export async function findActiveAgentRefreshTokenByGrantId(params) {
300
+ const executor = params.executor ?? db();
301
+ const rows = await executor.select().from(agentRefreshTokens).where(and(eq(agentRefreshTokens.grantId, params.grantId), isNull(agentRefreshTokens.rotatedAt), isNull(agentRefreshTokens.revokedAt), gt(agentRefreshTokens.expiresAt, sql`now()`))).limit(1);
302
+ const row = rows[0];
303
+ return row ? toAgentRefreshToken(row) : undefined;
304
+ }
305
+ /**
306
+ * Resolves a replay of a retained, rotated token. A grace hit returns the one
307
+ * live successor so the caller can mint an access token without issuing a new
308
+ * refresh cookie. A replay after grace revokes the complete grant family.
309
+ * Missing or pruned rows return undefined and therefore remain plain 401s.
310
+ */ export async function resolveAgentRefreshTokenReplay(params) {
311
+ return await db().transaction((tx)=>resolveAgentRefreshTokenReplayTx(tx, params));
312
+ }
313
+ export async function resolveAgentRefreshTokenReplayTx(tx, params) {
314
+ const rows = await tx.select().from(agentRefreshTokens).where(and(eq(agentRefreshTokens.hashedToken, params.hashedToken), isNull(agentRefreshTokens.revokedAt))).limit(1);
315
+ const existing = rows[0];
316
+ if (!existing) return undefined;
317
+ const grant = await lockAgentGrant(tx, {
318
+ grantId: existing.grantId
319
+ });
320
+ if (!grant || grant.revokedAt || grant.terminalAt) return undefined;
321
+ if (!await isActiveUser(tx, grant.userId)) return undefined;
322
+ const currentRows = await tx.select().from(agentRefreshTokens).where(and(eq(agentRefreshTokens.id, existing.id), isNull(agentRefreshTokens.revokedAt))).limit(1);
323
+ const current = currentRows[0];
324
+ if (!current?.rotatedAt) return undefined;
325
+ const now = params.now ?? new Date();
326
+ if (isWithinAgentRefreshRotationGrace({
327
+ rotatedAt: current.rotatedAt,
328
+ now,
329
+ graceSeconds: params.graceSeconds
330
+ })) {
331
+ const successorRows = await tx.select().from(agentRefreshTokens).where(and(eq(agentRefreshTokens.grantId, current.grantId), isNull(agentRefreshTokens.rotatedAt), isNull(agentRefreshTokens.revokedAt), gt(agentRefreshTokens.expiresAt, now))).limit(1);
332
+ const successor = successorRows[0];
333
+ if (!successor) return undefined;
334
+ return {
335
+ kind: 'grace',
336
+ grant,
337
+ predecessor: toAgentRefreshToken(current),
338
+ successor: toAgentRefreshToken(successor)
339
+ };
340
+ }
341
+ const revokedGrant = await revokeAgentGrantTx(tx, {
342
+ grantId: current.grantId
343
+ });
344
+ if (!revokedGrant) return undefined;
345
+ return {
346
+ kind: 'reused',
347
+ grant: revokedGrant,
348
+ predecessor: toAgentRefreshToken(current)
349
+ };
350
+ }
167
351
  /** Rotates a refresh token atomically; a concurrent rotation cannot win twice. */ export async function rotateAgentRefreshToken(params) {
168
352
  return await db().transaction((tx)=>rotateAgentRefreshTokenTx(tx, params));
169
353
  }
170
354
  export async function rotateAgentRefreshTokenTx(tx, params) {
355
+ const replacementExpiresAt = params.replacementExpiresAt ?? agentRefreshTokenExpiresAt();
356
+ const existingRows = await tx.select().from(agentRefreshTokens).where(and(eq(agentRefreshTokens.hashedToken, params.hashedToken), isNull(agentRefreshTokens.revokedAt))).limit(1);
357
+ const existing = existingRows[0];
358
+ if (!existing) return undefined;
359
+ const grant = await lockAgentGrant(tx, {
360
+ grantId: existing.grantId
361
+ });
362
+ if (!grant || grant.revokedAt || grant.terminalAt) return undefined;
363
+ if (!await isActiveUser(tx, grant.userId)) return undefined;
171
364
  const rows = await tx.update(agentRefreshTokens).set({
172
365
  rotatedAt: sql`now()`,
173
366
  lastUsedAt: sql`now()`,
174
367
  updatedAt: sql`now()`
175
- }).where(and(eq(agentRefreshTokens.hashedToken, params.hashedToken), isNull(agentRefreshTokens.rotatedAt), isNull(agentRefreshTokens.revokedAt), gt(agentRefreshTokens.expiresAt, sql`now()`))).returning();
368
+ }).where(and(eq(agentRefreshTokens.id, existing.id), eq(agentRefreshTokens.hashedToken, params.hashedToken), isNull(agentRefreshTokens.rotatedAt), isNull(agentRefreshTokens.revokedAt), gt(agentRefreshTokens.expiresAt, sql`now()`))).returning();
176
369
  const row = rows[0];
177
370
  if (!row) return undefined;
178
- return await createAgentRefreshTokenTx(tx, {
371
+ await tx.update(agentGrants).set({
372
+ lastUsedAt: sql`now()`,
373
+ updatedAt: sql`now()`
374
+ }).where(eq(agentGrants.id, row.grantId));
375
+ const successorRows = await tx.insert(agentRefreshTokens).values({
179
376
  grantId: row.grantId,
180
377
  hashedToken: params.replacementHashedToken,
181
- expiresAt: params.replacementExpiresAt
182
- });
378
+ expiresAt: replacementExpiresAt
379
+ }).returning();
380
+ const successor = successorRows[0];
381
+ if (!successor) throw new Error('Insert returned no rows');
382
+ return toAgentRefreshToken(successor);
183
383
  }
184
384
  export async function revokeAgentRefreshToken(params) {
185
385
  return await db().transaction(async (tx)=>{
386
+ const existingRows = await tx.select().from(agentRefreshTokens).where(eq(agentRefreshTokens.id, params.id)).limit(1);
387
+ const existing = existingRows[0];
388
+ if (!existing) return undefined;
389
+ const grant = await lockAgentGrant(tx, {
390
+ grantId: existing.grantId
391
+ });
392
+ if (!grant) return undefined;
186
393
  const rows = await tx.update(agentRefreshTokens).set({
187
394
  revokedAt: sql`now()`,
188
395
  updatedAt: sql`now()`
@@ -191,6 +398,76 @@ export async function revokeAgentRefreshToken(params) {
191
398
  return row ? toAgentRefreshToken(row) : undefined;
192
399
  });
193
400
  }
401
+ /** Revokes a grant and every refresh-token generation in its family. */ export async function revokeAgentGrant(params) {
402
+ return await db().transaction((tx)=>revokeAgentGrantTx(tx, params));
403
+ }
404
+ export async function revokeAgentGrantTx(tx, params) {
405
+ const grant = await lockAgentGrant(tx, params);
406
+ if (!grant) return undefined;
407
+ const rows = await tx.update(agentGrants).set({
408
+ revokedAt: grant.revokedAt ?? sql`now()`,
409
+ terminalAt: grant.terminalAt ?? sql`now()`,
410
+ updatedAt: sql`now()`
411
+ }).where(eq(agentGrants.id, grant.id)).returning();
412
+ const updatedGrant = rows[0];
413
+ if (!updatedGrant) return undefined;
414
+ await tx.update(agentRefreshTokens).set({
415
+ revokedAt: sql`now()`,
416
+ updatedAt: sql`now()`
417
+ }).where(and(eq(agentRefreshTokens.grantId, grant.id), isNull(agentRefreshTokens.revokedAt)));
418
+ return toAgentGrant(updatedGrant);
419
+ }
420
+ /**
421
+ * Marks grants terminal once they have no usable refresh token or authorization
422
+ * code left. Each candidate is locked before its children are inspected so a
423
+ * concurrent code exchange or token rotation cannot create a live child after
424
+ * the decision.
425
+ */ export async function transitionAgentGrantsToTerminal(params = {}) {
426
+ return await db().transaction((tx)=>transitionAgentGrantsToTerminalTx(tx, params));
427
+ }
428
+ export async function transitionAgentGrantsToTerminalTx(tx, params = {}) {
429
+ const limit = batchLimit(params.limit);
430
+ const now = params.now ?? new Date();
431
+ const liveRefreshTokens = tx.select({
432
+ id: agentRefreshTokens.id
433
+ }).from(agentRefreshTokens).where(and(eq(agentRefreshTokens.grantId, agentGrants.id), isNull(agentRefreshTokens.rotatedAt), isNull(agentRefreshTokens.revokedAt), gt(agentRefreshTokens.expiresAt, now)));
434
+ const liveAuthorizationCodes = tx.select({
435
+ id: agentAuthorizationCodes.id
436
+ }).from(agentAuthorizationCodes).where(and(eq(agentAuthorizationCodes.grantId, agentGrants.id), isNull(agentAuthorizationCodes.consumedAt), gt(agentAuthorizationCodes.expiresAt, now)));
437
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
438
+ const candidates = await tx.select({
439
+ id: agentGrants.id
440
+ }).from(agentGrants).where(and(isNull(agentGrants.terminalAt), or(isNotNull(agentGrants.revokedAt), and(notExists(liveRefreshTokens), notExists(liveAuthorizationCodes))))).orderBy(asc(agentGrants.createdAt), asc(agentGrants.id)).limit(limit);
441
+ let transitioned = 0;
442
+ for (const candidate of candidates){
443
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
444
+ const grant = await lockAgentGrant(tx, {
445
+ grantId: candidate.id
446
+ });
447
+ if (!grant || grant.terminalAt) continue;
448
+ if (!grant.revokedAt) {
449
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
450
+ const liveRefreshTokens = await tx.select({
451
+ id: agentRefreshTokens.id
452
+ }).from(agentRefreshTokens).where(and(eq(agentRefreshTokens.grantId, grant.id), isNull(agentRefreshTokens.rotatedAt), isNull(agentRefreshTokens.revokedAt), gt(agentRefreshTokens.expiresAt, now))).limit(1);
453
+ if (liveRefreshTokens.length > 0) continue;
454
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
455
+ const liveAuthorizationCodes = await tx.select({
456
+ id: agentAuthorizationCodes.id
457
+ }).from(agentAuthorizationCodes).where(and(eq(agentAuthorizationCodes.grantId, grant.id), isNull(agentAuthorizationCodes.consumedAt), gt(agentAuthorizationCodes.expiresAt, now))).limit(1);
458
+ if (liveAuthorizationCodes.length > 0) continue;
459
+ }
460
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
461
+ const rows = await tx.update(agentGrants).set({
462
+ terminalAt: now,
463
+ updatedAt: now
464
+ }).where(and(eq(agentGrants.id, grant.id), isNull(agentGrants.terminalAt))).returning({
465
+ id: agentGrants.id
466
+ });
467
+ if (rows.length > 0) transitioned += 1;
468
+ }
469
+ return transitioned;
470
+ }
194
471
  export async function createAgentPersonalAccessToken(params) {
195
472
  return await db().transaction((tx)=>createAgentPersonalAccessTokenTx(tx, params));
196
473
  }
@@ -208,17 +485,29 @@ export async function findAgentPersonalAccessTokenByHash(params) {
208
485
  }
209
486
  export async function findActiveAgentPersonalAccessTokenByHash(params) {
210
487
  const executor = params.executor ?? db();
211
- const rows = await executor.select().from(agentPersonalAccessTokens).where(and(eq(agentPersonalAccessTokens.hashedToken, params.hashedToken), isNull(agentPersonalAccessTokens.revokedAt), gt(agentPersonalAccessTokens.expiresAt, sql`now()`))).limit(1);
212
- const row = rows[0];
488
+ const rows = await executor.select({
489
+ token: agentPersonalAccessTokens
490
+ }).from(agentPersonalAccessTokens).innerJoin(users, eq(agentPersonalAccessTokens.userId, users.id)).where(and(eq(agentPersonalAccessTokens.hashedToken, params.hashedToken), isNull(agentPersonalAccessTokens.revokedAt), gt(agentPersonalAccessTokens.expiresAt, sql`now()`), eq(users.status, 'active'))).limit(1);
491
+ const row = rows[0]?.token;
213
492
  return row ? toAgentPersonalAccessToken(row) : undefined;
214
493
  }
215
494
  export async function markAgentPersonalAccessTokenUsed(params) {
495
+ const throttleSeconds = params.throttleSeconds ?? 60;
496
+ if (!Number.isFinite(throttleSeconds) || throttleSeconds < 0) {
497
+ throw new Error('PAT last-used throttle must be a non-negative finite number of seconds');
498
+ }
216
499
  const rows = await db().update(agentPersonalAccessTokens).set({
217
500
  lastUsedAt: sql`now()`,
218
501
  updatedAt: sql`now()`
219
- }).where(eq(agentPersonalAccessTokens.id, params.id)).returning();
502
+ }).where(and(eq(agentPersonalAccessTokens.id, params.id), isNull(agentPersonalAccessTokens.revokedAt), gt(agentPersonalAccessTokens.expiresAt, sql`now()`), or(isNull(agentPersonalAccessTokens.lastUsedAt), lte(agentPersonalAccessTokens.lastUsedAt, sql`now() - (${throttleSeconds} || ' seconds')::interval`)))).returning();
220
503
  const row = rows[0];
221
- return row ? toAgentPersonalAccessToken(row) : undefined;
504
+ if (row) return toAgentPersonalAccessToken(row);
505
+ // A valid token can intentionally skip the write inside the throttle
506
+ // window. Return its current row so callers can distinguish that from an
507
+ // invalid, expired, or revoked token.
508
+ const existingRows = await db().select().from(agentPersonalAccessTokens).where(and(eq(agentPersonalAccessTokens.id, params.id), isNull(agentPersonalAccessTokens.revokedAt), gt(agentPersonalAccessTokens.expiresAt, sql`now()`))).limit(1);
509
+ const existing = existingRows[0];
510
+ return existing ? toAgentPersonalAccessToken(existing) : undefined;
222
511
  }
223
512
  export async function revokeAgentPersonalAccessToken(params) {
224
513
  const rows = await db().update(agentPersonalAccessTokens).set({
@@ -228,39 +517,188 @@ export async function revokeAgentPersonalAccessToken(params) {
228
517
  const row = rows[0];
229
518
  return row ? toAgentPersonalAccessToken(row) : undefined;
230
519
  }
231
- /** Deletes terminal credentials and ephemeral authorization rows in bounded batches. */ export async function pruneAgentAccess(params = {}) {
520
+ async function deleteExpiredAgentAuthorizationRequests(tx, cutoff, limit, deadlineMs) {
521
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
522
+ const candidates = await tx.select({
523
+ id: agentAuthorizationRequests.id
524
+ }).from(agentAuthorizationRequests).where(lte(agentAuthorizationRequests.expiresAt, cutoff)).orderBy(asc(agentAuthorizationRequests.expiresAt), asc(agentAuthorizationRequests.id)).limit(limit);
525
+ if (candidates.length === 0) return 0;
526
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
527
+ const rows = await tx.delete(agentAuthorizationRequests).where(inArray(agentAuthorizationRequests.id, candidates.map(({ id })=>id))).returning({
528
+ id: agentAuthorizationRequests.id
529
+ });
530
+ return rows.length;
531
+ }
532
+ async function deleteExpiredAgentAuthorizationCodes(tx, cutoff, limit, deadlineMs) {
533
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
534
+ const candidates = await tx.select({
535
+ id: agentAuthorizationCodes.id
536
+ }).from(agentAuthorizationCodes).where(lte(agentAuthorizationCodes.expiresAt, cutoff)).orderBy(asc(agentAuthorizationCodes.expiresAt), asc(agentAuthorizationCodes.id)).limit(limit);
537
+ if (candidates.length === 0) return 0;
538
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
539
+ const rows = await tx.delete(agentAuthorizationCodes).where(inArray(agentAuthorizationCodes.id, candidates.map(({ id })=>id))).returning({
540
+ id: agentAuthorizationCodes.id
541
+ });
542
+ return rows.length;
543
+ }
544
+ async function deleteRetainedAgentRefreshTokens(tx, cutoff, limit, deadlineMs) {
545
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
546
+ const candidates = await tx.select({
547
+ id: agentRefreshTokens.id
548
+ }).from(agentRefreshTokens).where(or(lte(agentRefreshTokens.rotatedAt, cutoff), lte(agentRefreshTokens.revokedAt, cutoff), and(isNull(agentRefreshTokens.rotatedAt), isNull(agentRefreshTokens.revokedAt), lte(agentRefreshTokens.expiresAt, cutoff)))).orderBy(asc(agentRefreshTokens.expiresAt), asc(agentRefreshTokens.id)).limit(limit);
549
+ if (candidates.length === 0) return 0;
550
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
551
+ const rows = await tx.delete(agentRefreshTokens).where(inArray(agentRefreshTokens.id, candidates.map(({ id })=>id))).returning({
552
+ id: agentRefreshTokens.id
553
+ });
554
+ return rows.length;
555
+ }
556
+ async function deleteRetainedAgentPersonalAccessTokens(tx, cutoff, limit, deadlineMs) {
557
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
558
+ const candidates = await tx.select({
559
+ id: agentPersonalAccessTokens.id
560
+ }).from(agentPersonalAccessTokens).where(or(lte(agentPersonalAccessTokens.revokedAt, cutoff), and(isNull(agentPersonalAccessTokens.revokedAt), lte(agentPersonalAccessTokens.expiresAt, cutoff)))).orderBy(asc(agentPersonalAccessTokens.expiresAt), asc(agentPersonalAccessTokens.id)).limit(limit);
561
+ if (candidates.length === 0) return 0;
562
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
563
+ const rows = await tx.delete(agentPersonalAccessTokens).where(inArray(agentPersonalAccessTokens.id, candidates.map(({ id })=>id))).returning({
564
+ id: agentPersonalAccessTokens.id
565
+ });
566
+ return rows.length;
567
+ }
568
+ async function pruneTerminalAgentGrants(tx, params) {
569
+ const remainingCodes = tx.select({
570
+ id: agentAuthorizationCodes.id
571
+ }).from(agentAuthorizationCodes).where(eq(agentAuthorizationCodes.grantId, agentGrants.id));
572
+ const remainingRefreshTokens = tx.select({
573
+ id: agentRefreshTokens.id
574
+ }).from(agentRefreshTokens).where(eq(agentRefreshTokens.grantId, agentGrants.id));
575
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
576
+ const candidates = await tx.select({
577
+ id: agentGrants.id,
578
+ clientId: agentGrants.clientId
579
+ }).from(agentGrants).where(and(lte(agentGrants.terminalAt, params.cutoff), notExists(remainingCodes), notExists(remainingRefreshTokens))).orderBy(asc(agentGrants.terminalAt), asc(agentGrants.id)).limit(params.limit).for('update');
580
+ if (candidates.length === 0) return 0;
581
+ const grantIds = candidates.map(({ id })=>id);
582
+ // Child retention runs before this function. Do not delete a child merely
583
+ // because its grant reached the 90-day horizon: a child can have been
584
+ // revoked after the grant became terminal and still needs its own 30-day
585
+ // replay/forensics window. Such a grant waits for the next sweep.
586
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
587
+ const remainingCodeGrants = await tx.select({
588
+ grantId: agentAuthorizationCodes.grantId
589
+ }).from(agentAuthorizationCodes).where(inArray(agentAuthorizationCodes.grantId, grantIds));
590
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
591
+ const remainingRefreshTokenGrants = await tx.select({
592
+ grantId: agentRefreshTokens.grantId
593
+ }).from(agentRefreshTokens).where(inArray(agentRefreshTokens.grantId, grantIds));
594
+ const grantsWithChildren = new Set([
595
+ ...remainingCodeGrants.map(({ grantId })=>grantId),
596
+ ...remainingRefreshTokenGrants.map(({ grantId })=>grantId)
597
+ ]);
598
+ const deletableGrantIds = grantIds.filter((grantId)=>!grantsWithChildren.has(grantId));
599
+ if (deletableGrantIds.length === 0) return 0;
600
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
601
+ const deletedGrants = await tx.delete(agentGrants).where(inArray(agentGrants.id, deletableGrantIds)).returning({
602
+ id: agentGrants.id,
603
+ clientId: agentGrants.clientId
604
+ });
605
+ const clientIds = new Set(deletedGrants.map(({ clientId })=>clientId));
606
+ if (clientIds.size > 0) {
607
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
608
+ await tx.update(agentClients).set({
609
+ unreferencedAt: sql`coalesce(${agentClients.unreferencedAt}, ${params.now})`,
610
+ updatedAt: params.now
611
+ }).where(and(inArray(agentClients.id, [
612
+ ...clientIds
613
+ ]), notExists(tx.select({
614
+ id: agentGrants.id
615
+ }).from(agentGrants).where(eq(agentGrants.clientId, agentClients.id)))));
616
+ }
617
+ return deletedGrants.length;
618
+ }
619
+ async function pruneUnreferencedAgentClients(tx, params) {
620
+ const liveAuthorizationRequests = tx.select({
621
+ id: agentAuthorizationRequests.id
622
+ }).from(agentAuthorizationRequests).where(and(eq(agentAuthorizationRequests.clientId, agentClients.id), isNull(agentAuthorizationRequests.consumedAt), gt(agentAuthorizationRequests.expiresAt, params.now)));
623
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
624
+ const candidates = await tx.select({
625
+ id: agentClients.id
626
+ }).from(agentClients).where(and(or(lte(agentClients.unreferencedAt, params.cutoff), and(isNull(agentClients.unreferencedAt), lte(agentClients.createdAt, params.cutoff), notExists(tx.select({
627
+ id: agentGrants.id
628
+ }).from(agentGrants).where(eq(agentGrants.clientId, agentClients.id))))), notExists(liveAuthorizationRequests))).orderBy(asc(agentClients.unreferencedAt), asc(agentClients.createdAt), asc(agentClients.id)).limit(params.limit).for('update');
629
+ if (candidates.length === 0) return 0;
630
+ const candidateIds = candidates.map(({ id })=>id);
631
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
632
+ const grantsForCandidates = await tx.select({
633
+ clientId: agentGrants.clientId
634
+ }).from(agentGrants).where(inArray(agentGrants.clientId, candidateIds));
635
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
636
+ const liveRequestsForCandidates = await tx.select({
637
+ clientId: agentAuthorizationRequests.clientId
638
+ }).from(agentAuthorizationRequests).where(and(inArray(agentAuthorizationRequests.clientId, candidateIds), isNull(agentAuthorizationRequests.consumedAt), gt(agentAuthorizationRequests.expiresAt, params.now)));
639
+ const protectedClientIds = new Set([
640
+ ...grantsForCandidates.map(({ clientId })=>clientId),
641
+ ...liveRequestsForCandidates.map(({ clientId })=>clientId)
642
+ ]);
643
+ const deletableClientIds = candidateIds.filter((id)=>!protectedClientIds.has(id));
644
+ if (deletableClientIds.length === 0) return 0;
645
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
646
+ const rows = await tx.delete(agentClients).where(and(inArray(agentClients.id, deletableClientIds), or(lte(agentClients.unreferencedAt, params.cutoff), and(isNull(agentClients.unreferencedAt), lte(agentClients.createdAt, params.cutoff))), notExists(tx.select({
647
+ id: agentGrants.id
648
+ }).from(agentGrants).where(eq(agentGrants.clientId, agentClients.id))), notExists(tx.select({
649
+ id: agentAuthorizationRequests.id
650
+ }).from(agentAuthorizationRequests).where(and(eq(agentAuthorizationRequests.clientId, agentClients.id), isNull(agentAuthorizationRequests.consumedAt), gt(agentAuthorizationRequests.expiresAt, params.now)))))).returning({
651
+ id: agentClients.id
652
+ });
653
+ return rows.length;
654
+ }
655
+ /**
656
+ * Transitions inactive grants and deletes retained agent-access rows in bounded
657
+ * batches. The caller can override the common horizon in tests; production uses
658
+ * the per-row windows from the agent-access retention policy.
659
+ */ export async function pruneAgentAccess(params = {}) {
660
+ return (await pruneAgentAccessBatch(params)).deleted;
661
+ }
662
+ /** Runs one bounded retention transaction and reports terminal transitions separately. */ export async function pruneAgentAccessBatch(params = {}) {
232
663
  const now = params.now ?? new Date();
233
- const retentionDays = params.retentionDays ?? 7;
234
- const limit = params.limit ?? 1000;
235
- const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000);
664
+ const limit = batchLimit(params.limit);
665
+ const retentionDays = params.retentionDays;
666
+ const authorizationCutoff = retentionCutoff(now, retentionDays ?? AGENT_AUTHORIZATION_RETENTION_DAYS);
667
+ const refreshTokenCutoff = retentionCutoff(now, retentionDays ?? AGENT_REFRESH_TOKEN_RETENTION_DAYS);
668
+ const grantCutoff = retentionCutoff(now, retentionDays ?? AGENT_GRANT_RETENTION_DAYS);
669
+ const patCutoff = retentionCutoff(now, retentionDays ?? AGENT_PAT_RETENTION_DAYS);
670
+ const clientCutoff = retentionCutoff(now, retentionDays ?? AGENT_CLIENT_RETENTION_DAYS);
671
+ const cutoffs = {
672
+ authorization: authorizationCutoff,
673
+ refreshToken: refreshTokenCutoff,
674
+ grant: grantCutoff,
675
+ pat: patCutoff,
676
+ client: clientCutoff
677
+ };
236
678
  return await db().transaction(async (tx)=>{
237
- const requestIds = tx.select({
238
- id: agentAuthorizationRequests.id
239
- }).from(agentAuthorizationRequests).where(or(lt(agentAuthorizationRequests.consumedAt, cutoff), and(isNull(agentAuthorizationRequests.consumedAt), lt(agentAuthorizationRequests.expiresAt, cutoff)))).orderBy(asc(agentAuthorizationRequests.expiresAt), asc(agentAuthorizationRequests.id)).limit(limit);
240
- const codeIds = tx.select({
241
- id: agentAuthorizationCodes.id
242
- }).from(agentAuthorizationCodes).where(or(lt(agentAuthorizationCodes.consumedAt, cutoff), and(isNull(agentAuthorizationCodes.consumedAt), lt(agentAuthorizationCodes.expiresAt, cutoff)))).orderBy(asc(agentAuthorizationCodes.expiresAt), asc(agentAuthorizationCodes.id)).limit(limit);
243
- const refreshTokenIds = tx.select({
244
- id: agentRefreshTokens.id
245
- }).from(agentRefreshTokens).where(or(lt(agentRefreshTokens.rotatedAt, cutoff), and(isNull(agentRefreshTokens.rotatedAt), lt(agentRefreshTokens.revokedAt, cutoff)), and(isNull(agentRefreshTokens.rotatedAt), isNull(agentRefreshTokens.revokedAt), lt(agentRefreshTokens.expiresAt, cutoff)))).orderBy(asc(agentRefreshTokens.expiresAt), asc(agentRefreshTokens.id)).limit(limit);
246
- const patIds = tx.select({
247
- id: agentPersonalAccessTokens.id
248
- }).from(agentPersonalAccessTokens).where(or(lt(agentPersonalAccessTokens.revokedAt, cutoff), and(isNull(agentPersonalAccessTokens.revokedAt), lt(agentPersonalAccessTokens.expiresAt, cutoff)))).orderBy(asc(agentPersonalAccessTokens.expiresAt), asc(agentPersonalAccessTokens.id)).limit(limit);
249
- const [requests, codes, refreshTokens, pats] = await Promise.all([
250
- tx.delete(agentAuthorizationRequests).where(inArray(agentAuthorizationRequests.id, requestIds)).returning({
251
- id: agentAuthorizationRequests.id
252
- }),
253
- tx.delete(agentAuthorizationCodes).where(inArray(agentAuthorizationCodes.id, codeIds)).returning({
254
- id: agentAuthorizationCodes.id
255
- }),
256
- tx.delete(agentRefreshTokens).where(inArray(agentRefreshTokens.id, refreshTokenIds)).returning({
257
- id: agentRefreshTokens.id
258
- }),
259
- tx.delete(agentPersonalAccessTokens).where(inArray(agentPersonalAccessTokens.id, patIds)).returning({
260
- id: agentPersonalAccessTokens.id
261
- })
262
- ]);
263
- return requests.length + codes.length + refreshTokens.length + pats.length;
679
+ // This runs first so grants whose last child expired can be terminalized in
680
+ // the same maintenance tick. It takes the same grant row lock as code
681
+ // exchange and refresh rotation.
682
+ const transitioned = await transitionAgentGrantsToTerminalTx(tx, {
683
+ limit,
684
+ now,
685
+ deadlineMs: params.deadlineMs
686
+ });
687
+ const deleted = await deleteExpiredAgentAuthorizationRequests(tx, cutoffs.authorization, limit, params.deadlineMs) + await deleteExpiredAgentAuthorizationCodes(tx, cutoffs.authorization, limit, params.deadlineMs) + await deleteRetainedAgentRefreshTokens(tx, cutoffs.refreshToken, limit, params.deadlineMs) + await deleteRetainedAgentPersonalAccessTokens(tx, cutoffs.pat, limit, params.deadlineMs) + await pruneTerminalAgentGrants(tx, {
688
+ cutoff: cutoffs.grant,
689
+ now,
690
+ limit,
691
+ deadlineMs: params.deadlineMs
692
+ }) + await pruneUnreferencedAgentClients(tx, {
693
+ cutoff: cutoffs.client,
694
+ now,
695
+ limit,
696
+ deadlineMs: params.deadlineMs
697
+ });
698
+ return {
699
+ deleted,
700
+ transitioned
701
+ };
264
702
  });
265
703
  }
266
704