@shipfox/api-auth 9.1.0 → 9.3.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 (136) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +44 -2
  4. package/dist/config.d.ts +5 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +26 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/core/admin-role-model.d.ts +5 -0
  9. package/dist/core/admin-role-model.d.ts.map +1 -0
  10. package/dist/core/admin-role-model.js +18 -0
  11. package/dist/core/admin-role-model.js.map +1 -0
  12. package/dist/core/admin-role.d.ts +13 -0
  13. package/dist/core/admin-role.d.ts.map +1 -0
  14. package/dist/core/admin-role.js +21 -0
  15. package/dist/core/admin-role.js.map +1 -0
  16. package/dist/core/administration.d.ts +27 -0
  17. package/dist/core/administration.d.ts.map +1 -0
  18. package/dist/core/administration.js +122 -0
  19. package/dist/core/administration.js.map +1 -0
  20. package/dist/core/auth.d.ts +9 -0
  21. package/dist/core/auth.d.ts.map +1 -1
  22. package/dist/core/auth.js +68 -20
  23. package/dist/core/auth.js.map +1 -1
  24. package/dist/core/entities/admin-grant.d.ts +10 -0
  25. package/dist/core/entities/admin-grant.d.ts.map +1 -0
  26. package/dist/core/entities/admin-grant.js +3 -0
  27. package/dist/core/entities/admin-grant.js.map +1 -0
  28. package/dist/core/errors.d.ts +25 -0
  29. package/dist/core/errors.d.ts.map +1 -1
  30. package/dist/core/errors.js +49 -0
  31. package/dist/core/errors.js.map +1 -1
  32. package/dist/core/ports.d.ts +13 -0
  33. package/dist/core/ports.d.ts.map +1 -0
  34. package/dist/core/ports.js +3 -0
  35. package/dist/core/ports.js.map +1 -0
  36. package/dist/core/signup-policy.d.ts +10 -0
  37. package/dist/core/signup-policy.d.ts.map +1 -0
  38. package/dist/core/signup-policy.js +39 -0
  39. package/dist/core/signup-policy.js.map +1 -0
  40. package/dist/db/admin-grants.d.ts +33 -0
  41. package/dist/db/admin-grants.d.ts.map +1 -0
  42. package/dist/db/admin-grants.js +193 -0
  43. package/dist/db/admin-grants.js.map +1 -0
  44. package/dist/db/db.d.ts +474 -0
  45. package/dist/db/db.d.ts.map +1 -1
  46. package/dist/db/db.js +4 -0
  47. package/dist/db/db.js.map +1 -1
  48. package/dist/db/schema/admin-command-results.d.ts +142 -0
  49. package/dist/db/schema/admin-command-results.d.ts.map +1 -0
  50. package/dist/db/schema/admin-command-results.js +21 -0
  51. package/dist/db/schema/admin-command-results.js.map +1 -0
  52. package/dist/db/schema/admin-grants.d.ts +115 -0
  53. package/dist/db/schema/admin-grants.d.ts.map +1 -0
  54. package/dist/db/schema/admin-grants.js +42 -0
  55. package/dist/db/schema/admin-grants.js.map +1 -0
  56. package/dist/index.d.ts +12 -4
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +15 -4
  59. package/dist/index.js.map +1 -1
  60. package/dist/metrics/instance.d.ts +1 -1
  61. package/dist/metrics/instance.d.ts.map +1 -1
  62. package/dist/metrics/instance.js.map +1 -1
  63. package/dist/presentation/dto/user.d.ts +2 -1
  64. package/dist/presentation/dto/user.d.ts.map +1 -1
  65. package/dist/presentation/dto/user.js +2 -1
  66. package/dist/presentation/dto/user.js.map +1 -1
  67. package/dist/presentation/inter-module.d.ts.map +1 -1
  68. package/dist/presentation/inter-module.js +26 -2
  69. package/dist/presentation/inter-module.js.map +1 -1
  70. package/dist/presentation/routes/administration.d.ts +3 -0
  71. package/dist/presentation/routes/administration.d.ts.map +1 -0
  72. package/dist/presentation/routes/administration.js +186 -0
  73. package/dist/presentation/routes/administration.js.map +1 -0
  74. package/dist/presentation/routes/index.d.ts +2 -1
  75. package/dist/presentation/routes/index.d.ts.map +1 -1
  76. package/dist/presentation/routes/index.js +2 -2
  77. package/dist/presentation/routes/index.js.map +1 -1
  78. package/dist/presentation/routes/rate-limit.d.ts +1 -0
  79. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  80. package/dist/presentation/routes/rate-limit.js +20 -1
  81. package/dist/presentation/routes/rate-limit.js.map +1 -1
  82. package/dist/presentation/routes/registration/signup.d.ts +2 -1
  83. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  84. package/dist/presentation/routes/registration/signup.js +14 -3
  85. package/dist/presentation/routes/registration/signup.js.map +1 -1
  86. package/dist/presentation/routes/session/me.d.ts.map +1 -1
  87. package/dist/presentation/routes/session/me.js +5 -1
  88. package/dist/presentation/routes/session/me.js.map +1 -1
  89. package/dist/tsconfig.test.tsbuildinfo +1 -1
  90. package/drizzle/0001_brainy_shriek.sql +14 -0
  91. package/drizzle/0002_burly_whizzer.sql +12 -0
  92. package/drizzle/meta/0001_snapshot.json +702 -0
  93. package/drizzle/meta/0002_snapshot.json +791 -0
  94. package/drizzle/meta/_journal.json +14 -0
  95. package/package.json +10 -9
  96. package/src/config.test.ts +39 -0
  97. package/src/config.ts +34 -0
  98. package/src/core/admin-role-model.ts +25 -0
  99. package/src/core/admin-role.test.ts +39 -0
  100. package/src/core/admin-role.ts +25 -0
  101. package/src/core/administration.ts +172 -0
  102. package/src/core/auth-default-signup-policy.test.ts +65 -0
  103. package/src/core/auth.test.ts +115 -0
  104. package/src/core/auth.ts +77 -21
  105. package/src/core/entities/admin-grant.ts +10 -0
  106. package/src/core/errors.ts +59 -0
  107. package/src/core/jwt.test.ts +1 -0
  108. package/src/core/ports.ts +7 -0
  109. package/src/core/signup-policy.test.ts +78 -0
  110. package/src/core/signup-policy.ts +42 -0
  111. package/src/db/admin-grants.test.ts +57 -0
  112. package/src/db/admin-grants.ts +340 -0
  113. package/src/db/db.ts +4 -0
  114. package/src/db/schema/admin-command-results.ts +41 -0
  115. package/src/db/schema/admin-grants.ts +49 -0
  116. package/src/index.test.ts +58 -1
  117. package/src/index.ts +47 -6
  118. package/src/metrics/instance.ts +1 -1
  119. package/src/presentation/auth/refresh-cookie.test.ts +4 -0
  120. package/src/presentation/dto/user.ts +7 -2
  121. package/src/presentation/e2eRoutes/index.test.ts +1 -0
  122. package/src/presentation/inter-module.test.ts +47 -0
  123. package/src/presentation/inter-module.ts +22 -1
  124. package/src/presentation/routes/administration.test.ts +253 -0
  125. package/src/presentation/routes/administration.ts +195 -0
  126. package/src/presentation/routes/index.test.ts +1 -0
  127. package/src/presentation/routes/index.ts +3 -1
  128. package/src/presentation/routes/rate-limit.ts +23 -2
  129. package/src/presentation/routes/registration/signup.test.ts +26 -0
  130. package/src/presentation/routes/registration/signup.ts +22 -2
  131. package/src/presentation/routes/session/login.test.ts +1 -0
  132. package/src/presentation/routes/session/me.test.ts +18 -2
  133. package/src/presentation/routes/session/me.ts +5 -1
  134. package/test/globalSetup.ts +6 -2
  135. package/test/routes.ts +7 -1
  136. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,340 @@
