@shipfox/api-auth 9.2.0 → 10.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 (82) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +56 -0
  3. package/README.md +32 -8
  4. package/dist/config.d.ts +1 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +4 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/core/administration.d.ts +43 -0
  9. package/dist/core/administration.d.ts.map +1 -0
  10. package/dist/core/administration.js +152 -0
  11. package/dist/core/administration.js.map +1 -0
  12. package/dist/core/auth.d.ts.map +1 -1
  13. package/dist/core/auth.js +2 -5
  14. package/dist/core/auth.js.map +1 -1
  15. package/dist/core/entities/administrator-read-model.d.ts +21 -0
  16. package/dist/core/entities/administrator-read-model.d.ts.map +1 -0
  17. package/dist/core/entities/administrator-read-model.js +3 -0
  18. package/dist/core/entities/administrator-read-model.js.map +1 -0
  19. package/dist/core/errors.d.ts +15 -0
  20. package/dist/core/errors.d.ts.map +1 -1
  21. package/dist/core/errors.js +30 -0
  22. package/dist/core/errors.js.map +1 -1
  23. package/dist/db/admin-grants.d.ts +34 -1
  24. package/dist/db/admin-grants.d.ts.map +1 -1
  25. package/dist/db/admin-grants.js +177 -5
  26. package/dist/db/admin-grants.js.map +1 -1
  27. package/dist/db/admin-users.d.ts +21 -0
  28. package/dist/db/admin-users.d.ts.map +1 -0
  29. package/dist/db/admin-users.js +32 -0
  30. package/dist/db/admin-users.js.map +1 -0
  31. package/dist/db/db.d.ts +256 -0
  32. package/dist/db/db.d.ts.map +1 -1
  33. package/dist/db/db.js +2 -0
  34. package/dist/db/db.js.map +1 -1
  35. package/dist/db/schema/admin-command-results.d.ts +142 -0
  36. package/dist/db/schema/admin-command-results.d.ts.map +1 -0
  37. package/dist/db/schema/admin-command-results.js +21 -0
  38. package/dist/db/schema/admin-command-results.js.map +1 -0
  39. package/dist/index.d.ts +2 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +12 -3
  42. package/dist/index.js.map +1 -1
  43. package/dist/metrics/instance.d.ts +1 -1
  44. package/dist/metrics/instance.d.ts.map +1 -1
  45. package/dist/metrics/instance.js.map +1 -1
  46. package/dist/presentation/routes/administration.d.ts +4 -0
  47. package/dist/presentation/routes/administration.d.ts.map +1 -0
  48. package/dist/presentation/routes/administration.js +267 -0
  49. package/dist/presentation/routes/administration.js.map +1 -0
  50. package/dist/presentation/routes/rate-limit.d.ts +1 -0
  51. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  52. package/dist/presentation/routes/rate-limit.js +26 -1
  53. package/dist/presentation/routes/rate-limit.js.map +1 -1
  54. package/dist/tsconfig.test.tsbuildinfo +1 -1
  55. package/drizzle/0002_burly_whizzer.sql +12 -0
  56. package/drizzle/meta/0002_snapshot.json +791 -0
  57. package/drizzle/meta/_journal.json +7 -0
  58. package/package.json +10 -9
  59. package/src/config.ts +4 -0
  60. package/src/core/administration.ts +213 -0
  61. package/src/core/auth-default-signup-policy.test.ts +65 -0
  62. package/src/core/auth.test.ts +5 -0
  63. package/src/core/auth.ts +2 -3
  64. package/src/core/entities/administrator-read-model.ts +23 -0
  65. package/src/core/errors.ts +35 -0
  66. package/src/db/admin-grants.test.ts +1 -7
  67. package/src/db/admin-grants.ts +310 -5
  68. package/src/db/admin-users.ts +58 -0
  69. package/src/db/db.ts +2 -0
  70. package/src/db/schema/admin-command-results.ts +41 -0
  71. package/src/index.test.ts +6 -2
  72. package/src/index.ts +23 -2
  73. package/src/metrics/instance.ts +1 -1
  74. package/src/presentation/auth/refresh-cookie.test.ts +4 -0
  75. package/src/presentation/e2eRoutes/index.test.ts +1 -0
  76. package/src/presentation/routes/administration.test.ts +537 -0
  77. package/src/presentation/routes/administration.ts +276 -0
  78. package/src/presentation/routes/index.test.ts +1 -0
  79. package/src/presentation/routes/rate-limit.ts +26 -2
  80. package/test/globalSetup.ts +6 -2
  81. package/test/routes.ts +10 -1
  82. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,12 +1,38 @@
