@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,4 +1,5 @@
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';
2
3
  import type {
3
4
  AgentAuthorizationCode,
4
5
  AgentAuthorizationRequest,
@@ -7,6 +8,14 @@ import type {
7
8
  AgentPersonalAccessToken,
8
9
  AgentRefreshToken,
9
10
  } from '#core/entities/agent-access.js';
11
+ import {
12
+ AGENT_ACCESS_RETENTION_TIMEOUT_MARGIN_MS,
13
+ AGENT_AUTHORIZATION_RETENTION_DAYS,
14
+ AGENT_CLIENT_RETENTION_DAYS,
15
+ AGENT_GRANT_RETENTION_DAYS,
16
+ AGENT_PAT_RETENTION_DAYS,
17
+ AGENT_REFRESH_TOKEN_RETENTION_DAYS,
18
+ } from './agent-access-retention.js';
10
19
  import {db} from './db.js';
11
20
  import {
12
21
  agentAuthorizationCodes,
@@ -22,11 +31,124 @@ import {
22
31
  toAgentPersonalAccessToken,
23
32
  toAgentRefreshToken,
24
33
  } from './schema/agent-access.js';
34
+ import {users} from './schema/users.js';
25
35
 
26
36
  export type AgentAccessTx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
27
37
 
28
38
  type AgentAccessExecutor = ReturnType<typeof db> | AgentAccessTx;
29
39
 
40
+ const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
41
+ const MILLISECONDS_PER_SECOND = 1000;
42
+
43
+ function retentionCutoff(now: Date, days: number): Date {
44
+ if (!Number.isFinite(days) || days < 0) {
45
+ throw new Error('Agent-access retention must be a non-negative finite number of days');
46
+ }
47
+ return new Date(now.getTime() - days * MILLISECONDS_PER_DAY);
48
+ }
49
+
50
+ function batchLimit(limit: number | undefined): number {
51
+ const value = limit ?? 1000;
52
+ if (!Number.isInteger(value) || value < 1) {
53
+ throw new Error('Agent-access batch limit must be a positive integer');
54
+ }
55
+ return value;
56
+ }
57
+
58
+ class AgentAccessRetentionDeadlineExceededError extends Error {
59
+ readonly code = '57014';
60
+
61
+ constructor() {
62
+ super('Agent-access retention deadline exceeded');
63
+ this.name = 'AgentAccessRetentionDeadlineExceededError';
64
+ }
65
+ }
66
+
67
+ function retentionStatementTimeoutMs(deadlineMs: number): number {
68
+ if (!Number.isFinite(deadlineMs)) {
69
+ throw new Error('Agent-access retention deadline must be a finite timestamp');
70
+ }
71
+ const remainingMs = Math.floor(
72
+ deadlineMs - Date.now() - AGENT_ACCESS_RETENTION_TIMEOUT_MARGIN_MS,
73
+ );
74
+ if (remainingMs < 1) throw new AgentAccessRetentionDeadlineExceededError();
75
+ return remainingMs;
76
+ }
77
+
78
+ async function prepareAgentAccessRetentionStatement(
79
+ tx: AgentAccessTx,
80
+ deadlineMs: number | undefined,
81
+ ): Promise<void> {
82
+ if (deadlineMs === undefined) return;
83
+ const timeout = `${retentionStatementTimeoutMs(deadlineMs)}ms`;
84
+ await tx.execute(
85
+ sql`select set_config('statement_timeout', ${timeout}, true), set_config('lock_timeout', ${timeout}, true)`,
86
+ );
87
+ }
88
+
89
+ async function requireActiveAgentGrant(tx: AgentAccessTx, grantId: string): Promise<AgentGrant> {
90
+ const grant = await lockAgentGrant(tx, {grantId});
91
+ if (!grant) throw new Error(`Agent grant ${grantId} was not found`);
92
+ if (grant.revokedAt || grant.terminalAt) {
93
+ throw new Error(`Agent grant ${grantId} is no longer active`);
94
+ }
95
+ return grant;
96
+ }
97
+
98
+ async function isActiveUser(tx: AgentAccessTx, userId: string): Promise<boolean> {
99
+ const rows = await tx
100
+ .select({id: users.id})
101
+ .from(users)
102
+ .where(and(eq(users.id, userId), eq(users.status, 'active')))
103
+ .limit(1);
104
+ return rows.length > 0;
105
+ }
106
+
107
+ async function lockAgentClient(
108
+ tx: AgentAccessTx,
109
+ clientId: string,
110
+ ): Promise<AgentClient | undefined> {
111
+ const rows = await tx
112
+ .select()
113
+ .from(agentClients)
114
+ .where(eq(agentClients.id, clientId))
115
+ .for('update')
116
+ .limit(1);
117
+ const row = rows[0];
118
+ return row ? toAgentClient(row) : undefined;
119
+ }
120
+
121
+ function validateRotationGraceSeconds(graceSeconds: number): number {
122
+ if (!Number.isFinite(graceSeconds) || graceSeconds < 0) {
123
+ throw new Error('Agent refresh rotation grace must be a non-negative finite number of seconds');
124
+ }
125
+ return graceSeconds;
126
+ }
127
+
128
+ /** Returns the default sliding lifetime for a newly issued agent refresh token. */
129
+ export function agentRefreshTokenExpiresAt(now: Date = new Date()): Date {
130
+ return new Date(now.getTime() + config.AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS * MILLISECONDS_PER_DAY);
131
+ }
132
+
133
+ /**
134
+ * A rotated token remains usable for an access-token-only grace response. The
135
+ * row must still exist; pruning deliberately turns later replays into plain
136
+ * unknown-token responses.
137
+ */
138
+ export function isWithinAgentRefreshRotationGrace(params: {
139
+ rotatedAt: Date | null;
140
+ now?: Date;
141
+ graceSeconds?: number | undefined;
142
+ }): boolean {
143
+ if (!params.rotatedAt) return false;
144
+ const now = params.now ?? new Date();
145
+ const graceSeconds = validateRotationGraceSeconds(
146
+ params.graceSeconds ?? config.AUTH_REFRESH_ROTATION_GRACE_SECONDS,
147
+ );
148
+ const elapsedMs = now.getTime() - params.rotatedAt.getTime();
149
+ return elapsedMs >= 0 && elapsedMs <= graceSeconds * MILLISECONDS_PER_SECOND;
150
+ }
151
+
30
152
  export interface CreateAgentClientParams {
31
153
  clientId: string;
32
154
  name: string;
@@ -59,6 +181,42 @@ export async function createAgentClientTx(
59
181
  return toAgentClient(row);
60
182
  }
61
183
 
184
+ export interface UpsertCimdAgentClientParams {
185
+ clientId: string;
186
+ name: string;
187
+ redirectUris: string[];
188
+ }
189
+
190
+ /**
191
+ * Stores the latest validated CIMD identity. The generic client upsert above
192
+ * intentionally preserves registration metadata; CIMD documents are the
193
+ * source of truth and therefore use this explicit update path.
194
+ */
195
+ export async function upsertCimdAgentClient(
196
+ params: UpsertCimdAgentClientParams,
197
+ ): Promise<AgentClient> {
198
+ return await db().transaction(async (tx) => {
199
+ const rows = await tx
200
+ .insert(agentClients)
201
+ .values({...params, kind: 'cimd'})
202
+ .onConflictDoUpdate({
203
+ target: agentClients.clientId,
204
+ set: {
205
+ name: params.name,
206
+ redirectUris: params.redirectUris,
207
+ kind: 'cimd',
208
+ lastSeenAt: sql`now()`,
209
+ unreferencedAt: null,
210
+ updatedAt: sql`now()`,
211
+ },
212
+ })
213
+ .returning();
214
+ const row = rows[0];
215
+ if (!row) throw new Error('CIMD client upsert returned no row');
216
+ return toAgentClient(row);
217
+ });
218
+ }
219
+
62
220
  export async function findAgentClientByClientId(params: {
63
221
  clientId: string;
64
222
  executor?: AgentAccessExecutor;
@@ -90,7 +248,17 @@ export async function markAgentClientUnreferenced(
90
248
  await tx
91
249
  .update(agentClients)
92
250
  .set({unreferencedAt: sql`now()`, updatedAt: sql`now()`})
93
- .where(eq(agentClients.id, params.clientUuid));
251
+ .where(
252
+ and(
253
+ eq(agentClients.id, params.clientUuid),
254
+ notExists(
255
+ tx
256
+ .select({id: agentGrants.id})
257
+ .from(agentGrants)
258
+ .where(eq(agentGrants.clientId, params.clientUuid)),
259
+ ),
260
+ ),
261
+ );
94
262
  }
95
263
 
96
264
  export interface CreateAgentAuthorizationRequestParams {
@@ -114,6 +282,9 @@ export async function createAgentAuthorizationRequestTx(
114
282
  tx: AgentAccessTx,
115
283
  params: CreateAgentAuthorizationRequestParams,
116
284
  ): Promise<AgentAuthorizationRequest> {
285
+ if (!(await lockAgentClient(tx, params.clientId))) {
286
+ throw new Error(`Agent client ${params.clientId} was not found`);
287
+ }
117
288
  const rows = await tx.insert(agentAuthorizationRequests).values(params).returning();
118
289
  const row = rows[0];
119
290
  if (!row) throw new Error('Insert returned no rows');
@@ -182,10 +353,19 @@ export async function createAgentGrant(params: CreateAgentGrantParams): Promise<
182
353
  return await db().transaction((tx) => createAgentGrantTx(tx, params));
183
354
  }
184
355
 
356
+ /**
357
+ * Creates or reauthorizes a grant while holding its client row lock. A newly
358
+ * created grant has no live child until its first authorization code or refresh
359
+ * token is issued, so approval callers must compose that first child creation
360
+ * with this Tx helper in one database transaction.
361
+ */
185
362
  export async function createAgentGrantTx(
186
363
  tx: AgentAccessTx,
187
364
  params: CreateAgentGrantParams,
188
365
  ): Promise<AgentGrant> {
366
+ if (!(await lockAgentClient(tx, params.clientId))) {
367
+ throw new Error(`Agent client ${params.clientId} was not found`);
368
+ }
189
369
  const rows = await tx
190
370
  .insert(agentGrants)
191
371
  .values(params)
@@ -271,6 +451,7 @@ export async function createAgentAuthorizationCodeTx(
271
451
  tx: AgentAccessTx,
272
452
  params: CreateAgentAuthorizationCodeParams,
273
453
  ): Promise<AgentAuthorizationCode> {
454
+ await requireActiveAgentGrant(tx, params.grantId);
274
455
  const rows = await tx.insert(agentAuthorizationCodes).values(params).returning();
275
456
  const row = rows[0];
276
457
  if (!row) throw new Error('Insert returned no rows');
@@ -322,11 +503,27 @@ export async function consumeAgentAuthorizationCodeTx(
322
503
  tx: AgentAccessTx,
323
504
  params: {hashedCode: string},
324
505
  ): Promise<AgentAuthorizationCode | undefined> {
506
+ // Read the binding before taking the grant lock. The conditional update below
507
+ // re-checks consumed_at and expiry after the lock, so concurrent exchanges
508
+ // still have exactly one winner even when both read the code first.
509
+ const existingRows = await tx
510
+ .select()
511
+ .from(agentAuthorizationCodes)
512
+ .where(eq(agentAuthorizationCodes.hashedCode, params.hashedCode))
513
+ .limit(1);
514
+ const existing = existingRows[0];
515
+ if (!existing) return undefined;
516
+
517
+ const grant = await lockAgentGrant(tx, {grantId: existing.grantId});
518
+ if (!grant || grant.revokedAt || grant.terminalAt) return undefined;
519
+ if (!(await isActiveUser(tx, grant.userId))) return undefined;
520
+
325
521
  const rows = await tx
326
522
  .update(agentAuthorizationCodes)
327
523
  .set({consumedAt: sql`now()`, updatedAt: sql`now()`})
328
524
  .where(
329
525
  and(
526
+ eq(agentAuthorizationCodes.id, existing.id),
330
527
  eq(agentAuthorizationCodes.hashedCode, params.hashedCode),
331
528
  isNull(agentAuthorizationCodes.consumedAt),
332
529
  gt(agentAuthorizationCodes.expiresAt, sql`now()`),
@@ -340,7 +537,8 @@ export async function consumeAgentAuthorizationCodeTx(
340
537
  export interface CreateAgentRefreshTokenParams {
341
538
  grantId: string;
342
539
  hashedToken: string;
343
- expiresAt: Date;
540
+ /** Defaults to the configured 14-day lifetime. */
541
+ expiresAt?: Date;
344
542
  }
345
543
 
346
544
  export async function createAgentRefreshToken(
@@ -353,7 +551,11 @@ export async function createAgentRefreshTokenTx(
353
551
  tx: AgentAccessTx,
354
552
  params: CreateAgentRefreshTokenParams,
355
553
  ): Promise<AgentRefreshToken> {
356
- const rows = await tx.insert(agentRefreshTokens).values(params).returning();
554
+ await requireActiveAgentGrant(tx, params.grantId);
555
+ const rows = await tx
556
+ .insert(agentRefreshTokens)
557
+ .values({...params, expiresAt: params.expiresAt ?? agentRefreshTokenExpiresAt()})
558
+ .returning();
357
559
  const row = rows[0];
358
560
  if (!row) throw new Error('Insert returned no rows');
359
561
  return toAgentRefreshToken(row);
@@ -367,7 +569,12 @@ export async function findAgentRefreshTokenByHash(params: {
367
569
  const rows = await executor
368
570
  .select()
369
571
  .from(agentRefreshTokens)
370
- .where(eq(agentRefreshTokens.hashedToken, params.hashedToken))
572
+ .where(
573
+ and(
574
+ eq(agentRefreshTokens.hashedToken, params.hashedToken),
575
+ isNull(agentRefreshTokens.revokedAt),
576
+ ),
577
+ )
371
578
  .limit(1);
372
579
  const row = rows[0];
373
580
  return row ? toAgentRefreshToken(row) : undefined;
@@ -394,11 +601,133 @@ export async function findActiveAgentRefreshTokenByHash(params: {
394
601
  return row ? toAgentRefreshToken(row) : undefined;
395
602
  }
396
603
 
604
+ /** Finds the one live refresh token currently attached to a grant. */
605
+ export async function findActiveAgentRefreshTokenByGrantId(params: {
606
+ grantId: string;
607
+ executor?: AgentAccessExecutor;
608
+ }): Promise<AgentRefreshToken | undefined> {
609
+ const executor = params.executor ?? db();
610
+ const rows = await executor
611
+ .select()
612
+ .from(agentRefreshTokens)
613
+ .where(
614
+ and(
615
+ eq(agentRefreshTokens.grantId, params.grantId),
616
+ isNull(agentRefreshTokens.rotatedAt),
617
+ isNull(agentRefreshTokens.revokedAt),
618
+ gt(agentRefreshTokens.expiresAt, sql`now()`),
619
+ ),
620
+ )
621
+ .limit(1);
622
+ const row = rows[0];
623
+ return row ? toAgentRefreshToken(row) : undefined;
624
+ }
625
+
626
+ export type AgentRefreshTokenReplayResult =
627
+ | {
628
+ kind: 'grace';
629
+ grant: AgentGrant;
630
+ predecessor: AgentRefreshToken;
631
+ successor: AgentRefreshToken;
632
+ }
633
+ | {
634
+ kind: 'reused';
635
+ grant: AgentGrant;
636
+ predecessor: AgentRefreshToken;
637
+ };
638
+
639
+ /**
640
+ * Resolves a replay of a retained, rotated token. A grace hit returns the one
641
+ * live successor so the caller can mint an access token without issuing a new
642
+ * refresh cookie. A replay after grace revokes the complete grant family.
643
+ * Missing or pruned rows return undefined and therefore remain plain 401s.
644
+ */
645
+ export async function resolveAgentRefreshTokenReplay(params: {
646
+ hashedToken: string;
647
+ now?: Date;
648
+ graceSeconds?: number;
649
+ }): Promise<AgentRefreshTokenReplayResult | undefined> {
650
+ return await db().transaction((tx) => resolveAgentRefreshTokenReplayTx(tx, params));
651
+ }
652
+
653
+ export async function resolveAgentRefreshTokenReplayTx(
654
+ tx: AgentAccessTx,
655
+ params: {
656
+ hashedToken: string;
657
+ now?: Date;
658
+ graceSeconds?: number;
659
+ },
660
+ ): Promise<AgentRefreshTokenReplayResult | undefined> {
661
+ const rows = await tx
662
+ .select()
663
+ .from(agentRefreshTokens)
664
+ .where(
665
+ and(
666
+ eq(agentRefreshTokens.hashedToken, params.hashedToken),
667
+ isNull(agentRefreshTokens.revokedAt),
668
+ ),
669
+ )
670
+ .limit(1);
671
+ const existing = rows[0];
672
+ if (!existing) return undefined;
673
+
674
+ const grant = await lockAgentGrant(tx, {grantId: existing.grantId});
675
+ if (!grant || grant.revokedAt || grant.terminalAt) return undefined;
676
+ if (!(await isActiveUser(tx, grant.userId))) return undefined;
677
+
678
+ const currentRows = await tx
679
+ .select()
680
+ .from(agentRefreshTokens)
681
+ .where(and(eq(agentRefreshTokens.id, existing.id), isNull(agentRefreshTokens.revokedAt)))
682
+ .limit(1);
683
+ const current = currentRows[0];
684
+ if (!current?.rotatedAt) return undefined;
685
+
686
+ const now = params.now ?? new Date();
687
+ if (
688
+ isWithinAgentRefreshRotationGrace({
689
+ rotatedAt: current.rotatedAt,
690
+ now,
691
+ graceSeconds: params.graceSeconds,
692
+ })
693
+ ) {
694
+ const successorRows = await tx
695
+ .select()
696
+ .from(agentRefreshTokens)
697
+ .where(
698
+ and(
699
+ eq(agentRefreshTokens.grantId, current.grantId),
700
+ isNull(agentRefreshTokens.rotatedAt),
701
+ isNull(agentRefreshTokens.revokedAt),
702
+ gt(agentRefreshTokens.expiresAt, now),
703
+ ),
704
+ )
705
+ .limit(1);
706
+ const successor = successorRows[0];
707
+ if (!successor) return undefined;
708
+ return {
709
+ kind: 'grace',
710
+ grant,
711
+ predecessor: toAgentRefreshToken(current),
712
+ successor: toAgentRefreshToken(successor),
713
+ };
714
+ }
715
+
716
+ const revokedGrant = await revokeAgentGrantTx(tx, {grantId: current.grantId});
717
+ if (!revokedGrant) return undefined;
718
+ return {
719
+ kind: 'reused',
720
+ grant: revokedGrant,
721
+ predecessor: toAgentRefreshToken(current),
722
+ };
723
+ }
724
+
397
725
  /** Rotates a refresh token atomically; a concurrent rotation cannot win twice. */
398
726
  export async function rotateAgentRefreshToken(params: {
399
727
  hashedToken: string;
400
728
  replacementHashedToken: string;
401
- replacementExpiresAt: Date;
729
+ /** Defaults to the configured 14-day sliding lifetime. */
730
+ replacementExpiresAt?: Date;
402
731
  }): Promise<AgentRefreshToken | undefined> {
403
732
  return await db().transaction((tx) => rotateAgentRefreshTokenTx(tx, params));
404
733
  }
@@ -408,14 +737,33 @@ export async function rotateAgentRefreshTokenTx(
408
737
  params: {
409
738
  hashedToken: string;
410
739
  replacementHashedToken: string;
411
- replacementExpiresAt: Date;
740
+ replacementExpiresAt?: Date;
412
741
  },
413
742
  ): Promise<AgentRefreshToken | undefined> {
743
+ const replacementExpiresAt = params.replacementExpiresAt ?? agentRefreshTokenExpiresAt();
744
+ const existingRows = await tx
745
+ .select()
746
+ .from(agentRefreshTokens)
747
+ .where(
748
+ and(
749
+ eq(agentRefreshTokens.hashedToken, params.hashedToken),
750
+ isNull(agentRefreshTokens.revokedAt),
751
+ ),
752
+ )
753
+ .limit(1);
754
+ const existing = existingRows[0];
755
+ if (!existing) return undefined;
756
+
757
+ const grant = await lockAgentGrant(tx, {grantId: existing.grantId});
758
+ if (!grant || grant.revokedAt || grant.terminalAt) return undefined;
759
+ if (!(await isActiveUser(tx, grant.userId))) return undefined;
760
+
414
761
  const rows = await tx
415
762
  .update(agentRefreshTokens)
416
763
  .set({rotatedAt: sql`now()`, lastUsedAt: sql`now()`, updatedAt: sql`now()`})
417
764
  .where(
418
765
  and(
766
+ eq(agentRefreshTokens.id, existing.id),
419
767
  eq(agentRefreshTokens.hashedToken, params.hashedToken),
420
768
  isNull(agentRefreshTokens.rotatedAt),
421
769
  isNull(agentRefreshTokens.revokedAt),
@@ -426,17 +774,38 @@ export async function rotateAgentRefreshTokenTx(
426
774
  const row = rows[0];
427
775
  if (!row) return undefined;
428
776
 
429
- return await createAgentRefreshTokenTx(tx, {
430
- grantId: row.grantId,
431
- hashedToken: params.replacementHashedToken,
432
- expiresAt: params.replacementExpiresAt,
433
- });
777
+ await tx
778
+ .update(agentGrants)
779
+ .set({lastUsedAt: sql`now()`, updatedAt: sql`now()`})
780
+ .where(eq(agentGrants.id, row.grantId));
781
+
782
+ const successorRows = await tx
783
+ .insert(agentRefreshTokens)
784
+ .values({
785
+ grantId: row.grantId,
786
+ hashedToken: params.replacementHashedToken,
787
+ expiresAt: replacementExpiresAt,
788
+ })
789
+ .returning();
790
+ const successor = successorRows[0];
791
+ if (!successor) throw new Error('Insert returned no rows');
792
+ return toAgentRefreshToken(successor);
434
793
  }
435
794
 
436
795
  export async function revokeAgentRefreshToken(params: {
437
796
  id: string;
438
797
  }): Promise<AgentRefreshToken | undefined> {
439
798
  return await db().transaction(async (tx) => {
799
+ const existingRows = await tx
800
+ .select()
801
+ .from(agentRefreshTokens)
802
+ .where(eq(agentRefreshTokens.id, params.id))
803
+ .limit(1);
804
+ const existing = existingRows[0];
805
+ if (!existing) return undefined;
806
+ const grant = await lockAgentGrant(tx, {grantId: existing.grantId});
807
+ if (!grant) return undefined;
808
+
440
809
  const rows = await tx
441
810
  .update(agentRefreshTokens)
442
811
  .set({revokedAt: sql`now()`, updatedAt: sql`now()`})
@@ -447,6 +816,148 @@ export async function revokeAgentRefreshToken(params: {
447
816
  });
448
817
  }
449
818
 
819
+ /** Revokes a grant and every refresh-token generation in its family. */
820
+ export async function revokeAgentGrant(params: {grantId: string}): Promise<AgentGrant | undefined> {
821
+ return await db().transaction((tx) => revokeAgentGrantTx(tx, params));
822
+ }
823
+
824
+ export async function revokeAgentGrantTx(
825
+ tx: AgentAccessTx,
826
+ params: {grantId: string},
827
+ ): Promise<AgentGrant | undefined> {
828
+ const grant = await lockAgentGrant(tx, params);
829
+ if (!grant) return undefined;
830
+
831
+ const rows = await tx
832
+ .update(agentGrants)
833
+ .set({
834
+ revokedAt: grant.revokedAt ?? sql`now()`,
835
+ terminalAt: grant.terminalAt ?? sql`now()`,
836
+ updatedAt: sql`now()`,
837
+ })
838
+ .where(eq(agentGrants.id, grant.id))
839
+ .returning();
840
+ const updatedGrant = rows[0];
841
+ if (!updatedGrant) return undefined;
842
+
843
+ await tx
844
+ .update(agentRefreshTokens)
845
+ .set({revokedAt: sql`now()`, updatedAt: sql`now()`})
846
+ .where(and(eq(agentRefreshTokens.grantId, grant.id), isNull(agentRefreshTokens.revokedAt)));
847
+
848
+ return toAgentGrant(updatedGrant);
849
+ }
850
+
851
+ export interface TransitionAgentGrantsToTerminalParams {
852
+ limit?: number;
853
+ now?: Date;
854
+ deadlineMs?: number | undefined;
855
+ }
856
+
857
+ /**
858
+ * Marks grants terminal once they have no usable refresh token or authorization
859
+ * code left. Each candidate is locked before its children are inspected so a
860
+ * concurrent code exchange or token rotation cannot create a live child after
861
+ * the decision.
862
+ */
863
+ export async function transitionAgentGrantsToTerminal(
864
+ params: TransitionAgentGrantsToTerminalParams = {},
865
+ ): Promise<number> {
866
+ return await db().transaction((tx) => transitionAgentGrantsToTerminalTx(tx, params));
867
+ }
868
+
869
+ export async function transitionAgentGrantsToTerminalTx(
870
+ tx: AgentAccessTx,
871
+ params: TransitionAgentGrantsToTerminalParams = {},
872
+ ): Promise<number> {
873
+ const limit = batchLimit(params.limit);
874
+ const now = params.now ?? new Date();
875
+ const liveRefreshTokens = tx
876
+ .select({id: agentRefreshTokens.id})
877
+ .from(agentRefreshTokens)
878
+ .where(
879
+ and(
880
+ eq(agentRefreshTokens.grantId, agentGrants.id),
881
+ isNull(agentRefreshTokens.rotatedAt),
882
+ isNull(agentRefreshTokens.revokedAt),
883
+ gt(agentRefreshTokens.expiresAt, now),
884
+ ),
885
+ );
886
+ const liveAuthorizationCodes = tx
887
+ .select({id: agentAuthorizationCodes.id})
888
+ .from(agentAuthorizationCodes)
889
+ .where(
890
+ and(
891
+ eq(agentAuthorizationCodes.grantId, agentGrants.id),
892
+ isNull(agentAuthorizationCodes.consumedAt),
893
+ gt(agentAuthorizationCodes.expiresAt, now),
894
+ ),
895
+ );
896
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
897
+ const candidates = await tx
898
+ .select({id: agentGrants.id})
899
+ .from(agentGrants)
900
+ .where(
901
+ and(
902
+ isNull(agentGrants.terminalAt),
903
+ or(
904
+ isNotNull(agentGrants.revokedAt),
905
+ and(notExists(liveRefreshTokens), notExists(liveAuthorizationCodes)),
906
+ ),
907
+ ),
908
+ )
909
+ .orderBy(asc(agentGrants.createdAt), asc(agentGrants.id))
910
+ .limit(limit);
911
+
912
+ let transitioned = 0;
913
+ for (const candidate of candidates) {
914
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
915
+ const grant = await lockAgentGrant(tx, {grantId: candidate.id});
916
+ if (!grant || grant.terminalAt) continue;
917
+
918
+ if (!grant.revokedAt) {
919
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
920
+ const liveRefreshTokens = await tx
921
+ .select({id: agentRefreshTokens.id})
922
+ .from(agentRefreshTokens)
923
+ .where(
924
+ and(
925
+ eq(agentRefreshTokens.grantId, grant.id),
926
+ isNull(agentRefreshTokens.rotatedAt),
927
+ isNull(agentRefreshTokens.revokedAt),
928
+ gt(agentRefreshTokens.expiresAt, now),
929
+ ),
930
+ )
931
+ .limit(1);
932
+ if (liveRefreshTokens.length > 0) continue;
933
+
934
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
935
+ const liveAuthorizationCodes = await tx
936
+ .select({id: agentAuthorizationCodes.id})
937
+ .from(agentAuthorizationCodes)
938
+ .where(
939
+ and(
940
+ eq(agentAuthorizationCodes.grantId, grant.id),
941
+ isNull(agentAuthorizationCodes.consumedAt),
942
+ gt(agentAuthorizationCodes.expiresAt, now),
943
+ ),
944
+ )
945
+ .limit(1);
946
+ if (liveAuthorizationCodes.length > 0) continue;
947
+ }
948
+
949
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
950
+ const rows = await tx
951
+ .update(agentGrants)
952
+ .set({terminalAt: now, updatedAt: now})
953
+ .where(and(eq(agentGrants.id, grant.id), isNull(agentGrants.terminalAt)))
954
+ .returning({id: agentGrants.id});
955
+ if (rows.length > 0) transitioned += 1;
956
+ }
957
+
958
+ return transitioned;
959
+ }
960
+
450
961
  export interface CreateAgentPersonalAccessTokenParams {
451
962
  userId: string;
452
963
  workspaceId: string;
@@ -493,30 +1004,68 @@ export async function findActiveAgentPersonalAccessTokenByHash(params: {
493
1004
  }): Promise<AgentPersonalAccessToken | undefined> {
494
1005
  const executor = params.executor ?? db();
495
1006
  const rows = await executor
496
- .select()
1007
+ .select({token: agentPersonalAccessTokens})
497
1008
  .from(agentPersonalAccessTokens)
1009
+ .innerJoin(users, eq(agentPersonalAccessTokens.userId, users.id))
498
1010
  .where(
499
1011
  and(
500
1012
  eq(agentPersonalAccessTokens.hashedToken, params.hashedToken),
501
1013
  isNull(agentPersonalAccessTokens.revokedAt),
502
1014
  gt(agentPersonalAccessTokens.expiresAt, sql`now()`),
1015
+ eq(users.status, 'active'),
503
1016
  ),
504
1017
  )
505
1018
  .limit(1);
506
- const row = rows[0];
1019
+ const row = rows[0]?.token;
507
1020
  return row ? toAgentPersonalAccessToken(row) : undefined;
508
1021
  }
509
1022
 
510
1023
  export async function markAgentPersonalAccessTokenUsed(params: {
511
1024
  id: string;
1025
+ throttleSeconds?: number;
512
1026
  }): Promise<AgentPersonalAccessToken | undefined> {
1027
+ const throttleSeconds = params.throttleSeconds ?? 60;
1028
+ if (!Number.isFinite(throttleSeconds) || throttleSeconds < 0) {
1029
+ throw new Error('PAT last-used throttle must be a non-negative finite number of seconds');
1030
+ }
1031
+
513
1032
  const rows = await db()
514
1033
  .update(agentPersonalAccessTokens)
515
1034
  .set({lastUsedAt: sql`now()`, updatedAt: sql`now()`})
516
- .where(eq(agentPersonalAccessTokens.id, params.id))
1035
+ .where(
1036
+ and(
1037
+ eq(agentPersonalAccessTokens.id, params.id),
1038
+ isNull(agentPersonalAccessTokens.revokedAt),
1039
+ gt(agentPersonalAccessTokens.expiresAt, sql`now()`),
1040
+ or(
1041
+ isNull(agentPersonalAccessTokens.lastUsedAt),
1042
+ lte(
1043
+ agentPersonalAccessTokens.lastUsedAt,
1044
+ sql`now() - (${throttleSeconds} || ' seconds')::interval`,
1045
+ ),
1046
+ ),
1047
+ ),
1048
+ )
517
1049
  .returning();
518
1050
  const row = rows[0];
519
- return row ? toAgentPersonalAccessToken(row) : undefined;
1051
+ if (row) return toAgentPersonalAccessToken(row);
1052
+
1053
+ // A valid token can intentionally skip the write inside the throttle
1054
+ // window. Return its current row so callers can distinguish that from an
1055
+ // invalid, expired, or revoked token.
1056
+ const existingRows = await db()
1057
+ .select()
1058
+ .from(agentPersonalAccessTokens)
1059
+ .where(
1060
+ and(
1061
+ eq(agentPersonalAccessTokens.id, params.id),
1062
+ isNull(agentPersonalAccessTokens.revokedAt),
1063
+ gt(agentPersonalAccessTokens.expiresAt, sql`now()`),
1064
+ ),
1065
+ )
1066
+ .limit(1);
1067
+ const existing = existingRows[0];
1068
+ return existing ? toAgentPersonalAccessToken(existing) : undefined;
520
1069
  }
521
1070
 
522
1071
  export async function revokeAgentPersonalAccessToken(params: {
@@ -534,96 +1083,409 @@ export async function revokeAgentPersonalAccessToken(params: {
534
1083
  }
535
1084
 
536
1085
  export interface PruneAgentAccessParams {
1086
+ /** Overrides every retention horizon for deterministic maintenance tests. */
537
1087
  retentionDays?: number;
538
1088
  limit?: number;
539
1089
  now?: Date;
1090
+ /** Absolute epoch deadline used to bound every PostgreSQL statement. */
1091
+ deadlineMs?: number;
540
1092
  }
541
1093
 
542
- /** Deletes terminal credentials and ephemeral authorization rows in bounded batches. */
543
- export async function pruneAgentAccess(params: PruneAgentAccessParams = {}): Promise<number> {
544
- const now = params.now ?? new Date();
545
- const retentionDays = params.retentionDays ?? 7;
546
- const limit = params.limit ?? 1000;
547
- const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000);
1094
+ export interface PruneAgentAccessResult {
1095
+ deleted: number;
1096
+ transitioned: number;
1097
+ }
548
1098
 
549
- return await db().transaction(async (tx) => {
550
- const requestIds = tx
551
- .select({id: agentAuthorizationRequests.id})
552
- .from(agentAuthorizationRequests)
553
- .where(
554
- or(
555
- lt(agentAuthorizationRequests.consumedAt, cutoff),
556
- and(
557
- isNull(agentAuthorizationRequests.consumedAt),
558
- lt(agentAuthorizationRequests.expiresAt, cutoff),
559
- ),
1099
+ interface AgentAccessRetentionCutoffs {
1100
+ authorization: Date;
1101
+ refreshToken: Date;
1102
+ grant: Date;
1103
+ pat: Date;
1104
+ client: Date;
1105
+ }
1106
+
1107
+ async function deleteExpiredAgentAuthorizationRequests(
1108
+ tx: AgentAccessTx,
1109
+ cutoff: Date,
1110
+ limit: number,
1111
+ deadlineMs: number | undefined,
1112
+ ): Promise<number> {
1113
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
1114
+ const candidates = await tx
1115
+ .select({id: agentAuthorizationRequests.id})
1116
+ .from(agentAuthorizationRequests)
1117
+ .where(lte(agentAuthorizationRequests.expiresAt, cutoff))
1118
+ .orderBy(asc(agentAuthorizationRequests.expiresAt), asc(agentAuthorizationRequests.id))
1119
+ .limit(limit);
1120
+ if (candidates.length === 0) return 0;
1121
+
1122
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
1123
+ const rows = await tx
1124
+ .delete(agentAuthorizationRequests)
1125
+ .where(
1126
+ inArray(
1127
+ agentAuthorizationRequests.id,
1128
+ candidates.map(({id}) => id),
1129
+ ),
1130
+ )
1131
+ .returning({id: agentAuthorizationRequests.id});
1132
+ return rows.length;
1133
+ }
1134
+
1135
+ async function deleteExpiredAgentAuthorizationCodes(
1136
+ tx: AgentAccessTx,
1137
+ cutoff: Date,
1138
+ limit: number,
1139
+ deadlineMs: number | undefined,
1140
+ ): Promise<number> {
1141
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
1142
+ const candidates = await tx
1143
+ .select({id: agentAuthorizationCodes.id})
1144
+ .from(agentAuthorizationCodes)
1145
+ .where(lte(agentAuthorizationCodes.expiresAt, cutoff))
1146
+ .orderBy(asc(agentAuthorizationCodes.expiresAt), asc(agentAuthorizationCodes.id))
1147
+ .limit(limit);
1148
+ if (candidates.length === 0) return 0;
1149
+
1150
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
1151
+ const rows = await tx
1152
+ .delete(agentAuthorizationCodes)
1153
+ .where(
1154
+ inArray(
1155
+ agentAuthorizationCodes.id,
1156
+ candidates.map(({id}) => id),
1157
+ ),
1158
+ )
1159
+ .returning({id: agentAuthorizationCodes.id});
1160
+ return rows.length;
1161
+ }
1162
+
1163
+ async function deleteRetainedAgentRefreshTokens(
1164
+ tx: AgentAccessTx,
1165
+ cutoff: Date,
1166
+ limit: number,
1167
+ deadlineMs: number | undefined,
1168
+ ): Promise<number> {
1169
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
1170
+ const candidates = await tx
1171
+ .select({id: agentRefreshTokens.id})
1172
+ .from(agentRefreshTokens)
1173
+ .where(
1174
+ or(
1175
+ lte(agentRefreshTokens.rotatedAt, cutoff),
1176
+ lte(agentRefreshTokens.revokedAt, cutoff),
1177
+ and(
1178
+ isNull(agentRefreshTokens.rotatedAt),
1179
+ isNull(agentRefreshTokens.revokedAt),
1180
+ lte(agentRefreshTokens.expiresAt, cutoff),
560
1181
  ),
561
- )
562
- .orderBy(asc(agentAuthorizationRequests.expiresAt), asc(agentAuthorizationRequests.id))
563
- .limit(limit);
564
- const codeIds = tx
565
- .select({id: agentAuthorizationCodes.id})
566
- .from(agentAuthorizationCodes)
1182
+ ),
1183
+ )
1184
+ .orderBy(asc(agentRefreshTokens.expiresAt), asc(agentRefreshTokens.id))
1185
+ .limit(limit);
1186
+ if (candidates.length === 0) return 0;
1187
+
1188
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
1189
+ const rows = await tx
1190
+ .delete(agentRefreshTokens)
1191
+ .where(
1192
+ inArray(
1193
+ agentRefreshTokens.id,
1194
+ candidates.map(({id}) => id),
1195
+ ),
1196
+ )
1197
+ .returning({id: agentRefreshTokens.id});
1198
+ return rows.length;
1199
+ }
1200
+
1201
+ async function deleteRetainedAgentPersonalAccessTokens(
1202
+ tx: AgentAccessTx,
1203
+ cutoff: Date,
1204
+ limit: number,
1205
+ deadlineMs: number | undefined,
1206
+ ): Promise<number> {
1207
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
1208
+ const candidates = await tx
1209
+ .select({id: agentPersonalAccessTokens.id})
1210
+ .from(agentPersonalAccessTokens)
1211
+ .where(
1212
+ or(
1213
+ lte(agentPersonalAccessTokens.revokedAt, cutoff),
1214
+ and(
1215
+ isNull(agentPersonalAccessTokens.revokedAt),
1216
+ lte(agentPersonalAccessTokens.expiresAt, cutoff),
1217
+ ),
1218
+ ),
1219
+ )
1220
+ .orderBy(asc(agentPersonalAccessTokens.expiresAt), asc(agentPersonalAccessTokens.id))
1221
+ .limit(limit);
1222
+ if (candidates.length === 0) return 0;
1223
+
1224
+ await prepareAgentAccessRetentionStatement(tx, deadlineMs);
1225
+ const rows = await tx
1226
+ .delete(agentPersonalAccessTokens)
1227
+ .where(
1228
+ inArray(
1229
+ agentPersonalAccessTokens.id,
1230
+ candidates.map(({id}) => id),
1231
+ ),
1232
+ )
1233
+ .returning({id: agentPersonalAccessTokens.id});
1234
+ return rows.length;
1235
+ }
1236
+
1237
+ async function pruneTerminalAgentGrants(
1238
+ tx: AgentAccessTx,
1239
+ params: {cutoff: Date; now: Date; limit: number; deadlineMs: number | undefined},
1240
+ ): Promise<number> {
1241
+ const remainingCodes = tx
1242
+ .select({id: agentAuthorizationCodes.id})
1243
+ .from(agentAuthorizationCodes)
1244
+ .where(eq(agentAuthorizationCodes.grantId, agentGrants.id));
1245
+ const remainingRefreshTokens = tx
1246
+ .select({id: agentRefreshTokens.id})
1247
+ .from(agentRefreshTokens)
1248
+ .where(eq(agentRefreshTokens.grantId, agentGrants.id));
1249
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
1250
+ const candidates = await tx
1251
+ .select({id: agentGrants.id, clientId: agentGrants.clientId})
1252
+ .from(agentGrants)
1253
+ .where(
1254
+ and(
1255
+ lte(agentGrants.terminalAt, params.cutoff),
1256
+ notExists(remainingCodes),
1257
+ notExists(remainingRefreshTokens),
1258
+ ),
1259
+ )
1260
+ .orderBy(asc(agentGrants.terminalAt), asc(agentGrants.id))
1261
+ .limit(params.limit)
1262
+ .for('update');
1263
+ if (candidates.length === 0) return 0;
1264
+
1265
+ const grantIds = candidates.map(({id}) => id);
1266
+ // Child retention runs before this function. Do not delete a child merely
1267
+ // because its grant reached the 90-day horizon: a child can have been
1268
+ // revoked after the grant became terminal and still needs its own 30-day
1269
+ // replay/forensics window. Such a grant waits for the next sweep.
1270
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
1271
+ const remainingCodeGrants = await tx
1272
+ .select({grantId: agentAuthorizationCodes.grantId})
1273
+ .from(agentAuthorizationCodes)
1274
+ .where(inArray(agentAuthorizationCodes.grantId, grantIds));
1275
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
1276
+ const remainingRefreshTokenGrants = await tx
1277
+ .select({grantId: agentRefreshTokens.grantId})
1278
+ .from(agentRefreshTokens)
1279
+ .where(inArray(agentRefreshTokens.grantId, grantIds));
1280
+ const grantsWithChildren = new Set([
1281
+ ...remainingCodeGrants.map(({grantId}) => grantId),
1282
+ ...remainingRefreshTokenGrants.map(({grantId}) => grantId),
1283
+ ]);
1284
+ const deletableGrantIds = grantIds.filter((grantId) => !grantsWithChildren.has(grantId));
1285
+ if (deletableGrantIds.length === 0) return 0;
1286
+
1287
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
1288
+ const deletedGrants = await tx
1289
+ .delete(agentGrants)
1290
+ .where(inArray(agentGrants.id, deletableGrantIds))
1291
+ .returning({id: agentGrants.id, clientId: agentGrants.clientId});
1292
+
1293
+ const clientIds = new Set(deletedGrants.map(({clientId}) => clientId));
1294
+ if (clientIds.size > 0) {
1295
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
1296
+ await tx
1297
+ .update(agentClients)
1298
+ .set({
1299
+ unreferencedAt: sql`coalesce(${agentClients.unreferencedAt}, ${params.now})`,
1300
+ updatedAt: params.now,
1301
+ })
567
1302
  .where(
568
- or(
569
- lt(agentAuthorizationCodes.consumedAt, cutoff),
570
- and(
571
- isNull(agentAuthorizationCodes.consumedAt),
572
- lt(agentAuthorizationCodes.expiresAt, cutoff),
1303
+ and(
1304
+ inArray(agentClients.id, [...clientIds]),
1305
+ notExists(
1306
+ tx
1307
+ .select({id: agentGrants.id})
1308
+ .from(agentGrants)
1309
+ .where(eq(agentGrants.clientId, agentClients.id)),
573
1310
  ),
574
1311
  ),
575
- )
576
- .orderBy(asc(agentAuthorizationCodes.expiresAt), asc(agentAuthorizationCodes.id))
577
- .limit(limit);
578
- const refreshTokenIds = tx
579
- .select({id: agentRefreshTokens.id})
580
- .from(agentRefreshTokens)
581
- .where(
1312
+ );
1313
+ }
1314
+
1315
+ return deletedGrants.length;
1316
+ }
1317
+
1318
+ async function pruneUnreferencedAgentClients(
1319
+ tx: AgentAccessTx,
1320
+ params: {cutoff: Date; now: Date; limit: number; deadlineMs: number | undefined},
1321
+ ): Promise<number> {
1322
+ const liveAuthorizationRequests = tx
1323
+ .select({id: agentAuthorizationRequests.id})
1324
+ .from(agentAuthorizationRequests)
1325
+ .where(
1326
+ and(
1327
+ eq(agentAuthorizationRequests.clientId, agentClients.id),
1328
+ isNull(agentAuthorizationRequests.consumedAt),
1329
+ gt(agentAuthorizationRequests.expiresAt, params.now),
1330
+ ),
1331
+ );
1332
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
1333
+ const candidates = await tx
1334
+ .select({id: agentClients.id})
1335
+ .from(agentClients)
1336
+ .where(
1337
+ and(
582
1338
  or(
583
- lt(agentRefreshTokens.rotatedAt, cutoff),
584
- and(isNull(agentRefreshTokens.rotatedAt), lt(agentRefreshTokens.revokedAt, cutoff)),
1339
+ lte(agentClients.unreferencedAt, params.cutoff),
585
1340
  and(
586
- isNull(agentRefreshTokens.rotatedAt),
587
- isNull(agentRefreshTokens.revokedAt),
588
- lt(agentRefreshTokens.expiresAt, cutoff),
1341
+ isNull(agentClients.unreferencedAt),
1342
+ lte(agentClients.createdAt, params.cutoff),
1343
+ notExists(
1344
+ tx
1345
+ .select({id: agentGrants.id})
1346
+ .from(agentGrants)
1347
+ .where(eq(agentGrants.clientId, agentClients.id)),
1348
+ ),
589
1349
  ),
590
1350
  ),
591
- )
592
- .orderBy(asc(agentRefreshTokens.expiresAt), asc(agentRefreshTokens.id))
593
- .limit(limit);
594
- const patIds = tx
595
- .select({id: agentPersonalAccessTokens.id})
596
- .from(agentPersonalAccessTokens)
597
- .where(
1351
+ notExists(liveAuthorizationRequests),
1352
+ ),
1353
+ )
1354
+ .orderBy(asc(agentClients.unreferencedAt), asc(agentClients.createdAt), asc(agentClients.id))
1355
+ .limit(params.limit)
1356
+ .for('update');
1357
+
1358
+ if (candidates.length === 0) return 0;
1359
+
1360
+ const candidateIds = candidates.map(({id}) => id);
1361
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
1362
+ const grantsForCandidates = await tx
1363
+ .select({clientId: agentGrants.clientId})
1364
+ .from(agentGrants)
1365
+ .where(inArray(agentGrants.clientId, candidateIds));
1366
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
1367
+ const liveRequestsForCandidates = await tx
1368
+ .select({clientId: agentAuthorizationRequests.clientId})
1369
+ .from(agentAuthorizationRequests)
1370
+ .where(
1371
+ and(
1372
+ inArray(agentAuthorizationRequests.clientId, candidateIds),
1373
+ isNull(agentAuthorizationRequests.consumedAt),
1374
+ gt(agentAuthorizationRequests.expiresAt, params.now),
1375
+ ),
1376
+ );
1377
+ const protectedClientIds = new Set([
1378
+ ...grantsForCandidates.map(({clientId}) => clientId),
1379
+ ...liveRequestsForCandidates.map(({clientId}) => clientId),
1380
+ ]);
1381
+ const deletableClientIds = candidateIds.filter((id) => !protectedClientIds.has(id));
1382
+ if (deletableClientIds.length === 0) return 0;
1383
+
1384
+ await prepareAgentAccessRetentionStatement(tx, params.deadlineMs);
1385
+ const rows = await tx
1386
+ .delete(agentClients)
1387
+ .where(
1388
+ and(
1389
+ inArray(agentClients.id, deletableClientIds),
598
1390
  or(
599
- lt(agentPersonalAccessTokens.revokedAt, cutoff),
600
- and(
601
- isNull(agentPersonalAccessTokens.revokedAt),
602
- lt(agentPersonalAccessTokens.expiresAt, cutoff),
603
- ),
1391
+ lte(agentClients.unreferencedAt, params.cutoff),
1392
+ and(isNull(agentClients.unreferencedAt), lte(agentClients.createdAt, params.cutoff)),
604
1393
  ),
605
- )
606
- .orderBy(asc(agentPersonalAccessTokens.expiresAt), asc(agentPersonalAccessTokens.id))
607
- .limit(limit);
608
-
609
- const [requests, codes, refreshTokens, pats] = await Promise.all([
610
- tx
611
- .delete(agentAuthorizationRequests)
612
- .where(inArray(agentAuthorizationRequests.id, requestIds))
613
- .returning({id: agentAuthorizationRequests.id}),
614
- tx
615
- .delete(agentAuthorizationCodes)
616
- .where(inArray(agentAuthorizationCodes.id, codeIds))
617
- .returning({id: agentAuthorizationCodes.id}),
618
- tx
619
- .delete(agentRefreshTokens)
620
- .where(inArray(agentRefreshTokens.id, refreshTokenIds))
621
- .returning({id: agentRefreshTokens.id}),
622
- tx
623
- .delete(agentPersonalAccessTokens)
624
- .where(inArray(agentPersonalAccessTokens.id, patIds))
625
- .returning({id: agentPersonalAccessTokens.id}),
626
- ]);
627
- return requests.length + codes.length + refreshTokens.length + pats.length;
1394
+ notExists(
1395
+ tx
1396
+ .select({id: agentGrants.id})
1397
+ .from(agentGrants)
1398
+ .where(eq(agentGrants.clientId, agentClients.id)),
1399
+ ),
1400
+ notExists(
1401
+ tx
1402
+ .select({id: agentAuthorizationRequests.id})
1403
+ .from(agentAuthorizationRequests)
1404
+ .where(
1405
+ and(
1406
+ eq(agentAuthorizationRequests.clientId, agentClients.id),
1407
+ isNull(agentAuthorizationRequests.consumedAt),
1408
+ gt(agentAuthorizationRequests.expiresAt, params.now),
1409
+ ),
1410
+ ),
1411
+ ),
1412
+ ),
1413
+ )
1414
+ .returning({id: agentClients.id});
1415
+ return rows.length;
1416
+ }
1417
+
1418
+ /**
1419
+ * Transitions inactive grants and deletes retained agent-access rows in bounded
1420
+ * batches. The caller can override the common horizon in tests; production uses
1421
+ * the per-row windows from the agent-access retention policy.
1422
+ */
1423
+ export async function pruneAgentAccess(params: PruneAgentAccessParams = {}): Promise<number> {
1424
+ return (await pruneAgentAccessBatch(params)).deleted;
1425
+ }
1426
+
1427
+ /** Runs one bounded retention transaction and reports terminal transitions separately. */
1428
+ export async function pruneAgentAccessBatch(
1429
+ params: PruneAgentAccessParams = {},
1430
+ ): Promise<PruneAgentAccessResult> {
1431
+ const now = params.now ?? new Date();
1432
+ const limit = batchLimit(params.limit);
1433
+ const retentionDays = params.retentionDays;
1434
+ const authorizationCutoff = retentionCutoff(
1435
+ now,
1436
+ retentionDays ?? AGENT_AUTHORIZATION_RETENTION_DAYS,
1437
+ );
1438
+ const refreshTokenCutoff = retentionCutoff(
1439
+ now,
1440
+ retentionDays ?? AGENT_REFRESH_TOKEN_RETENTION_DAYS,
1441
+ );
1442
+ const grantCutoff = retentionCutoff(now, retentionDays ?? AGENT_GRANT_RETENTION_DAYS);
1443
+ const patCutoff = retentionCutoff(now, retentionDays ?? AGENT_PAT_RETENTION_DAYS);
1444
+ const clientCutoff = retentionCutoff(now, retentionDays ?? AGENT_CLIENT_RETENTION_DAYS);
1445
+ const cutoffs: AgentAccessRetentionCutoffs = {
1446
+ authorization: authorizationCutoff,
1447
+ refreshToken: refreshTokenCutoff,
1448
+ grant: grantCutoff,
1449
+ pat: patCutoff,
1450
+ client: clientCutoff,
1451
+ };
1452
+
1453
+ return await db().transaction(async (tx) => {
1454
+ // This runs first so grants whose last child expired can be terminalized in
1455
+ // the same maintenance tick. It takes the same grant row lock as code
1456
+ // exchange and refresh rotation.
1457
+ const transitioned = await transitionAgentGrantsToTerminalTx(tx, {
1458
+ limit,
1459
+ now,
1460
+ deadlineMs: params.deadlineMs,
1461
+ });
1462
+ const deleted =
1463
+ (await deleteExpiredAgentAuthorizationRequests(
1464
+ tx,
1465
+ cutoffs.authorization,
1466
+ limit,
1467
+ params.deadlineMs,
1468
+ )) +
1469
+ (await deleteExpiredAgentAuthorizationCodes(
1470
+ tx,
1471
+ cutoffs.authorization,
1472
+ limit,
1473
+ params.deadlineMs,
1474
+ )) +
1475
+ (await deleteRetainedAgentRefreshTokens(tx, cutoffs.refreshToken, limit, params.deadlineMs)) +
1476
+ (await deleteRetainedAgentPersonalAccessTokens(tx, cutoffs.pat, limit, params.deadlineMs)) +
1477
+ (await pruneTerminalAgentGrants(tx, {
1478
+ cutoff: cutoffs.grant,
1479
+ now,
1480
+ limit,
1481
+ deadlineMs: params.deadlineMs,
1482
+ })) +
1483
+ (await pruneUnreferencedAgentClients(tx, {
1484
+ cutoff: cutoffs.client,
1485
+ now,
1486
+ limit,
1487
+ deadlineMs: params.deadlineMs,
1488
+ }));
1489
+ return {deleted, transitioned};
628
1490
  });
629
1491
  }