@shipfox/api-auth 9.0.3 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +28 -3
  4. package/dist/config.d.ts +4 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +22 -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/auth.d.ts +9 -0
  17. package/dist/core/auth.d.ts.map +1 -1
  18. package/dist/core/auth.js +71 -20
  19. package/dist/core/auth.js.map +1 -1
  20. package/dist/core/entities/admin-grant.d.ts +10 -0
  21. package/dist/core/entities/admin-grant.d.ts.map +1 -0
  22. package/dist/core/entities/admin-grant.js +3 -0
  23. package/dist/core/entities/admin-grant.js.map +1 -0
  24. package/dist/core/errors.d.ts +10 -0
  25. package/dist/core/errors.d.ts.map +1 -1
  26. package/dist/core/errors.js +19 -0
  27. package/dist/core/errors.js.map +1 -1
  28. package/dist/core/ports.d.ts +13 -0
  29. package/dist/core/ports.d.ts.map +1 -0
  30. package/dist/core/ports.js +3 -0
  31. package/dist/core/ports.js.map +1 -0
  32. package/dist/core/signup-policy.d.ts +10 -0
  33. package/dist/core/signup-policy.d.ts.map +1 -0
  34. package/dist/core/signup-policy.js +39 -0
  35. package/dist/core/signup-policy.js.map +1 -0
  36. package/dist/db/admin-grants.d.ts +15 -0
  37. package/dist/db/admin-grants.d.ts.map +1 -0
  38. package/dist/db/admin-grants.js +49 -0
  39. package/dist/db/admin-grants.js.map +1 -0
  40. package/dist/db/db.d.ts +218 -0
  41. package/dist/db/db.d.ts.map +1 -1
  42. package/dist/db/db.js +2 -0
  43. package/dist/db/db.js.map +1 -1
  44. package/dist/db/schema/admin-grants.d.ts +115 -0
  45. package/dist/db/schema/admin-grants.d.ts.map +1 -0
  46. package/dist/db/schema/admin-grants.js +42 -0
  47. package/dist/db/schema/admin-grants.js.map +1 -0
  48. package/dist/index.d.ts +11 -4
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +6 -3
  51. package/dist/index.js.map +1 -1
  52. package/dist/presentation/dto/user.d.ts +2 -1
  53. package/dist/presentation/dto/user.d.ts.map +1 -1
  54. package/dist/presentation/dto/user.js +2 -1
  55. package/dist/presentation/dto/user.js.map +1 -1
  56. package/dist/presentation/inter-module.d.ts.map +1 -1
  57. package/dist/presentation/inter-module.js +26 -2
  58. package/dist/presentation/inter-module.js.map +1 -1
  59. package/dist/presentation/routes/index.d.ts +2 -1
  60. package/dist/presentation/routes/index.d.ts.map +1 -1
  61. package/dist/presentation/routes/index.js +2 -2
  62. package/dist/presentation/routes/index.js.map +1 -1
  63. package/dist/presentation/routes/registration/signup.d.ts +2 -1
  64. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  65. package/dist/presentation/routes/registration/signup.js +14 -3
  66. package/dist/presentation/routes/registration/signup.js.map +1 -1
  67. package/dist/presentation/routes/session/me.d.ts.map +1 -1
  68. package/dist/presentation/routes/session/me.js +5 -1
  69. package/dist/presentation/routes/session/me.js.map +1 -1
  70. package/dist/tsconfig.test.tsbuildinfo +1 -1
  71. package/drizzle/0001_brainy_shriek.sql +14 -0
  72. package/drizzle/meta/0001_snapshot.json +702 -0
  73. package/drizzle/meta/_journal.json +7 -0
  74. package/package.json +6 -6
  75. package/src/config.test.ts +39 -0
  76. package/src/config.ts +30 -0
  77. package/src/core/admin-role-model.ts +25 -0
  78. package/src/core/admin-role.test.ts +39 -0
  79. package/src/core/admin-role.ts +25 -0
  80. package/src/core/auth.test.ts +110 -0
  81. package/src/core/auth.ts +78 -21
  82. package/src/core/entities/admin-grant.ts +10 -0
  83. package/src/core/errors.ts +24 -0
  84. package/src/core/jwt.test.ts +1 -0
  85. package/src/core/ports.ts +7 -0
  86. package/src/core/signup-policy.test.ts +78 -0
  87. package/src/core/signup-policy.ts +42 -0
  88. package/src/db/admin-grants.test.ts +57 -0
  89. package/src/db/admin-grants.ts +83 -0
  90. package/src/db/db.ts +2 -0
  91. package/src/db/schema/admin-grants.ts +49 -0
  92. package/src/index.test.ts +53 -0
  93. package/src/index.ts +28 -5
  94. package/src/presentation/dto/user.ts +7 -2
  95. package/src/presentation/inter-module.test.ts +47 -0
  96. package/src/presentation/inter-module.ts +22 -1
  97. package/src/presentation/routes/index.ts +3 -1
  98. package/src/presentation/routes/registration/signup.test.ts +26 -0
  99. package/src/presentation/routes/registration/signup.ts +22 -2
  100. package/src/presentation/routes/session/login.test.ts +1 -0
  101. package/src/presentation/routes/session/me.test.ts +18 -2
  102. package/src/presentation/routes/session/me.ts +5 -1
  103. package/test/globalSetup.ts +1 -1
  104. package/tsconfig.build.tsbuildinfo +1 -1
