@shipfox/api-auth 17.0.0 → 19.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 (99) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +44 -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/entities/agent-access.d.ts +75 -0
  12. package/dist/core/entities/agent-access.d.ts.map +1 -0
  13. package/dist/core/entities/agent-access.js +3 -0
  14. package/dist/core/entities/agent-access.js.map +1 -0
  15. package/dist/core/errors.d.ts +3 -0
  16. package/dist/core/errors.d.ts.map +1 -1
  17. package/dist/core/errors.js +6 -0
  18. package/dist/core/errors.js.map +1 -1
  19. package/dist/core/runner-session-token.d.ts.map +1 -1
  20. package/dist/core/runner-session-token.js +2 -1
  21. package/dist/core/runner-session-token.js.map +1 -1
  22. package/dist/db/admin-user-moderation.d.ts.map +1 -1
  23. package/dist/db/admin-user-moderation.js +30 -26
  24. package/dist/db/admin-user-moderation.js.map +1 -1
  25. package/dist/db/admin-user-summary.d.ts +22 -0
  26. package/dist/db/admin-user-summary.d.ts.map +1 -1
  27. package/dist/db/admin-user-summary.js +75 -1
  28. package/dist/db/admin-user-summary.js.map +1 -1
  29. package/dist/db/admin-users.d.ts +11 -0
  30. package/dist/db/admin-users.d.ts.map +1 -1
  31. package/dist/db/admin-users.js +4 -1
  32. package/dist/db/admin-users.js.map +1 -1
  33. package/dist/db/agent-access.d.ts +159 -0
  34. package/dist/db/agent-access.d.ts.map +1 -0
  35. package/dist/db/agent-access.js +267 -0
  36. package/dist/db/agent-access.js.map +1 -0
  37. package/dist/db/db.d.ts +2604 -238
  38. package/dist/db/db.d.ts.map +1 -1
  39. package/dist/db/db.js +7 -0
  40. package/dist/db/db.js.map +1 -1
  41. package/dist/db/schema/agent-access.d.ts +1204 -0
  42. package/dist/db/schema/agent-access.d.ts.map +1 -0
  43. package/dist/db/schema/agent-access.js +267 -0
  44. package/dist/db/schema/agent-access.js.map +1 -0
  45. package/dist/db/schema/users.d.ts.map +1 -1
  46. package/dist/db/schema/users.js +8 -3
  47. package/dist/db/schema/users.js.map +1 -1
  48. package/dist/index.d.ts +3 -2
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +2 -2
  51. package/dist/index.js.map +1 -1
  52. package/dist/metrics/instance.d.ts +1 -1
  53. package/dist/metrics/instance.d.ts.map +1 -1
  54. package/dist/metrics/instance.js.map +1 -1
  55. package/dist/presentation/routes/administration.d.ts.map +1 -1
  56. package/dist/presentation/routes/administration.js +123 -20
  57. package/dist/presentation/routes/administration.js.map +1 -1
  58. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
  59. package/dist/presentation/routes/email-verification/verify-email-resend.js +6 -1
  60. package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
  61. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  62. package/dist/presentation/routes/rate-limit.js +10 -0
  63. package/dist/presentation/routes/rate-limit.js.map +1 -1
  64. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  65. package/dist/presentation/routes/registration/signup.js +59 -50
  66. package/dist/presentation/routes/registration/signup.js.map +1 -1
  67. package/dist/tsconfig.test.tsbuildinfo +1 -1
  68. package/drizzle/0003_brainy_luckman.sql +6 -0
  69. package/drizzle/0004_yummy_runaways.sql +110 -0
  70. package/drizzle/meta/0003_snapshot.json +844 -0
  71. package/drizzle/meta/0004_snapshot.json +1751 -0
  72. package/drizzle/meta/_journal.json +14 -0
  73. package/package.json +7 -7
  74. package/src/core/admin-role.test.ts +70 -0
  75. package/src/core/administration.ts +78 -1
  76. package/src/core/auth.ts +3 -5
  77. package/src/core/entities/agent-access.ts +80 -0
  78. package/src/core/errors.ts +7 -0
  79. package/src/core/runner-session-token.test.ts +2 -0
  80. package/src/core/runner-session-token.ts +1 -0
  81. package/src/db/admin-user-moderation.ts +53 -45
  82. package/src/db/admin-user-summary.test.ts +228 -0
  83. package/src/db/admin-user-summary.ts +143 -1
  84. package/src/db/admin-users.ts +21 -1
  85. package/src/db/agent-access.test.ts +370 -0
  86. package/src/db/agent-access.ts +629 -0
  87. package/src/db/db.ts +14 -0
  88. package/src/db/schema/agent-access.ts +273 -0
  89. package/src/db/schema/users.ts +9 -3
  90. package/src/index.ts +6 -0
  91. package/src/metrics/instance.ts +1 -0
  92. package/src/presentation/routes/administration.test.ts +359 -2
  93. package/src/presentation/routes/administration.ts +142 -20
  94. package/src/presentation/routes/email-verification/verify-email-resend.ts +7 -1
  95. package/src/presentation/routes/rate-limit.ts +4 -0
  96. package/src/presentation/routes/registration/signup.ts +52 -41
  97. package/test/globalSetup.ts +1 -1
  98. package/test/routes.ts +3 -1
  99. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,629 @@