1
1
  import type {AdminRole} from '@shipfox/api-auth-dto';
2
- import {and, asc, eq, isNull, sql} from 'drizzle-orm';
2
+ import type {
3
+ AdministrationActionEvent,
4
+ AdministrationActionEventMap,
5
+ } from '@shipfox/api-common-dto';
6
+ import {
7
+ paginateTimestampIdRows,
8
+ type TimestampIdCursor,
9
+ timestampIdCursorWhere,
10
+ } from '@shipfox/node-drizzle';
11
+ import {writeOutboxEvent} from '@shipfox/node-outbox';
12
+ import {and, desc, eq, isNull, sql} from 'drizzle-orm';
3
13
  import {highestAdminRole} from '#core/admin-role-model.js';
4
14
  import type {AdminGrant} from '#core/entities/admin-grant.js';
5
- import {LastAdminOwnerError} from '#core/errors.js';
15
+ import type {AdministratorGrantSummary} from '#core/entities/administrator-read-model.js';
16
+ import {
17
+ AdminBootstrapClosedError,
18
+ AdminGrantAlreadyExistsError,
19
+ AdminGrantNotFoundError,
20
+ AdminIdempotencyKeyReuseError,
21
+ LastAdminOwnerError,
22
+ UserNotFoundError,
23
+ } from '#core/errors.js';
6
24
  import {db} from './db.js';
25
+ import {
26
+ type AdminCommandResultDb,
27
+ adminCommandResults,
28
+ type StoredAdminGrant,
29
+ } from './schema/admin-command-results.js';
7
30
  import {adminGrants, toAdminGrant} from './schema/admin-grants.js';
31
+ import {authOutbox} from './schema/outbox.js';
8
32
  import {users} from './schema/users.js';
9
33
 
34
+ type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
35
+
10
36
  export interface CreateAdminGrantParams {
11
37
  userId: string;
12
38
  role: AdminRole;
@@ -22,9 +48,51 @@ export async function createAdminGrant(params: CreateAdminGrantParams): Promise<
22
48
  return toAdminGrant(row);
23
49
  }
24
50
 
25
- export async function listAdminGrants(): Promise<AdminGrant[]> {
26
- const rows = await db().select().from(adminGrants).orderBy(asc(adminGrants.createdAt));
27
- return rows.map(toAdminGrant);
51
+ export interface AdministratorGrantSummaryRecord {
52
+ id: string;
53
+ role: AdminRole;
54
+ createdAt: Date;
55
+ revokedAt: Date | null;
56
+ user: AdministratorGrantSummary['user'];
57
+ }
58
+
59
+ export async function listAdminGrantSummaries(params: {
60
+ limit: number;
61
+ cursor?: TimestampIdCursor;
62
+ }): Promise<{
63
+ rows: AdministratorGrantSummaryRecord[];
64
+ nextCursor: TimestampIdCursor | null;
65
+ }> {
66
+ const cursorCondition = timestampIdCursorWhere({
67
+ timestampColumn: adminGrants.createdAt,
68
+ idColumn: adminGrants.id,
69
+ cursor: params.cursor,
70
+ });
71
+ const conditions = cursorCondition ? [cursorCondition] : [];
72
+ const rows = await db()
73
+ .select({
74
+ id: adminGrants.id,
75
+ role: adminGrants.role,
76
+ createdAt: adminGrants.createdAt,
77
+ revokedAt: adminGrants.revokedAt,
78
+ user: {
79
+ id: users.id,
80
+ email: users.email,
81
+ name: users.name,
82
+ status: users.status,
83
+ },
84
+ })
85
+ .from(adminGrants)
86
+ .innerJoin(users, eq(adminGrants.userId, users.id))
87
+ .where(and(...conditions))
88
+ .orderBy(desc(adminGrants.createdAt), desc(adminGrants.id))
89
+ .limit(params.limit + 1);
90
+
91
+ const page = paginateTimestampIdRows({rows, limit: params.limit, timestampKey: 'createdAt'});
92
+ return {
93
+ rows: page.pageRows,
94
+ nextCursor: page.nextCursor,
95
+ };
28
96
  }
29
97
 
30
98
  export async function findCurrentAdminRole(params: {userId: string}): Promise<AdminRole | null> {
@@ -81,3 +149,240 @@ export async function revokeAdminGrant(params: {grantId: string}): Promise<Admin
81
149
  return row ? toAdminGrant(row) : undefined;
82
150
  });
