@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.
- package/.turbo/turbo-build.log +17 -2
- package/CHANGELOG.md +13 -0
- package/dist/core/cimd.d.ts +39 -0
- package/dist/core/cimd.d.ts.map +1 -0
- package/dist/core/cimd.js +392 -0
- package/dist/core/cimd.js.map +1 -0
- package/dist/core/errors.d.ts +14 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +26 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/oauth-client-resolver.d.ts +54 -0
- package/dist/core/oauth-client-resolver.d.ts.map +1 -0
- package/dist/core/oauth-client-resolver.js +154 -0
- package/dist/core/oauth-client-resolver.js.map +1 -0
- package/dist/core/oauth-client.d.ts +39 -0
- package/dist/core/oauth-client.d.ts.map +1 -0
- package/dist/core/oauth-client.js +236 -0
- package/dist/core/oauth-client.js.map +1 -0
- package/dist/db/agent-access-retention.d.ts +10 -0
- package/dist/db/agent-access-retention.d.ts.map +1 -0
- package/dist/db/agent-access-retention.js +11 -0
- package/dist/db/agent-access-retention.js.map +1 -0
- package/dist/db/agent-access.d.ts +100 -4
- package/dist/db/agent-access.d.ts.map +1 -1
- package/dist/db/agent-access.js +483 -45
- package/dist/db/agent-access.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +1 -1
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js.map +1 -1
- package/dist/presentation/routes/oauth.d.ts +9 -0
- package/dist/presentation/routes/oauth.d.ts.map +1 -0
- package/dist/presentation/routes/oauth.js +150 -0
- package/dist/presentation/routes/oauth.js.map +1 -0
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +24 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/temporal/activities/agent-access-retention.d.ts +13 -0
- package/dist/temporal/activities/agent-access-retention.d.ts.map +1 -0
- package/dist/temporal/activities/agent-access-retention.js +58 -0
- package/dist/temporal/activities/agent-access-retention.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +5 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +8 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/constants.d.ts +2 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +3 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/workflows/agent-access-retention-cron.d.ts +2 -0
- package/dist/temporal/workflows/agent-access-retention-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/agent-access-retention-cron.js +22 -0
- package/dist/temporal/workflows/agent-access-retention-cron.js.map +1 -0
- package/dist/temporal/workflows/index.bundle.js +25227 -0
- package/dist/temporal/workflows/index.bundle.meta.json +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +7 -4
- package/src/core/cimd.test.ts +213 -0
- package/src/core/cimd.ts +532 -0
- package/src/core/errors.ts +41 -0
- package/src/core/oauth-client-resolver.test.ts +198 -0
- package/src/core/oauth-client-resolver.ts +253 -0
- package/src/core/oauth-client.test.ts +157 -0
- package/src/core/oauth-client.ts +331 -0
- package/src/db/agent-access-retention.ts +10 -0
- package/src/db/agent-access.test.ts +697 -3
- package/src/db/agent-access.ts +953 -91
- package/src/index.test.ts +27 -0
- package/src/index.ts +76 -0
- package/src/metrics/instance.ts +5 -1
- package/src/presentation/routes/oauth.test.ts +133 -0
- package/src/presentation/routes/oauth.ts +144 -0
- package/src/presentation/routes/rate-limit.ts +12 -0
- package/src/temporal/activities/agent-access-retention.ts +76 -0
- package/src/temporal/activities/index.ts +5 -0
- package/src/temporal/constants.ts +1 -0
- package/src/temporal/workflows/agent-access-retention-cron.ts +23 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/db/agent-access.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {and, asc, eq, gt, inArray, isNull,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
584
|
-
and(isNull(agentRefreshTokens.rotatedAt), lt(agentRefreshTokens.revokedAt, cutoff)),
|
|
1339
|
+
lte(agentClients.unreferencedAt, params.cutoff),
|
|
585
1340
|
and(
|
|
586
|
-
isNull(
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
}
|