@shipfox/api-auth 18.0.0 → 20.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +24 -9
  4. package/dist/core/administration.d.ts +15 -0
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +44 -2
  7. package/dist/core/administration.js.map +1 -1
  8. package/dist/core/auth.d.ts.map +1 -1
  9. package/dist/core/auth.js +5 -3
  10. package/dist/core/auth.js.map +1 -1
  11. package/dist/core/cimd.d.ts +39 -0
  12. package/dist/core/cimd.d.ts.map +1 -0
  13. package/dist/core/cimd.js +392 -0
  14. package/dist/core/cimd.js.map +1 -0
  15. package/dist/core/entities/agent-access.d.ts +75 -0
  16. package/dist/core/entities/agent-access.d.ts.map +1 -0
  17. package/dist/core/entities/agent-access.js +3 -0
  18. package/dist/core/entities/agent-access.js.map +1 -0
  19. package/dist/core/errors.d.ts +17 -0
  20. package/dist/core/errors.d.ts.map +1 -1
  21. package/dist/core/errors.js +32 -0
  22. package/dist/core/errors.js.map +1 -1
  23. package/dist/core/oauth-client-resolver.d.ts +54 -0
  24. package/dist/core/oauth-client-resolver.d.ts.map +1 -0
  25. package/dist/core/oauth-client-resolver.js +154 -0
  26. package/dist/core/oauth-client-resolver.js.map +1 -0
  27. package/dist/core/oauth-client.d.ts +39 -0
  28. package/dist/core/oauth-client.d.ts.map +1 -0
  29. package/dist/core/oauth-client.js +236 -0
  30. package/dist/core/oauth-client.js.map +1 -0
  31. package/dist/db/admin-user-moderation.d.ts.map +1 -1
  32. package/dist/db/admin-user-moderation.js +30 -26
  33. package/dist/db/admin-user-moderation.js.map +1 -1
  34. package/dist/db/admin-user-summary.d.ts +22 -0
  35. package/dist/db/admin-user-summary.d.ts.map +1 -1
  36. package/dist/db/admin-user-summary.js +75 -1
  37. package/dist/db/admin-user-summary.js.map +1 -1
  38. package/dist/db/admin-users.d.ts +11 -0
  39. package/dist/db/admin-users.d.ts.map +1 -1
  40. package/dist/db/admin-users.js +4 -1
  41. package/dist/db/admin-users.js.map +1 -1
  42. package/dist/db/agent-access-retention.d.ts +10 -0
  43. package/dist/db/agent-access-retention.d.ts.map +1 -0
  44. package/dist/db/agent-access-retention.js +11 -0
  45. package/dist/db/agent-access-retention.js.map +1 -0
  46. package/dist/db/agent-access.d.ts +255 -0
  47. package/dist/db/agent-access.d.ts.map +1 -0
  48. package/dist/db/agent-access.js +705 -0
  49. package/dist/db/agent-access.js.map +1 -0
  50. package/dist/db/db.d.ts +2604 -238
  51. package/dist/db/db.d.ts.map +1 -1
  52. package/dist/db/db.js +7 -0
  53. package/dist/db/db.js.map +1 -1
  54. package/dist/db/schema/agent-access.d.ts +1204 -0
  55. package/dist/db/schema/agent-access.d.ts.map +1 -0
  56. package/dist/db/schema/agent-access.js +267 -0
  57. package/dist/db/schema/agent-access.js.map +1 -0
  58. package/dist/db/schema/users.d.ts.map +1 -1
  59. package/dist/db/schema/users.js +8 -3
  60. package/dist/db/schema/users.js.map +1 -1
  61. package/dist/index.d.ts +11 -2
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +26 -2
  64. package/dist/index.js.map +1 -1
  65. package/dist/metrics/instance.d.ts +1 -1
  66. package/dist/metrics/instance.d.ts.map +1 -1
  67. package/dist/metrics/instance.js.map +1 -1
  68. package/dist/presentation/routes/administration.d.ts.map +1 -1
  69. package/dist/presentation/routes/administration.js +123 -20
  70. package/dist/presentation/routes/administration.js.map +1 -1
  71. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
  72. package/dist/presentation/routes/email-verification/verify-email-resend.js +6 -1
  73. package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
  74. package/dist/presentation/routes/oauth.d.ts +9 -0
  75. package/dist/presentation/routes/oauth.d.ts.map +1 -0
  76. package/dist/presentation/routes/oauth.js +150 -0
  77. package/dist/presentation/routes/oauth.js.map +1 -0
  78. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  79. package/dist/presentation/routes/rate-limit.js +34 -0
  80. package/dist/presentation/routes/rate-limit.js.map +1 -1
  81. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  82. package/dist/presentation/routes/registration/signup.js +59 -50
  83. package/dist/presentation/routes/registration/signup.js.map +1 -1
  84. package/dist/temporal/activities/agent-access-retention.d.ts +13 -0
  85. package/dist/temporal/activities/agent-access-retention.d.ts.map +1 -0
  86. package/dist/temporal/activities/agent-access-retention.js +58 -0
  87. package/dist/temporal/activities/agent-access-retention.js.map +1 -0
  88. package/dist/temporal/activities/index.d.ts +5 -0
  89. package/dist/temporal/activities/index.d.ts.map +1 -0
  90. package/dist/temporal/activities/index.js +8 -0
  91. package/dist/temporal/activities/index.js.map +1 -0
  92. package/dist/temporal/constants.d.ts +2 -0
  93. package/dist/temporal/constants.d.ts.map +1 -0
  94. package/dist/temporal/constants.js +3 -0
  95. package/dist/temporal/constants.js.map +1 -0
  96. package/dist/temporal/workflows/agent-access-retention-cron.d.ts +2 -0
  97. package/dist/temporal/workflows/agent-access-retention-cron.d.ts.map +1 -0
  98. package/dist/temporal/workflows/agent-access-retention-cron.js +22 -0
  99. package/dist/temporal/workflows/agent-access-retention-cron.js.map +1 -0
  100. package/dist/temporal/workflows/index.bundle.js +25227 -0
  101. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  102. package/dist/temporal/workflows/index.d.ts +2 -0
  103. package/dist/temporal/workflows/index.d.ts.map +1 -0
  104. package/dist/temporal/workflows/index.js +3 -0
  105. package/dist/temporal/workflows/index.js.map +1 -0
  106. package/dist/tsconfig.test.tsbuildinfo +1 -1
  107. package/drizzle/0003_brainy_luckman.sql +6 -0
  108. package/drizzle/0004_yummy_runaways.sql +110 -0
  109. package/drizzle/meta/0003_snapshot.json +844 -0
  110. package/drizzle/meta/0004_snapshot.json +1751 -0
  111. package/drizzle/meta/_journal.json +14 -0
  112. package/package.json +11 -8
  113. package/src/core/admin-role.test.ts +70 -0
  114. package/src/core/administration.ts +78 -1
  115. package/src/core/auth.ts +3 -5
  116. package/src/core/cimd.test.ts +213 -0
  117. package/src/core/cimd.ts +532 -0
  118. package/src/core/entities/agent-access.ts +80 -0
  119. package/src/core/errors.ts +48 -0
  120. package/src/core/oauth-client-resolver.test.ts +198 -0
  121. package/src/core/oauth-client-resolver.ts +253 -0
  122. package/src/core/oauth-client.test.ts +157 -0
  123. package/src/core/oauth-client.ts +331 -0
  124. package/src/db/admin-user-moderation.ts +53 -45
  125. package/src/db/admin-user-summary.test.ts +228 -0
  126. package/src/db/admin-user-summary.ts +143 -1
  127. package/src/db/admin-users.ts +21 -1
  128. package/src/db/agent-access-retention.ts +10 -0
  129. package/src/db/agent-access.test.ts +1064 -0
  130. package/src/db/agent-access.ts +1491 -0
  131. package/src/db/db.ts +14 -0
  132. package/src/db/schema/agent-access.ts +273 -0
  133. package/src/db/schema/users.ts +9 -3
  134. package/src/index.test.ts +27 -0
  135. package/src/index.ts +82 -0
  136. package/src/metrics/instance.ts +6 -1
  137. package/src/presentation/routes/administration.test.ts +359 -2
  138. package/src/presentation/routes/administration.ts +142 -20
  139. package/src/presentation/routes/email-verification/verify-email-resend.ts +7 -1
  140. package/src/presentation/routes/oauth.test.ts +133 -0
  141. package/src/presentation/routes/oauth.ts +144 -0
  142. package/src/presentation/routes/rate-limit.ts +16 -0
  143. package/src/presentation/routes/registration/signup.ts +52 -41
  144. package/src/temporal/activities/agent-access-retention.ts +76 -0
  145. package/src/temporal/activities/index.ts +5 -0
  146. package/src/temporal/constants.ts +1 -0
  147. package/src/temporal/workflows/agent-access-retention-cron.ts +23 -0
  148. package/src/temporal/workflows/index.ts +1 -0
  149. package/test/globalSetup.ts +1 -1
  150. package/test/routes.ts +3 -1
  151. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,1491 @@