83
151
  }
152
+
153
+ interface AuditedAdminCommandParams {
154
+ actorId: string;
155
+ idempotencyKeyFingerprint: string;
156
+ requestFingerprint: string;
157
+ event: AdministrationActionEvent;
158
+ }
159
+
160
+ function toStoredAdminGrant(grant: AdminGrant): StoredAdminGrant {
161
+ return {
162
+ id: grant.id,
163
+ userId: grant.userId,
164
+ role: grant.role,
165
+ revokedAt: grant.revokedAt?.toISOString() ?? null,
166
+ createdAt: grant.createdAt.toISOString(),
167
+ updatedAt: grant.updatedAt.toISOString(),
168
+ };
169
+ }
170
+
171
+ function fromStoredAdminGrant(grant: StoredAdminGrant): AdminGrant {
172
+ return {
173
+ id: grant.id,
174
+ userId: grant.userId,
175
+ role: grant.role,
176
+ revokedAt: grant.revokedAt ? new Date(grant.revokedAt) : null,
177
+ createdAt: new Date(grant.createdAt),
178
+ updatedAt: new Date(grant.updatedAt),
179
+ };
180
+ }
181
+
182
+ async function findCommandResult(
183
+ tx: Tx,
184
+ params: Pick<
185
+ AuditedAdminCommandParams,
186
+ 'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
187
+ > & {
188
+ command: string;
189
+ },
190
+ ): 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];
202
+ if (!result) return undefined;
203
+ if (
204
+ result.command !== params.command ||
205
+ result.requestFingerprint !== params.requestFingerprint
206
+ ) {
207
+ throw new AdminIdempotencyKeyReuseError();
208
+ }
209
+ return fromStoredAdminGrant(result.result.grant);
210
+ }
211
+
212
+ async function storeCommandResult(
213
+ tx: Tx,
214
+ params: AuditedAdminCommandParams,
215
+ grant: AdminGrant,
216
+ ): 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
+ );
240
+ }
241
+
242
+ export async function bootstrapFirstAdminOwner(
243
+ params: AuditedAdminCommandParams & {userId: string},
244
+ ): Promise<AdminGrant> {
245
+ return await db().transaction(async (tx) => {
246
+ await lockAdminCommand(tx, params);
247
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
248
+
249
+ const existing = await findCommandResult(tx, {
250
+ ...params,
251
+ command: params.event.command,
252
+ });
253
+ if (existing) return existing;
254
+
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();
268
+
269
+ const userRows = await tx
270
+ .select({id: users.id, status: users.status})
271
+ .from(users)
272
+ .where(eq(users.id, params.userId))
273
+ .limit(1);
274
+ const user = userRows[0];
275
+ if (user?.status !== 'active') throw new UserNotFoundError(params.userId);
276
+
277
+ const rows = await tx
278
+ .insert(adminGrants)
279
+ .values({userId: params.userId, role: 'admin-owner'})
280
+ .returning();
281
+ const row = rows[0];
282
+ if (!row) throw new Error('Bootstrap grant insert returned no rows');
283
+
284
+ const grant = toAdminGrant(row);
285
+ await writeAdminAction(tx, params.event);
286
+ await storeCommandResult(tx, params, grant);
287
+ return grant;
288
+ });
289
+ }
290
+
291
+ export async function grantAdminRoleWithAudit(
292
+ params: AuditedAdminCommandParams & {userId: string; role: AdminRole},
293
+ ): Promise<AdminGrant> {
294
+ return await db().transaction(async (tx) => {
295
+ await lockAdminCommand(tx, params);
296
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
297
+
298
+ const existing = await findCommandResult(tx, {
299
+ ...params,
300
+ command: params.event.command,
301
+ });
302
+ if (existing) return existing;
303
+
304
+ const userRows = await tx
305
+ .select({id: users.id, status: users.status})
306
+ .from(users)
307
+ .where(eq(users.id, params.userId))
308
+ .limit(1);
309
+ const user = userRows[0];
310
+ if (user?.status !== 'active') throw new UserNotFoundError(params.userId);
311
+
312
+ const activeRows = await tx
313
+ .select({id: adminGrants.id})
314
+ .from(adminGrants)
315
+ .where(
316
+ and(
317
+ eq(adminGrants.userId, params.userId),
318
+ eq(adminGrants.role, params.role),
319
+ isNull(adminGrants.revokedAt),
320
+ ),
321
+ )
322
+ .limit(1);
323
+ if (activeRows.length > 0) throw new AdminGrantAlreadyExistsError();
324
+
325
+ const rows = await tx
326
+ .insert(adminGrants)
327
+ .values({userId: params.userId, role: params.role})
328
+ .returning();
329
+ const row = rows[0];
330
+ if (!row) throw new Error('Administrator grant insert returned no rows');
331
+
332
+ const grant = toAdminGrant(row);
333
+ await writeAdminAction(tx, params.event);
334
+ await storeCommandResult(tx, params, grant);
335
+ return grant;
336
+ });
337
+ }
338
+
339
+ export async function revokeAdminGrantWithAudit(
340
+ params: AuditedAdminCommandParams & {grantId: string},
341
+ ): Promise<AdminGrant> {
342
+ return await db().transaction(async (tx) => {
343
+ await lockAdminCommand(tx, params);
344
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
345
+
346
+ const existing = await findCommandResult(tx, {
347
+ ...params,
348
+ command: params.event.command,
349
+ });
350
+ if (existing) return existing;
351
+
352
+ const rows = await tx
353
+ .select()
354
+ .from(adminGrants)
355
+ .where(and(eq(adminGrants.id, params.grantId), isNull(adminGrants.revokedAt)))
356
+ .limit(1);
357
+ const grant = rows[0];
358
+ if (!grant) throw new AdminGrantNotFoundError();
359
+
360
+ 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
+ );
372
+ if (activeOwners.length <= 1) throw new LastAdminOwnerError();
373
+ }
374
+
375
+ const updated = await tx
376
+ .update(adminGrants)
377
+ .set({revokedAt: new Date(), updatedAt: new Date()})
378
+ .where(and(eq(adminGrants.id, grant.id), isNull(adminGrants.revokedAt)))
379
+ .returning();
380
+ const updatedRow = updated[0];
381
+ if (!updatedRow) throw new AdminGrantNotFoundError();
382
+
383
+ const revoked = toAdminGrant(updatedRow);
384
+ await writeAdminAction(tx, params.event);
385
+ await storeCommandResult(tx, params, revoked);
386
+ return revoked;
387
+ });
388
+ }
@@ -0,0 +1,58 @@
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';
5
+ import {db} from './db.js';
6
+ import {adminGrants} from './schema/admin-grants.js';
7
+ import {users} from './schema/users.js';
8
+
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
+ }
18
+
19
+ type AdministratorUserLookup = {id: string; email?: never} | {email: string; id?: never};
20
+
21
+ export async function findAdministratorUser(
22
+ params: AdministratorUserLookup,
23
+ ): 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
+ };
58
+ }
package/src/db/db.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
2
2
  import {pgClient} from '@shipfox/node-postgres';
