@shipfox/api-auth 10.0.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 (82) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +20 -1
  4. package/dist/core/administration.d.ts +18 -1
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +111 -15
  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 +9 -0
  16. package/dist/db/admin-grants.d.ts.map +1 -1
  17. package/dist/db/admin-grants.js +31 -40
  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 +3 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/metrics/instance.d.ts +1 -1
  43. package/dist/metrics/instance.d.ts.map +1 -1
  44. package/dist/metrics/instance.js.map +1 -1
  45. package/dist/presentation/auth/bearer-token-auth.d.ts +1 -0
  46. package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
  47. package/dist/presentation/auth/bearer-token-auth.js +4 -3
  48. package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
  49. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  50. package/dist/presentation/auth/jwt-auth.js +32 -4
  51. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  52. package/dist/presentation/routes/administration.d.ts +1 -0
  53. package/dist/presentation/routes/administration.d.ts.map +1 -1
  54. package/dist/presentation/routes/administration.js +116 -3
  55. package/dist/presentation/routes/administration.js.map +1 -1
  56. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  57. package/dist/presentation/routes/rate-limit.js +6 -0
  58. package/dist/presentation/routes/rate-limit.js.map +1 -1
  59. package/dist/tsconfig.test.tsbuildinfo +1 -1
  60. package/package.json +4 -4
  61. package/src/core/administration.ts +142 -15
  62. package/src/core/auth.ts +9 -2
  63. package/src/db/admin-command.ts +85 -0
  64. package/src/db/admin-grants.test.ts +17 -1
  65. package/src/db/admin-grants.ts +52 -92
  66. package/src/db/admin-user-moderation.ts +230 -0
  67. package/src/db/admin-user-summary.ts +51 -0
  68. package/src/db/admin-users.ts +4 -49
  69. package/src/db/refresh-tokens.ts +54 -0
  70. package/src/db/schema/admin-command-results.ts +23 -2
  71. package/src/index.test.ts +4 -1
  72. package/src/index.ts +5 -0
  73. package/src/metrics/instance.ts +6 -1
  74. package/src/presentation/auth/bearer-token-auth.test.ts +17 -0
  75. package/src/presentation/auth/bearer-token-auth.ts +9 -2
  76. package/src/presentation/auth/jwt-auth.test.ts +1 -1
  77. package/src/presentation/auth/jwt-auth.ts +30 -1
  78. package/src/presentation/routes/administration.test.ts +373 -2
  79. package/src/presentation/routes/administration.ts +106 -1
  80. package/src/presentation/routes/rate-limit.ts +3 -0
  81. package/test/routes.ts +7 -1
  82. 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.0.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.0.0",
31
- "@shipfox/api-auth-dto": "10.0.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,14 +1,22 @@
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,
8
9
  grantAdminRoleWithAudit,
10
+ hasActiveAdminOwner,
9
11
  listAdminGrantSummaries,
10
12
  revokeAdminGrantWithAudit,
11
13
  } from '#db/admin-grants.js';
14
+ import {
15
+ reactivateUserWithAudit,
16
+ revokeUserSessionsWithAudit,
17
+ suspendUserWithAudit,
18
+ type UserModerationResult,
19
+ } from '#db/admin-user-moderation.js';
12
20
  import {findAdministratorUser as findAdministratorUserInDb} from '#db/admin-users.js';
13
21
  import {requireAdminRole} from './admin-role.js';
14
22
  import type {AdminGrant} from './entities/admin-grant.js';
