@shipfox/api-auth 10.1.0 → 11.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 (84) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +30 -0
  3. package/README.md +42 -11
  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 +27 -10
  10. package/dist/core/auth.js.map +1 -1
  11. package/dist/core/jwt.d.ts +10 -0
  12. package/dist/core/jwt.d.ts.map +1 -1
  13. package/dist/core/jwt.js +3 -2
  14. package/dist/core/jwt.js.map +1 -1
  15. package/dist/db/admin-command.d.ts +18 -0
  16. package/dist/db/admin-command.d.ts.map +1 -0
  17. package/dist/db/admin-command.js +37 -0
  18. package/dist/db/admin-command.js.map +1 -0
  19. package/dist/db/admin-grants.d.ts +1 -1
  20. package/dist/db/admin-grants.d.ts.map +1 -1
  21. package/dist/db/admin-grants.js +12 -30
  22. package/dist/db/admin-grants.js.map +1 -1
  23. package/dist/db/admin-user-moderation.d.ts +29 -0
  24. package/dist/db/admin-user-moderation.d.ts.map +1 -0
  25. package/dist/db/admin-user-moderation.js +156 -0
  26. package/dist/db/admin-user-moderation.js.map +1 -0
  27. package/dist/db/admin-user-summary.d.ts +14 -0
  28. package/dist/db/admin-user-summary.d.ts.map +1 -0
  29. package/dist/db/admin-user-summary.js +31 -0
  30. package/dist/db/admin-user-summary.js.map +1 -0
  31. package/dist/db/admin-users.d.ts +2 -11
  32. package/dist/db/admin-users.d.ts.map +1 -1
  33. package/dist/db/admin-users.js +2 -27
  34. package/dist/db/admin-users.js.map +1 -1
  35. package/dist/db/refresh-tokens.d.ts +6 -4
  36. package/dist/db/refresh-tokens.d.ts.map +1 -1
  37. package/dist/db/refresh-tokens.js +31 -6
  38. package/dist/db/refresh-tokens.js.map +1 -1
  39. package/dist/db/schema/admin-command-results.d.ts +19 -2
  40. package/dist/db/schema/admin-command-results.d.ts.map +1 -1
  41. package/dist/db/schema/admin-command-results.js.map +1 -1
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +1 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/presentation/auth/bearer-token-auth.d.ts +1 -0
  47. package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
  48. package/dist/presentation/auth/bearer-token-auth.js +4 -3
  49. package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
  50. package/dist/presentation/auth/jwt-auth.d.ts +1 -4
  51. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  52. package/dist/presentation/auth/jwt-auth.js +23 -19
  53. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  54. package/dist/presentation/routes/administration.d.ts.map +1 -1
  55. package/dist/presentation/routes/administration.js +94 -3
  56. package/dist/presentation/routes/administration.js.map +1 -1
  57. package/dist/tsconfig.test.tsbuildinfo +1 -1
  58. package/package.json +4 -4
  59. package/src/core/administration.ts +137 -15
  60. package/src/core/auth.test.ts +29 -10
  61. package/src/core/auth.ts +36 -14
  62. package/src/core/jwt.test.ts +20 -4
  63. package/src/core/jwt.ts +2 -1
  64. package/src/db/admin-command.ts +85 -0
  65. package/src/db/admin-grants.ts +19 -57
  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.test.ts +0 -19
  70. package/src/db/refresh-tokens.ts +54 -23
  71. package/src/db/schema/admin-command-results.ts +23 -2
  72. package/src/index.ts +3 -0
  73. package/src/presentation/auth/bearer-token-auth.test.ts +17 -0
  74. package/src/presentation/auth/bearer-token-auth.ts +9 -2
  75. package/src/presentation/auth/jwt-auth.test.ts +34 -9
  76. package/src/presentation/auth/jwt-auth.ts +19 -13
  77. package/src/presentation/routes/administration.test.ts +253 -2
  78. package/src/presentation/routes/administration.ts +88 -1
  79. package/src/presentation/routes/password/change-password.test.ts +6 -0
  80. package/src/presentation/routes/password/password-reset-confirm.test.ts +14 -0
  81. package/src/presentation/routes/registration/signup.test.ts +2 -2
  82. package/src/presentation/routes/session/logout.test.ts +6 -0
  83. package/src/presentation/routes/session/refresh.test.ts +11 -0
  84. package/tsconfig.build.tsbuildinfo +1 -1