1
+ import type {AdminRole} from '@shipfox/api-auth-dto';
2
+ import type {
3
+ AdministrationActionEvent,
4
+ AdministrationActionEventMap,
5
+ } from '@shipfox/api-common-dto';
6
+ import {writeOutboxEvent} from '@shipfox/node-outbox';
7
+ import {and, asc, eq, isNull, sql} from 'drizzle-orm';
8
+ import {highestAdminRole} from '#core/admin-role-model.js';
9
+ import type {AdminGrant} from '#core/entities/admin-grant.js';
10
+ import {
11
+ AdminBootstrapClosedError,
12
+ AdminGrantAlreadyExistsError,
13
+ AdminGrantNotFoundError,
14
+ AdminIdempotencyKeyReuseError,
15
+ LastAdminOwnerError,
16
+ UserNotFoundError,
17
+ } from '#core/errors.js';
18
+ import {db} from './db.js';
19
+ import {
20
+ type AdminCommandResultDb,
21
+ adminCommandResults,
22
+ type StoredAdminGrant,
23
+ } from './schema/admin-command-results.js';
24
+ import {adminGrants, toAdminGrant} from './schema/admin-grants.js';
25
+ import {authOutbox} from './schema/outbox.js';
26
+ import {users} from './schema/users.js';
27
+
28
+ type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
29
+
30
+ export interface CreateAdminGrantParams {
31
+ userId: string;
32
+ role: AdminRole;
33
+ }
34
+
35
+ export async function createAdminGrant(params: CreateAdminGrantParams): Promise<AdminGrant> {
36
+ const rows = await db()
37
+ .insert(adminGrants)
38
+ .values({userId: params.userId, role: params.role})
39
+ .returning();
40
+ const row = rows[0];
41
+ if (!row) throw new Error('Insert returned no rows');
42
+ return toAdminGrant(row);
43
+ }
44
+
45
+ export async function listAdminGrants(): Promise<AdminGrant[]> {
46
+ const rows = await db().select().from(adminGrants).orderBy(asc(adminGrants.createdAt));
47
+ return rows.map(toAdminGrant);
48
+ }
49
+
50
+ export async function findCurrentAdminRole(params: {userId: string}): Promise<AdminRole | null> {
51
+ const rows = await db()
52
+ .select({role: adminGrants.role})
53
+ .from(adminGrants)
54
+ .innerJoin(users, eq(adminGrants.userId, users.id))
55
+ .where(
56
+ and(
57
+ eq(adminGrants.userId, params.userId),
58
+ isNull(adminGrants.revokedAt),
59
+ eq(users.status, 'active'),
60
+ ),
61
+ );
62
+
63
+ return highestAdminRole(rows.map(({role}) => role));
64
+ }
65
+
66
+ export async function revokeAdminGrant(params: {grantId: string}): Promise<AdminGrant | undefined> {
67
+ return await db().transaction(async (tx) => {
68
+ // All owner grant changes share one lock so concurrent revocations cannot
69
+ // both observe the same final owner and leave the instance ownerless.
70
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
71
+
72
+ const rows = await tx
73
+ .select()
74
+ .from(adminGrants)
75
+ .where(and(eq(adminGrants.id, params.grantId), isNull(adminGrants.revokedAt)))
76
+ .limit(1);
77
+ const grant = rows[0];
78
+ if (!grant) return undefined;
79
+
80
+ if (grant.role === 'admin-owner') {
81
+ const activeOwners = await tx
82
+ .select({id: adminGrants.id})
83
+ .from(adminGrants)
84
+ .innerJoin(users, eq(adminGrants.userId, users.id))
85
+ .where(
86
+ and(
87
+ eq(adminGrants.role, 'admin-owner'),
88
+ isNull(adminGrants.revokedAt),
89
+ eq(users.status, 'active'),
90
+ ),
91
+ );
92
+ if (activeOwners.length <= 1) throw new LastAdminOwnerError();
93
+ }
94
+
95
+ const updated = await tx
96
+ .update(adminGrants)
97
+ .set({revokedAt: new Date(), updatedAt: new Date()})
98
+ .where(and(eq(adminGrants.id, grant.id), isNull(adminGrants.revokedAt)))
99
+ .returning();
100
+ const row = updated[0];
101
+ return row ? toAdminGrant(row) : undefined;
102
+ });
103
+ }
104
+
105
+ interface AuditedAdminCommandParams {
106
+ actorId: string;
107
+ idempotencyKeyFingerprint: string;
108
+ requestFingerprint: string;
109
+ event: AdministrationActionEvent;
110
+ }
111
+
112
+ function toStoredAdminGrant(grant: AdminGrant): StoredAdminGrant {
113
+ return {
114
+ id: grant.id,
115
+ userId: grant.userId,
116
+ role: grant.role,
117
+ revokedAt: grant.revokedAt?.toISOString() ?? null,
118
+ createdAt: grant.createdAt.toISOString(),
119
+ updatedAt: grant.updatedAt.toISOString(),
120
+ };
121
+ }
122
+
123
+ function fromStoredAdminGrant(grant: StoredAdminGrant): AdminGrant {
124
+ return {
125
+ id: grant.id,
126
+ userId: grant.userId,
127
+ role: grant.role,
128
+ revokedAt: grant.revokedAt ? new Date(grant.revokedAt) : null,
129
+ createdAt: new Date(grant.createdAt),
130
+ updatedAt: new Date(grant.updatedAt),
131
+ };
132
+ }
133
+
134
+ async function findCommandResult(
135
+ tx: Tx,
136
+ params: Pick<
137
+ AuditedAdminCommandParams,
138
+ 'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
139
+ > & {
140
+ command: string;
141
+ },
142
+ ): Promise<AdminGrant | undefined> {
143
+ const rows = await tx
144
+ .select()
145
+ .from(adminCommandResults)
146
+ .where(
147
+ and(
148
+ eq(adminCommandResults.actorId, params.actorId),
149
+ eq(adminCommandResults.idempotencyKeyFingerprint, params.idempotencyKeyFingerprint),
150
+ ),
151
+ )
152
+ .limit(1);
153
+ const result: AdminCommandResultDb | undefined = rows[0];
154
+ if (!result) return undefined;
155
+ if (
156
+ result.command !== params.command ||
157
+ result.requestFingerprint !== params.requestFingerprint
158
+ ) {
159
+ throw new AdminIdempotencyKeyReuseError();
160
+ }
161
+ return fromStoredAdminGrant(result.result.grant);
162
+ }
163
+
164
+ async function storeCommandResult(
165
+ tx: Tx,
166
+ params: AuditedAdminCommandParams,
167
+ grant: AdminGrant,
168
+ ): Promise<void> {
169
+ await tx.insert(adminCommandResults).values({
170
+ actorId: params.actorId,
171
+ idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
172
+ command: params.event.command,
173
+ requestFingerprint: params.requestFingerprint,
174
+ result: {grant: toStoredAdminGrant(grant)},
175
+ });
176
+ }
177
+
178
+ async function writeAdminAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
179
+ await writeOutboxEvent<AdministrationActionEventMap>(tx, authOutbox, {
180
+ type: 'administration.action.performed',
181
+ payload: event,
182
+ });
183
+ }
184
+
185
+ async function lockAdminCommand(
186
+ tx: Tx,
187
+ params: {actorId: string; idempotencyKeyFingerprint: string},
188
+ ) {
189
+ await tx.execute(
190
+ sql`select pg_advisory_xact_lock(hashtext(${`auth_admin_command:${params.actorId}:${params.idempotencyKeyFingerprint}`}))`,
191
+ );
192
+ }
193
+
194
+ export async function bootstrapFirstAdminOwner(
195
+ params: AuditedAdminCommandParams & {userId: string},
196
+ ): Promise<AdminGrant> {
197
+ return await db().transaction(async (tx) => {
198
+ await lockAdminCommand(tx, params);
199
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
200
+
201
+ const existing = await findCommandResult(tx, {
202
+ ...params,
203
+ command: params.event.command,
204
+ });
205
+ if (existing) return existing;
206
+
207
+ const activeOwners = await tx
208
+ .select({id: adminGrants.id})
209
+ .from(adminGrants)
210
+ .innerJoin(users, eq(adminGrants.userId, users.id))
211
+ .where(
212
+ and(
213
+ eq(adminGrants.role, 'admin-owner'),
214
+ isNull(adminGrants.revokedAt),
215
+ eq(users.status, 'active'),
216
+ ),
217
+ )
218
+ .limit(1);
219
+ if (activeOwners.length > 0) throw new AdminBootstrapClosedError();
220
+
221
+ const userRows = await tx
222
+ .select({id: users.id, status: users.status})
223
+ .from(users)
224
+ .where(eq(users.id, params.userId))
225
+ .limit(1);
226
+ const user = userRows[0];
227
+ if (user?.status !== 'active') throw new UserNotFoundError(params.userId);
228
+
229
+ const rows = await tx
230
+ .insert(adminGrants)
231
+ .values({userId: params.userId, role: 'admin-owner'})
232
+ .returning();
233
+ const row = rows[0];
234
+ if (!row) throw new Error('Bootstrap grant insert returned no rows');
235
+
236
+ const grant = toAdminGrant(row);
237
+ await writeAdminAction(tx, params.event);
238
+ await storeCommandResult(tx, params, grant);
239
+ return grant;
240
+ });
241
+ }
242
+
243
+ export async function grantAdminRoleWithAudit(
244
+ params: AuditedAdminCommandParams & {userId: string; role: AdminRole},
245
+ ): Promise<AdminGrant> {
246
+ return await db().transaction(async (tx) => {
247
+ await lockAdminCommand(tx, params);
248
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
249
+
250
+ const existing = await findCommandResult(tx, {
251
+ ...params,
252
+ command: params.event.command,
253
+ });
254
+ if (existing) return existing;
255
+
256
+ const userRows = await tx
257
+ .select({id: users.id, status: users.status})
258
+ .from(users)
259
+ .where(eq(users.id, params.userId))
260
+ .limit(1);
261
+ const user = userRows[0];
262
+ if (user?.status !== 'active') throw new UserNotFoundError(params.userId);
263
+
264
+ const activeRows = await tx
265
+ .select({id: adminGrants.id})
266
+ .from(adminGrants)
267
+ .where(
268
+ and(
269
+ eq(adminGrants.userId, params.userId),
270
+ eq(adminGrants.role, params.role),
271
+ isNull(adminGrants.revokedAt),
272
+ ),
273
+ )
274
+ .limit(1);
275
+ if (activeRows.length > 0) throw new AdminGrantAlreadyExistsError();
276
+
277
+ const rows = await tx
278
+ .insert(adminGrants)
279
+ .values({userId: params.userId, role: params.role})
280
+ .returning();
281
+ const row = rows[0];
282
+ if (!row) throw new Error('Administrator 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 revokeAdminGrantWithAudit(
292
+ params: AuditedAdminCommandParams & {grantId: string},
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 rows = await tx
305
+ .select()
306
+ .from(adminGrants)
307
+ .where(and(eq(adminGrants.id, params.grantId), isNull(adminGrants.revokedAt)))
308
+ .limit(1);
309
+ const grant = rows[0];
310
+ if (!grant) throw new AdminGrantNotFoundError();
311
+
312
+ if (grant.role === 'admin-owner') {
313
+ const activeOwners = await tx
314
+ .select({id: adminGrants.id})
315
+ .from(adminGrants)
316
+ .innerJoin(users, eq(adminGrants.userId, users.id))
317
+ .where(
318
+ and(
319
+ eq(adminGrants.role, 'admin-owner'),
320
+ isNull(adminGrants.revokedAt),
321
+ eq(users.status, 'active'),
322
+ ),
323
+ );
324
+ if (activeOwners.length <= 1) throw new LastAdminOwnerError();
325
+ }
326
+
327
+ const updated = await tx
328
+ .update(adminGrants)
329
+ .set({revokedAt: new Date(), updatedAt: new Date()})
330
+ .where(and(eq(adminGrants.id, grant.id), isNull(adminGrants.revokedAt)))
331
+ .returning();
332
+ const updatedRow = updated[0];
333
+ if (!updatedRow) throw new AdminGrantNotFoundError();
334
+
335
+ const revoked = toAdminGrant(updatedRow);
336
+ await writeAdminAction(tx, params.event);
337
+ await storeCommandResult(tx, params, revoked);
338
+ return revoked;
339
+ });
340
+ }
package/src/db/db.ts CHANGED
@@ -1,5 +1,7 @@
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';
4
+ import {adminGrants} from './schema/admin-grants.js';
3
5
  import {authOutbox} from './schema/outbox.js';
4
6
  import {passwordResets} from './schema/password-resets.js';
5
7
  import {authRateLimits} from './schema/rate-limits.js';
@@ -7,6 +9,8 @@ import {refreshTokens} from './schema/refresh-tokens.js';
7
9
  import {users} from './schema/users.js';
8
10
 
9
11
  export const schema = {
12
+ adminGrants,
13
+ adminCommandResults,
10
14
  users,
11
15
  passwordResets,
12
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;
@@ -0,0 +1,49 @@
1
+ import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
+ import {sql} from 'drizzle-orm';
3
+ import {index, pgEnum, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
4
+ import type {AdminGrant} from '#core/entities/admin-grant.js';
5
+ import {pgTable} from './common.js';
6
+ import {users} from './users.js';
7
+
8
+ export const adminRoleEnum = pgEnum('auth_admin_role', [
9
+ 'admin-observer',
10
+ 'admin-operator',
11
+ 'admin-owner',
12
+ ]);
13
+
14
+ export const adminGrants = pgTable(
15
+ 'admin_grants',
16
+ {
17
+ id: uuidv7PrimaryKey(),
18
+ userId: uuid('user_id')
19
+ .notNull()
20
+ .references(() => users.id, {onDelete: 'cascade'}),
21
+ role: adminRoleEnum('role').notNull(),
22
+ revokedAt: timestamp('revoked_at', {withTimezone: true}),
23
+ createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
24
+ updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
25
+ },
26
+ (table) => [
27
+ index('auth_admin_grants_user_id_idx').on(table.userId),
28
+ index('auth_admin_grants_active_owners_idx')
29
+ .on(table.userId)
30
+ .where(sql`${table.role} = 'admin-owner' AND ${table.revokedAt} IS NULL`),
31
+ uniqueIndex('auth_admin_grants_active_user_role_unique')
32
+ .on(table.userId, table.role)
33
+ .where(sql`${table.revokedAt} IS NULL`),
34
+ ],
35
+ );
36
+
37
+ export type AdminGrantDb = typeof adminGrants.$inferSelect;
38
+ export type AdminGrantCreateDb = typeof adminGrants.$inferInsert;
39
+
40
+ export function toAdminGrant(row: AdminGrantDb): AdminGrant {
41
+ return {
42
+ id: row.id,
43
+ userId: row.userId,
44
+ role: row.role,
45
+ revokedAt: row.revokedAt,
46
+ createdAt: row.createdAt,
47
+ updatedAt: row.updatedAt,
48
+ };
49
+ }
package/src/index.test.ts CHANGED
@@ -3,9 +3,26 @@ 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
 
10
+ type SignupPolicyLike = {
11
+ isSignupAllowed(params: {
12
+ email: string;
13
+ emailVerified: boolean;
14
+ source: string;
15
+ }): Promise<{allowed: true} | {allowed: false; message?: string}>;
16
+ };
17
+
18
+ const buildAuthRoutes = vi.hoisted(() =>
19
+ vi.fn((_passwordEnabled: boolean, _workspaces: unknown, _signupPolicy?: SignupPolicyLike) => ({
20
+ prefix: '/auth',
21
+ plugins: [],
22
+ routes: [],
23
+ })),
24
+ );
25
+
9
26
  vi.mock('#config.js', () => ({
10
27
  config: {
11
28
  AUTH_JWT_EXPIRES_IN: '15m',
@@ -14,15 +31,24 @@ vi.mock('#config.js', () => ({
14
31
  AUTH_REFRESH_ROTATION_GRACE_SECONDS: 30,
15
32
  AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
16
33
  AUTH_PASSWORD_ENABLED: true,
34
+ AUTH_SIGNUP_GATE_ENABLED: true,
35
+ AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: 'example.com',
36
+ AUTH_SIGNUP_ALLOWED_EMAILS: '',
37
+ AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined,
17
38
  CLIENT_BASE_URL: 'https://app.example.test',
18
39
  },
19
40
  }));
20
41
 
42
+ vi.mock('#presentation/routes/index.js', () => ({buildAuthRoutes}));
43
+
21
44
  vi.mock('@shipfox/node-mailer', () => ({
22
45
  mailer: {send: vi.fn()},
23
46
  }));
24
47
 
25
48
  describe('authModule', () => {
49
+ const signupPolicy = {
50
+ isSignupAllowed: async () => ({allowed: true as const}),
51
+ };
26
52
  const authModule = createAuthModule({
27
53
  workspaces: {
28
54
  listMembershipsForTokenClaims: vi.fn(),
@@ -30,7 +56,9 @@ describe('authModule', () => {
30
56
  preflightInvitationAcceptance: vi.fn(),
31
57
  acceptInvitation: vi.fn(),
32
58
  requireActiveMembership: vi.fn(),
59
+ getWorkspaceOperatingState: vi.fn(),
33
60
  },
61
+ signupPolicy,
34
62
  });
35
63
  test('declares password login only when password login is enabled', () => {
36
64
  expect(passwordLoginMethods(true)).toEqual([{id: 'password'}]);
@@ -38,12 +66,41 @@ describe('authModule', () => {
38
66
  expect(authModule.loginMethods).toEqual([{id: 'password'}]);
39
67
  });
40
68
 
69
+ test('uses the environment signup policy when none is provided', async () => {
70
+ buildAuthRoutes.mockClear();
71
+ const module = createAuthModule({
72
+ workspaces: {
73
+ listMembershipsForTokenClaims: vi.fn(),
74
+ getWorkspaceCreator: vi.fn(),
75
+ preflightInvitationAcceptance: vi.fn(),
76
+ acceptInvitation: vi.fn(),
77
+ requireActiveMembership: vi.fn(),
78
+ getWorkspaceOperatingState: vi.fn(),
79
+ },
80
+ });
81
+ expect(module.routes).toHaveLength(2);
82
+ const signupPolicy = buildAuthRoutes.mock.calls[0]?.[2];
83
+
84
+ expect(signupPolicy).toEqual(expect.objectContaining({isSignupAllowed: expect.any(Function)}));
85
+ await expect(
86
+ signupPolicy?.isSignupAllowed({
87
+ email: 'person@other.example',
88
+ emailVerified: false,
89
+ source: 'password',
90
+ }),
91
+ ).resolves.toEqual({
92
+ allowed: false,
93
+ message: 'This Shipfox deployment does not accept new accounts right now.',
94
+ });
95
+ });
96
+
41
97
  test('registers auth email outbox publisher and subscribers', () => {
42
98
  const publisher = authModule.publishers?.find((pub) => pub.name === 'auth');
43
99
  const events = authModule.subscribers?.map((subscriber) => subscriber.event);
44
100
 
45
- expect(publisher?.eventSchemas).toBe(authEventSchemas);
101
+ expect(publisher?.eventSchemas).not.toBe(authEventSchemas);
46
102
  expect(Object.keys(publisher?.eventSchemas ?? {}).sort()).toEqual([
103
+ ADMINISTRATION_ACTION_PERFORMED,
47
104
  AUTH_PASSWORD_RESET_SEND_REQUESTED,
48
105
  AUTH_USER_SIGNED_UP,
49
106
  ]);
package/src/index.ts CHANGED
@@ -3,9 +3,12 @@ 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';
10
+ import type {SignupPolicy} from '#core/ports.js';
11
+ import {createEnvironmentSignupPolicy} from '#core/signup-policy.js';
9
12
  import {db} from '#db/db.js';
10
13
  import {migrationsPath} from '#db/migrations.js';
11
14
  import {authOutbox} from '#db/schema/outbox.js';
@@ -14,11 +17,28 @@ import {createLeaseTokenAuthMethod} from '#presentation/auth/lease-token-auth.js
14
17
  import {createRunnerSessionAuthMethod} from '#presentation/auth/runner-session-auth.js';
15
18
  import {createAuthE2eRoutes} from '#presentation/e2eRoutes/index.js';
16
19
  import {createAuthInterModulePresentation} from '#presentation/inter-module.js';
20
+ import {administrationRoutes} from '#presentation/routes/administration.js';
17
21
  import {buildAuthRoutes} from '#presentation/routes/index.js';
18
22
  import {onPasswordResetSendRequested} from '#presentation/subscribers/index.js';
19
23
  import {passwordLoginMethods} from './login-methods.js';
20
24
 
21
- export type {JobLeaseTokenClaims, RunnerSessionTokenClaims} from '@shipfox/api-auth-dto';
25
+ const authPublisherEventSchemas = {...authEventSchemas, ...administrationActionEventSchemas};
26
+
27
+ export type {AdminRole, JobLeaseTokenClaims, RunnerSessionTokenClaims} from '@shipfox/api-auth-dto';
28
+ export {
29
+ ADMIN_ROLES,
30
+ getCurrentAdminRole,
31
+ hasMinimumAdminRole,
32
+ highestAdminRole,
33
+ requireAdminRole,
34
+ revokeAdminGrant,
35
+ } from '#core/admin-role.js';
36
+ export {
37
+ bootstrapFirstAdminOwner,
38
+ grantAdministratorRole,
39
+ listAdministratorGrants,
40
+ revokeAdministratorGrant,
41
+ } from '#core/administration.js';
22
42
  export type {
23
43
  CreateSessionForUserError,
24
44
  CreateSessionForUserParams,
@@ -28,11 +48,20 @@ export type {
28
48
  export {createSessionForUser, provisionUser} from '#core/auth.js';
29
49
  export type {EmailOwner, FindUserByEmailParams} from '#core/email-owner.js';
30
50
  export {findUserByEmail} from '#core/email-owner.js';
51
+ export type {AdminGrant} from '#core/entities/admin-grant.js';
31
52
  export type {User, UserStatus} from '#core/entities/user.js';
32
53
  export {
54
+ AdminBootstrapClosedError,
55
+ AdminGrantAlreadyExistsError,
56
+ AdminGrantNotFoundError,
57
+ AdminIdempotencyKeyReuseError,
58
+ AdminRoleRequiredError,
33
59
  AuthDependencyUnavailableError,
34
60
  EmailNotVerifiedError,
61
+ InvalidAdminBootstrapTokenError,
35
62
  InvalidCredentialsError,
63
+ LastAdminOwnerError,
64
+ SignupNotAllowedError,
36
65
  UserNotFoundError,
37
66
  } from '#core/errors.js';
38
67
  export {
@@ -40,10 +69,15 @@ export {
40
69
  jobLeaseParamsFrom,
41
70
  verifyJobLeaseToken,
42
71
  } from '#core/job-lease-token.js';
72
+ export type {SignupPolicy} from '#core/ports.js';
43
73
  export {
44
74
  issueRunnerSessionToken,
45
75
  verifyRunnerSessionToken,
46
76
  } from '#core/runner-session-token.js';
77
+ export {
78
+ createEnvironmentSignupPolicy,
79
+ DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE,
80
+ } from '#core/signup-policy.js';
47
81
  export {
48
82
  type AuthenticatedSessionContext,
49
83
  createJwtAuthMethod,
@@ -62,19 +96,26 @@ export {createRunnerSessionAuthMethod} from '#presentation/auth/runner-session-a
62
96
 
63
97
  const subscriber = subscriberFactory<AuthEventMap>();
64
98
 
99
+ export interface CreateAuthModuleOptions {
100
+ workspaces: import('@shipfox/api-workspaces-dto/inter-module').WorkspacesInterModuleClient;
101
+ signupPolicy?: SignupPolicy;
102
+ }
103
+
65
104
  export function createAuthModule({
66
105
  workspaces,
67
- }: {
68
- workspaces: import('@shipfox/api-workspaces-dto/inter-module').WorkspacesInterModuleClient;
69
- }): ShipfoxModule {
106
+ signupPolicy = createEnvironmentSignupPolicy(),
107
+ }: CreateAuthModuleOptions): ShipfoxModule {
70
108
  return {
71
109
  name: 'auth',
72
110
  database: {db, migrationsPath, databaseNamespace: 'auth'},
73
111
  auth: [createJwtAuthMethod(), createLeaseTokenAuthMethod(), createRunnerSessionAuthMethod()],
74
112
  loginMethods: passwordLoginMethods(config.AUTH_PASSWORD_ENABLED),
75
- routes: [buildAuthRoutes(config.AUTH_PASSWORD_ENABLED, workspaces)],
113
+ routes: [
114
+ buildAuthRoutes(config.AUTH_PASSWORD_ENABLED, workspaces, signupPolicy),
115
+ administrationRoutes,
116
+ ],
76
117
  e2eRoutes: [createAuthE2eRoutes(workspaces)],
77
- publishers: [{name: 'auth', table: authOutbox, db, eventSchemas: authEventSchemas}],
118
+ publishers: [{name: 'auth', table: authOutbox, db, eventSchemas: authPublisherEventSchemas}],
78
119
  subscribers: [subscriber(AUTH_PASSWORD_RESET_SEND_REQUESTED, onPasswordResetSendRequested)],
79
120
  interModulePresentations: [createAuthInterModulePresentation()],
80
121
  };
@@ -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';
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