1
+ import {and, asc, eq, gt, inArray, isNotNull, isNull, lte, notExists, or, sql} from 'drizzle-orm';
2
+ import {config} from '#config.js';
3
+ import type {
4
+ AgentAuthorizationCode,
5
+ AgentAuthorizationRequest,
6
+ AgentClient,
7
+ AgentGrant,
8
+ AgentPersonalAccessToken,
9
+ AgentRefreshToken,
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';
19
+ import {db} from './db.js';
20
+ import {
21
+ agentAuthorizationCodes,
22
+ agentAuthorizationRequests,
23
+ agentClients,
24
+ agentGrants,
25
+ agentPersonalAccessTokens,
26
+ agentRefreshTokens,
27
+ toAgentAuthorizationCode,
28
+ toAgentAuthorizationRequest,
29
+ toAgentClient,
30
+ toAgentGrant,
31
+ toAgentPersonalAccessToken,
32
+ toAgentRefreshToken,
33
+ } from './schema/agent-access.js';
34
+ import {users} from './schema/users.js';
35
+
36
+ export type AgentAccessTx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
37
+
38
+ type AgentAccessExecutor = ReturnType<typeof db> | AgentAccessTx;
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
+
152
+ export interface CreateAgentClientParams {
153
+ clientId: string;
154
+ name: string;
155
+ redirectUris: string[];
156
+ kind: 'registered' | 'cimd';
157
+ }
158
+
159
+ export async function createAgentClient(params: CreateAgentClientParams): Promise<AgentClient> {
160
+ return await db().transaction((tx) => createAgentClientTx(tx, params));
161
+ }
162
+
163
+ export async function createAgentClientTx(
164
+ tx: AgentAccessTx,
165
+ params: CreateAgentClientParams,
166
+ ): Promise<AgentClient> {
167
+ const rows = await tx
168
+ .insert(agentClients)
169
+ .values(params)
170
+ .onConflictDoUpdate({
171
+ target: agentClients.clientId,
172
+ set: {
173
+ lastSeenAt: sql`now()`,
174
+ unreferencedAt: null,
175
+ updatedAt: sql`now()`,
176
+ },
177
+ })
178
+ .returning();
179
+ const row = rows[0];
180
+ if (!row) throw new Error('Upsert returned no rows');
181
+ return toAgentClient(row);
182
+ }
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
+
220
+ export async function findAgentClientByClientId(params: {
221
+ clientId: string;
222
+ executor?: AgentAccessExecutor;
223
+ }): Promise<AgentClient | undefined> {
224
+ const executor = params.executor ?? db();
225
+ const rows = await executor
226
+ .select()
227
+ .from(agentClients)
228
+ .where(eq(agentClients.clientId, params.clientId))
229
+ .limit(1);
230
+ const row = rows[0];
231
+ return row ? toAgentClient(row) : undefined;
232
+ }
233
+
234
+ export async function markAgentClientReferenced(
235
+ tx: AgentAccessTx,
236
+ params: {clientUuid: string},
237
+ ): Promise<void> {
238
+ await tx
239
+ .update(agentClients)
240
+ .set({unreferencedAt: null, lastSeenAt: sql`now()`, updatedAt: sql`now()`})
241
+ .where(eq(agentClients.id, params.clientUuid));
242
+ }
243
+
244
+ export async function markAgentClientUnreferenced(
245
+ tx: AgentAccessTx,
246
+ params: {clientUuid: string},
247
+ ): Promise<void> {
248
+ await tx
249
+ .update(agentClients)
250
+ .set({unreferencedAt: sql`now()`, updatedAt: sql`now()`})
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
+ );
262
+ }
263
+
264
+ export interface CreateAgentAuthorizationRequestParams {
265
+ /** Internal `auth_agent_clients.id`, not the public OAuth `client_id`. */
266
+ clientId: string;
267
+ redirectUri: string;
268
+ resource: string;
269
+ scopes: string[];
270
+ codeChallenge: string;
271
+ state: string | null;
272
+ expiresAt: Date;
273
+ }
274
+
275
+ export async function createAgentAuthorizationRequest(
276
+ params: CreateAgentAuthorizationRequestParams,
277
+ ): Promise<AgentAuthorizationRequest> {
278
+ return await db().transaction((tx) => createAgentAuthorizationRequestTx(tx, params));
279
+ }
280
+
281
+ export async function createAgentAuthorizationRequestTx(
282
+ tx: AgentAccessTx,
283
+ params: CreateAgentAuthorizationRequestParams,
284
+ ): Promise<AgentAuthorizationRequest> {
285
+ if (!(await lockAgentClient(tx, params.clientId))) {
286
+ throw new Error(`Agent client ${params.clientId} was not found`);
287
+ }
288
+ const rows = await tx.insert(agentAuthorizationRequests).values(params).returning();
289
+ const row = rows[0];
290
+ if (!row) throw new Error('Insert returned no rows');
291
+ return toAgentAuthorizationRequest(row);
292
+ }
293
+
294
+ export async function findPendingAgentAuthorizationRequest(params: {
295
+ id: string;
296
+ executor?: AgentAccessExecutor;
297
+ }): Promise<AgentAuthorizationRequest | undefined> {
298
+ const executor = params.executor ?? db();
299
+ const rows = await executor
300
+ .select()
301
+ .from(agentAuthorizationRequests)
302
+ .where(
303
+ and(
304
+ eq(agentAuthorizationRequests.id, params.id),
305
+ isNull(agentAuthorizationRequests.consumedAt),
306
+ gt(agentAuthorizationRequests.expiresAt, sql`now()`),
307
+ ),
308
+ )
309
+ .limit(1);
310
+ const row = rows[0];
311
+ return row ? toAgentAuthorizationRequest(row) : undefined;
312
+ }
313
+
314
+ export async function consumeAgentAuthorizationRequest(params: {
315
+ id: string;
316
+ }): Promise<AgentAuthorizationRequest | undefined> {
317
+ return await db().transaction((tx) => consumeAgentAuthorizationRequestTx(tx, params));
318
+ }
319
+
320
+ /**
321
+ * Claims a pending request in the caller's transaction. The expiry and
322
+ * consumed-at predicates are part of the update so concurrent approval and
323
+ * denial can have only one winner.
324
+ */
325
+ export async function consumeAgentAuthorizationRequestTx(
326
+ tx: AgentAccessTx,
327
+ params: {id: string},
328
+ ): Promise<AgentAuthorizationRequest | undefined> {
329
+ const rows = await tx
330
+ .update(agentAuthorizationRequests)
331
+ .set({consumedAt: sql`now()`, updatedAt: sql`now()`})
332
+ .where(
333
+ and(
334
+ eq(agentAuthorizationRequests.id, params.id),
335
+ isNull(agentAuthorizationRequests.consumedAt),
336
+ gt(agentAuthorizationRequests.expiresAt, sql`now()`),
337
+ ),
338
+ )
339
+ .returning();
340
+ const row = rows[0];
341
+ return row ? toAgentAuthorizationRequest(row) : undefined;
342
+ }
343
+
344
+ export interface CreateAgentGrantParams {
345
+ userId: string;
346
+ workspaceId: string;
347
+ /** Internal `auth_agent_clients.id`, not the public OAuth `client_id`. */
348
+ clientId: string;
349
+ scopes: string[];
350
+ }
351
+
352
+ export async function createAgentGrant(params: CreateAgentGrantParams): Promise<AgentGrant> {
353
+ return await db().transaction((tx) => createAgentGrantTx(tx, params));
354
+ }
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
+ */
362
+ export async function createAgentGrantTx(
363
+ tx: AgentAccessTx,
364
+ params: CreateAgentGrantParams,
365
+ ): Promise<AgentGrant> {
366
+ if (!(await lockAgentClient(tx, params.clientId))) {
367
+ throw new Error(`Agent client ${params.clientId} was not found`);
368
+ }
369
+ const rows = await tx
370
+ .insert(agentGrants)
371
+ .values(params)
372
+ .onConflictDoUpdate({
373
+ target: [agentGrants.userId, agentGrants.workspaceId, agentGrants.clientId],
374
+ targetWhere: sql`${agentGrants.revokedAt} IS NULL AND ${agentGrants.terminalAt} IS NULL`,
375
+ set: {
376
+ scopes: params.scopes,
377
+ updatedAt: sql`now()`,
378
+ },
379
+ })
380
+ .returning();
381
+ const row = rows[0];
382
+ if (!row) throw new Error('Upsert returned no rows');
383
+
384
+ // Refresh tokens derive their scopes from the grant. Reauthorization must
385
+ // invalidate existing tokens before the grant's scopes can be widened.
386
+ await tx
387
+ .update(agentRefreshTokens)
388
+ .set({revokedAt: sql`now()`, updatedAt: sql`now()`})
389
+ .where(
390
+ and(
391
+ eq(agentRefreshTokens.grantId, row.id),
392
+ isNull(agentRefreshTokens.rotatedAt),
393
+ isNull(agentRefreshTokens.revokedAt),
394
+ ),
395
+ );
396
+
397
+ await markAgentClientReferenced(tx, {clientUuid: params.clientId});
398
+ return toAgentGrant(row);
399
+ }
400
+
401
+ export async function findAgentGrant(params: {
402
+ id: string;
403
+ executor?: AgentAccessExecutor;
404
+ }): Promise<AgentGrant | undefined> {
405
+ const executor = params.executor ?? db();
406
+ const rows = await executor
407
+ .select()
408
+ .from(agentGrants)
409
+ .where(eq(agentGrants.id, params.id))
410
+ .limit(1);
411
+ const row = rows[0];
412
+ return row ? toAgentGrant(row) : undefined;
413
+ }
414
+
415
+ /**
416
+ * Locks one grant row until the surrounding transaction ends. Code exchange
417
+ * and lifecycle transitions must use this same primitive before inspecting or
418
+ * changing grant state. Callers must keep the transaction database-only while
419
+ * this lock is held; external I/O must happen after commit.
420
+ */
421
+ export async function lockAgentGrant(
422
+ tx: AgentAccessTx,
423
+ params: {grantId: string},
424
+ ): Promise<AgentGrant | undefined> {
425
+ const rows = await tx
426
+ .select()
427
+ .from(agentGrants)
428
+ .where(eq(agentGrants.id, params.grantId))
429
+ .for('update')
430
+ .limit(1);
431
+ const row = rows[0];
432
+ return row ? toAgentGrant(row) : undefined;
433
+ }
434
+
435
+ export interface CreateAgentAuthorizationCodeParams {
436
+ grantId: string;
437
+ hashedCode: string;
438
+ codeChallenge: string;
439
+ redirectUri: string;
440
+ resource: string;
441
+ expiresAt: Date;
442
+ }
443
+
444
+ export async function createAgentAuthorizationCode(
445
+ params: CreateAgentAuthorizationCodeParams,
446
+ ): Promise<AgentAuthorizationCode> {
447
+ return await db().transaction((tx) => createAgentAuthorizationCodeTx(tx, params));
448
+ }
449
+
450
+ export async function createAgentAuthorizationCodeTx(
451
+ tx: AgentAccessTx,
452
+ params: CreateAgentAuthorizationCodeParams,
453
+ ): Promise<AgentAuthorizationCode> {
454
+ await requireActiveAgentGrant(tx, params.grantId);
455
+ const rows = await tx.insert(agentAuthorizationCodes).values(params).returning();
456
+ const row = rows[0];
457
+ if (!row) throw new Error('Insert returned no rows');
458
+ return toAgentAuthorizationCode(row);
459
+ }
460
+
461
+ export async function findAgentAuthorizationCodeByHash(params: {
462
+ hashedCode: string;
463
+ executor?: AgentAccessExecutor;
464
+ }): Promise<AgentAuthorizationCode | undefined> {
465
+ const executor = params.executor ?? db();
466
+ const rows = await executor
467
+ .select()
468
+ .from(agentAuthorizationCodes)
469
+ .where(eq(agentAuthorizationCodes.hashedCode, params.hashedCode))
470
+ .limit(1);
471
+ const row = rows[0];
472
+ return row ? toAgentAuthorizationCode(row) : undefined;
473
+ }
474
+
475
+ export async function findActiveAgentAuthorizationCodeByHash(params: {
476
+ hashedCode: string;
477
+ executor?: AgentAccessExecutor;
478
+ }): Promise<AgentAuthorizationCode | undefined> {
479
+ const executor = params.executor ?? db();
480
+ const rows = await executor
481
+ .select()
482
+ .from(agentAuthorizationCodes)
483
+ .where(
484
+ and(
485
+ eq(agentAuthorizationCodes.hashedCode, params.hashedCode),
486
+ isNull(agentAuthorizationCodes.consumedAt),
487
+ gt(agentAuthorizationCodes.expiresAt, sql`now()`),
488
+ ),
489
+ )
490
+ .limit(1);
491
+ const row = rows[0];
492
+ return row ? toAgentAuthorizationCode(row) : undefined;
493
+ }
494
+
495
+ export async function consumeAgentAuthorizationCode(params: {
496
+ hashedCode: string;
497
+ }): Promise<AgentAuthorizationCode | undefined> {
498
+ return await db().transaction((tx) => consumeAgentAuthorizationCodeTx(tx, params));
499
+ }
500
+
501
+ /** Claims an authorization code exactly once, including its 60-second TTL. */
502
+ export async function consumeAgentAuthorizationCodeTx(
503
+ tx: AgentAccessTx,
504
+ params: {hashedCode: string},
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
+
521
+ const rows = await tx
522
+ .update(agentAuthorizationCodes)
523
+ .set({consumedAt: sql`now()`, updatedAt: sql`now()`})
524
+ .where(
525
+ and(
526
+ eq(agentAuthorizationCodes.id, existing.id),
527
+ eq(agentAuthorizationCodes.hashedCode, params.hashedCode),
528
+ isNull(agentAuthorizationCodes.consumedAt),
529
+ gt(agentAuthorizationCodes.expiresAt, sql`now()`),
530
+ ),
531
+ )
532
+ .returning();
533
+ const row = rows[0];
534
+ return row ? toAgentAuthorizationCode(row) : undefined;
535
+ }
536
+
537
+ export interface CreateAgentRefreshTokenParams {
538
+ grantId: string;
539
+ hashedToken: string;
540
+ /** Defaults to the configured 14-day lifetime. */
541
+ expiresAt?: Date;
542
+ }
543
+
544
+ export async function createAgentRefreshToken(
545
+ params: CreateAgentRefreshTokenParams,
546
+ ): Promise<AgentRefreshToken> {
547
+ return await db().transaction((tx) => createAgentRefreshTokenTx(tx, params));
548
+ }
549
+
550
+ export async function createAgentRefreshTokenTx(
551
+ tx: AgentAccessTx,
552
+ params: CreateAgentRefreshTokenParams,
553
+ ): Promise<AgentRefreshToken> {
554
+ await requireActiveAgentGrant(tx, params.grantId);
555
+ const rows = await tx
556
+ .insert(agentRefreshTokens)
557
+ .values({...params, expiresAt: params.expiresAt ?? agentRefreshTokenExpiresAt()})
558
+ .returning();
559
+ const row = rows[0];
560
+ if (!row) throw new Error('Insert returned no rows');
561
+ return toAgentRefreshToken(row);
562
+ }
563
+
564
+ export async function findAgentRefreshTokenByHash(params: {
565
+ hashedToken: string;
566
+ executor?: AgentAccessExecutor;
567
+ }): Promise<AgentRefreshToken | undefined> {
568
+ const executor = params.executor ?? db();
569
+ const rows = await executor
570
+ .select()
571
+ .from(agentRefreshTokens)
572
+ .where(
573
+ and(
574
+ eq(agentRefreshTokens.hashedToken, params.hashedToken),
575
+ isNull(agentRefreshTokens.revokedAt),
576
+ ),
577
+ )
578
+ .limit(1);
579
+ const row = rows[0];
580
+ return row ? toAgentRefreshToken(row) : undefined;
581
+ }
582
+
583
+ export async function findActiveAgentRefreshTokenByHash(params: {
584
+ hashedToken: string;
585
+ executor?: AgentAccessExecutor;
586
+ }): Promise<AgentRefreshToken | undefined> {
587
+ const executor = params.executor ?? db();
588
+ const rows = await executor
589
+ .select()
590
+ .from(agentRefreshTokens)
591
+ .where(
592
+ and(
593
+ eq(agentRefreshTokens.hashedToken, params.hashedToken),
594
+ isNull(agentRefreshTokens.rotatedAt),
595
+ isNull(agentRefreshTokens.revokedAt),
596
+ gt(agentRefreshTokens.expiresAt, sql`now()`),
597
+ ),
598
+ )
599
+ .limit(1);
600
+ const row = rows[0];
601
+ return row ? toAgentRefreshToken(row) : undefined;
602
+ }
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
+
725
+ /** Rotates a refresh token atomically; a concurrent rotation cannot win twice. */
726
+ export async function rotateAgentRefreshToken(params: {
727
+ hashedToken: string;
728
+ replacementHashedToken: string;
729
+ /** Defaults to the configured 14-day sliding lifetime. */
730
+ replacementExpiresAt?: Date;
731
+ }): Promise<AgentRefreshToken | undefined> {
732
+ return await db().transaction((tx) => rotateAgentRefreshTokenTx(tx, params));
733
+ }
734
+
735
+ export async function rotateAgentRefreshTokenTx(
736
+ tx: AgentAccessTx,
737
+ params: {
738
+ hashedToken: string;
739
+ replacementHashedToken: string;
740
+ replacementExpiresAt?: Date;
741
+ },
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
+
761
+ const rows = await tx
762
+ .update(agentRefreshTokens)
763
+ .set({rotatedAt: sql`now()`, lastUsedAt: sql`now()`, updatedAt: sql`now()`})
764
+ .where(
765
+ and(
766
+ eq(agentRefreshTokens.id, existing.id),
767
+ eq(agentRefreshTokens.hashedToken, params.hashedToken),
768
+ isNull(agentRefreshTokens.rotatedAt),
769
+ isNull(agentRefreshTokens.revokedAt),
770
+ gt(agentRefreshTokens.expiresAt, sql`now()`),
771
+ ),
772
+ )
773
+ .returning();
774
+ const row = rows[0];
775
+ if (!row) return undefined;
776
+
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);
793
+ }
794
+
795
+ export async function revokeAgentRefreshToken(params: {
796
+ id: string;
797
+ }): Promise<AgentRefreshToken | undefined> {
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
+
809
+ const rows = await tx
810
+ .update(agentRefreshTokens)
811
+ .set({revokedAt: sql`now()`, updatedAt: sql`now()`})
812
+ .where(and(eq(agentRefreshTokens.id, params.id), isNull(agentRefreshTokens.revokedAt)))
813
+ .returning();
814
+ const row = rows[0];
815
+ return row ? toAgentRefreshToken(row) : undefined;
816
+ });
817
+ }
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
+
961
+ export interface CreateAgentPersonalAccessTokenParams {
962
+ userId: string;
963
+ workspaceId: string;
964
+ hashedToken: string;
965
+ prefix: string;
966
+ name: string;
967
+ scopes: string[];
968
+ expiresAt: Date;
969
+ }
970
+
971
+ export async function createAgentPersonalAccessToken(
972
+ params: CreateAgentPersonalAccessTokenParams,
973
+ ): Promise<AgentPersonalAccessToken> {
974
+ return await db().transaction((tx) => createAgentPersonalAccessTokenTx(tx, params));
975
+ }
976
+
977
+ export async function createAgentPersonalAccessTokenTx(
978
+ tx: AgentAccessTx,
979
+ params: CreateAgentPersonalAccessTokenParams,
980
+ ): Promise<AgentPersonalAccessToken> {
981
+ const rows = await tx.insert(agentPersonalAccessTokens).values(params).returning();
982
+ const row = rows[0];
983
+ if (!row) throw new Error('Insert returned no rows');
984
+ return toAgentPersonalAccessToken(row);
985
+ }
986
+
987
+ export async function findAgentPersonalAccessTokenByHash(params: {
988
+ hashedToken: string;
989
+ executor?: AgentAccessExecutor;
990
+ }): Promise<AgentPersonalAccessToken | undefined> {
991
+ const executor = params.executor ?? db();
992
+ const rows = await executor
993
+ .select()
994
+ .from(agentPersonalAccessTokens)
995
+ .where(eq(agentPersonalAccessTokens.hashedToken, params.hashedToken))
996
+ .limit(1);
997
+ const row = rows[0];
998
+ return row ? toAgentPersonalAccessToken(row) : undefined;
999
+ }
1000
+
1001
+ export async function findActiveAgentPersonalAccessTokenByHash(params: {
1002
+ hashedToken: string;
1003
+ executor?: AgentAccessExecutor;
1004
+ }): Promise<AgentPersonalAccessToken | undefined> {
1005
+ const executor = params.executor ?? db();
1006
+ const rows = await executor
1007
+ .select({token: agentPersonalAccessTokens})
1008
+ .from(agentPersonalAccessTokens)
1009
+ .innerJoin(users, eq(agentPersonalAccessTokens.userId, users.id))
1010
+ .where(
1011
+ and(
1012
+ eq(agentPersonalAccessTokens.hashedToken, params.hashedToken),
1013
+ isNull(agentPersonalAccessTokens.revokedAt),
1014
+ gt(agentPersonalAccessTokens.expiresAt, sql`now()`),
1015
+ eq(users.status, 'active'),
1016
+ ),
1017
+ )
1018
+ .limit(1);
1019
+ const row = rows[0]?.token;
1020
+ return row ? toAgentPersonalAccessToken(row) : undefined;
1021
+ }
1022
+
1023
+ export async function markAgentPersonalAccessTokenUsed(params: {
1024
+ id: string;
1025
+ throttleSeconds?: number;
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
+
1032
+ const rows = await db()
1033
+ .update(agentPersonalAccessTokens)
1034
+ .set({lastUsedAt: sql`now()`, updatedAt: sql`now()`})
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
+ )
1049
+ .returning();
1050
+ const row = rows[0];
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;
1069
+ }
1070
+
1071
+ export async function revokeAgentPersonalAccessToken(params: {
1072
+ id: string;
1073
+ }): Promise<AgentPersonalAccessToken | undefined> {
1074
+ const rows = await db()
1075
+ .update(agentPersonalAccessTokens)
1076
+ .set({revokedAt: sql`now()`, updatedAt: sql`now()`})
1077
+ .where(
1078
+ and(eq(agentPersonalAccessTokens.id, params.id), isNull(agentPersonalAccessTokens.revokedAt)),
1079
+ )
1080
+ .returning();
1081
+ const row = rows[0];
1082
+ return row ? toAgentPersonalAccessToken(row) : undefined;
1083
+ }
1084
+
1085
+ export interface PruneAgentAccessParams {
1086
+ /** Overrides every retention horizon for deterministic maintenance tests. */
1087
+ retentionDays?: number;
1088
+ limit?: number;
1089
+ now?: Date;
1090
+ /** Absolute epoch deadline used to bound every PostgreSQL statement. */
1091
+ deadlineMs?: number;
1092
+ }
1093
+
1094
+ export interface PruneAgentAccessResult {
1095
+ deleted: number;
1096
+ transitioned: number;
1097
+ }
1098
+
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),
1181
+ ),
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
+ })
1302
+ .where(
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)),
1310
+ ),
1311
+ ),
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(
1338
+ or(
1339
+ lte(agentClients.unreferencedAt, params.cutoff),
1340
+ and(
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
+ ),
1349
+ ),
1350
+ ),
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),
1390
+ or(
1391
+ lte(agentClients.unreferencedAt, params.cutoff),
1392
+ and(isNull(agentClients.unreferencedAt), lte(agentClients.createdAt, params.cutoff)),
1393
+ ),
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};
1490
+ });
1491
+ }