3
+ import {adminCommandResults} from './schema/admin-command-results.js';
3
4
  import {adminGrants} from './schema/admin-grants.js';
4
5
  import {authOutbox} from './schema/outbox.js';
5
6
  import {passwordResets} from './schema/password-resets.js';
@@ -9,6 +10,7 @@ import {users} from './schema/users.js';
9
10
 
10
11
  export const schema = {
11
12
  adminGrants,
13
+ adminCommandResults,
12
14
  users,
13
15
  passwordResets,
14
16
  refreshTokens,
@@ -0,0 +1,41 @@
1
+ import type {AdminRole} from '@shipfox/api-auth-dto';
2
+ import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
3
+ import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
4
+ import {pgTable} from './common.js';
5
+ import {users} from './users.js';
6
+
7
+ export interface StoredAdminGrant {
8
+ id: string;
9
+ userId: string;
10
+ role: AdminRole;
11
+ revokedAt: string | null;
12
+ createdAt: string;
13
+ updatedAt: string;
14
+ }
15
+
16
+ export interface StoredAdminCommandResult {
17
+ grant: StoredAdminGrant;
18
+ }
19
+
20
+ export const adminCommandResults = pgTable(
21
+ 'admin_command_results',
22
+ {
23
+ id: uuidv7PrimaryKey(),
24
+ actorId: uuid('actor_id')
25
+ .notNull()
26
+ .references(() => users.id, {onDelete: 'cascade'}),
27
+ idempotencyKeyFingerprint: text('idempotency_key_fingerprint').notNull(),
28
+ command: text('command').notNull(),
29
+ requestFingerprint: text('request_fingerprint').notNull(),
30
+ result: jsonb('result').$type<StoredAdminCommandResult>().notNull(),
31
+ createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
32
+ },
33
+ (table) => [
34
+ uniqueIndex('auth_admin_command_results_actor_key_unique').on(
35
+ table.actorId,
36
+ table.idempotencyKeyFingerprint,
37
+ ),
38
+ ],
39
+ );
40
+
41
+ export type AdminCommandResultDb = typeof adminCommandResults.$inferSelect;
package/src/index.test.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  AUTH_USER_SIGNED_UP,
4
4
  authEventSchemas,
5
5
  } from '@shipfox/api-auth-dto';