@@ -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,
@@ -0,0 +1,230 @@
1
+ import type {AdministrationActionEvent} from '@shipfox/api-common-dto';
2
+ import {and, eq, gt, isNull, sql} from 'drizzle-orm';
3
+ import {hasMinimumAdminRole, highestAdminRole} from '#core/admin-role-model.js';
4
+ import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
5
+ import {AdminRoleRequiredError, LastAdminOwnerError, UserNotFoundError} from '#core/errors.js';
6
+ import {
7
+ findAdminCommandResult,
8
+ lockAdminCommand,
9
+ lockAdminOwnerGrants,
10
+ storeAdminCommandResult,
11
+ type Tx,
12
+ writeAdminAction,
13
+ } from './admin-command.js';
14
+ import {findAdministratorUserSummary} from './admin-user-summary.js';
15
+ import {db} from './db.js';
16
+ import {lockUserSessionMutations} from './refresh-tokens.js';
17
+ import type {
18
+ StoredAdministratorUserSummary,
19
+ StoredAdminUserModerationResult,
20
+ } from './schema/admin-command-results.js';
21
+ import {adminGrants} from './schema/admin-grants.js';
22
+ import {refreshTokens} from './schema/refresh-tokens.js';
23
+ import {users} from './schema/users.js';
24
+
25
+ interface UserModerationCommandParams {
26
+ actorId: string;
27
+ userId: string;
28
+ idempotencyKeyFingerprint: string;
29
+ requestFingerprint: string;
30
+ event: AdministrationActionEvent;
31
+ }
32
+
33
+ function toStoredUserSummary(user: AdministratorUserSummary): StoredAdministratorUserSummary {
34
+ return {
35
+ id: user.id,
36
+ email: user.email,
37
+ name: user.name,
38
+ emailVerifiedAt: user.emailVerifiedAt?.toISOString() ?? null,
39
+ status: user.status,
40
+ createdAt: user.createdAt.toISOString(),
41
+ adminRole: user.adminRole,
42
+ };
43
+ }
44
+
45
+ async function findUserSummary(tx: Tx, userId: string): Promise<StoredAdministratorUserSummary> {
46
+ const user = await findAdministratorUserSummary(tx, {id: userId});
47
+ if (!user) throw new Error('User summary query returned no rows');
48
+ return toStoredUserSummary(user);
49
+ }
50
+
51
+ function fromStoredResult(result: StoredAdminUserModerationResult) {
52
+ return {
53
+ user: {
54
+ id: result.user.id,
55
+ email: result.user.email,
56
+ name: result.user.name,
57
+ emailVerifiedAt: result.user.emailVerifiedAt ? new Date(result.user.emailVerifiedAt) : null,
58
+ status: result.user.status,
59
+ createdAt: new Date(result.user.createdAt),
60
+ adminRole: result.user.adminRole,
61
+ },
62
+ correlationId: result.correlationId,
63
+ sessionsRevoked: result.sessionsRevoked,
64
+ };
65
+ }
66
+
67
+ async function findCommandResult(
68
+ tx: Tx,
69
+ params: Pick<
70
+ UserModerationCommandParams,
71
+ 'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
72
+ > & {command: string},
73
+ ) {
74
+ const result = await findAdminCommandResult(tx, params);
75
+ if (!result) return undefined;
76
+ if (!('userModeration' in result.result)) {
77
+ throw new Error('Administrator command result has an unexpected shape');
78
+ }
79
+ return fromStoredResult(result.result.userModeration);
80
+ }
81
+
82
+ async function storeCommandResult(
83
+ tx: Tx,
84
+ params: UserModerationCommandParams,
85
+ result: StoredAdminUserModerationResult,
86
+ ): Promise<void> {
87
+ await storeAdminCommandResult(tx, params, {userModeration: result});
88
+ }
89
+
90
+ async function readTargetUserForUpdate(tx: Tx, userId: string) {
91
+ const rows = await tx
92
+ .select({id: users.id, status: users.status})
93
+ .from(users)
94
+ .where(eq(users.id, userId))
95
+ .limit(1)
96
+ .for('update');
97
+ const user = rows[0];
98
+ if (!user || user.status === 'deleted') throw new UserNotFoundError(userId);
99
+ return user;
100
+ }
101
+
102
+ async function requireActiveAdminOperator(tx: Tx, actorId: string): Promise<void> {
103
+ const actorRows = await tx
104
+ .select({status: users.status})
105
+ .from(users)
106
+ .where(eq(users.id, actorId))
107
+ .limit(1);
108
+ const actor = actorRows[0];
109
+ const grants = await tx
110
+ .select({role: adminGrants.role})
111
+ .from(adminGrants)
112
+ .where(and(eq(adminGrants.userId, actorId), isNull(adminGrants.revokedAt)));
113
+ const role = highestAdminRole(grants.map(({role}) => role));
114
+
115
+ if (actor?.status !== 'active' || !role || !hasMinimumAdminRole(role, 'admin-operator')) {
116
+ throw new AdminRoleRequiredError('admin-operator');
117
+ }
118
+ }
119
+
120
+ async function revokeActiveSessions(tx: Tx, userId: string): Promise<number> {
121
+ const revoked = await tx
122
+ .update(refreshTokens)
123
+ .set({revokedAt: sql`now()`, updatedAt: sql`now()`})
124
+ .where(
125
+ and(
126
+ eq(refreshTokens.userId, userId),
127
+ isNull(refreshTokens.revokedAt),
128
+ gt(refreshTokens.expiresAt, sql`now()`),
129
+ ),
130
+ )
131
+ .returning({sessionId: refreshTokens.sessionId});
132
+
133
+ return new Set(revoked.map(({sessionId}) => sessionId)).size;
134
+ }
135
+
136
+ async function executeUserModerationCommand(
137
+ params: UserModerationCommandParams & {
138
+ operation: 'suspend' | 'reactivate' | 'revoke-sessions';
139
+ },
140
+ ) {
141
+ return await db().transaction(async (tx) => {
142
+ await lockAdminCommand(tx, params);
143
+ if (params.operation === 'suspend') await lockAdminOwnerGrants(tx);
144
+
145
+ const existing = await findCommandResult(tx, {
146
+ ...params,
147
+ command: params.event.command,
148
+ });
149
+ if (existing) return existing;
150
+
151
+ await lockUserSessionMutations(tx, params.userId);
152
+ const user = await readTargetUserForUpdate(tx, params.userId);
153
+ await requireActiveAdminOperator(tx, params.actorId);
154
+ let sessionsRevoked = 0;
155
+
156
+ if (params.operation === 'suspend') {
157
+ if (user.status === 'active') {
158
+ const activeOwners = await tx
159
+ .select({id: adminGrants.id})
160
+ .from(adminGrants)
161
+ .innerJoin(users, eq(adminGrants.userId, users.id))
162
+ .where(
163
+ and(
164
+ eq(adminGrants.role, 'admin-owner'),
165
+ isNull(adminGrants.revokedAt),
166
+ eq(users.status, 'active'),
167
+ ),
168
+ )
169
+ .limit(2);
170
+ const targetIsActiveOwner = await tx
171
+ .select({id: adminGrants.id})
172
+ .from(adminGrants)
173
+ .where(
174
+ and(
175
+ eq(adminGrants.userId, user.id),
176
+ eq(adminGrants.role, 'admin-owner'),
177
+ isNull(adminGrants.revokedAt),
178
+ ),
179
+ )
180
+ .limit(1);
181
+ if (targetIsActiveOwner.length > 0 && activeOwners.length <= 1) {
182
+ throw new LastAdminOwnerError();
183
+ }
184
+
185
+ await tx
186
+ .update(users)
187
+ .set({status: 'suspended', updatedAt: sql`now()`})
188
+ .where(eq(users.id, user.id));
189
+ }
190
+ sessionsRevoked = await revokeActiveSessions(tx, user.id);
191
+ } else if (params.operation === 'reactivate' && user.status === 'suspended') {
192
+ await tx
193
+ .update(users)
194
+ .set({status: 'active', updatedAt: sql`now()`})
195
+ .where(eq(users.id, user.id));
196
+ } else if (params.operation === 'revoke-sessions') {
197
+ sessionsRevoked = await revokeActiveSessions(tx, user.id);
198
+ }
199
+
200
+ const summary = await findUserSummary(tx, user.id);
201
+ const result: StoredAdminUserModerationResult = {
202
+ user: summary,
203
+ correlationId: params.event.correlationId,
204
+ sessionsRevoked,
205
+ };
206
+ await writeAdminAction(tx, params.event);
207
+ await storeCommandResult(tx, params, result);
208
+ return fromStoredResult(result);
209
+ });
210
+ }
211
+
212
+ export type UserModerationResult = Awaited<ReturnType<typeof executeUserModerationCommand>>;
213
+
214
+ export async function suspendUserWithAudit(
215
+ params: UserModerationCommandParams,
216
+ ): Promise<UserModerationResult> {
217
+ return await executeUserModerationCommand({...params, operation: 'suspend'});
218
+ }
219
+
220
+ export async function reactivateUserWithAudit(
221
+ params: UserModerationCommandParams,
222
+ ): Promise<UserModerationResult> {
223
+ return await executeUserModerationCommand({...params, operation: 'reactivate'});
224
+ }
225
+
226
+ export async function revokeUserSessionsWithAudit(
227
+ params: UserModerationCommandParams,
228
+ ): Promise<UserModerationResult> {
229
+ return await executeUserModerationCommand({...params, operation: 'revoke-sessions'});
230
+ }
@@ -0,0 +1,51 @@
1
+ import {and, eq, isNull} from 'drizzle-orm';
2
+ import {highestAdminRole} from '#core/admin-role-model.js';
3
+ import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
4
+ import type {db} from './db.js';
5
+ import {adminGrants} from './schema/admin-grants.js';
6
+ import {users} from './schema/users.js';
7
+
8
+ type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
9
+ export type AdministratorUserSummaryExecutor = ReturnType<typeof db> | Tx;
10
+
11
+ type AdministratorUserLookup = {id: string; email?: never} | {email: string; id?: never};
12
+
13
+ export async function findAdministratorUserSummary(
14
+ executor: AdministratorUserSummaryExecutor,
15
+ params: AdministratorUserLookup,
16
+ ): Promise<AdministratorUserSummary | undefined> {
17
+ const identifier = 'id' in params ? eq(users.id, params.id) : eq(users.email, params.email);
18
+ const rows = await executor
19
+ .select({
20
+ id: users.id,
21
+ email: users.email,
22
+ name: users.name,
23
+ emailVerifiedAt: users.emailVerifiedAt,
24
+ status: users.status,
25
+ createdAt: users.createdAt,
26
+ adminRole: adminGrants.role,
27
+ })
28
+ .from(users)
29
+ .leftJoin(
30
+ adminGrants,
31
+ and(
32
+ eq(adminGrants.userId, users.id),
33
+ isNull(adminGrants.revokedAt),
34
+ eq(users.status, 'active'),
35
+ ),
36
+ )
37
+ .where(identifier);
38
+
39
+ const first = rows[0];
40
+ if (!first) return undefined;
41
+
42
+ return {
43
+ id: first.id,
44
+ email: first.email,
45
+ name: first.name,
46
+ emailVerifiedAt: first.emailVerifiedAt,
47
+ status: first.status,
48
+ createdAt: first.createdAt,
49
+ adminRole: highestAdminRole(rows.flatMap(({adminRole}) => (adminRole ? [adminRole] : []))),
50
+ } satisfies AdministratorUserSummary;
51
+ }
@@ -1,58 +1,13 @@
1
- import type {AdminRole} from '@shipfox/api-auth-dto';
2
- import {and, eq, isNull} from 'drizzle-orm';
3
- import {highestAdminRole} from '#core/admin-role-model.js';
4
- import type {UserStatus} from '#core/entities/user.js';
1
+ import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
2
+ import {findAdministratorUserSummary} from './admin-user-summary.js';
5
3
  import {db} from './db.js';