@@ -8,6 +8,13 @@
8
8
  "when": 1784102113104,
9
9
  "tag": "0000_initial",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1785065259979,
16
+ "tag": "0001_brainy_shriek",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
20
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-auth",
3
3
  "license": "MIT",
4
- "version": "9.0.3",
4
+ "version": "9.2.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -26,17 +26,17 @@
26
26
  "@node-rs/argon2": "^2.0.2",
27
27
  "drizzle-orm": "^0.45.2",
28
28
  "zod": "^4.4.3",
29
- "@shipfox/api-auth-context": "9.0.3",
30
- "@shipfox/api-auth-dto": "9.0.2",
31
- "@shipfox/api-email-challenges": "1.1.2",
32
- "@shipfox/api-workspaces-dto": "9.0.2",
29
+ "@shipfox/api-auth-context": "9.2.0",
30
+ "@shipfox/api-auth-dto": "9.2.0",
31
+ "@shipfox/api-email-challenges": "1.1.4",
32
+ "@shipfox/api-workspaces-dto": "9.2.0",
33
33
  "@shipfox/inter-module": "0.2.2",
34
34
  "@shipfox/config": "1.2.4",
35
35
  "@shipfox/node-drizzle": "0.3.4",
36
36
  "@shipfox/node-auth-root-key": "0.2.3",
37
37
  "@shipfox/node-fastify": "0.3.3",
38
38
  "@shipfox/node-jwt": "0.3.2",
39
- "@shipfox/node-email": "0.3.3",
39
+ "@shipfox/node-email": "0.3.4",
40
40
  "@shipfox/node-mailer": "0.2.3",
41
41
  "@shipfox/node-module": "1.0.2",
42
42
  "@shipfox/node-opentelemetry": "0.6.2",
@@ -0,0 +1,39 @@
1
+ describe('signup gate configuration', () => {
2
+ afterEach(() => {
3
+ vi.unstubAllEnvs();
4
+ vi.resetModules();
5
+ });
6
+
7
+ test('uses the Auth-prefixed defaults', async () => {
8
+ vi.resetModules();
9
+
10
+ const {config} = await import('#config.js');
11
+
12
+ expect(config.AUTH_SIGNUP_GATE_ENABLED).toBe(false);
13
+ expect(config.AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS).toBe('');
14
+ expect(config.AUTH_SIGNUP_ALLOWED_EMAILS).toBe('');
15
+ expect(config.AUTH_SIGNUP_NOT_ALLOWED_MESSAGE).toBeUndefined();
16
+ });
17
+
18
+ test('fails startup when the enabled gate has no allowlist', async () => {
19
+ vi.stubEnv('AUTH_SIGNUP_GATE_ENABLED', 'true');
20
+ vi.stubEnv('AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS', ', ');
21
+ vi.stubEnv('AUTH_SIGNUP_ALLOWED_EMAILS', ' , ');
22
+ vi.resetModules();
23
+
24
+ await expect(import('#config.js')).rejects.toThrow(
25
+ 'AUTH_SIGNUP_GATE_ENABLED requires AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS or AUTH_SIGNUP_ALLOWED_EMAILS',
26
+ );
27
+ });
28
+
29
+ test('accepts an enabled gate with either allowlist', async () => {
30
+ vi.stubEnv('AUTH_SIGNUP_GATE_ENABLED', 'true');
31
+ vi.stubEnv('AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS', 'shipfox.io');
32
+ vi.resetModules();
33
+
34
+ const {config} = await import('#config.js');
35
+
36
+ expect(config.AUTH_SIGNUP_GATE_ENABLED).toBe(true);
37
+ expect(config.AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS).toBe('shipfox.io');
38
+ });
39
+ });
package/src/config.ts CHANGED
@@ -29,8 +29,38 @@ export const config = createConfig({
29
29
  desc: 'Whether password login is available. Use true or false. Defaults to true. When false, password and email-verification routes are not registered, and server startup requires another module to contribute a login method.',
30
30
  default: true,
31
31
  }),
