@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
@@ -22,6 +22,20 @@
22
22
  "when": 1785075183023,
23
23
  "tag": "0002_burly_whizzer",
24
24
  "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1788171920746,
30
+ "tag": "0003_brainy_luckman",
31
+ "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "7",
36
+ "when": 1788195662870,
37
+ "tag": "0004_yummy_runaways",
38
+ "breakpoints": true
25
39
  }
26
40
  ]
27
41
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-auth",
3
3
  "license": "MIT",
4
- "version": "17.0.0",
4
+ "version": "19.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,9 +27,9 @@
27
27
  "drizzle-orm": "^0.45.2",
28
28
  "zod": "^4.4.3",
29
29
  "@shipfox/api-common-dto": "15.0.0",
30
- "@shipfox/api-auth-context": "17.0.0",
31
- "@shipfox/api-auth-dto": "17.0.0",
32
- "@shipfox/api-email-challenges": "1.1.11",
30
+ "@shipfox/api-auth-context": "19.0.0",
31
+ "@shipfox/api-auth-dto": "19.0.0",
32
+ "@shipfox/api-email-challenges": "1.1.12",
33
33
  "@shipfox/api-workspaces-dto": "15.0.0",
34
34
  "@shipfox/inter-module": "0.2.3",
35
35
  "@shipfox/config": "1.2.4",
@@ -39,12 +39,12 @@
39
39
  "@shipfox/node-jwt": "0.4.0",
40
40
  "@shipfox/node-email": "0.3.5",
41
41
  "@shipfox/node-mailer": "0.2.6",
42
- "@shipfox/node-module": "1.0.7",
42
+ "@shipfox/node-module": "1.0.8",
43
43
  "@shipfox/node-opentelemetry": "0.6.5",
44
- "@shipfox/node-outbox": "0.2.6",
44
+ "@shipfox/node-outbox": "0.2.7",
45
45
  "@shipfox/node-postgres": "0.5.1",
46
46
  "@shipfox/node-rate-limit": "0.4.0",
47
- "@shipfox/node-tokens": "0.3.3"
47
+ "@shipfox/node-tokens": "1.1.0"
48
48
  },