6
- import {adminGrants} from './schema/admin-grants.js';
7
- import {users} from './schema/users.js';
8
4
 
9
- export interface AdministratorUserRecord {
10
- id: string;
11
- email: string;
12
- name: string | null;
13
- emailVerifiedAt: Date | null;
14
- status: UserStatus;
15
- createdAt: Date;
16
- adminRole: AdminRole | null;
17
- }
5
+ export type AdministratorUserRecord = AdministratorUserSummary;
18
6
 
19
7
  type AdministratorUserLookup = {id: string; email?: never} | {email: string; id?: never};
20
8
 
21
9
  export async function findAdministratorUser(
22
10
  params: AdministratorUserLookup,
23
11
  ): Promise<AdministratorUserRecord | undefined> {
24
- const identifier = 'id' in params ? eq(users.id, params.id) : eq(users.email, params.email);
25
- const rows = await db()
26
- .select({
27
- id: users.id,
28
- email: users.email,
29
- name: users.name,
30
- emailVerifiedAt: users.emailVerifiedAt,
31
- status: users.status,
32
- createdAt: users.createdAt,
33
- adminRole: adminGrants.role,
34
- })
35
- .from(users)
36
- .leftJoin(
37
- adminGrants,
38
- and(
39
- eq(adminGrants.userId, users.id),
40
- isNull(adminGrants.revokedAt),
41
- eq(users.status, 'active'),
42
- ),
43
- )
44
- .where(identifier);
45
-
46
- const first = rows[0];
47
- if (!first) return undefined;
48
-
49
- return {
50
- id: first.id,
51
- email: first.email,
52
- name: first.name,
53
- emailVerifiedAt: first.emailVerifiedAt,
54
- status: first.status,
55
- createdAt: first.createdAt,
56
- adminRole: highestAdminRole(rows.flatMap(({adminRole}) => (adminRole ? [adminRole] : []))),
57
- };
12
+ return await findAdministratorUserSummary(db(), params);
58
13
  }