32
+ AUTH_SIGNUP_GATE_ENABLED: bool({
33
+ desc: 'Whether new account creation is restricted to the configured signup email allowlist. Defaults to false, which allows every signup.',
34
+ default: false,
35
+ }),
36
+ AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: str({
37
+ desc: 'Comma-separated email domains allowed to create accounts, such as shipfox.io,acme.com. Required when AUTH_SIGNUP_GATE_ENABLED is true unless AUTH_SIGNUP_ALLOWED_EMAILS is set.',
38
+ default: '',
39
+ }),
40
+ AUTH_SIGNUP_ALLOWED_EMAILS: str({
41
+ desc: 'Comma-separated exact email addresses allowed to create accounts. Required when AUTH_SIGNUP_GATE_ENABLED is true unless AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS is set.',
42
+ default: '',
43
+ }),
44
+ AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: str({
45
+ desc: 'Description returned when the signup gate blocks an account. Optional. The default is This Shipfox deployment does not accept new accounts right now.',
46
+ default: undefined,
47
+ }),
32
48
  CLIENT_BASE_URL: str({
33
49
  desc: 'Base URL of the client app. Used to build links in emails such as password resets.',
34
50
  default: 'http://localhost:5173',
35
51
  }),
36
52
  });
53
+
54
+ if (
55
+ config.AUTH_SIGNUP_GATE_ENABLED &&
56
+ !hasSignupAllowlistEntry(config.AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS) &&
57
+ !hasSignupAllowlistEntry(config.AUTH_SIGNUP_ALLOWED_EMAILS)
58
+ ) {
59
+ throw new Error(
60
+ 'AUTH_SIGNUP_GATE_ENABLED requires AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS or AUTH_SIGNUP_ALLOWED_EMAILS to be set.',
61
+ );
62
+ }
63
+
64
+ function hasSignupAllowlistEntry(value: string): boolean {
65
+ return value.split(',').some((entry) => entry.trim().length > 0);
66
+ }
@@ -0,0 +1,25 @@
1
+ import type {AdminRole} from '@shipfox/api-auth-dto';
2
+
3
+ export const ADMIN_ROLES: readonly AdminRole[] = [
4
+ 'admin-observer',
5
+ 'admin-operator',
6
+ 'admin-owner',
7
+ ];
8
+
9
+ const ADMIN_ROLE_RANK: Record<AdminRole, number> = {
10
+ 'admin-observer': 1,
11
+ 'admin-operator': 2,
12
+ 'admin-owner': 3,
13
+ };
14
+
15
+ export function hasMinimumAdminRole(role: AdminRole, minimumRole: AdminRole): boolean {
16
+ return ADMIN_ROLE_RANK[role] >= ADMIN_ROLE_RANK[minimumRole];
17
+ }
18
+
19
+ export function highestAdminRole(roles: readonly AdminRole[]): AdminRole | null {
20
+ return roles.reduce<AdminRole | null>(
21
+ (highest, role) =>
22
+ highest === null || ADMIN_ROLE_RANK[role] > ADMIN_ROLE_RANK[highest] ? role : highest,
23
+ null,
24
+ );
25
+ }
@@ -0,0 +1,39 @@
1
+ import {createAdminGrant} from '#db/admin-grants.js';
2
+ import {userFactory} from '#test/index.js';
3
+ import {hasMinimumAdminRole, highestAdminRole, requireAdminRole} from './admin-role.js';
4
+ import type {AdminRoleRequiredError} from './errors.js';
5
+
6
+ describe('admin role policy', () => {
7
+ test.each([
8
+ ['admin-observer', 'admin-observer', true],
9
+ ['admin-observer', 'admin-operator', false],
10
+ ['admin-operator', 'admin-observer', true],
11
+ ['admin-operator', 'admin-owner', false],
12
+ ['admin-owner', 'admin-observer', true],
13
+ ['admin-owner', 'admin-operator', true],
14
+ ['admin-owner', 'admin-owner', true],
15
+ ] as const)('%s satisfies %s: %s', (role, minimumRole, expected) => {
16
+ expect(hasMinimumAdminRole(role, minimumRole)).toBe(expected);
17
+ });
18
+
19
+ test('selects the highest role from fixed grants', () => {
20
+ expect(highestAdminRole(['admin-observer', 'admin-owner', 'admin-operator'])).toBe(
21
+ 'admin-owner',
22
+ );
23
+ expect(highestAdminRole([])).toBeNull();
24
+ });
25
+
26
+ test('evaluates the current role from Auth storage for every required minimum', async () => {
27
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
28
+ await createAdminGrant({userId: user.id, role: 'admin-operator'});
29
+
30
+ await expect(requireAdminRole({userId: user.id, minimumRole: 'admin-observer'})).resolves.toBe(
31
+ 'admin-operator',
32
+ );
33
+ await expect(requireAdminRole({userId: user.id, minimumRole: 'admin-owner'})).rejects.toEqual(
34
+ expect.objectContaining<Partial<AdminRoleRequiredError>>({
35
+ minimumRole: 'admin-owner',
36
+ }),
37
+ );
38
+ });
39
+ });
@@ -0,0 +1,25 @@
1
+ import type {AdminRole} from '@shipfox/api-auth-dto';
2
+ import {findCurrentAdminRole, revokeAdminGrant as revokeAdminGrantInDb} from '#db/admin-grants.js';
3
+ import {hasMinimumAdminRole} from './admin-role-model.js';
4
+ import {AdminRoleRequiredError} from './errors.js';
5
+
6
+ export {ADMIN_ROLES, hasMinimumAdminRole, highestAdminRole} from './admin-role-model.js';
7
+
8
+ export async function getCurrentAdminRole(params: {userId: string}): Promise<AdminRole | null> {
9
+ return await findCurrentAdminRole(params);
10
+ }
11
+
12
+ export async function requireAdminRole(params: {
13
+ userId: string;
14
+ minimumRole: AdminRole;
15
+ }): Promise<AdminRole> {
16
+ const role = await getCurrentAdminRole({userId: params.userId});
17
+ if (!role || !hasMinimumAdminRole(role, params.minimumRole)) {
18
+ throw new AdminRoleRequiredError(params.minimumRole);
19
+ }
20
+ return role;
21
+ }
22
+
23
+ export async function revokeAdminGrant(params: {grantId: string}) {
24
+ return await revokeAdminGrantInDb(params);
25
+ }
@@ -160,6 +160,71 @@ describe('auth core', () => {
160
160
  await expect(promise).rejects.toBeInstanceOf(EmailTakenError);
161
161
  });
