@shipfox/api-auth 10.1.0 → 10.2.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 (70) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +15 -0
  4. package/dist/core/administration.d.ts +17 -1
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +107 -14
  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 +11 -2
  10. package/dist/core/auth.js.map +1 -1
  11. package/dist/db/admin-command.d.ts +18 -0
  12. package/dist/db/admin-command.d.ts.map +1 -0
  13. package/dist/db/admin-command.js +37 -0
  14. package/dist/db/admin-command.js.map +1 -0
  15. package/dist/db/admin-grants.d.ts +1 -1
  16. package/dist/db/admin-grants.d.ts.map +1 -1
  17. package/dist/db/admin-grants.js +12 -30
  18. package/dist/db/admin-grants.js.map +1 -1
  19. package/dist/db/admin-user-moderation.d.ts +29 -0
  20. package/dist/db/admin-user-moderation.d.ts.map +1 -0
  21. package/dist/db/admin-user-moderation.js +156 -0
  22. package/dist/db/admin-user-moderation.js.map +1 -0
  23. package/dist/db/admin-user-summary.d.ts +14 -0
  24. package/dist/db/admin-user-summary.d.ts.map +1 -0
  25. package/dist/db/admin-user-summary.js +31 -0
  26. package/dist/db/admin-user-summary.js.map +1 -0
  27. package/dist/db/admin-users.d.ts +2 -11
  28. package/dist/db/admin-users.d.ts.map +1 -1
  29. package/dist/db/admin-users.js +2 -27
  30. package/dist/db/admin-users.js.map +1 -1
  31. package/dist/db/refresh-tokens.d.ts +6 -0
  32. package/dist/db/refresh-tokens.d.ts.map +1 -1
  33. package/dist/db/refresh-tokens.js +31 -0
  34. package/dist/db/refresh-tokens.js.map +1 -1
  35. package/dist/db/schema/admin-command-results.d.ts +19 -2
  36. package/dist/db/schema/admin-command-results.d.ts.map +1 -1
  37. package/dist/db/schema/admin-command-results.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/presentation/auth/bearer-token-auth.d.ts +1 -0
  43. package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
  44. package/dist/presentation/auth/bearer-token-auth.js +4 -3
  45. package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
  46. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  47. package/dist/presentation/auth/jwt-auth.js +32 -4
  48. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  49. package/dist/presentation/routes/administration.d.ts.map +1 -1
  50. package/dist/presentation/routes/administration.js +94 -3
  51. package/dist/presentation/routes/administration.js.map +1 -1
  52. package/dist/tsconfig.test.tsbuildinfo +1 -1
  53. package/package.json +4 -4
  54. package/src/core/administration.ts +137 -15
  55. package/src/core/auth.ts +9 -2
  56. package/src/db/admin-command.ts +85 -0
  57. package/src/db/admin-grants.ts +19 -57
  58. package/src/db/admin-user-moderation.ts +230 -0
  59. package/src/db/admin-user-summary.ts +51 -0
  60. package/src/db/admin-users.ts +4 -49
  61. package/src/db/refresh-tokens.ts +54 -0
  62. package/src/db/schema/admin-command-results.ts +23 -2
  63. package/src/index.ts +3 -0
  64. package/src/presentation/auth/bearer-token-auth.test.ts +17 -0
  65. package/src/presentation/auth/bearer-token-auth.ts +9 -2
  66. package/src/presentation/auth/jwt-auth.test.ts +1 -1
  67. package/src/presentation/auth/jwt-auth.ts +30 -1
  68. package/src/presentation/routes/administration.test.ts +245 -2
  69. package/src/presentation/routes/administration.ts +88 -1
  70. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-auth",
3
3
  "license": "MIT",
4
- "version": "10.1.0",
4
+ "version": "10.2.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,10 +27,10 @@
27
27
  "drizzle-orm": "^0.45.2",
28
28
  "zod": "^4.4.3",
29
29
  "@shipfox/api-common-dto": "9.2.0",
