@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
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,32 @@ 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';
52
+ import {createEnvironmentSignupPolicy} from './signup-policy.js';
49
53
 
50
54
  const RESET_TTL_HOURS = 1;
51
55
  const PASSWORD_VERIFICATION_PURPOSE = 'password-verification';
56
+ const DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE =
57
+ 'This Shipfox deployment does not accept new accounts right now.';
58
+
59
+ const defaultSignupPolicy: SignupPolicy = createEnvironmentSignupPolicy();
60
+
61
+ async function assertSignupAllowed(params: {
62
+ signupPolicy?: SignupPolicy | undefined;
63
+ email: string;
64
+ emailVerified: boolean;
65
+ source: string;
66
+ }): Promise<void> {
67
+ const result = await (params.signupPolicy ?? defaultSignupPolicy).isSignupAllowed({
68
+ email: params.email,
69
+ emailVerified: params.emailVerified,
70
+ source: params.source,
71
+ });
72
+
73
+ if (!result.allowed) {
74
+ throw new SignupNotAllowedError(result.message ?? DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE);
75
+ }
76
+ }
52
77
 
53
78
  let dummyHashCache: string | undefined;
54
79
  async function getDummyHash(): Promise<string> {
@@ -114,11 +139,12 @@ async function createRefreshSession(
114
139
  async function createSessionTokens(
115
140
  user: User,
116
141
  workspaces: WorkspacesInterModuleClient,
117
- ): Promise<{token: string; refreshToken: string}> {
142
+ ): Promise<{token: string; refreshToken: string; adminRole: AdminRole | null}> {
118
143
  const refreshSessionId = crypto.randomUUID();
119
144
  const token = await signAccessToken(user, workspaces, refreshSessionId);
145
+ const adminRole = await getCurrentAdminRole({userId: user.id});
120
146
  const {refreshToken} = await createRefreshSession(user, refreshSessionId);
121
- return {token, refreshToken};
147
+ return {token, refreshToken, adminRole};
122
148
  }
123
149
 
124
150
  function passwordResetLink(rawToken: string): string {
@@ -156,11 +182,13 @@ export interface SignupParams {
156
182
  email: string;
157
183
  password: string;
158
184
  name?: string | undefined;
185
+ signupPolicy?: SignupPolicy | undefined;
159
186
  }
160
187
 
161
188
  export interface ProvisionUserParams {
162
189
  email: string;
163
190
  name?: string | null | undefined;
191
+ signupPolicy?: SignupPolicy | undefined;
164
192
  }
165
193
 
166
194
  /**
@@ -168,8 +196,19 @@ export interface ProvisionUserParams {
168
196
  * Existing users are returned unchanged, including their password and profile.
169
197
  */
170
198
  export async function provisionUser(params: ProvisionUserParams): Promise<User> {
199
+ const email = emailSchema.parse(params.email);
200
+ const existing = await findUserByEmail({email});
201
+ if (existing) return existing;
202
+
203
+ await assertSignupAllowed({
204
+ signupPolicy: params.signupPolicy,
205
+ email,
206
+ emailVerified: true,
207
+ source: 'external-identity',
208
+ });
209
+
171
210
  return await provisionDbUser({
172
- email: emailSchema.parse(params.email),
211
+ email,
173
212
  name: params.name ?? null,
174
213
  });
175
214
  }
@@ -179,7 +218,8 @@ export type SignupResult = User & {
179
218
  };
180
219
 
181
220
  export async function signup(params: SignupParams & {sourceIp?: string}): Promise<SignupResult> {
182
- const existing = await findUserByEmail({email: params.email});
221
+ const email = emailSchema.parse(params.email);
222
+ const existing = await findUserByEmail({email});
183
223
  if (existing) {
184
224
  const canResumeVerification =
185
225
  existing.status === 'active' &&
@@ -196,12 +236,19 @@ export async function signup(params: SignupParams & {sourceIp?: string}): Promis
196
236
  });
197
237
  return {...existing, emailChallenge};
198
238
  }
199
- throw new EmailTakenError(params.email);
239
+ throw new EmailTakenError(email);
200
240
  }
201
241
 
242
+ await assertSignupAllowed({
243
+ signupPolicy: params.signupPolicy,
244
+ email,
245
+ emailVerified: false,
246
+ source: 'password',
247
+ });
248
+
202
249
  const hashedPassword = await hashPassword({password: params.password});
203
250
  const user = await createDbUser({
204
- email: params.email,
251
+ email,
205
252
  hashedPassword,
206
253
  name: params.name ?? null,
207
254
  signedUp: {viaInvitation: false},
@@ -289,12 +336,12 @@ export async function signupWithInvitation(
289
336
 
290
337
  // Step 4: Issue session. signAccessToken reads memberships through the
291
338
  // workspaces module API, so a successful accept is reflected in the JWT.
292
- const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
339
+ const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
293
340
 
294
341
  if (acceptError) {
295
- return {token, refreshToken, user, membership, acceptError};
342
+ return {token, refreshToken, user, membership, acceptError, adminRole};
296
343
  }
297
- return {token, refreshToken, user, membership};
344
+ return {token, refreshToken, user, membership, adminRole};
298
345
  }
299
346
 
300
347
  export interface CreateUserParams extends SignupParams {
@@ -328,6 +375,7 @@ export interface LoginResult {
328
375
  token: string;
329
376
  refreshToken: string;
330
377
  user: User;
378
+ adminRole: AdminRole | null;
331
379
  }
332
380
 
333
381
  export async function login(params: LoginParams): Promise<LoginResult> {
@@ -351,9 +399,9 @@ export async function login(params: LoginParams): Promise<LoginResult> {
351
399
  throw new EmailNotVerifiedError();
352
400
  }
353
401
 
354
- const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
402
+ const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
355
403
 
356
- return {token, refreshToken, user};
404
+ return {token, refreshToken, user, adminRole};
357
405
  }
358
406
 
359
407
  export interface CreateSessionForUserParams {
@@ -366,6 +414,7 @@ export interface CreateSessionForUserResult {
366
414
  token: string;
367
415
  refreshToken: string;
368
416
  user: User;
417
+ adminRole: AdminRole | null;
369
418
  }
370
419
 
371
420
  export type CreateSessionForUserError =
@@ -393,9 +442,9 @@ export async function createSessionForUser(
393
442
  throw new InvalidCredentialsError();
394
443
  }
395
444
 
396
- const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
445
+ const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
397
446
 
398
- return {token, refreshToken, user};
447
+ return {token, refreshToken, user, adminRole};
399
448
  }
400
449
 
401
450
  export interface RefreshAccessTokenResult {
@@ -403,6 +452,7 @@ export interface RefreshAccessTokenResult {
403
452
  /** Undefined on a grace-window hit: keep the existing cookie instead of rotating it. */
404
453
  refreshToken: string | undefined;
405
454
  user: User;
455
+ adminRole: AdminRole | null;
406
456
  }
407
457
 
408
458
  export async function refreshAccessToken(params: {
@@ -422,6 +472,7 @@ export async function refreshAccessToken(params: {
422
472
  recordRefreshOutcome('rejected');
423
473
  throw new TokenInvalidError('Refresh token is invalid or expired');
424
474
  }
475
+ const adminRole = await getCurrentAdminRole({userId: user.id});
425
476
 
426
477
  // Within the grace window a rotated token means a concurrent refresh (e.g. a
427
478
  // second tab); past it, reuse of a retired token means a compromised session.
@@ -429,7 +480,7 @@ export async function refreshAccessToken(params: {
429
480
  if (isWithinRotationGrace(current)) {
430
481
  const token = await signAccessToken(user, params.workspaces, current.sessionId);
431
482
  recordRefreshOutcome('grace');
432
- return {token, refreshToken: undefined, user};
483
+ return {token, refreshToken: undefined, user, adminRole};
433
484
  }
434
485
  await revokeRefreshTokensForUser({userId: user.id});
435
486
  recordRefreshOutcome('rejected');
@@ -453,18 +504,19 @@ export async function refreshAccessToken(params: {
453
504
  }
454
505
  const token = await signAccessToken(user, params.workspaces, latest.sessionId);
455
506
  recordRefreshOutcome('grace');
456
- return {token, refreshToken: undefined, user};
507
+ return {token, refreshToken: undefined, user, adminRole};
457
508
  }
458
509
 
459
510
  const token = await signAccessToken(user, params.workspaces, current.sessionId);
460
511
  recordRefreshOutcome('rotated');
461
- return {token, refreshToken: nextRefreshToken, user};
512
+ return {token, refreshToken: nextRefreshToken, user, adminRole};
462
513
  }
463
514
 
464
515
  export interface ConfirmEmailVerificationResult {
465
516
  token: string;
466
517
  refreshToken: string;
467
518
  user: User;
519
+ adminRole: AdminRole | null;
468
520
  }
469
521
 
470
522
  export interface ResendEmailVerificationResult {
@@ -491,9 +543,12 @@ export async function confirmEmailVerification(params: {
491
543
  throw new TokenInvalidError('Verification code is invalid or expired');
492
544
  }
493
545
 
494
- const {token, refreshToken} = await createSessionTokens(verifiedUser, params.workspaces);
546
+ const {token, refreshToken, adminRole} = await createSessionTokens(
547
+ verifiedUser,
548
+ params.workspaces,
549
+ );
495
550
 
496
- return {token, refreshToken, user: verifiedUser};
551
+ return {token, refreshToken, user: verifiedUser, adminRole};
497
552
  }
498
553
 
499
554
  export async function resendEmailVerification(params: {
@@ -537,6 +592,7 @@ export interface ConfirmPasswordResetResult {
537
592
  token: string;
538
593
  refreshToken: string;
539
594
  user: User;
595
+ adminRole: AdminRole | null;
540
596
  }
541
597
 
542
598
  export async function confirmPasswordReset(params: {
@@ -562,9 +618,9 @@ export async function confirmPasswordReset(params: {
562
618
 
563
619
  await revokeRefreshTokensForUser({userId: consumed.userId});
564
620
 
565
- const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
621
+ const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
566
622
 
567
- return {token, refreshToken, user};
623
+ return {token, refreshToken, user, adminRole};
568
624
  }
569
625
 
570
626
  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,58 @@ 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
+
100
+ export class AdminGrantNotFoundError extends Error {
101
+ constructor() {
102
+ super('Administrator grant not found');
103
+ this.name = 'AdminGrantNotFoundError';
104
+ }
105
+ }
106
+
107
+ export class AdminGrantAlreadyExistsError extends Error {
108
+ constructor() {
109
+ super('Administrator grant already exists');
110
+ this.name = 'AdminGrantAlreadyExistsError';
111
+ }
112
+ }
113
+
114
+ export class AdminBootstrapClosedError extends Error {
115
+ constructor() {
116
+ super('First administrator owner has already been created');
117
+ this.name = 'AdminBootstrapClosedError';
118
+ }
119
+ }
120
+
121
+ export class InvalidAdminBootstrapTokenError extends Error {
122
+ constructor() {
123
+ super('Bootstrap token is invalid');
124
+ this.name = 'InvalidAdminBootstrapTokenError';
125
+ }
126
+ }
127
+
128
+ export class AdminIdempotencyKeyReuseError extends Error {
129
+ constructor() {
130
+ super('Idempotency key was already used for a different command');
131
+ this.name = 'AdminIdempotencyKeyReuseError';
132
+ }
133
+ }
134
+
76
135
  export class TokenInvalidError extends Error {
77
136
  constructor(reason?: string) {
78
137
  super(reason ? `Invalid token: ${reason}` : 'Invalid token');
@@ -30,6 +30,7 @@ describe('jwt', () => {
30
30
  expect(claims.email).toBe(email);
31
31
  expect(claims.name).toBe('Token User');
32
32
  expect(claims.memberships).toEqual(memberships);
33
+ expect(claims).not.toHaveProperty('adminRole');
33
34
  expect(claims.iat).toBeTypeOf('number');
34
35
  expect(claims.exp).toBeGreaterThan(claims.iat);
35
36
  });
@@ -0,0 +1,7 @@
1
+ export interface SignupPolicy {
2
+ isSignupAllowed(params: {
3
+ email: string;
4
+ emailVerified: boolean;
5
+ source: string;
6
+ }): Promise<{allowed: true} | {allowed: false; message?: string}>;
7
+ }
@@ -0,0 +1,78 @@
1
+ import {config} from '#config.js';
2
+ import {
3
+ createEnvironmentSignupPolicy,
4
+ DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE,
5
+ } from './signup-policy.js';
6
+
7
+ vi.mock('#config.js', () => ({
8
+ config: {
9
+ AUTH_SIGNUP_GATE_ENABLED: true,
10
+ AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: ' Shipfox.io, acme.com ',
11
+ AUTH_SIGNUP_ALLOWED_EMAILS: ' Alice@Example.com, ',
12
+ AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined,
13
+ },
14
+ }));
15
+
16
+ describe('createEnvironmentSignupPolicy', () => {
17
+ afterEach(() => {
18
+ vi.mocked(config).AUTH_SIGNUP_GATE_ENABLED = true;
19
+ vi.mocked(config).AUTH_SIGNUP_NOT_ALLOWED_MESSAGE = undefined;
20
+ });
21
+
22
+ test('allows exact addresses and domains after normalization', async () => {
23
+ const policy = createEnvironmentSignupPolicy();
24
+
25
+ await expect(
26
+ policy.isSignupAllowed({
27
+ email: ' alice@example.com ',
28
+ emailVerified: false,
29
+ source: 'password',
30
+ }),
31
+ ).resolves.toEqual({allowed: true});
32
+ await expect(
33
+ policy.isSignupAllowed({
34
+ email: 'member@SHIPFOX.IO',
35
+ emailVerified: true,
36
+ source: 'oauth-google',
37
+ }),
38
+ ).resolves.toEqual({allowed: true});
39
+ });
40
+
41
+ test('does not match subdomains', async () => {
42
+ const policy = createEnvironmentSignupPolicy();
43
+
44
+ await expect(
45
+ policy.isSignupAllowed({
46
+ email: 'member@eu.shipfox.io',
47
+ emailVerified: true,
48
+ source: 'oauth-google',
49
+ }),
50
+ ).resolves.toEqual({allowed: false, message: DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE});
51
+ });
52
+
53
+ test('returns the configured denial message for an unmatched address', async () => {
54
+ vi.mocked(config).AUTH_SIGNUP_NOT_ALLOWED_MESSAGE = 'Ask your administrator for access.';
55
+ const policy = createEnvironmentSignupPolicy();
56
+
57
+ await expect(
58
+ policy.isSignupAllowed({
59
+ email: 'member@other.example',
60
+ emailVerified: true,
61
+ source: 'oauth-google',
62
+ }),
63
+ ).resolves.toEqual({allowed: false, message: 'Ask your administrator for access.'});
64
+ });
65
+
66
+ test('allows every address when the gate is disabled', async () => {
67
+ vi.mocked(config).AUTH_SIGNUP_GATE_ENABLED = false;
68
+ const policy = createEnvironmentSignupPolicy();
69
+
70
+ await expect(
71
+ policy.isSignupAllowed({
72
+ email: 'member@other.example',
73
+ emailVerified: false,
74
+ source: 'password',
75
+ }),
76
+ ).resolves.toEqual({allowed: true});
77
+ });
78
+ });
@@ -0,0 +1,42 @@
1
+ import {config} from '#config.js';
2
+ import type {SignupPolicy} from './ports.js';
3
+
4
+ export const DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE =
5
+ 'This Shipfox deployment does not accept new accounts right now.';
6
+
7
+ /**
8
+ * Builds the default signup policy from the Auth module's environment settings.
9
+ *
10
+ * The allowlist is resolved once at startup. Environment values are normalized
11
+ * before matching, but the configured denial message is returned unchanged.
12
+ */
13
+ export function createEnvironmentSignupPolicy(): SignupPolicy {
14
+ const allowedEmails = parseAllowlist(config.AUTH_SIGNUP_ALLOWED_EMAILS);
15
+ const allowedDomains = parseAllowlist(config.AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS);
16
+
17
+ return {
18
+ isSignupAllowed({email}) {
19
+ if (!config.AUTH_SIGNUP_GATE_ENABLED) return Promise.resolve({allowed: true});
20
+
21
+ const normalizedEmail = normalize(email);
22
+ if (allowedEmails.has(normalizedEmail)) return Promise.resolve({allowed: true});
23
+
24
+ const atIndex = normalizedEmail.lastIndexOf('@');
25
+ const domain = atIndex === -1 ? '' : normalizedEmail.slice(atIndex + 1);
26
+ if (allowedDomains.has(domain)) return Promise.resolve({allowed: true});
27
+
28
+ return Promise.resolve({
29
+ allowed: false,
30
+ message: config.AUTH_SIGNUP_NOT_ALLOWED_MESSAGE ?? DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE,
31
+ });
32
+ },
33
+ };
34
+ }
35
+
36
+ function parseAllowlist(value: string): ReadonlySet<string> {
37
+ return new Set(value.split(',').map(normalize).filter(Boolean));
38
+ }
39
+
40
+ function normalize(value: string): string {
41
+ return value.trim().toLowerCase();
42
+ }
@@ -0,0 +1,57 @@
1
+ import {eq} from 'drizzle-orm';
2
+ import {LastAdminOwnerError} from '#core/errors.js';
3
+ import {userFactory} from '#test/index.js';
4
+ import {
5
+ createAdminGrant,
6
+ findCurrentAdminRole,
7
+ listAdminGrants,
8
+ revokeAdminGrant,
9
+ } from './admin-grants.js';
10
+ import {db} from './db.js';
11
+ import {users} from './schema/users.js';
12
+
13
+ describe('admin grants db', () => {
14
+ test('evaluates the highest active grant independently of workspace roles', async () => {
15
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
16
+ const observer = await createAdminGrant({userId: user.id, role: 'admin-observer'});
17
+ await createAdminGrant({userId: user.id, role: 'admin-operator'});
18
+
19
+ expect(observer.userId).toBe(user.id);
20
+ expect(await findCurrentAdminRole({userId: user.id})).toBe('admin-operator');
21
+ expect((await listAdminGrants()).filter((grant) => grant.userId === user.id)).toHaveLength(2);
22
+ });
23
+
24
+ test('does not evaluate revoked grants or grants for suspended users', async () => {
25
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
26
+ const grant = await createAdminGrant({userId: user.id, role: 'admin-owner'});
27
+
28
+ await expect(revokeAdminGrant({grantId: grant.id})).rejects.toBeInstanceOf(LastAdminOwnerError);
29
+ expect(await findCurrentAdminRole({userId: user.id})).toBe('admin-owner');
30
+
31
+ await db().update(users).set({status: 'suspended'}).where(eq(users.id, user.id));
32
+ expect(await findCurrentAdminRole({userId: user.id})).toBeNull();
33
+ });
34
+
35
+ test('prevents revoking the final active owner but permits replacement first', async () => {
36
+ const firstOwner = await userFactory.create({emailVerifiedAt: new Date()});
37
+ const secondOwner = await userFactory.create({emailVerifiedAt: new Date()});
38
+ const firstGrant = await createAdminGrant({userId: firstOwner.id, role: 'admin-owner'});
39
+ const secondGrant = await createAdminGrant({userId: secondOwner.id, role: 'admin-owner'});
40
+
41
+ await expect(revokeAdminGrant({grantId: firstGrant.id})).resolves.toMatchObject({
42
+ id: firstGrant.id,
43
+ revokedAt: expect.any(Date),
44
+ });
45
+ await expect(revokeAdminGrant({grantId: secondGrant.id})).rejects.toBeInstanceOf(
46
+ LastAdminOwnerError,
47
+ );
48
+ expect(await findCurrentAdminRole({userId: secondOwner.id})).toBe('admin-owner');
49
+ });
50
+
51
+ test('enforces one active grant of each fixed role per user', async () => {
52
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
53
+ await createAdminGrant({userId: user.id, role: 'admin-observer'});
54
+
55
+ await expect(createAdminGrant({userId: user.id, role: 'admin-observer'})).rejects.toThrow();
56
+ });
57
+ });