162
162
 
163
+ test('signup normalizes the email before checking the policy', async () => {
164
+ const email = `signup-policy-${crypto.randomUUID()}@example.com`;
165
+ const isSignupAllowed = vi.fn().mockResolvedValue({allowed: true});
166
+
167
+ await signup({
168
+ email: ` ${email.toUpperCase()} `,
169
+ password: 'correct horse battery staple',
170
+ signupPolicy: {isSignupAllowed},
171
+ });
172
+
173
+ expect(isSignupAllowed).toHaveBeenCalledWith({
174
+ email,
175
+ emailVerified: false,
176
+ source: 'password',
177
+ });
178
+ });
179
+
180
+ test('signup denies a new user when the policy does not allow it', async () => {
181
+ const email = `signup-policy-denied-${crypto.randomUUID()}@example.com`;
182
+ const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false});
183
+
184
+ await expect(
185
+ signup({
186
+ email,
187
+ password: 'correct horse battery staple',
188
+ signupPolicy: {isSignupAllowed},
189
+ }),
190
+ ).rejects.toEqual(
191
+ expect.objectContaining({
192
+ name: 'SignupNotAllowedError',
193
+ message: 'This Shipfox deployment does not accept new accounts right now.',
194
+ }),
195
+ );
196
+ expect(await findUserByEmail({email})).toBeUndefined();
197
+ });
198
+
199
+ test('signup fails closed when the policy throws', async () => {
200
+ const email = `signup-policy-error-${crypto.randomUUID()}@example.com`;
201
+ const policyError = new Error('policy unavailable');
202
+ const isSignupAllowed = vi.fn().mockRejectedValue(policyError);
203
+
204
+ await expect(
205
+ signup({
206
+ email,
207
+ password: 'correct horse battery staple',
208
+ signupPolicy: {isSignupAllowed},
209
+ }),
210
+ ).rejects.toBe(policyError);
211
+ expect(await findUserByEmail({email})).toBeUndefined();
212
+ });
213
+
214
+ test('signup does not check the policy for an existing user', async () => {
215
+ const existing = await userFactory.create({emailVerifiedAt: new Date()});
216
+ const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false});
217
+
218
+ await expect(
219
+ signup({
220
+ email: ` ${existing.email.toUpperCase()} `,
221
+ password: 'correct horse battery staple',
222
+ signupPolicy: {isSignupAllowed},
223
+ }),
224
+ ).rejects.toBeInstanceOf(EmailTakenError);
225
+ expect(isSignupAllowed).not.toHaveBeenCalled();
226
+ });
227
+
163
228
  test('signup with an invitation writes the signed-up event with its user insert', async () => {
164
229
  const email = `signup-invitation-${crypto.randomUUID()}@example.com`;
165
230
  const userId = crypto.randomUUID();
@@ -175,6 +240,9 @@ describe('auth core', () => {
175
240
  name: 'Invited User',
176
241
  invitationToken: `invite-${crypto.randomUUID()}`,
177
242
  workspaces,
243
+ signupPolicy: {
244
+ isSignupAllowed: vi.fn().mockRejectedValue(new Error('policy unavailable')),
245
+ },
178
246
  });
179
247
 
180
248
  const events = await outboxEventsTo(email, AUTH_USER_SIGNED_UP);
@@ -199,24 +267,66 @@ describe('auth core', () => {
199
267
  expect(user.status).toBe('active');
200
268
  });
201
269
 
270
+ test('provisionUser normalizes the email before checking the policy', async () => {
271
+ const email = `provision-policy-${crypto.randomUUID()}@example.com`;
272
+ const isSignupAllowed = vi.fn().mockResolvedValue({allowed: true});
273
+
274
+ await provisionUser({
275
+ email: ` ${email.toUpperCase()} `,
276
+ signupPolicy: {isSignupAllowed},
277
+ });
278
+
279
+ expect(isSignupAllowed).toHaveBeenCalledWith({
280
+ email,
281
+ emailVerified: true,
282
+ source: 'external-identity',
283
+ });
284
+ });
285
+
286
+ test('provisionUser denies a new user when the policy does not allow it', async () => {
287
+ const email = `provision-policy-denied-${crypto.randomUUID()}@example.com`;
288
+ const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false, message: 'Closed beta'});
289
+
290
+ await expect(provisionUser({email, signupPolicy: {isSignupAllowed}})).rejects.toEqual(
291
+ expect.objectContaining({
292
+ name: 'SignupNotAllowedError',
293
+ message: 'Closed beta',
294
+ }),
295
+ );
296
+ expect(await findUserByEmail({email})).toBeUndefined();
297
+ });
298
+
299
+ test('provisionUser fails closed when the policy throws', async () => {
300
+ const email = `provision-policy-error-${crypto.randomUUID()}@example.com`;
301
+ const policyError = new Error('policy unavailable');
302
+ const isSignupAllowed = vi.fn().mockRejectedValue(policyError);
303
+
304
+ await expect(provisionUser({email, signupPolicy: {isSignupAllowed}})).rejects.toBe(policyError);
305
+ expect(await findUserByEmail({email})).toBeUndefined();
306
+ });
307
+
202
308
  test('provisionUser returns existing unverified and suspended users unchanged', async () => {
203
309
  const unverified = await userFactory.create();
204
310
  const suspended = await userFactory.create({emailVerifiedAt: new Date()});
205
311
  await db().update(users).set({status: 'suspended'}).where(eq(users.id, suspended.id));
206
312
  const storedUnverified = await findUserById({id: unverified.id});
207
313
  const storedSuspended = await findUserById({id: suspended.id});
314
+ const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false});
208
315
 