30
- "@shipfox/api-auth-context": "10.1.0",
31
- "@shipfox/api-auth-dto": "10.1.0",
30
+ "@shipfox/api-auth-context": "10.2.0",
31
+ "@shipfox/api-auth-dto": "10.2.0",
32
32
  "@shipfox/api-email-challenges": "1.1.6",
33
- "@shipfox/api-workspaces-dto": "10.0.0",
33
+ "@shipfox/api-workspaces-dto": "10.2.0",
34
34
  "@shipfox/inter-module": "0.2.2",
35
35
  "@shipfox/config": "1.2.4",
36
36
  "@shipfox/node-drizzle": "0.3.4",
@@ -1,7 +1,8 @@
1
- import {createHash, timingSafeEqual} from 'node:crypto';
1
+ import {timingSafeEqual} from 'node:crypto';
2
2
  import type {AdminRole} from '@shipfox/api-auth-dto';
3
3
  import {createAdministrationActionEvent} from '@shipfox/api-common-dto';
4
4
  import type {TimestampIdCursor} from '@shipfox/node-drizzle';
5
+ import {hashOpaqueToken} from '@shipfox/node-tokens';
5
6
  import {config} from '#config.js';
6
7
  import {
7
8
  bootstrapFirstAdminOwner as bootstrapFirstAdminOwnerInDb,
@@ -10,6 +11,12 @@ import {
10
11
  listAdminGrantSummaries,
11
12
  revokeAdminGrantWithAudit,
12
13
  } from '#db/admin-grants.js';
14
+ import {
15
+ reactivateUserWithAudit,
16
+ revokeUserSessionsWithAudit,
17
+ suspendUserWithAudit,
18
+ type UserModerationResult,
19
+ } from '#db/admin-user-moderation.js';
13
20
  import {findAdministratorUser as findAdministratorUserInDb} from '#db/admin-users.js';
14
21
  import {requireAdminRole} from './admin-role.js';
15
22
  import type {AdminGrant} from './entities/admin-grant.js';
@@ -29,22 +36,22 @@ import {
29
36
 
30
37
  const ADMIN_OWNER_ROLE: AdminRole = 'admin-owner';
31
38
  const ADMIN_OBSERVER_ROLE: AdminRole = 'admin-observer';
39
+ const ADMIN_OPERATOR_ROLE: AdminRole = 'admin-operator';
32
40
  const BOOTSTRAP_COMMAND = 'auth.admin_grant.bootstrap';
33
41
  const GRANT_COMMAND = 'auth.admin_grant.grant';
34
42
  const REVOKE_COMMAND = 'auth.admin_grant.revoke';
35
-
36
- export function hashAdministrationValue(value: string): string {
37
- return createHash('sha256').update(value).digest('hex');
38
- }
43
+ const SUSPEND_USER_COMMAND = 'auth.user.suspend';
44
+ const REACTIVATE_USER_COMMAND = 'auth.user.reactivate';
45
+ const REVOKE_USER_SESSIONS_COMMAND = 'auth.user.revoke-sessions';
39
46
 
40
47
  export function administrationCommandFingerprint(command: string, input: unknown): string {
41
- return hashAdministrationValue(`${command}:${JSON.stringify(input)}`);
48
+ return hashOpaqueToken(`${command}:${JSON.stringify(input)}`);
42
49
  }
43
50
 
44
51
  function bootstrapTokenMatches(candidate: string, expected: string | undefined): boolean {
45
52
  if (!expected) return false;
46
- const candidateHash = Buffer.from(hashAdministrationValue(candidate), 'hex');
47
- const expectedHash = Buffer.from(hashAdministrationValue(expected), 'hex');
53
+ const candidateHash = Buffer.from(hashOpaqueToken(candidate), 'hex');
54
+ const expectedHash = Buffer.from(hashOpaqueToken(expected), 'hex');
48
55
  return timingSafeEqual(candidateHash, expectedHash);
49
56
  }
50
57
 
@@ -55,13 +62,14 @@ function administrationEvent(params: {
55
62
  targetType: string;
56
63
  targetId: string;
57
64
  reason: string;
65
+ requiredRole?: AdminRole;
58
66
  correlationId: string;
59
67
  idempotencyKeyFingerprint: string;
60
68
  }) {
61
69
  return createAdministrationActionEvent({
62
70
  actorId: params.actorId,
63
71
  actorRole: params.actorRole,
64
- requiredRole: ADMIN_OWNER_ROLE,
72
+ requiredRole: params.requiredRole ?? ADMIN_OWNER_ROLE,
65
73
  command: params.command,
66
74
  targetType: params.targetType,
67
75
  targetId: params.targetId,
@@ -79,6 +87,22 @@ export interface AdministrationMutationContext {
79
87
  correlationId: string;
80
88
  }
81
89
 
90
+ export interface AdministratorUserMutationResult {
91
+ user: AdministratorUserSummary;
92
+ correlationId: string;
93
+ sessionsRevoked: number;
94
+ }
95
+
96
+ function toAdministratorUserMutationResult(
97
+ result: UserModerationResult,
98
+ ): AdministratorUserMutationResult {
99
+ return {
100
+ user: result.user,
101
+ correlationId: result.correlationId,
102
+ sessionsRevoked: result.sessionsRevoked,
103
+ };
104
+ }
105
+
82
106
  export async function bootstrapFirstAdminOwner(
83
107
  params: AdministrationMutationContext & {bootstrapToken: string},
84
108
  ): Promise<AdminGrant> {
@@ -89,7 +113,7 @@ export async function bootstrapFirstAdminOwner(
89
113
  return await bootstrapFirstAdminOwnerInDb({
90
114
  userId: params.actorId,
91
115
  actorId: params.actorId,
92
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
116
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
93
117
  requestFingerprint: administrationCommandFingerprint(BOOTSTRAP_COMMAND, {
94
118
  actorId: params.actorId,
95
119
  }),
@@ -101,7 +125,7 @@ export async function bootstrapFirstAdminOwner(
101
125
  targetId: params.actorId,
102
126
  reason: 'Initial administrator owner bootstrap',
103
127
  correlationId: params.correlationId,
104
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
128
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
105
129
  }),
106
130
  });
107
131
  }
@@ -160,7 +184,7 @@ export async function grantAdministratorRole(
160
184
  userId: params.userId,
161
185
  role: params.role,
162
186
  actorId: params.actorId,
163
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
187
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
164
188
  requestFingerprint: administrationCommandFingerprint(GRANT_COMMAND, {
165
189
  userId: params.userId,
166
190
  role: params.role,
@@ -174,7 +198,7 @@ export async function grantAdministratorRole(
174
198
  targetId: params.userId,
175
199
  reason: params.reason,
176
200
  correlationId: params.correlationId,
177
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
201
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
178
202
  }),
179
203
  });
180
204
  }
@@ -189,7 +213,7 @@ export async function revokeAdministratorGrant(
189
213
  return await revokeAdminGrantWithAudit({
190
214
  grantId: params.grantId,
191
215
  actorId: params.actorId,
192
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
216
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
193
217
  requestFingerprint: administrationCommandFingerprint(REVOKE_COMMAND, {
194
218
  grantId: params.grantId,
195
219
  reason: params.reason,
@@ -202,11 +226,109 @@ export async function revokeAdministratorGrant(
202
226
  targetId: params.grantId,
203
227
  reason: params.reason,
204
228
  correlationId: params.correlationId,
205
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
229
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
206
230
  }),
207
231
  });
208
232
  }
209
233
 
234
+ export async function suspendAdministratorUser(
235
+ params: AdministrationMutationContext & {userId: string; reason: string},
236
+ ): Promise<AdministratorUserMutationResult> {
237
+ const actorRole = await requireAdminRole({
238
+ userId: params.actorId,
239
+ minimumRole: ADMIN_OPERATOR_ROLE,
240
+ });
241
+ const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
242
+ return toAdministratorUserMutationResult(
243
+ await suspendUserWithAudit({
244
+ actorId: params.actorId,
245
+ userId: params.userId,
246
+ idempotencyKeyFingerprint,
247
+ requestFingerprint: administrationCommandFingerprint(SUSPEND_USER_COMMAND, {
248
+ userId: params.userId,
249
+ reason: params.reason,
250
+ }),
251
+ event: administrationEvent({
252
+ actorId: params.actorId,
253
+ actorRole,
254
+ requiredRole: ADMIN_OPERATOR_ROLE,
255
+ command: SUSPEND_USER_COMMAND,
256
+ targetType: 'user',
257
+ targetId: params.userId,
258
+ reason: params.reason,
259
+ correlationId: params.correlationId,
260
+ idempotencyKeyFingerprint,
261
+ }),
262
+ }),
263
+ );
264
+ }
265
+
266
+ export async function reactivateAdministratorUser(
267
+ params: AdministrationMutationContext & {userId: string; reason?: string},
268
+ ): Promise<AdministratorUserMutationResult> {
269
+ const actorRole = await requireAdminRole({
270
+ userId: params.actorId,
271
+ minimumRole: ADMIN_OPERATOR_ROLE,
272
+ });
273
+ const reason = params.reason ?? 'User reactivation requested by an administrator';
274
+ const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
275
+ return toAdministratorUserMutationResult(
276
+ await reactivateUserWithAudit({
277
+ actorId: params.actorId,
278
+ userId: params.userId,
279
+ idempotencyKeyFingerprint,
280
+ requestFingerprint: administrationCommandFingerprint(REACTIVATE_USER_COMMAND, {
281
+ userId: params.userId,
282
+ reason,
283
+ }),
284
+ event: administrationEvent({
285
+ actorId: params.actorId,
286
+ actorRole,
287
+ requiredRole: ADMIN_OPERATOR_ROLE,
288
+ command: REACTIVATE_USER_COMMAND,
289
+ targetType: 'user',
290
+ targetId: params.userId,
291
+ reason,
292
+ correlationId: params.correlationId,
293
+ idempotencyKeyFingerprint,
294
+ }),
295
+ }),
296
+ );
297
+ }
298
+
299
+ export async function revokeAdministratorUserSessions(
300
+ params: AdministrationMutationContext & {userId: string; reason?: string},
301
+ ): Promise<AdministratorUserMutationResult> {
302
+ const actorRole = await requireAdminRole({
303
+ userId: params.actorId,
304
+ minimumRole: ADMIN_OPERATOR_ROLE,
305
+ });
306
+ const reason = params.reason ?? 'All active user sessions revoked by an administrator';
307
+ const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
308
+ return toAdministratorUserMutationResult(
309
+ await revokeUserSessionsWithAudit({
310
+ actorId: params.actorId,
311
+ userId: params.userId,
312
+ idempotencyKeyFingerprint,
313
+ requestFingerprint: administrationCommandFingerprint(REVOKE_USER_SESSIONS_COMMAND, {
314
+ userId: params.userId,
315
+ reason,
316
+ }),
317
+ event: administrationEvent({
318
+ actorId: params.actorId,
319
+ actorRole,
320
+ requiredRole: ADMIN_OPERATOR_ROLE,
321
+ command: REVOKE_USER_SESSIONS_COMMAND,
322
+ targetType: 'user',
323
+ targetId: params.userId,
324
+ reason,
325
+ correlationId: params.correlationId,
326
+ idempotencyKeyFingerprint,
327
+ }),
328
+ }),
329
+ );
330
+ }
331
+
210
332
  export {
211
333
  AdminBootstrapClosedError,
212
334
  AdminGrantAlreadyExistsError,
package/src/core/auth.ts CHANGED
@@ -15,7 +15,7 @@ import {generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
15
15
  import {config} from '#config.js';
16
16
  import {consumePasswordReset, createPasswordReset} from '#db/password-resets.js';
17
17
  import {
18
- createRefreshToken,
18
+ createRefreshTokenForActiveUser,
19
19
  findActiveRefreshTokenByHash,
20
20
  findRefreshTokenByHash,
21
21
  revokeRefreshSession,
@@ -127,12 +127,13 @@ async function createRefreshSession(
127
127
  refreshSessionId: string,
128
128
  ): Promise<{refreshToken: string; refreshSessionId: string}> {
129
129
  const refreshToken = generateOpaqueToken('refreshToken');
130
- const session = await createRefreshToken({
130
+ const session = await createRefreshTokenForActiveUser({
131
131
  sessionId: refreshSessionId,
132
132
  userId: user.id,
133
133
  hashedToken: hashOpaqueToken(refreshToken),
134
134
  expiresAt: daysFromNow(config.AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS),
135
135
  });
136
+ if (!session) throw new InvalidCredentialsError();
136
137
  return {refreshToken, refreshSessionId: session.sessionId};
137
138
  }
138
139
 
@@ -491,12 +492,18 @@ export async function refreshAccessToken(params: {
491
492
  // revoked, or expired the token before this refresh could claim it.
492
493
  const nextRefreshToken = generateOpaqueToken('refreshToken');
493
494
  const rotated = await rotateRefreshToken({
495
+ userId: current.userId,
494
496
  id: current.id,
495
497
  currentHashedToken,
496
498
  nextHashedToken: hashOpaqueToken(nextRefreshToken),
497
499
  expiresAt: daysFromNow(config.AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS),
498
500
  });
499
501
  if (!rotated) {
502
+ const latestUser = await findUserById({id: current.userId});
503
+ if (latestUser?.status !== 'active') {
504
+ recordRefreshOutcome('rejected');
505
+ throw new TokenInvalidError('Refresh token is invalid or expired');
506
+ }
500
507
  const latest = await findRefreshTokenByHash({hashedToken: currentHashedToken});
501
508
  if (!latest || !isWithinRotationGrace(latest)) {
502
509
  recordRefreshOutcome('rejected');
@@ -0,0 +1,85 @@
1
+ import type {
2
+ AdministrationActionEvent,
3
+ AdministrationActionEventMap,
4
+ } from '@shipfox/api-common-dto';
5
+ import {writeOutboxEvent} from '@shipfox/node-outbox';
6
+ import {and, eq, sql} from 'drizzle-orm';
7
+ import {AdminIdempotencyKeyReuseError} from '#core/errors.js';
8
+ import type {db} from './db.js';
9
+ import {
10
+ type AdminCommandResultDb,
11
+ adminCommandResults,
12
+ type StoredAdminCommandResult,
13
+ } from './schema/admin-command-results.js';
14
+ import {authOutbox} from './schema/outbox.js';
15
+
16
+ export type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
17
+
18
+ export interface AdminCommandTransactionParams {
19
+ actorId: string;
20
+ idempotencyKeyFingerprint: string;
21
+ requestFingerprint: string;
22
+ event: AdministrationActionEvent;
23
+ }
24
+
25
+ export async function lockAdminCommand(
26
+ tx: Tx,
27
+ params: Pick<AdminCommandTransactionParams, 'actorId' | 'idempotencyKeyFingerprint'>,
28
+ ): Promise<void> {
29
+ await tx.execute(
30
+ sql`select pg_advisory_xact_lock(hashtext(${`auth_admin_command:${params.actorId}:${params.idempotencyKeyFingerprint}`}))`,
31
+ );
32
+ }
33
+
34
+ export async function lockAdminOwnerGrants(tx: Tx): Promise<void> {
35
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
36
+ }
37
+
38
+ export async function findAdminCommandResult(
39
+ tx: Tx,
40
+ params: Pick<
41
+ AdminCommandTransactionParams,
42
+ 'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
43
+ > & {command: string},
44
+ ): Promise<AdminCommandResultDb | undefined> {
45
+ const rows = await tx
46
+ .select()
47
+ .from(adminCommandResults)
48
+ .where(
49
+ and(
50
+ eq(adminCommandResults.actorId, params.actorId),
51
+ eq(adminCommandResults.idempotencyKeyFingerprint, params.idempotencyKeyFingerprint),
52
+ ),
53
+ )
54
+ .limit(1);
55
+ const result = rows[0];
56
+ if (!result) return undefined;
57
+ if (
58
+ result.command !== params.command ||
59
+ result.requestFingerprint !== params.requestFingerprint
60
+ ) {
61
+ throw new AdminIdempotencyKeyReuseError();
62
+ }
63
+ return result;
64
+ }
65
+
66
+ export async function storeAdminCommandResult(
67
+ tx: Tx,
68
+ params: AdminCommandTransactionParams,
69
+ result: StoredAdminCommandResult,
70
+ ): Promise<void> {
71
+ await tx.insert(adminCommandResults).values({
72
+ actorId: params.actorId,
73
+ idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
74
+ command: params.event.command,
75
+ requestFingerprint: params.requestFingerprint,
76
+ result,
77
+ });
78
+ }
79
+
80
+ export async function writeAdminAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
81
+ await writeOutboxEvent<AdministrationActionEventMap>(tx, authOutbox, {
82
+ type: 'administration.action.performed',
83
+ payload: event,
84
+ });
85
+ }
@@ -1,15 +1,11 @@
1
1
  import type {AdminRole} from '@shipfox/api-auth-dto';
2
- import type {
3
- AdministrationActionEvent,
4
- AdministrationActionEventMap,
5
- } from '@shipfox/api-common-dto';
2
+ import type {AdministrationActionEvent} from '@shipfox/api-common-dto';
6
3
  import {
7
4
  paginateTimestampIdRows,
8
5
  type TimestampIdCursor,
9
6
  timestampIdCursorWhere,
10
7
  } from '@shipfox/node-drizzle';
11
- import {writeOutboxEvent} from '@shipfox/node-outbox';
12
- import {and, desc, eq, isNull, sql} from 'drizzle-orm';
8
+ import {and, desc, eq, isNull} from 'drizzle-orm';
13
9
  import {highestAdminRole} from '#core/admin-role-model.js';
14
10
  import type {AdminGrant} from '#core/entities/admin-grant.js';
15
11
  import type {AdministratorGrantSummary} from '#core/entities/administrator-read-model.js';
@@ -17,21 +13,22 @@ import {
17
13
  AdminBootstrapClosedError,
18
14
  AdminGrantAlreadyExistsError,
19
15
  AdminGrantNotFoundError,
20
- AdminIdempotencyKeyReuseError,
21
16
  LastAdminOwnerError,
22
17
  UserNotFoundError,
23
18
  } from '#core/errors.js';
24
- import {db} from './db.js';
25
19
  import {
26
- type AdminCommandResultDb,
27
- adminCommandResults,
28
- type StoredAdminGrant,
29
- } from './schema/admin-command-results.js';
20
+ findAdminCommandResult,
21
+ lockAdminCommand,
22
+ lockAdminOwnerGrants,
23
+ storeAdminCommandResult,
24
+ type Tx,
25
+ writeAdminAction,
26
+ } from './admin-command.js';
27
+ import {db} from './db.js';
28
+ import type {StoredAdminGrant} from './schema/admin-command-results.js';
30
29
  import {adminGrants, toAdminGrant} from './schema/admin-grants.js';
31
- import {authOutbox} from './schema/outbox.js';
32
30
  import {users} from './schema/users.js';
33
31
 
34
- type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
35
32
  type AdminOwnerQueryExecutor = ReturnType<typeof db> | Tx;
36
33
 
37
34
  function activeAdminOwnerWhere() {
@@ -145,7 +142,7 @@ export async function revokeAdminGrant(params: {grantId: string}): Promise<Admin
145
142
  return await db().transaction(async (tx) => {
146
143
  // All owner grant changes share one lock so concurrent revocations cannot
147
144
  // both observe the same final owner and leave the instance ownerless.
148
- await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
145
+ await lockAdminOwnerGrants(tx);
149
146
 
150
147
  const rows = await tx
151
148
  .select()
@@ -208,23 +205,10 @@ async function findCommandResult(
208
205
  command: string;
209
206
  },
210
207
  ): Promise<AdminGrant | undefined> {
211
- const rows = await tx
212
- .select()
213
- .from(adminCommandResults)
214
- .where(
215
- and(
216
- eq(adminCommandResults.actorId, params.actorId),
217
- eq(adminCommandResults.idempotencyKeyFingerprint, params.idempotencyKeyFingerprint),
218
- ),
219
- )
220
- .limit(1);
221
- const result: AdminCommandResultDb | undefined = rows[0];
208
+ const result = await findAdminCommandResult(tx, params);
222
209
  if (!result) return undefined;
223
- if (
224
- result.command !== params.command ||
225
- result.requestFingerprint !== params.requestFingerprint
226
- ) {
227
- throw new AdminIdempotencyKeyReuseError();
210
+ if (!('grant' in result.result)) {
211
+ throw new Error('Administrator command result has an unexpected shape');
228
212
  }
229
213
  return fromStoredAdminGrant(result.result.grant);
230
214
  }
@@ -234,29 +218,7 @@ async function storeCommandResult(
234
218
  params: AuditedAdminCommandParams,
235
219
  grant: AdminGrant,
236
220
  ): Promise<void> {
237
- await tx.insert(adminCommandResults).values({
238
- actorId: params.actorId,
239
- idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
240
- command: params.event.command,
241
- requestFingerprint: params.requestFingerprint,
242
- result: {grant: toStoredAdminGrant(grant)},
243
- });
244
- }
245
-
246
- async function writeAdminAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
247
- await writeOutboxEvent<AdministrationActionEventMap>(tx, authOutbox, {
248
- type: 'administration.action.performed',
249
- payload: event,
250
- });
251
- }
252
-
253
- async function lockAdminCommand(
254
- tx: Tx,
255
- params: {actorId: string; idempotencyKeyFingerprint: string},
256
- ) {
257
- await tx.execute(
258
- sql`select pg_advisory_xact_lock(hashtext(${`auth_admin_command:${params.actorId}:${params.idempotencyKeyFingerprint}`}))`,
259
- );
221
+ await storeAdminCommandResult(tx, params, {grant: toStoredAdminGrant(grant)});
260
222
  }
261
223
 
262
224
  export async function bootstrapFirstAdminOwner(
@@ -264,7 +226,7 @@ export async function bootstrapFirstAdminOwner(
264
226
  ): Promise<AdminGrant> {
265
227
  return await db().transaction(async (tx) => {
266
228
  await lockAdminCommand(tx, params);
267
- await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
229
+ await lockAdminOwnerGrants(tx);
268
230
 
269
231
  const existing = await findCommandResult(tx, {
270
232
  ...params,
@@ -301,7 +263,7 @@ export async function grantAdminRoleWithAudit(
301
263
  ): Promise<AdminGrant> {
302
264
  return await db().transaction(async (tx) => {
303
265
  await lockAdminCommand(tx, params);
304
- await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
266
+ await lockAdminOwnerGrants(tx);
305
267
 
306
268
  const existing = await findCommandResult(tx, {
307
269
  ...params,
@@ -349,7 +311,7 @@ export async function revokeAdminGrantWithAudit(
349
311
  ): Promise<AdminGrant> {
350
312
  return await db().transaction(async (tx) => {
351
313
  await lockAdminCommand(tx, params);
352
- await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
314
+ await lockAdminOwnerGrants(tx);
353
315
 
354
316
  const existing = await findCommandResult(tx, {
355
317
  ...params,