49
49
  "imports": {
50
50
  "#*": "./dist/*"
@@ -1,7 +1,11 @@
1
1
  import {createAdminGrant} from '#db/admin-grants.js';
2
+ import {db} from '#db/db.js';
3
+ import {users} from '#db/schema/users.js';
2
4
  import {userFactory} from '#test/index.js';
3
5
  import {hasMinimumAdminRole, highestAdminRole, requireAdminRole} from './admin-role.js';
6
+ import {listAdministratorUsers} from './administration.js';
4
7
  import type {AdminRoleRequiredError} from './errors.js';
8
+ import {InvalidAdministratorUserDirectoryFilterError} from './errors.js';
5
9
 
6
10
  describe('admin role policy', () => {
7
11
  test.each([
@@ -23,6 +27,72 @@ describe('admin role policy', () => {
23
27
  expect(highestAdminRole([])).toBeNull();
24
28
  });
25
29
 
30
+ test('protects the administrator user directory with the observer role', async () => {
31
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
32
+
33
+ await expect(listAdministratorUsers({actorId: user.id, limit: 10})).rejects.toEqual(
34
+ expect.objectContaining<Partial<AdminRoleRequiredError>>({
35
+ minimumRole: 'admin-observer',
36
+ }),
37
+ );
38
+ });
39
+
40
+ test('bounds administrator user directory search input', async () => {
41
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
42
+ await createAdminGrant({userId: user.id, role: 'admin-observer'});
43
+
44
+ await expect(
45
+ listAdministratorUsers({
46
+ actorId: user.id,
47
+ limit: 10,
48
+ search: Array.from({length: 11}, () => 'term').join(' '),
49
+ }),
50
+ ).rejects.toThrow('at most 10 terms');
51
+ await expect(
52
+ listAdministratorUsers({actorId: user.id, limit: 10, search: 'x'.repeat(101)}),
53
+ ).rejects.toThrow('at most 100 characters');
54
+ });
55
+
56
+ test('rejects an active eligibility filter combined with a non-active status', async () => {
57
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
58
+ await createAdminGrant({userId: user.id, role: 'admin-observer'});
59
+
60
+ await expect(
61
+ listAdministratorUsers({
62
+ actorId: user.id,
63
+ limit: 10,
64
+ eligible: true,
65
+ status: 'suspended',
66
+ }),
67
+ ).rejects.toBeInstanceOf(InvalidAdministratorUserDirectoryFilterError);
68
+ });
69
+
70
+ test('caps the directory page size in the core operation', async () => {
71
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
72
+ await createAdminGrant({userId: user.id, role: 'admin-observer'});
73
+ const marker = `admin-directory-cap-${crypto.randomUUID()}`;
74
+ await db()
75
+ .insert(users)
76
+ .values(
77
+ Array.from({length: 101}, (_, index) => ({
78
+ email: `${marker}-${index}@example.com`,
79
+ name: `Directory User ${index}`,
80
+ hashedPassword: null,
81
+ emailVerifiedAt: new Date(),
82
+ })),
83
+ );
84
+
85
+ const result = await listAdministratorUsers({
86
+ actorId: user.id,
87
+ limit: 1_000,
88
+ search: marker,
89
+ });
90
+
91
+ expect(result.users).toHaveLength(100);
92
+ expect(result.rows).toEqual(result.users);
93
+ expect(result.nextCursor).not.toBeNull();
94
+ });
95
+
26
96
  test('evaluates the current role from Auth storage for every required minimum', async () => {
27
97
  const user = await userFactory.create({emailVerifiedAt: new Date()});
28
98
  await createAdminGrant({userId: user.id, role: 'admin-operator'});
@@ -18,7 +18,10 @@ import {
18
18
  suspendUserWithAudit,
19
19
  type UserModerationResult,
20
20
  } from '#db/admin-user-moderation.js';
21
- import {findAdministratorUser as findAdministratorUserInDb} from '#db/admin-users.js';
21
+ import {
22
+ findAdministratorUser as findAdministratorUserInDb,
23
+ listAdministratorUsers as listAdministratorUsersInDb,
24
+ } from '#db/admin-users.js';
22
25
  import {
23
26
  type ImpersonationResult,
24
27
  impersonateUserWithAudit,
@@ -45,6 +48,7 @@ import {
45
48
  ImpersonationExpiredError,
46
49
  ImpersonationTargetNotActiveError,
47
50
  InvalidAdminBootstrapTokenError,
51
+ InvalidAdministratorUserDirectoryFilterError,
48
52
  InvalidCredentialsError,
49
53
  LastAdminOwnerError,
50
54
  UserNotFoundError,
@@ -53,6 +57,10 @@ import {
53
57
  const ADMIN_OWNER_ROLE: AdminRole = 'admin-owner';
54
58
  const ADMIN_OBSERVER_ROLE: AdminRole = 'admin-observer';
55
59
  const ADMIN_OPERATOR_ROLE: AdminRole = 'admin-operator';
60
+ const ADMINISTRATOR_DIRECTORY_MAX_PAGE_SIZE = 100;
61
+ const ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERMS = 10;
62
+ const ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERM_LENGTH = 100;
63
+ const ADMINISTRATOR_DIRECTORY_SEARCH_TERM_SEPARATOR = /\s+/g;
56
64
  const BOOTSTRAP_COMMAND = 'auth.admin_grant.bootstrap';
57
65
  const GRANT_COMMAND = 'auth.admin_grant.grant';
58
66
  const REVOKE_COMMAND = 'auth.admin_grant.revoke';
@@ -192,6 +200,75 @@ export async function findAdministratorUserSummary(
192
200
  return user;
193
201
  }
194
202
 
203
+ export interface ListAdministratorUsersParams {
204
+ actorId: string;
205
+ limit: number;
206
+ cursor?: TimestampIdCursor | undefined;
207
+ search?: string | undefined;
208
+ status?: AdministratorUserSummary['status'] | undefined;
209
+ eligible?: boolean | undefined;
210
+ }
211
+
212
+ export interface ListAdministratorUsersResult {
213
+ /** Retained for compatibility with the original database-shaped result. */
214
+ rows: AdministratorUserSummary[];
215
+ users: AdministratorUserSummary[];
216
+ nextCursor: TimestampIdCursor | null;
217
+ }
218
+
219
+ function normalizeAdministratorUserDirectorySearch(search: string | undefined): string | undefined {
220
+ const normalizedSearch = search
221
+ ?.trim()
222
+ .replace(ADMINISTRATOR_DIRECTORY_SEARCH_TERM_SEPARATOR, ' ');
223
+ if (!normalizedSearch) return;
224
+
225
+ const terms = normalizedSearch.split(' ');
226
+ if (terms.length > ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERMS) {
227
+ throw new InvalidAdministratorUserDirectoryFilterError(
228
+ `Administrator user directory search accepts at most ${ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERMS} terms`,
229
+ );
230
+ }
231
+
232
+ if (terms.some((term) => term.length > ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERM_LENGTH)) {
233
+ throw new InvalidAdministratorUserDirectoryFilterError(
234
+ `Administrator user directory search terms must be at most ${ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERM_LENGTH} characters`,
235
+ );
236
+ }
237
+
238
+ return normalizedSearch;
239
+ }
240
+
241
+ function validateAdministratorUserDirectoryFilters(
242
+ params: ListAdministratorUsersParams,
243
+ ): string | undefined {
244
+ const normalizedSearch = normalizeAdministratorUserDirectorySearch(params.search);
245
+ if (params.eligible === true && params.status && params.status !== 'active') {
246
+ throw new InvalidAdministratorUserDirectoryFilterError(
247
+ 'eligible users must have active status',
248
+ );
249
+ }
250
+ return normalizedSearch;
251
+ }
252
+
253
+ export async function listAdministratorUsers(
254
+ params: ListAdministratorUsersParams,
255
+ ): Promise<ListAdministratorUsersResult> {
256
+ await requireAdminRole({userId: params.actorId, minimumRole: ADMIN_OBSERVER_ROLE});
257
+ const normalizedSearch = validateAdministratorUserDirectoryFilters(params);
258
+ const result = await listAdministratorUsersInDb({
259
+ ...params,
260
+ limit: Math.min(params.limit, ADMINISTRATOR_DIRECTORY_MAX_PAGE_SIZE),
261
+ search: normalizedSearch,
262
+ });
263
+ return {
264
+ rows: result.rows,
265
+ // Keep the original `rows` result while exposing the domain-named field
266
+ // used by the directory route.
267
+ users: result.rows,
268
+ nextCursor: result.nextCursor,
269
+ };
270
+ }
271
+
195
272
  export async function listAdministratorGrantSummaries(params: {
196
273
  actorId: string;
197
274
  limit: number;
package/src/core/auth.ts CHANGED
@@ -442,11 +442,9 @@ export type CreateSessionForUserError =
442
442
  export async function createSessionForUser(
443
443
  params: CreateSessionForUserParams,
444
444
  ): Promise<CreateSessionForUserResult> {
445
- const user = params.userId
446
- ? await findUserById({id: params.userId})
447
- : params.email
448
- ? await findUserByEmail({email: emailSchema.parse(params.email)})
449
- : undefined;
445
+ let user: User | undefined;
446
+ if (params.userId) user = await findUserById({id: params.userId});
447
+ else if (params.email) user = await findUserByEmail({email: emailSchema.parse(params.email)});
450
448
 
451
449
  if (!user) {
452
450
  throw new UserNotFoundError(params.userId ?? params.email ?? 'unknown');
@@ -0,0 +1,80 @@
1
+ export type AgentClientKind = 'registered' | 'cimd';
2
+
3
+ export interface AgentClient {
4
+ id: string;
5
+ clientId: string;
6
+ name: string;
7
+ redirectUris: string[];
8
+ kind: AgentClientKind;
9
+ lastSeenAt: Date | null;
10
+ unreferencedAt: Date | null;
11
+ createdAt: Date;
12
+ updatedAt: Date;
13
+ }
14
+
15
+ export interface AgentAuthorizationRequest {
16
+ id: string;
17
+ clientId: string;
18
+ redirectUri: string;
19
+ resource: string;
20
+ scopes: string[];
21
+ codeChallenge: string;
22
+ state: string | null;
23
+ expiresAt: Date;
24
+ consumedAt: Date | null;
25
+ createdAt: Date;
26
+ updatedAt: Date;
27
+ }
28
+
29
+ export interface AgentGrant {
30
+ id: string;
31
+ userId: string;
32
+ workspaceId: string;
33
+ clientId: string;
34
+ scopes: string[];
35
+ lastUsedAt: Date | null;
36
+ revokedAt: Date | null;
37
+ terminalAt: Date | null;
38
+ createdAt: Date;
39
+ updatedAt: Date;
40
+ }
41
+
42
+ export interface AgentAuthorizationCode {
43
+ id: string;
44
+ grantId: string;
45
+ hashedCode: string;
46
+ codeChallenge: string;
47
+ redirectUri: string;
48
+ resource: string;
49
+ expiresAt: Date;
50
+ consumedAt: Date | null;
51
+ createdAt: Date;
52
+ updatedAt: Date;
53
+ }
54
+
55
+ export interface AgentRefreshToken {
56
+ id: string;
57
+ grantId: string;
58
+ hashedToken: string;
59
+ expiresAt: Date;
60
+ rotatedAt: Date | null;
61
+ revokedAt: Date | null;
62
+ lastUsedAt: Date | null;
63
+ createdAt: Date;
64
+ updatedAt: Date;
65
+ }
66
+
67
+ export interface AgentPersonalAccessToken {
68
+ id: string;
69
+ userId: string;
70
+ workspaceId: string;
71
+ hashedToken: string;
72
+ prefix: string;
73
+ name: string;
74
+ scopes: string[];
75
+ expiresAt: Date;
76
+ lastUsedAt: Date | null;
77
+ revokedAt: Date | null;
78
+ createdAt: Date;
79
+ updatedAt: Date;
80
+ }
@@ -95,6 +95,13 @@ export class AdminRoleRequiredError extends Error {
95
95
  }
96
96
  }
97
97
 
98
+ export class InvalidAdministratorUserDirectoryFilterError extends Error {
99
+ constructor(message: string) {
100
+ super(message);
101
+ this.name = 'InvalidAdministratorUserDirectoryFilterError';
102
+ }
103
+ }
104
+
98
105
  export class LastAdminOwnerError extends Error {
99
106
  constructor() {
100
107
  super('Cannot remove the final active administrator owner');
@@ -14,6 +14,7 @@ function claims() {
14
14
  scope: 'workspace' as const,
15
15
  labels: ['linux', 'x64'],
16
16
  maxClaims: null,
17
+ lifecycleCapabilities: ['local_execution_fence_v1'],
17
18
  };
18
19
  }
19
20
 
@@ -30,6 +31,7 @@ describe('runner-session-token', () => {
30
31
  expect(verified?.scope).toBe(input.scope);
31
32
  expect(verified?.labels).toEqual(input.labels);
32
33
  expect(verified?.maxClaims).toBeNull();
34
+ expect(verified?.lifecycleCapabilities).toEqual(input.lifecycleCapabilities);
33
35
  expect(verified?.aud).toBe(RUNNER_SESSION_TOKEN_AUDIENCE);
34
36
  expect(verified?.iat).toBeTypeOf('number');
35
37
  expect(verified?.exp).toBeGreaterThan(verified?.iat ?? 0);
@@ -21,6 +21,7 @@ export async function issueRunnerSessionToken(
21
21
  scope: claims.scope,
22
22
  labels: claims.labels,
23
23
  maxClaims: claims.maxClaims,
24
+ lifecycleCapabilities: claims.lifecycleCapabilities,
24
25
  },
25
26
  secret: runnerSessionTokenKey(),
26
27
  expiresIn: config.AUTH_RUNNER_SESSION_TOKEN_EXPIRES_IN,
@@ -139,6 +139,58 @@ async function revokeActiveSessions(tx: Tx, userId: string): Promise<number> {
139
139
  return new Set(revoked.map(({sessionId}) => sessionId)).size;
140
140
  }
141
141
 
142
+ async function suspendUser(tx: Tx, user: Awaited<ReturnType<typeof readTargetUserForUpdate>>) {
143
+ if (user.status !== 'active') return;
144
+ const activeOwners = await tx
145
+ .select({id: adminGrants.id})
146
+ .from(adminGrants)
147
+ .innerJoin(users, eq(adminGrants.userId, users.id))
148
+ .where(
149
+ and(
150
+ eq(adminGrants.role, 'admin-owner'),
151
+ isNull(adminGrants.revokedAt),
152
+ eq(users.status, 'active'),
153
+ ),
154
+ )
155
+ .limit(2);
156
+ const targetIsActiveOwner = await tx
157
+ .select({id: adminGrants.id})
158
+ .from(adminGrants)
159
+ .where(
160
+ and(
161
+ eq(adminGrants.userId, user.id),
162
+ eq(adminGrants.role, 'admin-owner'),
163
+ isNull(adminGrants.revokedAt),
164
+ ),
165
+ )
166
+ .limit(1);
167
+ if (targetIsActiveOwner.length > 0 && activeOwners.length <= 1) throw new LastAdminOwnerError();
168
+ await tx
169
+ .update(users)
170
+ .set({status: 'suspended', updatedAt: sql`now()`})
171
+ .where(eq(users.id, user.id));
172
+ }
173
+
174
+ async function applyModerationOperation(
175
+ tx: Tx,
176
+ user: Awaited<ReturnType<typeof readTargetUserForUpdate>>,
177
+ operation: 'suspend' | 'reactivate' | 'revoke-sessions',
178
+ ): Promise<number> {
179
+ if (operation === 'suspend') {
180
+ await suspendUser(tx, user);
181
+ return await revokeActiveSessions(tx, user.id);
182
+ }
183
+ if (operation === 'reactivate' && user.status === 'suspended') {
184
+ await tx
185
+ .update(users)
186
+ .set({status: 'active', updatedAt: sql`now()`})
187
+ .where(eq(users.id, user.id));
188
+ return 0;
189
+ }
190
+ if (operation === 'revoke-sessions') return await revokeActiveSessions(tx, user.id);
191
+ return 0;
192
+ }
193
+
142
194
  async function executeUserModerationCommand(
143
195
  params: UserModerationCommandParams & {
144
196
  operation: 'suspend' | 'reactivate' | 'revoke-sessions';
@@ -157,51 +209,7 @@ async function executeUserModerationCommand(
157
209
  await lockUserSessionMutations(tx, params.userId);
158
210
  const user = await readTargetUserForUpdate(tx, params.userId);
159
211
  await requireActiveAdminOperator(tx, params.actorId);
160
- let sessionsRevoked = 0;
161
-
162
- if (params.operation === 'suspend') {
163
- if (user.status === 'active') {
164
- const activeOwners = await tx
165
- .select({id: adminGrants.id})
166
- .from(adminGrants)
167
- .innerJoin(users, eq(adminGrants.userId, users.id))
168
- .where(
169
- and(
170
- eq(adminGrants.role, 'admin-owner'),
171
- isNull(adminGrants.revokedAt),
172
- eq(users.status, 'active'),
173
- ),
174
- )
175
- .limit(2);
176
- const targetIsActiveOwner = await tx
177
- .select({id: adminGrants.id})
178
- .from(adminGrants)
179
- .where(
180
- and(
181
- eq(adminGrants.userId, user.id),
182
- eq(adminGrants.role, 'admin-owner'),
183
- isNull(adminGrants.revokedAt),
184
- ),
185
- )
186
- .limit(1);
187
- if (targetIsActiveOwner.length > 0 && activeOwners.length <= 1) {
188
- throw new LastAdminOwnerError();
189
- }
190
-
191
- await tx
192
- .update(users)
193
- .set({status: 'suspended', updatedAt: sql`now()`})
194
- .where(eq(users.id, user.id));
195
- }
196
- sessionsRevoked = await revokeActiveSessions(tx, user.id);
197
- } else if (params.operation === 'reactivate' && user.status === 'suspended') {
198
- await tx
199
- .update(users)
200
- .set({status: 'active', updatedAt: sql`now()`})
201
- .where(eq(users.id, user.id));
202
- } else if (params.operation === 'revoke-sessions') {
203
- sessionsRevoked = await revokeActiveSessions(tx, user.id);
204
- }
212
+ const sessionsRevoked = await applyModerationOperation(tx, user, params.operation);
205
213
 
206
214
  const summary = await findUserSummary(tx, user.id);
207
215
  const result: StoredAdminUserModerationResult = {