209
316
  const existingUnverified = await provisionUser({
210
317
  email: ` ${unverified.email.toUpperCase()} `,
211
318
  name: 'Replacement Name',
319
+ signupPolicy: {isSignupAllowed},
212
320
  });
213
321
  const existingSuspended = await provisionUser({
214
322
  email: ` ${suspended.email.toUpperCase()} `,
215
323
  name: 'Replacement Name',
324
+ signupPolicy: {isSignupAllowed},
216
325
  });
217
326
 
218
327
  expect(existingUnverified).toEqual(storedUnverified);
219
328
  expect(existingSuspended).toEqual(storedSuspended);
329
+ expect(isSignupAllowed).not.toHaveBeenCalled();
220
330
  });
221
331
 
222
332
  test('provisionUser returns one unchanged user for concurrent callbacks', async () => {
package/src/core/auth.ts CHANGED
@@ -1,4 +1,4 @@
1
- import {emailSchema} from '@shipfox/api-auth-dto';
1
+ import {type AdminRole, emailSchema} from '@shipfox/api-auth-dto';
2
2
  import {
3
3
  confirmEmailChallenge,
4
4
  consumeEmailChallengeProof,
@@ -31,6 +31,7 @@ import {
31
31
  updateUserPassword,
32
32
  } from '#db/users.js';
33
33
  import {type AuthTokenRefreshOutcome, recordTokenRefreshed} from '#metrics/index.js';
34
+ import {getCurrentAdminRole} from './admin-role.js';
34
35
  import type {RefreshToken} from './entities/refresh-token.js';
35
36
  import type {User} from './entities/user.js';
36
37
  import {
@@ -39,6 +40,7 @@ import {
39
40
  EmailTakenError,
40
41
  InvalidCredentialsError,
41
42
  InvitationEmailMismatchError,
43
+ SignupNotAllowedError,
42
44
  TokenAlreadyUsedError,
43
45
  TokenExpiredError,
44
46
  TokenInvalidError,
@@ -46,9 +48,33 @@ import {
46
48
  } from './errors.js';
47
49
  import {signUserToken} from './jwt.js';
48
50
  import {hashPassword, verifyPassword} from './password.js';
51
+ import type {SignupPolicy} from './ports.js';
49
52
 
50
53
  const RESET_TTL_HOURS = 1;
51
54
  const PASSWORD_VERIFICATION_PURPOSE = 'password-verification';
55
+ const DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE =
56
+ 'This Shipfox deployment does not accept new accounts right now.';
57
+
58
+ const defaultSignupPolicy: SignupPolicy = {
59
+ isSignupAllowed: async () => ({allowed: true}),
60
+ };
61
+
62
+ async function assertSignupAllowed(params: {
63
+ signupPolicy?: SignupPolicy | undefined;
64
+ email: string;
65
+ emailVerified: boolean;
66
+ source: string;
67
+ }): Promise<void> {
68
+ const result = await (params.signupPolicy ?? defaultSignupPolicy).isSignupAllowed({
69
+ email: params.email,
70
+ emailVerified: params.emailVerified,
71
+ source: params.source,
72
+ });
73
+
74
+ if (!result.allowed) {
75
+ throw new SignupNotAllowedError(result.message ?? DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE);
76
+ }
77
+ }
52
78
 
53
79
  let dummyHashCache: string | undefined;
54
80
  async function getDummyHash(): Promise<string> {
@@ -114,11 +140,12 @@ async function createRefreshSession(
114
140
  async function createSessionTokens(
115
141
  user: User,
116
142
  workspaces: WorkspacesInterModuleClient,
117
- ): Promise<{token: string; refreshToken: string}> {
143
+ ): Promise<{token: string; refreshToken: string; adminRole: AdminRole | null}> {
118
144
  const refreshSessionId = crypto.randomUUID();
119
145
  const token = await signAccessToken(user, workspaces, refreshSessionId);
146
+ const adminRole = await getCurrentAdminRole({userId: user.id});
120
147
  const {refreshToken} = await createRefreshSession(user, refreshSessionId);
121
- return {token, refreshToken};
148
+ return {token, refreshToken, adminRole};
122
149
  }
123
150
 
124
151
  function passwordResetLink(rawToken: string): string {
@@ -156,11 +183,13 @@ export interface SignupParams {
156
183
  email: string;
157
184
  password: string;
158
185
  name?: string | undefined;
186
+ signupPolicy?: SignupPolicy | undefined;
159
187
  }
160
188
 
161
189
  export interface ProvisionUserParams {
162
190
  email: string;
163
191
  name?: string | null | undefined;
192
+ signupPolicy?: SignupPolicy | undefined;
164
193
  }
165
194
 
166
195
  /**
@@ -168,8 +197,19 @@ export interface ProvisionUserParams {
168
197
  * Existing users are returned unchanged, including their password and profile.
169
198
  */
170
199
  export async function provisionUser(params: ProvisionUserParams): Promise<User> {
200
+ const email = emailSchema.parse(params.email);
201
+ const existing = await findUserByEmail({email});
202
+ if (existing) return existing;
203
+
204
+ await assertSignupAllowed({
205
+ signupPolicy: params.signupPolicy,
206
+ email,
207
+ emailVerified: true,
208
+ source: 'external-identity',
209
+ });
210
+
171
211
  return await provisionDbUser({
172
- email: emailSchema.parse(params.email),
212
+ email,
173
213
  name: params.name ?? null,
174
214
  });
175
215
  }
@@ -179,7 +219,8 @@ export type SignupResult = User & {
179
219
  };
180
220
 
181
221
  export async function signup(params: SignupParams & {sourceIp?: string}): Promise<SignupResult> {
182
- const existing = await findUserByEmail({email: params.email});
222
+ const email = emailSchema.parse(params.email);
223
+ const existing = await findUserByEmail({email});
183
224
  if (existing) {
184
225
  const canResumeVerification =
185
226
  existing.status === 'active' &&
@@ -196,12 +237,19 @@ export async function signup(params: SignupParams & {sourceIp?: string}): Promis
196
237
  });
197
238
  return {...existing, emailChallenge};
198
239
  }
199
- throw new EmailTakenError(params.email);
240
+ throw new EmailTakenError(email);
200
241
  }
201
242
 
243
+ await assertSignupAllowed({
244
+ signupPolicy: params.signupPolicy,
245
+ email,
246
+ emailVerified: false,
247
+ source: 'password',
248
+ });
249
+
202
250
  const hashedPassword = await hashPassword({password: params.password});
203
251
  const user = await createDbUser({
204
- email: params.email,
252
+ email,
205
253
  hashedPassword,
206
254
  name: params.name ?? null,
207
255
  signedUp: {viaInvitation: false},
@@ -289,12 +337,12 @@ export async function signupWithInvitation(
289
337
 
290
338
  // Step 4: Issue session. signAccessToken reads memberships through the
291
339
  // workspaces module API, so a successful accept is reflected in the JWT.
292
- const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
340
+ const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
293
341
 
294
342
  if (acceptError) {
295
- return {token, refreshToken, user, membership, acceptError};
343
+ return {token, refreshToken, user, membership, acceptError, adminRole};
296
344
  }
297
- return {token, refreshToken, user, membership};
345
+ return {token, refreshToken, user, membership, adminRole};
298
346
  }
299
347
 
300
348
  export interface CreateUserParams extends SignupParams {
@@ -328,6 +376,7 @@ export interface LoginResult {
328
376
  token: string;
329
377
  refreshToken: string;
330
378
  user: User;
379
+ adminRole: AdminRole | null;
331
380
  }
332
381
 
333
382
  export async function login(params: LoginParams): Promise<LoginResult> {
@@ -351,9 +400,9 @@ export async function login(params: LoginParams): Promise<LoginResult> {
351
400
  throw new EmailNotVerifiedError();
352
401
  }
353
402
 
354
- const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
403
+ const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
355
404
 
356
- return {token, refreshToken, user};
405
+ return {token, refreshToken, user, adminRole};
357
406
  }
358
407
 
359
408
  export interface CreateSessionForUserParams {
@@ -366,6 +415,7 @@ export interface CreateSessionForUserResult {
366
415
  token: string;
367
416
  refreshToken: string;
368
417
  user: User;
418
+ adminRole: AdminRole | null;
369
419
  }
370
420
 
371
421
  export type CreateSessionForUserError =
@@ -393,9 +443,9 @@ export async function createSessionForUser(
393
443
  throw new InvalidCredentialsError();
394
444
  }
395
445
 
396
- const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
446
+ const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
397
447
 
398
- return {token, refreshToken, user};
448
+ return {token, refreshToken, user, adminRole};
399
449
  }
400
450
 
401
451
  export interface RefreshAccessTokenResult {
@@ -403,6 +453,7 @@ export interface RefreshAccessTokenResult {
403
453
  /** Undefined on a grace-window hit: keep the existing cookie instead of rotating it. */
404
454
  refreshToken: string | undefined;
405
455
  user: User;
456
+ adminRole: AdminRole | null;
406
457
  }
407
458
 
408
459
  export async function refreshAccessToken(params: {
@@ -422,6 +473,7 @@ export async function refreshAccessToken(params: {
422
473
  recordRefreshOutcome('rejected');
423
474
  throw new TokenInvalidError('Refresh token is invalid or expired');
424
475
  }
476
+ const adminRole = await getCurrentAdminRole({userId: user.id});
425
477
 
426
478
  // Within the grace window a rotated token means a concurrent refresh (e.g. a
427
479
  // second tab); past it, reuse of a retired token means a compromised session.
@@ -429,7 +481,7 @@ export async function refreshAccessToken(params: {
429
481
  if (isWithinRotationGrace(current)) {
430
482
  const token = await signAccessToken(user, params.workspaces, current.sessionId);
431
483
  recordRefreshOutcome('grace');
432
- return {token, refreshToken: undefined, user};
484
+ return {token, refreshToken: undefined, user, adminRole};
433
485
  }
434
486
  await revokeRefreshTokensForUser({userId: user.id});
435
487
  recordRefreshOutcome('rejected');
@@ -453,18 +505,19 @@ export async function refreshAccessToken(params: {
453
505
  }
454
506
  const token = await signAccessToken(user, params.workspaces, latest.sessionId);
455
507
  recordRefreshOutcome('grace');
456
- return {token, refreshToken: undefined, user};
508
+ return {token, refreshToken: undefined, user, adminRole};
457
509
  }
458
510
 
459
511
  const token = await signAccessToken(user, params.workspaces, current.sessionId);
460
512
  recordRefreshOutcome('rotated');
461
- return {token, refreshToken: nextRefreshToken, user};
513
+ return {token, refreshToken: nextRefreshToken, user, adminRole};
462
514
  }
463
515
 
464
516
  export interface ConfirmEmailVerificationResult {
465
517
  token: string;
466
518
  refreshToken: string;
467
519
  user: User;
520
+ adminRole: AdminRole | null;
468
521
  }
469
522
 
470
523
  export interface ResendEmailVerificationResult {
@@ -491,9 +544,12 @@ export async function confirmEmailVerification(params: {
491
544
  throw new TokenInvalidError('Verification code is invalid or expired');
492
545
  }
493
546
 
494
- const {token, refreshToken} = await createSessionTokens(verifiedUser, params.workspaces);
547
+ const {token, refreshToken, adminRole} = await createSessionTokens(
548
+ verifiedUser,
549
+ params.workspaces,
550
+ );
495
551
 
496
- return {token, refreshToken, user: verifiedUser};
552
+ return {token, refreshToken, user: verifiedUser, adminRole};
497
553
  }
498
554
 
499
555
  export async function resendEmailVerification(params: {
@@ -537,6 +593,7 @@ export interface ConfirmPasswordResetResult {
537
593
  token: string;
538
594
  refreshToken: string;
539
595
  user: User;
596
+ adminRole: AdminRole | null;
540
597
  }
541
598
 
542
599
  export async function confirmPasswordReset(params: {
@@ -562,9 +619,9 @@ export async function confirmPasswordReset(params: {
562
619
 
563
620
  await revokeRefreshTokensForUser({userId: consumed.userId});
564
621
 
565
- const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
622
+ const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
566
623
 
567
- return {token, refreshToken, user};
624
+ return {token, refreshToken, user, adminRole};
568
625
  }
569
626
 
570
627
  export async function changePassword(params: {
@@ -0,0 +1,10 @@
1
+ import type {AdminRole} from '@shipfox/api-auth-dto';
2
+
3
+ export interface AdminGrant {
4
+ id: string;
5
+ userId: string;
6
+ role: AdminRole;
7
+ revokedAt: Date | null;
8
+ createdAt: Date;
9
+ updatedAt: Date;
10
+ }
@@ -47,6 +47,13 @@ export class EmailTakenError extends Error {
47
47
  }
48
48
  }
49
49
 
50
+ export class SignupNotAllowedError extends Error {
51
+ constructor(message: string) {
52
+ super(message);
53
+ this.name = 'SignupNotAllowedError';
54
+ }
55
+ }
56
+
50
57
  export class InvalidCredentialsError extends Error {
51
58
  constructor() {
52
59
  super('Invalid credentials');
@@ -73,6 +80,23 @@ export class AuthDependencyUnavailableError extends Error {
73
80
  }
74
81
  }
75
82
 
83
+ export class AdminRoleRequiredError extends Error {
84
+ readonly minimumRole: import('@shipfox/api-auth-dto').AdminRole;
85
+
86
+ constructor(minimumRole: import('@shipfox/api-auth-dto').AdminRole) {
87
+ super(`Administrator role required: ${minimumRole}`);
88
+ this.name = 'AdminRoleRequiredError';
89
+ this.minimumRole = minimumRole;
90
+ }
91
+ }
92
+
93
+ export class LastAdminOwnerError extends Error {
94
+ constructor() {
95
+ super('Cannot remove the final active administrator owner');
96
+ this.name = 'LastAdminOwnerError';
97
+ }
98
+ }
99
+
76
100
  export class TokenInvalidError extends Error {
77
101
  constructor(reason?: string) {
78
102
  super(reason ? `Invalid token: ${reason}` : 'Invalid token');