6
+ import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
6
7
  import {createAuthModule} from './index.js';
7
8
  import {passwordLoginMethods} from './login-methods.js';
8
9
 
@@ -55,6 +56,7 @@ describe('authModule', () => {
55
56
  preflightInvitationAcceptance: vi.fn(),
56
57
  acceptInvitation: vi.fn(),
57
58
  requireActiveMembership: vi.fn(),
59
+ getWorkspaceOperatingState: vi.fn(),
58
60
  },
59
61
  signupPolicy,
60
62
  });
@@ -73,9 +75,10 @@ describe('authModule', () => {
73
75
  preflightInvitationAcceptance: vi.fn(),
74
76
  acceptInvitation: vi.fn(),
75
77
  requireActiveMembership: vi.fn(),
78
+ getWorkspaceOperatingState: vi.fn(),
76
79
  },
77
80
  });
78
- expect(module.routes).toHaveLength(1);
81
+ expect(module.routes).toHaveLength(3);
79
82
  const signupPolicy = buildAuthRoutes.mock.calls[0]?.[2];
80
83
 
81
84
  expect(signupPolicy).toEqual(expect.objectContaining({isSignupAllowed: expect.any(Function)}));
@@ -95,8 +98,9 @@ describe('authModule', () => {
95
98
  const publisher = authModule.publishers?.find((pub) => pub.name === 'auth');
96
99
  const events = authModule.subscribers?.map((subscriber) => subscriber.event);
97
100
 
98
- expect(publisher?.eventSchemas).toBe(authEventSchemas);
101
+ expect(publisher?.eventSchemas).not.toBe(authEventSchemas);
99
102
  expect(Object.keys(publisher?.eventSchemas ?? {}).sort()).toEqual([
103
+ ADMINISTRATION_ACTION_PERFORMED,
100
104
  AUTH_PASSWORD_RESET_SEND_REQUESTED,
101
105
  AUTH_USER_SIGNED_UP,
102
106
  ]);
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  type AuthEventMap,
4
4
  authEventSchemas,
5
5
  } from '@shipfox/api-auth-dto';
6
+ import {administrationActionEventSchemas} from '@shipfox/api-common-dto';
6
7
  import type {ShipfoxModule} from '@shipfox/node-module';
7
8
  import {subscriberFactory} from '@shipfox/node-module';
8
9
  import {config} from '#config.js';
@@ -16,10 +17,16 @@ import {createLeaseTokenAuthMethod} from '#presentation/auth/lease-token-auth.js
16
17
  import {createRunnerSessionAuthMethod} from '#presentation/auth/runner-session-auth.js';
17
18
  import {createAuthE2eRoutes} from '#presentation/e2eRoutes/index.js';
18
19
  import {createAuthInterModulePresentation} from '#presentation/inter-module.js';
20
+ import {
21
+ administrationRoutes,
22
+ administrationUserRoutes,
23
+ } from '#presentation/routes/administration.js';
19
24
  import {buildAuthRoutes} from '#presentation/routes/index.js';
20
25
  import {onPasswordResetSendRequested} from '#presentation/subscribers/index.js';
21
26
  import {passwordLoginMethods} from './login-methods.js';
22
27
 
28
+ const authPublisherEventSchemas = {...authEventSchemas, ...administrationActionEventSchemas};
29
+
23
30
  export type {AdminRole, JobLeaseTokenClaims, RunnerSessionTokenClaims} from '@shipfox/api-auth-dto';
24
31
  export {
25
32
  ADMIN_ROLES,
@@ -29,6 +36,11 @@ export {
29
36
  requireAdminRole,
30
37
  revokeAdminGrant,
31
38
  } from '#core/admin-role.js';
39
+ export {
40
+ bootstrapFirstAdminOwner,
41
+ grantAdministratorRole,
42
+ revokeAdministratorGrant,
43
+ } from '#core/administration.js';
32
44
  export type {
33
45
  CreateSessionForUserError,
34
46
  CreateSessionForUserParams,
@@ -41,9 +53,14 @@ export {findUserByEmail} from '#core/email-owner.js';
41
53
  export type {AdminGrant} from '#core/entities/admin-grant.js';
42
54
  export type {User, UserStatus} from '#core/entities/user.js';
43
55
  export {
56
+ AdminBootstrapClosedError,
57
+ AdminGrantAlreadyExistsError,
58
+ AdminGrantNotFoundError,
59
+ AdminIdempotencyKeyReuseError,
44
60
  AdminRoleRequiredError,
45
61
  AuthDependencyUnavailableError,
46
62
  EmailNotVerifiedError,
63
+ InvalidAdminBootstrapTokenError,
47
64
  InvalidCredentialsError,
48
65
  LastAdminOwnerError,
49
66
  SignupNotAllowedError,
@@ -95,9 +112,13 @@ export function createAuthModule({
95
112
  database: {db, migrationsPath, databaseNamespace: 'auth'},
96
113
  auth: [createJwtAuthMethod(), createLeaseTokenAuthMethod(), createRunnerSessionAuthMethod()],
97
114
  loginMethods: passwordLoginMethods(config.AUTH_PASSWORD_ENABLED),
98
- routes: [buildAuthRoutes(config.AUTH_PASSWORD_ENABLED, workspaces, signupPolicy)],
115
+ routes: [
116
+ buildAuthRoutes(config.AUTH_PASSWORD_ENABLED, workspaces, signupPolicy),
117
+ administrationRoutes,
118
+ administrationUserRoutes,
119
+ ],
99
120
  e2eRoutes: [createAuthE2eRoutes(workspaces)],
100
- publishers: [{name: 'auth', table: authOutbox, db, eventSchemas: authEventSchemas}],
121
+ publishers: [{name: 'auth', table: authOutbox, db, eventSchemas: authPublisherEventSchemas}],
101
122
  subscribers: [subscriber(AUTH_PASSWORD_RESET_SEND_REQUESTED, onPasswordResetSendRequested)],
102
123
  interModulePresentations: [createAuthInterModulePresentation()],
103
124
  };
@@ -3,7 +3,7 @@ import {instanceMetrics} from '@shipfox/node-opentelemetry';
3
3
  export type AuthTokenType = 'session' | 'job_lease' | 'runner_session';
4
4
  export type AuthTokenVerificationOutcome = 'ok' | 'rejected';
5
5
  export type AuthTokenRefreshOutcome = 'rotated' | 'grace' | 'rejected';
6
- export type AuthRateLimitAction = 'login' | 'email-send';
6
+ export type AuthRateLimitAction = 'login' | 'email-send' | 'bootstrap' | 'lookup';
7
7
  export type AuthRateLimitScope = 'ip' | 'email';
8
8
  export type AuthRateLimitOutcome = 'allowed' | 'blocked' | 'unavailable';
9
9
 
@@ -6,6 +6,10 @@ vi.mock('#config.js', () => ({
6
6
  AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
7
7
  AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS: 14,
8
8
  AUTH_PASSWORD_ENABLED: true,
9
+ AUTH_SIGNUP_GATE_ENABLED: false,
10
+ AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: '',
11
+ AUTH_SIGNUP_ALLOWED_EMAILS: '',
12
+ AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined,
9
13
  },
10
14
  }));
11
15
 
@@ -14,6 +14,7 @@ describe('auth E2E routes', () => {
14
14
  preflightInvitationAcceptance: vi.fn(),
15
15
  acceptInvitation: vi.fn(),
16
16
  requireActiveMembership: vi.fn(),
17
+ getWorkspaceOperatingState: vi.fn(),
17
18
  }),
18
19
  ],
19
20
  swagger: false,