@@ -1,7 +1,6 @@
1
1
  import {hashOpaqueToken} from '@shipfox/node-tokens';
2
2
  import {
3
3
  createRefreshToken,
4
- findActiveRefreshSession,
5
4
  findActiveRefreshTokenByHash,
6
5
  findRefreshTokenByHash,
7
6
  revokeRefreshSession,
@@ -45,24 +44,6 @@ describe('refresh-tokens db', () => {
45
44
  expect(found).toBeUndefined();
46
45
  });
47
46
 
48
- test('finds only the active session owned by the requested user', async () => {
49
- const user = await createUser({email: emailFor('rt-session'), hashedPassword: 'h'});
50
- const otherUser = await createUser({email: emailFor('rt-session-other'), hashedPassword: 'h'});
51
- const sessionId = crypto.randomUUID();
52
- await createRefreshToken({
53
- userId: user.id,
54
- sessionId,
55
- hashedToken: hashOpaqueToken(`active-${crypto.randomUUID()}`),
56
- expiresAt: new Date(Date.now() + 60_000),
57
- });
58
-
59
- const found = await findActiveRefreshSession({sessionId, userId: user.id});
60
- const otherUserFound = await findActiveRefreshSession({sessionId, userId: otherUser.id});
61
-
62
- expect(found?.sessionId).toBe(sessionId);
63
- expect(otherUserFound).toBeUndefined();
64
- });
65
-
66
47
  test('rotates a token once, creates the successor, and only the first caller wins', async () => {
67
48
  const user = await createUser({email: emailFor('rt-rotate'), hashedPassword: 'h'});
68
49
  const currentHashedToken = hashOpaqueToken(`current-${crypto.randomUUID()}`);
@@ -2,6 +2,13 @@ import {and, eq, gt, isNull, ne, sql} from 'drizzle-orm';
2
2
  import type {RefreshToken} from '#core/entities/refresh-token.js';
3
3
  import {db} from './db.js';
4
4
  import {refreshTokens, toRefreshToken} from './schema/refresh-tokens.js';
5
+ import {users} from './schema/users.js';
6
+
7
+ type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
8
+
9
+ export async function lockUserSessionMutations(tx: Tx, userId: string): Promise<void> {
10
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`auth_user_sessions:${userId}`}))`);
11
+ }
5
12
 
6
13
  export interface CreateRefreshTokenParams {
7
14
  sessionId?: string | undefined;
@@ -26,6 +33,33 @@ export async function createRefreshToken(params: CreateRefreshTokenParams): Prom
26
33
  return toRefreshToken(row);
27
34
  }
28
35
 
36
+ export async function createRefreshTokenForActiveUser(
37
+ params: CreateRefreshTokenParams,
38
+ ): Promise<RefreshToken | undefined> {
39
+ return await db().transaction(async (tx) => {
40
+ await lockUserSessionMutations(tx, params.userId);
41
+ const userRows = await tx
42
+ .select({status: users.status})
43
+ .from(users)
44
+ .where(eq(users.id, params.userId))
45
+ .limit(1);
46
+ if (userRows[0]?.status !== 'active') return undefined;
47
+
48
+ const rows = await tx
49
+ .insert(refreshTokens)
50
+ .values({
51
+ sessionId: params.sessionId,
52
+ userId: params.userId,
53
+ hashedToken: params.hashedToken,
54
+ expiresAt: params.expiresAt,
55
+ })
56
+ .returning();
57
+ const row = rows[0];
58
+ if (!row) throw new Error('Insert returned no rows');
59
+ return toRefreshToken(row);
60
+ });
61
+ }
62
+
29
63
  /**
30
64
  * Looks up the live session token by hash — one that can still authenticate as
31
65
  * the current session. Returns `undefined` for revoked, expired, or already
@@ -53,29 +87,6 @@ export async function findActiveRefreshTokenByHash(params: {
53
87
  return toRefreshToken(row);
54
88
  }
55
89
 
56
- export async function findActiveRefreshSession(params: {
57
- sessionId: string;
58
- userId: string;
59
- }): Promise<RefreshToken | undefined> {
60
- const rows = await db()
61
- .select()
62
- .from(refreshTokens)
63
- .where(
64
- and(
65
- eq(refreshTokens.sessionId, params.sessionId),
66
- eq(refreshTokens.userId, params.userId),
67
- isNull(refreshTokens.revokedAt),
68
- isNull(refreshTokens.rotatedAt),
69
- gt(refreshTokens.expiresAt, sql`now()`),
70
- ),
71
- )
72
- .limit(1);
73
-
74
- const row = rows[0];
75
- if (!row) return undefined;
76
- return toRefreshToken(row);
77
- }
78
-
79
90
  /**
80
91
  * Looks up a non-revoked, non-expired token by hash, including ones already
81
92
  * rotated. Rotation reads {@link RefreshToken.rotatedAt} to decide whether to
@@ -108,12 +119,32 @@ export async function findRefreshTokenByHash(params: {
108
119
  * If the successor insert fails, the predecessor rotation rolls back.
109
120
  */
110
121
  export async function rotateRefreshToken(params: {
122
+ userId?: string | undefined;
111
123
  id: string;
112
124
  currentHashedToken: string;
113
125
  nextHashedToken: string;
114
126
  expiresAt: Date;
115
127
  }): Promise<RefreshToken | undefined> {
116
128
  return await db().transaction(async (tx) => {
129
+ const userId =
130
+ params.userId ??
131
+ (
132
+ await tx
133
+ .select({userId: refreshTokens.userId})
134
+ .from(refreshTokens)
135
+ .where(eq(refreshTokens.id, params.id))
136
+ .limit(1)
137
+ )[0]?.userId;
138
+ if (!userId) return undefined;
139
+
140
+ await lockUserSessionMutations(tx, userId);
141
+ const userRows = await tx
142
+ .select({status: users.status})
143
+ .from(users)
144
+ .where(eq(users.id, userId))
145
+ .limit(1);
146
+ if (userRows[0]?.status !== 'active') return undefined;
147
+
117
148
  const rotatedRows = await tx
118
149
  .update(refreshTokens)
119
150
  .set({
@@ -1,6 +1,7 @@
1
1
  import type {AdminRole} from '@shipfox/api-auth-dto';
2
2
  import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
3
3
  import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
4
+ import type {UserStatus} from '#core/entities/user.js';
4
5
  import {pgTable} from './common.js';
5
6
  import {users} from './users.js';
6
7
 
@@ -13,10 +14,30 @@ export interface StoredAdminGrant {
13
14
  updatedAt: string;
14
15
  }
15
16
 
16
- export interface StoredAdminCommandResult {
17
- grant: StoredAdminGrant;
17
+ export interface StoredAdministratorUserSummary {
18
+ id: string;
19
+ email: string;
20
+ name: string | null;
21
+ emailVerifiedAt: string | null;
22
+ status: UserStatus;
23
+ createdAt: string;
24
+ adminRole: AdminRole | null;
18
25
  }
19
26
 
27
+ export interface StoredAdminUserModerationResult {
28
+ user: StoredAdministratorUserSummary;
29
+ correlationId: string;
30
+ sessionsRevoked: number;
31
+ }
32
+
33
+ export type StoredAdminCommandResult =
34
+ | {
35
+ grant: StoredAdminGrant;
36
+ }
37
+ | {
38
+ userModeration: StoredAdminUserModerationResult;
39
+ };
40
+
20
41
  export const adminCommandResults = pgTable(
21
42
  'admin_command_results',
22
43
  {
package/src/index.ts CHANGED
@@ -40,7 +40,10 @@ export {
40
40
  export {
41
41
  bootstrapFirstAdminOwner,
42
42
  grantAdministratorRole,
43
+ reactivateAdministratorUser,
43
44
  revokeAdministratorGrant,
45
+ revokeAdministratorUserSessions,
46
+ suspendAdministratorUser,
44
47
  } from '#core/administration.js';
45
48
  export type {
46
49
  CreateSessionForUserError,