@@ -28,22 +36,22 @@ import {
28
36
 
29
37
  const ADMIN_OWNER_ROLE: AdminRole = 'admin-owner';
30
38
  const ADMIN_OBSERVER_ROLE: AdminRole = 'admin-observer';
39
+ const ADMIN_OPERATOR_ROLE: AdminRole = 'admin-operator';
31
40
  const BOOTSTRAP_COMMAND = 'auth.admin_grant.bootstrap';
32
41
  const GRANT_COMMAND = 'auth.admin_grant.grant';
33
42
  const REVOKE_COMMAND = 'auth.admin_grant.revoke';
34
-
35
- export function hashAdministrationValue(value: string): string {
36
- return createHash('sha256').update(value).digest('hex');
37
- }
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';
38
46
 
39
47
  export function administrationCommandFingerprint(command: string, input: unknown): string {
40
- return hashAdministrationValue(`${command}:${JSON.stringify(input)}`);
48
+ return hashOpaqueToken(`${command}:${JSON.stringify(input)}`);
41
49
  }
42
50
 
43
51
  function bootstrapTokenMatches(candidate: string, expected: string | undefined): boolean {
44
52
  if (!expected) return false;
45
- const candidateHash = Buffer.from(hashAdministrationValue(candidate), 'hex');
46
- const expectedHash = Buffer.from(hashAdministrationValue(expected), 'hex');
53
+ const candidateHash = Buffer.from(hashOpaqueToken(candidate), 'hex');
54
+ const expectedHash = Buffer.from(hashOpaqueToken(expected), 'hex');
47
55
  return timingSafeEqual(candidateHash, expectedHash);
48
56
  }
49
57
 
@@ -54,13 +62,14 @@ function administrationEvent(params: {
54
62
  targetType: string;
55
63
  targetId: string;
56
64
  reason: string;
65
+ requiredRole?: AdminRole;
57
66
  correlationId: string;
58
67
  idempotencyKeyFingerprint: string;
59
68
  }) {
60
69
  return createAdministrationActionEvent({
61
70
  actorId: params.actorId,
62
71
  actorRole: params.actorRole,
63
- requiredRole: ADMIN_OWNER_ROLE,
72
+ requiredRole: params.requiredRole ?? ADMIN_OWNER_ROLE,
64
73
  command: params.command,
65
74
  targetType: params.targetType,
66
75
  targetId: params.targetId,
@@ -78,6 +87,22 @@ export interface AdministrationMutationContext {
78
87
  correlationId: string;
79
88
  }
80
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
+
81
106
  export async function bootstrapFirstAdminOwner(
82
107
  params: AdministrationMutationContext & {bootstrapToken: string},
83
108
  ): Promise<AdminGrant> {
@@ -88,7 +113,7 @@ export async function bootstrapFirstAdminOwner(
88
113
  return await bootstrapFirstAdminOwnerInDb({
89
114
  userId: params.actorId,
90
115
  actorId: params.actorId,
91
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
116
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
92
117
  requestFingerprint: administrationCommandFingerprint(BOOTSTRAP_COMMAND, {
93
118
  actorId: params.actorId,
94
119
  }),
@@ -100,11 +125,15 @@ export async function bootstrapFirstAdminOwner(
100
125
  targetId: params.actorId,
101
126
  reason: 'Initial administrator owner bootstrap',
102
127
  correlationId: params.correlationId,
103
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
128
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
104
129
  }),
105
130
  });
106
131
  }
107
132
 
133
+ export async function getAdminBootstrapState(): Promise<'available' | 'closed'> {
134
+ return (await hasActiveAdminOwner()) ? 'closed' : 'available';
135
+ }
136
+
108
137
  export async function findAdministratorUserSummary(
109
138
  params: {actorId: string} & ({id: string; email?: never} | {email: string; id?: never}),
110
139
  ): Promise<AdministratorUserSummary | undefined> {
@@ -155,7 +184,7 @@ export async function grantAdministratorRole(
155
184
  userId: params.userId,
156
185
  role: params.role,
157
186
  actorId: params.actorId,
158
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
187
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
159
188
  requestFingerprint: administrationCommandFingerprint(GRANT_COMMAND, {
160
189
  userId: params.userId,
161
190
  role: params.role,
@@ -169,7 +198,7 @@ export async function grantAdministratorRole(
169
198
  targetId: params.userId,
170
199
  reason: params.reason,
171
200
  correlationId: params.correlationId,
172
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
201
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
173
202
  }),
174
203
  });
175
204
  }
@@ -184,7 +213,7 @@ export async function revokeAdministratorGrant(
184
213
  return await revokeAdminGrantWithAudit({
185
214
  grantId: params.grantId,
186
215
  actorId: params.actorId,
187
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
216
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
188
217
  requestFingerprint: administrationCommandFingerprint(REVOKE_COMMAND, {
189
218
  grantId: params.grantId,
190
219
  reason: params.reason,
@@ -197,11 +226,109 @@ export async function revokeAdministratorGrant(
197
226
  targetId: params.grantId,
198
227
  reason: params.reason,
199
228
  correlationId: params.correlationId,
200
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
229
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
201
230
  }),
202
231
  });
203
232
  }
204
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
+
205
332
  export {
206
333
  AdminBootstrapClosedError,
207
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,7 +1,12 @@
1
1
  import {eq} from 'drizzle-orm';
2
2
  import {LastAdminOwnerError} from '#core/errors.js';
3
3
  import {userFactory} from '#test/index.js';
4
- import {createAdminGrant, findCurrentAdminRole, revokeAdminGrant} from './admin-grants.js';
4
+ import {
5
+ createAdminGrant,
6
+ findCurrentAdminRole,
7
+ hasActiveAdminOwner,
8
+ revokeAdminGrant,
9
+ } from './admin-grants.js';
5
10
  import {db} from './db.js';
6
11
  import {users} from './schema/users.js';
7
12
 
@@ -26,6 +31,17 @@ describe('admin grants db', () => {
26
31
  expect(await findCurrentAdminRole({userId: user.id})).toBeNull();
27
32
  });
28
33
 
34
+ test('reopens bootstrap when the sole owner user is suspended', async () => {
35
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
36
+ await createAdminGrant({userId: user.id, role: 'admin-owner'});
37
+
38
+ await expect(hasActiveAdminOwner()).resolves.toBe(true);
39
+
40
+ await db().update(users).set({status: 'suspended'}).where(eq(users.id, user.id));
41
+
42
+ await expect(hasActiveAdminOwner()).resolves.toBe(false);
43
+ });
44
+
29
45
  test('prevents revoking the final active owner but permits replacement first', async () => {
30
46
  const firstOwner = await userFactory.create({emailVerifiedAt: new Date()});
31
47
  const secondOwner = await userFactory.create({emailVerifiedAt: new Date()});
@@ -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,40 @@ 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];
32
+ type AdminOwnerQueryExecutor = ReturnType<typeof db> | Tx;
33
+
34
+ function activeAdminOwnerWhere() {
35
+ return and(
36
+ eq(adminGrants.role, 'admin-owner'),
37
+ isNull(adminGrants.revokedAt),
38
+ eq(users.status, 'active'),
39
+ );
40
+ }
41
+
42
+ async function listActiveAdminOwners(executor: AdminOwnerQueryExecutor) {
43
+ return await executor
44
+ .select({id: adminGrants.id})
45
+ .from(adminGrants)
46
+ .innerJoin(users, eq(adminGrants.userId, users.id))
47
+ .where(activeAdminOwnerWhere())
48
+ .limit(2);
49
+ }
35
50
 
36
51
  export interface CreateAdminGrantParams {
37
52
  userId: string;
@@ -111,11 +126,23 @@ export async function findCurrentAdminRole(params: {userId: string}): Promise<Ad
111
126
  return highestAdminRole(rows.map(({role}) => role));
112
127
  }
113
128
 
129
+ /**
130
+ * An owner is active only while both the grant and its user account remain
131
+ * active. A suspended owner's grant is retained, but it intentionally does
132
+ * not prevent deployment-bound bootstrap recovery.
133
+ */
134
+ export async function hasActiveAdminOwner(
135
+ executor: AdminOwnerQueryExecutor = db(),
136
+ ): Promise<boolean> {
137
+ const rows = await listActiveAdminOwners(executor);
138
+ return rows.length > 0;
139
+ }
140
+
114
141
  export async function revokeAdminGrant(params: {grantId: string}): Promise<AdminGrant | undefined> {
115
142
  return await db().transaction(async (tx) => {
116
143
  // All owner grant changes share one lock so concurrent revocations cannot
117
144
  // both observe the same final owner and leave the instance ownerless.
118
- await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
145
+ await lockAdminOwnerGrants(tx);
119
146
 
120
147
  const rows = await tx
121
148
  .select()
@@ -126,17 +153,7 @@ export async function revokeAdminGrant(params: {grantId: string}): Promise<Admin
126
153
  if (!grant) return undefined;
127
154
 
128
155
  if (grant.role === 'admin-owner') {
129
- const activeOwners = await tx
130
- .select({id: adminGrants.id})
131
- .from(adminGrants)
132
- .innerJoin(users, eq(adminGrants.userId, users.id))
133
- .where(
134
- and(
135
- eq(adminGrants.role, 'admin-owner'),
136
- isNull(adminGrants.revokedAt),
137
- eq(users.status, 'active'),
138
- ),
139
- );
156
+ const activeOwners = await listActiveAdminOwners(tx);
140
157
  if (activeOwners.length <= 1) throw new LastAdminOwnerError();
141
158
  }
142
159
 
@@ -188,23 +205,10 @@ async function findCommandResult(
188
205
  command: string;
189
206
  },
190
207
  ): Promise<AdminGrant | undefined> {
191
- const rows = await tx
192
- .select()
193
- .from(adminCommandResults)
194
- .where(
195
- and(
196
- eq(adminCommandResults.actorId, params.actorId),
197
- eq(adminCommandResults.idempotencyKeyFingerprint, params.idempotencyKeyFingerprint),
198
- ),
199
- )
200
- .limit(1);
201
- const result: AdminCommandResultDb | undefined = rows[0];
208
+ const result = await findAdminCommandResult(tx, params);
202
209
  if (!result) return undefined;
203
- if (
204
- result.command !== params.command ||
205
- result.requestFingerprint !== params.requestFingerprint
206
- ) {
207
- throw new AdminIdempotencyKeyReuseError();
210
+ if (!('grant' in result.result)) {
211
+ throw new Error('Administrator command result has an unexpected shape');
208
212
  }
209
213
  return fromStoredAdminGrant(result.result.grant);
210
214
  }
@@ -214,29 +218,7 @@ async function storeCommandResult(
214
218
  params: AuditedAdminCommandParams,
215
219
  grant: AdminGrant,
216
220
  ): Promise<void> {
217
- await tx.insert(adminCommandResults).values({
218
- actorId: params.actorId,
219
- idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
220
- command: params.event.command,
221
- requestFingerprint: params.requestFingerprint,
222
- result: {grant: toStoredAdminGrant(grant)},
223
- });
224
- }
225
-
226
- async function writeAdminAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
227
- await writeOutboxEvent<AdministrationActionEventMap>(tx, authOutbox, {
228
- type: 'administration.action.performed',
229
- payload: event,
230
- });
231
- }
232
-
233
- async function lockAdminCommand(
234
- tx: Tx,
235
- params: {actorId: string; idempotencyKeyFingerprint: string},
236
- ) {
237
- await tx.execute(
238
- sql`select pg_advisory_xact_lock(hashtext(${`auth_admin_command:${params.actorId}:${params.idempotencyKeyFingerprint}`}))`,
239
- );
221
+ await storeAdminCommandResult(tx, params, {grant: toStoredAdminGrant(grant)});
240
222
  }
241
223
 
242
224
  export async function bootstrapFirstAdminOwner(
@@ -244,7 +226,7 @@ export async function bootstrapFirstAdminOwner(
244
226
  ): Promise<AdminGrant> {
245
227
  return await db().transaction(async (tx) => {
246
228
  await lockAdminCommand(tx, params);
247
- await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
229
+ await lockAdminOwnerGrants(tx);
248
230
 
249
231
  const existing = await findCommandResult(tx, {
250
232
  ...params,
@@ -252,19 +234,7 @@ export async function bootstrapFirstAdminOwner(
252
234
  });
253
235
  if (existing) return existing;
254
236
 
255
- const activeOwners = await tx
256
- .select({id: adminGrants.id})
257
- .from(adminGrants)
258
- .innerJoin(users, eq(adminGrants.userId, users.id))
259
- .where(
260
- and(
261
- eq(adminGrants.role, 'admin-owner'),
262
- isNull(adminGrants.revokedAt),
263
- eq(users.status, 'active'),
264
- ),
265
- )
266
- .limit(1);
267
- if (activeOwners.length > 0) throw new AdminBootstrapClosedError();
237
+ if (await hasActiveAdminOwner(tx)) throw new AdminBootstrapClosedError();
268
238
 
269
239
  const userRows = await tx
270
240
  .select({id: users.id, status: users.status})
@@ -293,7 +263,7 @@ export async function grantAdminRoleWithAudit(
293
263
  ): Promise<AdminGrant> {
294
264
  return await db().transaction(async (tx) => {
295
265
  await lockAdminCommand(tx, params);
296
- await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
266
+ await lockAdminOwnerGrants(tx);
297
267
 
298
268
  const existing = await findCommandResult(tx, {
299
269
  ...params,
@@ -341,7 +311,7 @@ export async function revokeAdminGrantWithAudit(
341
311
  ): Promise<AdminGrant> {
342
312
  return await db().transaction(async (tx) => {
343
313
  await lockAdminCommand(tx, params);
344
- await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
314
+ await lockAdminOwnerGrants(tx);
345
315
 
346
316
  const existing = await findCommandResult(tx, {
347
317
  ...params,
@@ -358,17 +328,7 @@ export async function revokeAdminGrantWithAudit(
358
328
  if (!grant) throw new AdminGrantNotFoundError();
359
329
 
360
330
  if (grant.role === 'admin-owner') {
361
- const activeOwners = await tx
362
- .select({id: adminGrants.id})
363
- .from(adminGrants)
364
- .innerJoin(users, eq(adminGrants.userId, users.id))
365
- .where(
366
- and(
367
- eq(adminGrants.role, 'admin-owner'),
368
- isNull(adminGrants.revokedAt),
369
- eq(users.status, 'active'),
370
- ),
371
- );
331
+ const activeOwners = await listActiveAdminOwners(tx);
372
332
  if (activeOwners.length <= 1) throw new LastAdminOwnerError();
373
333
  }
374
334