1
+ import {and, asc, eq, gt, inArray, isNull, lt, or, sql} from 'drizzle-orm';
2
+ import type {
3
+ AgentAuthorizationCode,
4
+ AgentAuthorizationRequest,
5
+ AgentClient,
6
+ AgentGrant,
7
+ AgentPersonalAccessToken,
8
+ AgentRefreshToken,
9
+ } from '#core/entities/agent-access.js';
10
+ import {db} from './db.js';
11
+ import {
12
+ agentAuthorizationCodes,
13
+ agentAuthorizationRequests,
14
+ agentClients,
15
+ agentGrants,
16
+ agentPersonalAccessTokens,
17
+ agentRefreshTokens,
18
+ toAgentAuthorizationCode,
19
+ toAgentAuthorizationRequest,
20
+ toAgentClient,
21
+ toAgentGrant,
22
+ toAgentPersonalAccessToken,
23
+ toAgentRefreshToken,
24
+ } from './schema/agent-access.js';
25
+
26
+ export type AgentAccessTx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
27
+
28
+ type AgentAccessExecutor = ReturnType<typeof db> | AgentAccessTx;
29
+
30
+ export interface CreateAgentClientParams {
31
+ clientId: string;
32
+ name: string;
33
+ redirectUris: string[];
34
+ kind: 'registered' | 'cimd';
35
+ }
36
+
37
+ export async function createAgentClient(params: CreateAgentClientParams): Promise<AgentClient> {
38
+ return await db().transaction((tx) => createAgentClientTx(tx, params));
39
+ }
40
+
41
+ export async function createAgentClientTx(
42
+ tx: AgentAccessTx,
43
+ params: CreateAgentClientParams,
44
+ ): Promise<AgentClient> {
45
+ const rows = await tx
46
+ .insert(agentClients)
47
+ .values(params)
48
+ .onConflictDoUpdate({
49
+ target: agentClients.clientId,
50
+ set: {
51
+ lastSeenAt: sql`now()`,
52
+ unreferencedAt: null,
53
+ updatedAt: sql`now()`,
54
+ },
55
+ })
56
+ .returning();
57
+ const row = rows[0];
58
+ if (!row) throw new Error('Upsert returned no rows');
59
+ return toAgentClient(row);
60
+ }
61
+
62
+ export async function findAgentClientByClientId(params: {
63
+ clientId: string;
64
+ executor?: AgentAccessExecutor;
65
+ }): Promise<AgentClient | undefined> {
66
+ const executor = params.executor ?? db();
67
+ const rows = await executor
68
+ .select()
69
+ .from(agentClients)
70
+ .where(eq(agentClients.clientId, params.clientId))
71
+ .limit(1);
72
+ const row = rows[0];
73
+ return row ? toAgentClient(row) : undefined;
74
+ }
75
+
76
+ export async function markAgentClientReferenced(
77
+ tx: AgentAccessTx,
78
+ params: {clientUuid: string},
79
+ ): Promise<void> {
80
+ await tx
81
+ .update(agentClients)
82
+ .set({unreferencedAt: null, lastSeenAt: sql`now()`, updatedAt: sql`now()`})
83
+ .where(eq(agentClients.id, params.clientUuid));
84
+ }
85
+
86
+ export async function markAgentClientUnreferenced(
87
+ tx: AgentAccessTx,
88
+ params: {clientUuid: string},
89
+ ): Promise<void> {
90
+ await tx
91
+ .update(agentClients)
92
+ .set({unreferencedAt: sql`now()`, updatedAt: sql`now()`})
93
+ .where(eq(agentClients.id, params.clientUuid));
94
+ }
95
+
96
+ export interface CreateAgentAuthorizationRequestParams {
97
+ /** Internal `auth_agent_clients.id`, not the public OAuth `client_id`. */
98
+ clientId: string;
99
+ redirectUri: string;
100
+ resource: string;
101
+ scopes: string[];
102
+ codeChallenge: string;
103
+ state: string | null;
104
+ expiresAt: Date;
105
+ }
106
+
107
+ export async function createAgentAuthorizationRequest(
108
+ params: CreateAgentAuthorizationRequestParams,
109
+ ): Promise<AgentAuthorizationRequest> {
110
+ return await db().transaction((tx) => createAgentAuthorizationRequestTx(tx, params));
111
+ }
112
+
113
+ export async function createAgentAuthorizationRequestTx(
114
+ tx: AgentAccessTx,
115
+ params: CreateAgentAuthorizationRequestParams,
116
+ ): Promise<AgentAuthorizationRequest> {
117
+ const rows = await tx.insert(agentAuthorizationRequests).values(params).returning();
118
+ const row = rows[0];
119
+ if (!row) throw new Error('Insert returned no rows');
120
+ return toAgentAuthorizationRequest(row);
121
+ }
122
+
123
+ export async function findPendingAgentAuthorizationRequest(params: {
124
+ id: string;
125
+ executor?: AgentAccessExecutor;
126
+ }): Promise<AgentAuthorizationRequest | undefined> {
127
+ const executor = params.executor ?? db();
128
+ const rows = await executor
129
+ .select()
130
+ .from(agentAuthorizationRequests)
131
+ .where(
132
+ and(
133
+ eq(agentAuthorizationRequests.id, params.id),
134
+ isNull(agentAuthorizationRequests.consumedAt),
135
+ gt(agentAuthorizationRequests.expiresAt, sql`now()`),
136
+ ),
137
+ )
138
+ .limit(1);
139
+ const row = rows[0];
140
+ return row ? toAgentAuthorizationRequest(row) : undefined;
141
+ }
142
+
143
+ export async function consumeAgentAuthorizationRequest(params: {
144
+ id: string;
145
+ }): Promise<AgentAuthorizationRequest | undefined> {
146
+ return await db().transaction((tx) => consumeAgentAuthorizationRequestTx(tx, params));
147
+ }
148
+
149
+ /**
150
+ * Claims a pending request in the caller's transaction. The expiry and
151
+ * consumed-at predicates are part of the update so concurrent approval and
152
+ * denial can have only one winner.
153
+ */
154
+ export async function consumeAgentAuthorizationRequestTx(
155
+ tx: AgentAccessTx,
156
+ params: {id: string},
157
+ ): Promise<AgentAuthorizationRequest | undefined> {
158
+ const rows = await tx
159
+ .update(agentAuthorizationRequests)
160
+ .set({consumedAt: sql`now()`, updatedAt: sql`now()`})
161
+ .where(
162
+ and(
163
+ eq(agentAuthorizationRequests.id, params.id),
164
+ isNull(agentAuthorizationRequests.consumedAt),
165
+ gt(agentAuthorizationRequests.expiresAt, sql`now()`),
166
+ ),
167
+ )
168
+ .returning();
169
+ const row = rows[0];
170
+ return row ? toAgentAuthorizationRequest(row) : undefined;
171
+ }
172
+
173
+ export interface CreateAgentGrantParams {
174
+ userId: string;
175
+ workspaceId: string;
176
+ /** Internal `auth_agent_clients.id`, not the public OAuth `client_id`. */
177
+ clientId: string;
178
+ scopes: string[];
179
+ }
180
+
181
+ export async function createAgentGrant(params: CreateAgentGrantParams): Promise<AgentGrant> {
182
+ return await db().transaction((tx) => createAgentGrantTx(tx, params));
183
+ }
184
+
185
+ export async function createAgentGrantTx(
186
+ tx: AgentAccessTx,
187
+ params: CreateAgentGrantParams,
188
+ ): Promise<AgentGrant> {
189
+ const rows = await tx
190
+ .insert(agentGrants)
191
+ .values(params)
192
+ .onConflictDoUpdate({
193
+ target: [agentGrants.userId, agentGrants.workspaceId, agentGrants.clientId],
194
+ targetWhere: sql`${agentGrants.revokedAt} IS NULL AND ${agentGrants.terminalAt} IS NULL`,
195
+ set: {
196
+ scopes: params.scopes,
197
+ updatedAt: sql`now()`,
198
+ },
199
+ })
200
+ .returning();
201
+ const row = rows[0];
202
+ if (!row) throw new Error('Upsert returned no rows');
203
+
204
+ // Refresh tokens derive their scopes from the grant. Reauthorization must
205
+ // invalidate existing tokens before the grant's scopes can be widened.
206
+ await tx
207
+ .update(agentRefreshTokens)
208
+ .set({revokedAt: sql`now()`, updatedAt: sql`now()`})
209
+ .where(
210
+ and(
211
+ eq(agentRefreshTokens.grantId, row.id),
212
+ isNull(agentRefreshTokens.rotatedAt),
213
+ isNull(agentRefreshTokens.revokedAt),
214
+ ),
215
+ );
216
+
217
+ await markAgentClientReferenced(tx, {clientUuid: params.clientId});
218
+ return toAgentGrant(row);
219
+ }
220
+
221
+ export async function findAgentGrant(params: {
222
+ id: string;
223
+ executor?: AgentAccessExecutor;
224
+ }): Promise<AgentGrant | undefined> {
225
+ const executor = params.executor ?? db();
226
+ const rows = await executor
227
+ .select()
228
+ .from(agentGrants)
229
+ .where(eq(agentGrants.id, params.id))
230
+ .limit(1);
231
+ const row = rows[0];
232
+ return row ? toAgentGrant(row) : undefined;
233
+ }
234
+
235
+ /**
236
+ * Locks one grant row until the surrounding transaction ends. Code exchange
237
+ * and lifecycle transitions must use this same primitive before inspecting or
238
+ * changing grant state. Callers must keep the transaction database-only while
239
+ * this lock is held; external I/O must happen after commit.
240
+ */
241
+ export async function lockAgentGrant(
242
+ tx: AgentAccessTx,
243
+ params: {grantId: string},
244
+ ): Promise<AgentGrant | undefined> {
245
+ const rows = await tx
246
+ .select()
247
+ .from(agentGrants)
248
+ .where(eq(agentGrants.id, params.grantId))
249
+ .for('update')
250
+ .limit(1);
251
+ const row = rows[0];
252
+ return row ? toAgentGrant(row) : undefined;
253
+ }
254
+
255
+ export interface CreateAgentAuthorizationCodeParams {
256
+ grantId: string;
257
+ hashedCode: string;
258
+ codeChallenge: string;
259
+ redirectUri: string;
260
+ resource: string;
261
+ expiresAt: Date;
262
+ }
263
+
264
+ export async function createAgentAuthorizationCode(
265
+ params: CreateAgentAuthorizationCodeParams,
266
+ ): Promise<AgentAuthorizationCode> {
267
+ return await db().transaction((tx) => createAgentAuthorizationCodeTx(tx, params));
268
+ }
269
+
270
+ export async function createAgentAuthorizationCodeTx(
271
+ tx: AgentAccessTx,
272
+ params: CreateAgentAuthorizationCodeParams,
273
+ ): Promise<AgentAuthorizationCode> {
274
+ const rows = await tx.insert(agentAuthorizationCodes).values(params).returning();
275
+ const row = rows[0];
276
+ if (!row) throw new Error('Insert returned no rows');
277
+ return toAgentAuthorizationCode(row);
278
+ }
279
+
280
+ export async function findAgentAuthorizationCodeByHash(params: {
281
+ hashedCode: string;
282
+ executor?: AgentAccessExecutor;
283
+ }): Promise<AgentAuthorizationCode | undefined> {
284
+ const executor = params.executor ?? db();
285
+ const rows = await executor
286
+ .select()
287
+ .from(agentAuthorizationCodes)
288
+ .where(eq(agentAuthorizationCodes.hashedCode, params.hashedCode))
289
+ .limit(1);
290
+ const row = rows[0];
291
+ return row ? toAgentAuthorizationCode(row) : undefined;
292
+ }
293
+
294
+ export async function findActiveAgentAuthorizationCodeByHash(params: {
295
+ hashedCode: string;
296
+ executor?: AgentAccessExecutor;
297
+ }): Promise<AgentAuthorizationCode | undefined> {
298
+ const executor = params.executor ?? db();
299
+ const rows = await executor
300
+ .select()
301
+ .from(agentAuthorizationCodes)
302
+ .where(
303
+ and(
304
+ eq(agentAuthorizationCodes.hashedCode, params.hashedCode),
305
+ isNull(agentAuthorizationCodes.consumedAt),
306
+ gt(agentAuthorizationCodes.expiresAt, sql`now()`),
307
+ ),
308
+ )
309
+ .limit(1);
310
+ const row = rows[0];
311
+ return row ? toAgentAuthorizationCode(row) : undefined;
312
+ }
313
+
314
+ export async function consumeAgentAuthorizationCode(params: {
315
+ hashedCode: string;
316
+ }): Promise<AgentAuthorizationCode | undefined> {
317
+ return await db().transaction((tx) => consumeAgentAuthorizationCodeTx(tx, params));
318
+ }
319
+
320
+ /** Claims an authorization code exactly once, including its 60-second TTL. */
321
+ export async function consumeAgentAuthorizationCodeTx(
322
+ tx: AgentAccessTx,
323
+ params: {hashedCode: string},
324
+ ): Promise<AgentAuthorizationCode | undefined> {
325
+ const rows = await tx
326
+ .update(agentAuthorizationCodes)
327
+ .set({consumedAt: sql`now()`, updatedAt: sql`now()`})
328
+ .where(
329
+ and(
330
+ eq(agentAuthorizationCodes.hashedCode, params.hashedCode),
331
+ isNull(agentAuthorizationCodes.consumedAt),
332
+ gt(agentAuthorizationCodes.expiresAt, sql`now()`),
333
+ ),
334
+ )
335
+ .returning();
336
+ const row = rows[0];
337
+ return row ? toAgentAuthorizationCode(row) : undefined;
338
+ }
339
+
340
+ export interface CreateAgentRefreshTokenParams {
341
+ grantId: string;
342
+ hashedToken: string;
343
+ expiresAt: Date;
344
+ }
345
+
346
+ export async function createAgentRefreshToken(
347
+ params: CreateAgentRefreshTokenParams,
348
+ ): Promise<AgentRefreshToken> {
349
+ return await db().transaction((tx) => createAgentRefreshTokenTx(tx, params));
350
+ }
351
+
352
+ export async function createAgentRefreshTokenTx(
353
+ tx: AgentAccessTx,
354
+ params: CreateAgentRefreshTokenParams,
355
+ ): Promise<AgentRefreshToken> {
356
+ const rows = await tx.insert(agentRefreshTokens).values(params).returning();
357
+ const row = rows[0];
358
+ if (!row) throw new Error('Insert returned no rows');
359
+ return toAgentRefreshToken(row);
360
+ }
361
+
362
+ export async function findAgentRefreshTokenByHash(params: {
363
+ hashedToken: string;
364
+ executor?: AgentAccessExecutor;
365
+ }): Promise<AgentRefreshToken | undefined> {
366
+ const executor = params.executor ?? db();
367
+ const rows = await executor
368
+ .select()
369
+ .from(agentRefreshTokens)
370
+ .where(eq(agentRefreshTokens.hashedToken, params.hashedToken))
371
+ .limit(1);
372
+ const row = rows[0];
373
+ return row ? toAgentRefreshToken(row) : undefined;
374
+ }
375
+
376
+ export async function findActiveAgentRefreshTokenByHash(params: {
377
+ hashedToken: string;
378
+ executor?: AgentAccessExecutor;
379
+ }): Promise<AgentRefreshToken | undefined> {
380
+ const executor = params.executor ?? db();
381
+ const rows = await executor
382
+ .select()
383
+ .from(agentRefreshTokens)
384
+ .where(
385
+ and(
386
+ eq(agentRefreshTokens.hashedToken, params.hashedToken),
387
+ isNull(agentRefreshTokens.rotatedAt),
388
+ isNull(agentRefreshTokens.revokedAt),
389
+ gt(agentRefreshTokens.expiresAt, sql`now()`),
390
+ ),
391
+ )
392
+ .limit(1);
393
+ const row = rows[0];
394
+ return row ? toAgentRefreshToken(row) : undefined;
395
+ }
396
+
397
+ /** Rotates a refresh token atomically; a concurrent rotation cannot win twice. */
398
+ export async function rotateAgentRefreshToken(params: {
399
+ hashedToken: string;
400
+ replacementHashedToken: string;
401
+ replacementExpiresAt: Date;
402
+ }): Promise<AgentRefreshToken | undefined> {
403
+ return await db().transaction((tx) => rotateAgentRefreshTokenTx(tx, params));
404
+ }
405
+
406
+ export async function rotateAgentRefreshTokenTx(
407
+ tx: AgentAccessTx,
408
+ params: {
409
+ hashedToken: string;
410
+ replacementHashedToken: string;
411
+ replacementExpiresAt: Date;
412
+ },
413
+ ): Promise<AgentRefreshToken | undefined> {
414
+ const rows = await tx
415
+ .update(agentRefreshTokens)
416
+ .set({rotatedAt: sql`now()`, lastUsedAt: sql`now()`, updatedAt: sql`now()`})
417
+ .where(
418
+ and(
419
+ eq(agentRefreshTokens.hashedToken, params.hashedToken),
420
+ isNull(agentRefreshTokens.rotatedAt),
421
+ isNull(agentRefreshTokens.revokedAt),
422
+ gt(agentRefreshTokens.expiresAt, sql`now()`),
423
+ ),
424
+ )
425
+ .returning();
426
+ const row = rows[0];
427
+ if (!row) return undefined;
428
+
429
+ return await createAgentRefreshTokenTx(tx, {
430
+ grantId: row.grantId,
431
+ hashedToken: params.replacementHashedToken,
432
+ expiresAt: params.replacementExpiresAt,
433
+ });
434
+ }
435
+
436
+ export async function revokeAgentRefreshToken(params: {
437
+ id: string;
438
+ }): Promise<AgentRefreshToken | undefined> {
439
+ return await db().transaction(async (tx) => {
440
+ const rows = await tx
441
+ .update(agentRefreshTokens)
442
+ .set({revokedAt: sql`now()`, updatedAt: sql`now()`})
443
+ .where(and(eq(agentRefreshTokens.id, params.id), isNull(agentRefreshTokens.revokedAt)))
444
+ .returning();
445
+ const row = rows[0];
446
+ return row ? toAgentRefreshToken(row) : undefined;
447
+ });
448
+ }
449
+
450
+ export interface CreateAgentPersonalAccessTokenParams {
451
+ userId: string;
452
+ workspaceId: string;
453
+ hashedToken: string;
454
+ prefix: string;
455
+ name: string;
456
+ scopes: string[];
457
+ expiresAt: Date;
458
+ }
459
+
460
+ export async function createAgentPersonalAccessToken(
461
+ params: CreateAgentPersonalAccessTokenParams,
462
+ ): Promise<AgentPersonalAccessToken> {
463
+ return await db().transaction((tx) => createAgentPersonalAccessTokenTx(tx, params));
464
+ }
465
+
466
+ export async function createAgentPersonalAccessTokenTx(
467
+ tx: AgentAccessTx,
468
+ params: CreateAgentPersonalAccessTokenParams,
469
+ ): Promise<AgentPersonalAccessToken> {
470
+ const rows = await tx.insert(agentPersonalAccessTokens).values(params).returning();
471
+ const row = rows[0];
472
+ if (!row) throw new Error('Insert returned no rows');
473
+ return toAgentPersonalAccessToken(row);
474
+ }
475
+
476
+ export async function findAgentPersonalAccessTokenByHash(params: {
477
+ hashedToken: string;
478
+ executor?: AgentAccessExecutor;
479
+ }): Promise<AgentPersonalAccessToken | undefined> {
480
+ const executor = params.executor ?? db();
481
+ const rows = await executor
482
+ .select()
483
+ .from(agentPersonalAccessTokens)
484
+ .where(eq(agentPersonalAccessTokens.hashedToken, params.hashedToken))
485
+ .limit(1);
486
+ const row = rows[0];
487
+ return row ? toAgentPersonalAccessToken(row) : undefined;
488
+ }
489
+
490
+ export async function findActiveAgentPersonalAccessTokenByHash(params: {
491
+ hashedToken: string;
492
+ executor?: AgentAccessExecutor;
493
+ }): Promise<AgentPersonalAccessToken | undefined> {
494
+ const executor = params.executor ?? db();
495
+ const rows = await executor
496
+ .select()
497
+ .from(agentPersonalAccessTokens)
498
+ .where(
499
+ and(
500
+ eq(agentPersonalAccessTokens.hashedToken, params.hashedToken),
501
+ isNull(agentPersonalAccessTokens.revokedAt),
502
+ gt(agentPersonalAccessTokens.expiresAt, sql`now()`),
503
+ ),
504
+ )
505
+ .limit(1);
506
+ const row = rows[0];
507
+ return row ? toAgentPersonalAccessToken(row) : undefined;
508
+ }
509
+
510
+ export async function markAgentPersonalAccessTokenUsed(params: {
511
+ id: string;
512
+ }): Promise<AgentPersonalAccessToken | undefined> {
513
+ const rows = await db()
514
+ .update(agentPersonalAccessTokens)
515
+ .set({lastUsedAt: sql`now()`, updatedAt: sql`now()`})
516
+ .where(eq(agentPersonalAccessTokens.id, params.id))
517
+ .returning();
518
+ const row = rows[0];
519
+ return row ? toAgentPersonalAccessToken(row) : undefined;
520
+ }
521
+
522
+ export async function revokeAgentPersonalAccessToken(params: {
523
+ id: string;
524
+ }): Promise<AgentPersonalAccessToken | undefined> {
525
+ const rows = await db()
526
+ .update(agentPersonalAccessTokens)
527
+ .set({revokedAt: sql`now()`, updatedAt: sql`now()`})
528
+ .where(
529
+ and(eq(agentPersonalAccessTokens.id, params.id), isNull(agentPersonalAccessTokens.revokedAt)),
530
+ )
531
+ .returning();
532
+ const row = rows[0];
533
+ return row ? toAgentPersonalAccessToken(row) : undefined;
534
+ }
535
+
536
+ export interface PruneAgentAccessParams {
537
+ retentionDays?: number;
538
+ limit?: number;
539
+ now?: Date;
540
+ }
541
+
542
+ /** Deletes terminal credentials and ephemeral authorization rows in bounded batches. */
543
+ export async function pruneAgentAccess(params: PruneAgentAccessParams = {}): Promise<number> {
544
+ const now = params.now ?? new Date();
545
+ const retentionDays = params.retentionDays ?? 7;
546
+ const limit = params.limit ?? 1000;
547
+ const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000);
548
+
549
+ return await db().transaction(async (tx) => {
550
+ const requestIds = tx
551
+ .select({id: agentAuthorizationRequests.id})
552
+ .from(agentAuthorizationRequests)
553
+ .where(
554
+ or(
555
+ lt(agentAuthorizationRequests.consumedAt, cutoff),
556
+ and(
557
+ isNull(agentAuthorizationRequests.consumedAt),
558
+ lt(agentAuthorizationRequests.expiresAt, cutoff),
559
+ ),
560
+ ),
561
+ )
562
+ .orderBy(asc(agentAuthorizationRequests.expiresAt), asc(agentAuthorizationRequests.id))
563
+ .limit(limit);
564
+ const codeIds = tx
565
+ .select({id: agentAuthorizationCodes.id})
566
+ .from(agentAuthorizationCodes)
567
+ .where(
568
+ or(
569
+ lt(agentAuthorizationCodes.consumedAt, cutoff),
570
+ and(
571
+ isNull(agentAuthorizationCodes.consumedAt),
572
+ lt(agentAuthorizationCodes.expiresAt, cutoff),
573
+ ),
574
+ ),
575
+ )
576
+ .orderBy(asc(agentAuthorizationCodes.expiresAt), asc(agentAuthorizationCodes.id))
577
+ .limit(limit);
578
+ const refreshTokenIds = tx
579
+ .select({id: agentRefreshTokens.id})
580
+ .from(agentRefreshTokens)
581
+ .where(
582
+ or(
583
+ lt(agentRefreshTokens.rotatedAt, cutoff),
584
+ and(isNull(agentRefreshTokens.rotatedAt), lt(agentRefreshTokens.revokedAt, cutoff)),
585
+ and(
586
+ isNull(agentRefreshTokens.rotatedAt),
587
+ isNull(agentRefreshTokens.revokedAt),
588
+ lt(agentRefreshTokens.expiresAt, cutoff),
589
+ ),
590
+ ),
591
+ )
592
+ .orderBy(asc(agentRefreshTokens.expiresAt), asc(agentRefreshTokens.id))
593
+ .limit(limit);
594
+ const patIds = tx
595
+ .select({id: agentPersonalAccessTokens.id})
596
+ .from(agentPersonalAccessTokens)
597
+ .where(
598
+ or(
599
+ lt(agentPersonalAccessTokens.revokedAt, cutoff),
600
+ and(
601
+ isNull(agentPersonalAccessTokens.revokedAt),
602
+ lt(agentPersonalAccessTokens.expiresAt, cutoff),
603
+ ),
604
+ ),
605
+ )
606
+ .orderBy(asc(agentPersonalAccessTokens.expiresAt), asc(agentPersonalAccessTokens.id))
607
+ .limit(limit);
608
+
609
+ const [requests, codes, refreshTokens, pats] = await Promise.all([
610
+ tx
611
+ .delete(agentAuthorizationRequests)
612
+ .where(inArray(agentAuthorizationRequests.id, requestIds))
613
+ .returning({id: agentAuthorizationRequests.id}),
614
+ tx
615
+ .delete(agentAuthorizationCodes)
616
+ .where(inArray(agentAuthorizationCodes.id, codeIds))
617
+ .returning({id: agentAuthorizationCodes.id}),
618
+ tx
619
+ .delete(agentRefreshTokens)
620
+ .where(inArray(agentRefreshTokens.id, refreshTokenIds))
621
+ .returning({id: agentRefreshTokens.id}),
622
+ tx
623
+ .delete(agentPersonalAccessTokens)
624
+ .where(inArray(agentPersonalAccessTokens.id, patIds))
625
+ .returning({id: agentPersonalAccessTokens.id}),
626
+ ]);
627
+ return requests.length + codes.length + refreshTokens.length + pats.length;
628
+ });
629
+ }
package/src/db/db.ts CHANGED
@@ -2,6 +2,14 @@ import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
2
2
  import {pgClient} from '@shipfox/node-postgres';
3
3
  import {adminCommandResults} from './schema/admin-command-results.js';
4
4
  import {adminGrants} from './schema/admin-grants.js';
5
+ import {
6
+ agentAuthorizationCodes,
7
+ agentAuthorizationRequests,
8
+ agentClients,
9
+ agentGrants,
10
+ agentPersonalAccessTokens,
11
+ agentRefreshTokens,
12
+ } from './schema/agent-access.js';
5
13
  import {authOutbox} from './schema/outbox.js';
6
14
  import {passwordResets} from './schema/password-resets.js';
7
15
  import {authRateLimits} from './schema/rate-limits.js';
@@ -10,6 +18,12 @@ import {users} from './schema/users.js';
10
18
 
11
19
  export const schema = {
12
20
  adminGrants,
21
+ agentClients,
22
+ agentAuthorizationRequests,
23
+ agentGrants,
24
+ agentAuthorizationCodes,
25
+ agentRefreshTokens,
26
+ agentPersonalAccessTokens,
13
27
  adminCommandResults,
14
28
  users,
15
29
  passwordResets,