@shipfox/api-auth 5.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +52 -0
  3. package/README.md +71 -37
  4. package/dist/config.d.ts +0 -12
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +0 -62
  7. package/dist/config.js.map +1 -1
  8. package/dist/core/auth.d.ts +21 -2
  9. package/dist/core/auth.d.ts.map +1 -1
  10. package/dist/core/auth.js +109 -77
  11. package/dist/core/auth.js.map +1 -1
  12. package/dist/core/email-owner.d.ts +12 -0
  13. package/dist/core/email-owner.d.ts.map +1 -0
  14. package/dist/core/email-owner.js +20 -0
  15. package/dist/core/email-owner.js.map +1 -0
  16. package/dist/core/job-lease-token.d.ts.map +1 -1
  17. package/dist/core/job-lease-token.js +3 -2
  18. package/dist/core/job-lease-token.js.map +1 -1
  19. package/dist/core/jwt.d.ts +2 -2
  20. package/dist/core/jwt.d.ts.map +1 -1
  21. package/dist/core/jwt.js.map +1 -1
  22. package/dist/core/rate-limit.d.ts.map +1 -1
  23. package/dist/core/rate-limit.js +2 -7
  24. package/dist/core/rate-limit.js.map +1 -1
  25. package/dist/core/runner-session-token.d.ts.map +1 -1
  26. package/dist/core/runner-session-token.js +3 -2
  27. package/dist/core/runner-session-token.js.map +1 -1
  28. package/dist/db/db.d.ts +0 -218
  29. package/dist/db/db.d.ts.map +1 -1
  30. package/dist/db/db.js +0 -2
  31. package/dist/db/db.js.map +1 -1
  32. package/dist/db/users.d.ts +3 -0
  33. package/dist/db/users.d.ts.map +1 -1
  34. package/dist/db/users.js +28 -10
  35. package/dist/db/users.js.map +1 -1
  36. package/dist/index.d.ts +5 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +40 -34
  39. package/dist/index.js.map +1 -1
  40. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  41. package/dist/presentation/auth/jwt-auth.js +2 -2
  42. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  43. package/dist/presentation/e2eRoutes/create-session.d.ts +2 -1
  44. package/dist/presentation/e2eRoutes/create-session.d.ts.map +1 -1
  45. package/dist/presentation/e2eRoutes/create-session.js +42 -39
  46. package/dist/presentation/e2eRoutes/create-session.js.map +1 -1
  47. package/dist/presentation/e2eRoutes/index.d.ts +2 -1
  48. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -1
  49. package/dist/presentation/e2eRoutes/index.js +12 -10
  50. package/dist/presentation/e2eRoutes/index.js.map +1 -1
  51. package/dist/presentation/inter-module.d.ts +4 -0
  52. package/dist/presentation/inter-module.d.ts.map +1 -0
  53. package/dist/presentation/inter-module.js +16 -0
  54. package/dist/presentation/inter-module.js.map +1 -0
  55. package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts +2 -1
  56. package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts.map +1 -1
  57. package/dist/presentation/routes/email-verification/verify-email-confirm.js +36 -25
  58. package/dist/presentation/routes/email-verification/verify-email-confirm.js.map +1 -1
  59. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
  60. package/dist/presentation/routes/email-verification/verify-email-resend.js +20 -4
  61. package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
  62. package/dist/presentation/routes/index.d.ts +2 -2
  63. package/dist/presentation/routes/index.d.ts.map +1 -1
  64. package/dist/presentation/routes/index.js +11 -13
  65. package/dist/presentation/routes/index.js.map +1 -1
  66. package/dist/presentation/routes/password/password-reset-confirm.d.ts +2 -1
  67. package/dist/presentation/routes/password/password-reset-confirm.d.ts.map +1 -1
  68. package/dist/presentation/routes/password/password-reset-confirm.js +29 -26
  69. package/dist/presentation/routes/password/password-reset-confirm.js.map +1 -1
  70. package/dist/presentation/routes/registration/signup.d.ts +2 -1
  71. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  72. package/dist/presentation/routes/registration/signup.js +86 -68
  73. package/dist/presentation/routes/registration/signup.js.map +1 -1
  74. package/dist/presentation/routes/session/login.d.ts +2 -1
  75. package/dist/presentation/routes/session/login.d.ts.map +1 -1
  76. package/dist/presentation/routes/session/login.js +40 -37
  77. package/dist/presentation/routes/session/login.js.map +1 -1
  78. package/dist/presentation/routes/session/refresh.d.ts +2 -1
  79. package/dist/presentation/routes/session/refresh.d.ts.map +1 -1
  80. package/dist/presentation/routes/session/refresh.js +42 -39
  81. package/dist/presentation/routes/session/refresh.js.map +1 -1
  82. package/dist/presentation/subscribers/index.d.ts +0 -1
  83. package/dist/presentation/subscribers/index.d.ts.map +1 -1
  84. package/dist/presentation/subscribers/index.js +0 -1
  85. package/dist/presentation/subscribers/index.js.map +1 -1
  86. package/dist/presentation/subscribers/on-password-reset-send-requested.js +1 -1
  87. package/dist/presentation/subscribers/on-password-reset-send-requested.js.map +1 -1
  88. package/dist/tsconfig.test.tsbuildinfo +1 -1
  89. package/drizzle/0000_initial.sql +1 -13
  90. package/drizzle/meta/0000_snapshot.json +0 -92
  91. package/package.json +17 -13
  92. package/src/config.ts +0 -56
  93. package/src/core/auth.test.ts +106 -187
  94. package/src/core/auth.ts +131 -87
  95. package/src/core/email-owner.test.ts +86 -0
  96. package/src/core/email-owner.ts +23 -0
  97. package/src/core/job-lease-token.test.ts +2 -1
  98. package/src/core/job-lease-token.ts +3 -2
  99. package/src/core/jwt.ts +2 -2
  100. package/src/core/rate-limit.test.ts +11 -28
  101. package/src/core/rate-limit.ts +3 -11
  102. package/src/core/runner-session-token.test.ts +2 -1
  103. package/src/core/runner-session-token.ts +3 -2
  104. package/src/db/db.ts +0 -2
  105. package/src/db/users.ts +32 -14
  106. package/src/index.test.ts +15 -11
  107. package/src/index.ts +23 -20
  108. package/src/presentation/auth/jwt-auth.test.ts +2 -1
  109. package/src/presentation/auth/jwt-auth.ts +2 -2
  110. package/src/presentation/e2eRoutes/create-session.ts +34 -30
  111. package/src/presentation/e2eRoutes/index.test.ts +9 -6
  112. package/src/presentation/e2eRoutes/index.ts +8 -5
  113. package/src/presentation/inter-module.ts +13 -0
  114. package/src/presentation/routes/email-verification/verify-email-confirm.ts +42 -24
  115. package/src/presentation/routes/email-verification/verify-email-resend.ts +18 -4
  116. package/src/presentation/routes/index.test.ts +10 -3
  117. package/src/presentation/routes/index.ts +22 -12
  118. package/src/presentation/routes/password/password-reset-confirm.ts +25 -22
  119. package/src/presentation/routes/password/password-reset-request.test.ts +1 -0
  120. package/src/presentation/routes/rate-limit.test.ts +2 -2
  121. package/src/presentation/routes/registration/signup.test.ts +39 -74
  122. package/src/presentation/routes/registration/signup.ts +89 -70
  123. package/src/presentation/routes/session/login.ts +38 -35
  124. package/src/presentation/routes/session/refresh.ts +43 -40
  125. package/src/presentation/subscribers/email-send-requested.test.ts +2 -30
  126. package/src/presentation/subscribers/index.ts +0 -1
  127. package/src/presentation/subscribers/on-password-reset-send-requested.ts +1 -1
  128. package/test/env.ts +1 -3
  129. package/test/globalSetup.ts +10 -1
  130. package/test/routes.ts +51 -84
  131. package/tsconfig.build.tsbuildinfo +1 -1
  132. package/dist/core/entities/email-verification.d.ts +0 -9
  133. package/dist/core/entities/email-verification.d.ts.map +0 -1
  134. package/dist/core/entities/email-verification.js +0 -3
  135. package/dist/core/entities/email-verification.js.map +0 -1
  136. package/dist/db/email-verifications.d.ts +0 -39
  137. package/dist/db/email-verifications.d.ts.map +0 -1
  138. package/dist/db/email-verifications.js +0 -95
  139. package/dist/db/email-verifications.js.map +0 -1
  140. package/dist/db/schema/email-verifications.d.ts +0 -114
  141. package/dist/db/schema/email-verifications.d.ts.map +0 -1
  142. package/dist/db/schema/email-verifications.js +0 -35
  143. package/dist/db/schema/email-verifications.js.map +0 -1
  144. package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts +0 -3
  145. package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts.map +0 -1
  146. package/dist/presentation/subscribers/on-email-verification-send-requested.js +0 -13
  147. package/dist/presentation/subscribers/on-email-verification-send-requested.js.map +0 -1
  148. package/src/core/entities/email-verification.ts +0 -8
  149. package/src/db/email-verifications.test.ts +0 -61
  150. package/src/db/email-verifications.ts +0 -160
  151. package/src/db/schema/email-verifications.ts +0 -37
  152. package/src/presentation/routes/email-verification/verify-email-confirm.test.ts +0 -66
  153. package/src/presentation/routes/email-verification/verify-email-resend.test.ts +0 -100
  154. package/src/presentation/subscribers/on-email-verification-send-requested.ts +0 -10
@@ -2,34 +2,37 @@ import {
2
2
  passwordResetConfirmBodySchema,
3
3
  passwordResetConfirmResponseSchema,
4
4
  } from '@shipfox/api-auth-dto';
5
+ import type {WorkspacesInterModuleClient} from '@shipfox/api-workspaces-dto/inter-module';
5
6
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
6
7
  import {confirmPasswordReset} from '#core/auth.js';
7
8
  import {TokenInvalidError} from '#core/errors.js';
8
9
  import {setRefreshTokenCookie} from '#presentation/auth/refresh-cookie.js';
9
10
  import {toAuthSessionDto} from '#presentation/dto/user.js';
10
11
 
11
- export const passwordResetConfirmRoute = defineRoute({
12
- method: 'POST',
13
- path: '/password-reset/confirm',
14
- description: 'Set a new password using the link sent by email.',
15
- schema: {
16
- body: passwordResetConfirmBodySchema,
17
- response: {
18
- 200: passwordResetConfirmResponseSchema,
12
+ export function createPasswordResetConfirmRoute(workspaces: WorkspacesInterModuleClient) {
13
+ return defineRoute({
14
+ method: 'POST',
15
+ path: '/password-reset/confirm',
16
+ description: 'Set a new password using the link sent by email.',
17
+ schema: {
18
+ body: passwordResetConfirmBodySchema,
19
+ response: {
20
+ 200: passwordResetConfirmResponseSchema,
21
+ },
19
22
  },
20
- },
21
- errorHandler: (error) => {
22
- if (error instanceof TokenInvalidError) {
23
- throw new ClientError('Reset token is invalid or expired', 'token-invalid', {status: 410});
24
- }
25
- throw error;
26
- },
27
- handler: async (request, reply) => {
28
- const {token, new_password} = request.body;
23
+ errorHandler: (error) => {
24
+ if (error instanceof TokenInvalidError) {
25
+ throw new ClientError('Reset token is invalid or expired', 'token-invalid', {status: 410});
26
+ }
27
+ throw error;
28
+ },
29
+ handler: async (request, reply) => {
30
+ const {token, new_password} = request.body;
29
31
 
30
- const result = await confirmPasswordReset({token, newPassword: new_password});
31
- setRefreshTokenCookie(reply, result.refreshToken);
32
+ const result = await confirmPasswordReset({token, newPassword: new_password, workspaces});
33
+ setRefreshTokenCookie(reply, result.refreshToken);
32
34
 
33
- return toAuthSessionDto(result);
34
- },
35
- });
35
+ return toAuthSessionDto(result);
36
+ },
37
+ });
38
+ }
@@ -27,6 +27,7 @@ describe('POST /auth/password-reset', () => {
27
27
 
28
28
  test('returns 204 and sends reset mail for an active account', async () => {
29
29
  const account = await signupVerifyLogin(app, 'reset-request');
30
+ resetCapturedMail();
30
31
 
31
32
  const res = await app.inject({
32
33
  method: 'POST',
@@ -210,13 +210,13 @@ describe('auth rate-limit routes', () => {
210
210
  method: 'POST',
211
211
  url: '/auth/password-reset',
212
212
  headers: {'x-forwarded-for': uniqueIp()},
213
- payload: {email: account.email},
213
+ payload: {email: account.email, challenge_id: account.emailChallengeId},
214
214
  });
215
215
  const blockedResend = await app.inject({
216
216
  method: 'POST',
217
217
  url: '/auth/verify-email/resend',
218
218
  headers: {'x-forwarded-for': uniqueIp()},
219
- payload: {email: account.email},
219
+ payload: {email: account.email, challenge_id: account.emailChallengeId},
220
220
  });
221
221
 
222
222
  expect(blockedReset.statusCode).toBe(429);
@@ -1,4 +1,5 @@
1
- import {AUTH_EMAIL_VERIFICATION_SEND_REQUESTED} from '@shipfox/api-auth-dto';
1
+ import {workspacesInterModuleContract} from '@shipfox/api-workspaces-dto/inter-module';
2
+ import {createInterModuleKnownError} from '@shipfox/inter-module';
2
3
  import type {FastifyInstance} from 'fastify';
3
4
  import {verifyUserToken} from '#core/jwt.js';
4
5
  import {
@@ -6,9 +7,7 @@ import {
6
7
  capturedMail,
7
8
  createAuthTestApp,
8
9
  getSetCookie,
9
- latestEmailLinkTo,
10
10
  listMembershipsByUserMock,
11
- outboxEventsTo,
12
11
  peekInvitationByRawTokenMock,
13
12
  ROUTE_TEST_SECRET,
14
13
  resetCapturedMail,
@@ -36,7 +35,7 @@ describe('POST /auth/signup', () => {
36
35
  const email = uniqueEmail('signup');
37
36
 
38
37
  const res = await signup(app, {
39
- email: email.toUpperCase(),
38
+ email: ` ${email.toUpperCase()} `,
40
39
  password,
41
40
  name: ' New User ',
42
41
  });
@@ -45,10 +44,11 @@ describe('POST /auth/signup', () => {
45
44
  expect(res.json().user.email).toBe(email);
46
45
  expect(res.json().user.name).toBe('New User');
47
46
  expect(res.json().user.email_verified_at).toBeNull();
48
- expect(await latestEmailLinkTo(email, AUTH_EMAIL_VERIFICATION_SEND_REQUESTED)).toContain(
49
- '/auth/verify-email?token=',
50
- );
51
- expect(capturedMail()).toHaveLength(0);
47
+ expect(res.json().email_challenge).toEqual({
48
+ id: expect.any(String),
49
+ next_resend_available_at: expect.any(String),
50
+ });
51
+ expect(capturedMail()).toHaveLength(1);
52
52
  });
53
53
 
54
54
  test.each([
@@ -72,14 +72,24 @@ describe('POST /auth/signup', () => {
72
72
  expect(res.statusCode).toBe(400);
73
73
  });
74
74
 
75
- test('transforms duplicate email into 409', async () => {
75
+ test('resumes verification when an unverified user retries signup with the same password', async () => {
76
76
  const email = uniqueEmail('duplicate');
77
77
  await signup(app, {email, password});
78
78
 
79
79
  const res = await signup(app, {email, password});
80
80
 
81
- expect(res.statusCode).toBe(409);
82
- expect(res.json().code).toBe('email-taken');
81
+ expect(res.statusCode).toBe(201);
82
+ expect(res.json().email_challenge.id).toEqual(expect.any(String));
83
+ });
84
+
85
+ test('normalizes email when resuming verification', async () => {
86
+ const email = uniqueEmail('duplicate-equivalent');
87
+ await signup(app, {email, password});
88
+
89
+ const res = await signup(app, {email: ` ${email.toUpperCase()} `, password});
90
+
91
+ expect(res.statusCode).toBe(201);
92
+ expect(res.json().email_challenge.id).toEqual(expect.any(String));
83
93
  });
84
94
 
85
95
  test('accepts a valid invitation during signup and skips verification email', async () => {
@@ -93,6 +103,7 @@ describe('POST /auth/signup', () => {
93
103
  email,
94
104
  hashedToken: 'hashed',
95
105
  expiresAt: new Date(Date.now() + 86_400_000),
106
+ revokedAt: null,
96
107
  acceptedAt: null,
97
108
  acceptedByUserId: null,
98
109
  invitedByUserId: crypto.randomUUID(),
@@ -107,6 +118,7 @@ describe('POST /auth/signup', () => {
107
118
  email,
108
119
  hashedToken: 'hashed',
109
120
  expiresAt: new Date(Date.now() + 86_400_000),
121
+ revokedAt: null,
110
122
  acceptedAt: new Date(),
111
123
  acceptedByUserId: params.userId,
112
124
  invitedByUserId: crypto.randomUUID(),
@@ -126,18 +138,9 @@ describe('POST /auth/signup', () => {
126
138
  },
127
139
  alreadyMember: false,
128
140
  }));
129
- listMembershipsByUserMock.mockResolvedValueOnce([
130
- {
131
- id: membershipId,
132
- userId: crypto.randomUUID(),
133
- userEmail: email,
134
- userName: 'Invitee',
135
- workspaceId,
136
- workspaceName: 'Acme',
137
- createdAt: new Date(),
138
- updatedAt: new Date(),
139
- },
140
- ]);
141
+ listMembershipsByUserMock.mockResolvedValueOnce({
142
+ memberships: [{workspaceId, role: 'admin' as const}],
143
+ });
141
144
  const res = await app.inject({
142
145
  method: 'POST',
143
146
  url: '/auth/signup',
@@ -162,7 +165,6 @@ describe('POST /auth/signup', () => {
162
165
  });
163
166
  expect(body.accept_error).toBeUndefined();
164
167
  expect(claims.memberships).toEqual([{workspaceId, role: 'admin'}]);
165
- expect(await outboxEventsTo(email, AUTH_EMAIL_VERIFICATION_SEND_REQUESTED)).toHaveLength(0);
166
168
  expect(capturedMail()).toHaveLength(0);
167
169
  });
168
170
 
@@ -175,6 +177,7 @@ describe('POST /auth/signup', () => {
175
177
  email,
176
178
  hashedToken: 'hashed',
177
179
  expiresAt: new Date(Date.now() + 86_400_000),
180
+ revokedAt: null,
178
181
  acceptedAt: null,
179
182
  acceptedByUserId: null,
180
183
  invitedByUserId: crypto.randomUUID(),
@@ -206,65 +209,27 @@ describe('POST /auth/signup', () => {
206
209
  });
207
210
 
208
211
  test.each([
209
- {
210
- name: 'invalid token',
211
- invitation: undefined,
212
- expectedStatus: 410,
213
- expectedCode: 'invitation-token-invalid',
214
- },
212
+ {name: 'invalid token', expectedCode: 'invitation-token-invalid' as const, expectedStatus: 410},
215
213
  {
216
214
  name: 'already accepted token',
217
- invitation: {
218
- acceptedAt: new Date(),
219
- expiresAt: new Date(Date.now() + 86_400_000),
220
- email: uniqueEmail('signup-invite-used'),
221
- },
222
- expectedStatus: 410,
223
- expectedCode: 'invitation-token-used',
224
- },
225
- {
226
- name: 'expired token',
227
- invitation: {
228
- acceptedAt: null,
229
- expiresAt: new Date(Date.now() - 60_000),
230
- email: uniqueEmail('signup-invite-expired'),
231
- },
215
+ expectedCode: 'invitation-token-used' as const,
232
216
  expectedStatus: 410,
233
- expectedCode: 'invitation-token-expired',
234
217
  },
218
+ {name: 'expired token', expectedCode: 'invitation-token-expired' as const, expectedStatus: 410},
235
219
  {
236
220
  name: 'email mismatch',
237
- invitation: {
238
- acceptedAt: null,
239
- expiresAt: new Date(Date.now() + 86_400_000),
240
- email: uniqueEmail('signup-invite-other'),
241
- },
221
+ expectedCode: 'invitation-email-mismatch' as const,
242
222
  expectedStatus: 403,
243
- expectedCode: 'invitation-email-mismatch',
244
223
  },
245
- ])('rejects signup with invitation for $name', async ({
246
- invitation,
247
- expectedStatus,
248
- expectedCode,
249
- }) => {
224
+ ])('rejects signup with invitation for $name', async ({expectedCode, expectedStatus}) => {
250
225
  const email = uniqueEmail('signup-invite-error');
251
- if (invitation === undefined) {
252
- peekInvitationByRawTokenMock.mockResolvedValueOnce(undefined);
253
- } else {
254
- peekInvitationByRawTokenMock.mockResolvedValueOnce({
255
- id: crypto.randomUUID(),
256
- workspaceId: crypto.randomUUID(),
257
- email: invitation.email,
258
- hashedToken: 'hashed',
259
- expiresAt: invitation.expiresAt,
260
- acceptedAt: invitation.acceptedAt,
261
- acceptedByUserId: null,
262
- invitedByUserId: crypto.randomUUID(),
263
- invitedByDisplay: null,
264
- createdAt: new Date(),
265
- updatedAt: new Date(),
266
- });
267
- }
226
+ peekInvitationByRawTokenMock.mockRejectedValueOnce(
227
+ createInterModuleKnownError(
228
+ workspacesInterModuleContract.methods.preflightInvitationAcceptance,
229
+ expectedCode,
230
+ {},
231
+ ),
232
+ );
268
233
 
269
234
  const res = await app.inject({
270
235
  method: 'POST',
@@ -1,81 +1,100 @@
1
1
  import {signupBodySchema, signupResponseSchema} from '@shipfox/api-auth-dto';
2
+ import {EmailChallengeError} from '@shipfox/api-email-challenges';
3
+ import type {WorkspacesInterModuleClient} from '@shipfox/api-workspaces-dto/inter-module';
4
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
5
+ import {signup, signupWithInvitation} from '#core/auth.js';
2
6
  import {
7
+ EmailTakenError,
3
8
  InvitationEmailMismatchError,
4
9
  TokenAlreadyUsedError,
5
10
  TokenExpiredError,
6
- TokenInvalidError as WorkspacesTokenInvalidError,
7
- } from '@shipfox/api-workspaces';
8
- import {ClientError, defineRoute} from '@shipfox/node-fastify';
9
- import {signup, signupWithInvitation} from '#core/auth.js';
10
- import {EmailTakenError} from '#core/errors.js';
11
+ TokenInvalidError,
12
+ } from '#core/errors.js';
11
13
  import {setRefreshTokenCookie} from '#presentation/auth/refresh-cookie.js';
12
14
  import {toUserDto} from '#presentation/dto/user.js';
13
15
 
14
- export const signupRoute = defineRoute({
15
- method: 'POST',
16
- path: '/signup',
17
- description:
18
- 'Create a user account. Sends a verification email unless an invitation token is supplied.',
19
- schema: {
20
- body: signupBodySchema,
21
- response: {
22
- 201: signupResponseSchema,
16
+ export function createSignupRoute(workspaces: WorkspacesInterModuleClient) {
17
+ return defineRoute({
18
+ method: 'POST',
19
+ path: '/signup',
20
+ description:
21
+ 'Create a user account. Sends a verification email unless an invitation token is supplied.',
22
+ schema: {
23
+ body: signupBodySchema,
24
+ response: {
25
+ 201: signupResponseSchema,
26
+ },
23
27
  },
24
- },
25
- errorHandler: (error) => {
26
- if (error instanceof EmailTakenError) {
27
- throw new ClientError('Email already registered', 'email-taken', {status: 409});
28
- }
29
- if (error instanceof WorkspacesTokenInvalidError) {
30
- throw new ClientError('Invitation token is invalid', 'invitation-token-invalid', {
31
- status: 410,
32
- });
33
- }
34
- if (error instanceof TokenAlreadyUsedError) {
35
- throw new ClientError('Invitation has already been accepted', 'invitation-token-used', {
36
- status: 410,
37
- });
38
- }
39
- if (error instanceof TokenExpiredError) {
40
- throw new ClientError('Invitation has expired', 'invitation-token-expired', {status: 410});
41
- }
42
- if (error instanceof InvitationEmailMismatchError) {
43
- throw new ClientError(
44
- 'Signup email does not match the invitation',
45
- 'invitation-email-mismatch',
46
- {status: 403},
47
- );
48
- }
49
- throw error;
50
- },
51
- handler: async (request, reply) => {
52
- const {email, password, name, invitation_token} = request.body;
28
+ errorHandler: (error) => {
29
+ if (error instanceof EmailChallengeError) {
30
+ throw new ClientError(error.message, `email-challenge-${error.code}`, {
31
+ status: error.code === 'limited' ? 429 : 400,
32
+ ...(error.retryAt ? {details: {retry_at: error.retryAt.toISOString()}} : {}),
33
+ });
34
+ }
35
+ if (error instanceof EmailTakenError) {
36
+ throw new ClientError('Email already registered', 'email-taken', {status: 409});
37
+ }
38
+ if (error instanceof TokenInvalidError) {
39
+ throw new ClientError('Invitation token is invalid', 'invitation-token-invalid', {
40
+ status: 410,
41
+ });
42
+ }
43
+ if (error instanceof TokenAlreadyUsedError) {
44
+ throw new ClientError('Invitation has already been accepted', 'invitation-token-used', {
45
+ status: 410,
46
+ });
47
+ }
48
+ if (error instanceof TokenExpiredError) {
49
+ throw new ClientError('Invitation has expired', 'invitation-token-expired', {
50
+ status: 410,
51
+ });
52
+ }
53
+ if (error instanceof InvitationEmailMismatchError) {
54
+ throw new ClientError(
55
+ 'Signup email does not match the invitation',
56
+ 'invitation-email-mismatch',
57
+ {status: 403},
58
+ );
59
+ }
60
+ throw error;
61
+ },
62
+ handler: async (request, reply) => {
63
+ const {email, password, name, invitation_token} = request.body;
53
64
 
54
- if (invitation_token === undefined) {
55
- const user = await signup({email, password, name});
56
- reply.code(201);
57
- return {user: toUserDto(user)};
58
- }
65
+ if (invitation_token === undefined) {
66
+ const result = await signup({email, password, name, sourceIp: request.ip});
67
+ reply.code(201);
68
+ return {
69
+ user: toUserDto(result),
70
+ email_challenge: {
71
+ id: result.emailChallenge.id,
72
+ next_resend_available_at: result.emailChallenge.nextResendAvailableAt.toISOString(),
73
+ },
74
+ };
75
+ }
59
76
 
60
- const result = await signupWithInvitation({
61
- email,
62
- password,
63
- name,
64
- invitationToken: invitation_token,
65
- });
66
- setRefreshTokenCookie(reply, result.refreshToken);
67
- reply.code(201);
68
- return {
69
- user: toUserDto(result.user),
70
- token: result.token,
71
- membership: result.membership
72
- ? {
73
- id: result.membership.id,
74
- user_id: result.membership.userId,
75
- workspace_id: result.membership.workspaceId,
76
- }
77
- : null,
78
- ...(result.acceptError ? {accept_error: result.acceptError} : {}),
79
- };
80
- },
81
- });
77
+ const result = await signupWithInvitation({
78
+ email,
79
+ password,
80
+ name,
81
+ invitationToken: invitation_token,
82
+ workspaces,
83
+ });
84
+ setRefreshTokenCookie(reply, result.refreshToken);
85
+ reply.code(201);
86
+ return {
87
+ user: toUserDto(result.user),
88
+ token: result.token,
89
+ membership: result.membership
90
+ ? {
91
+ id: result.membership.id,
92
+ user_id: result.membership.userId,
93
+ workspace_id: result.membership.workspaceId,
94
+ }
95
+ : null,
96
+ ...(result.acceptError ? {accept_error: result.acceptError} : {}),
97
+ };
98
+ },
99
+ });
100
+ }
@@ -1,4 +1,5 @@
1
1
  import {loginBodySchema, loginResponseSchema} from '@shipfox/api-auth-dto';
2
+ import type {WorkspacesInterModuleClient} from '@shipfox/api-workspaces-dto/inter-module';
2
3
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
3
4
  import {login} from '#core/auth.js';
4
5
  import {
@@ -10,41 +11,43 @@ import {setRefreshTokenCookie} from '#presentation/auth/refresh-cookie.js';
10
11
  import {toAuthSessionDto} from '#presentation/dto/user.js';
11
12
  import {createAuthRateLimitPreHandler} from '#presentation/routes/rate-limit.js';
12
13
 
13
- export const loginRoute = defineRoute({
14
- method: 'POST',
15
- path: '/login',
16
- description: 'Sign in with an email address and password.',
17
- schema: {
18
- body: loginBodySchema,
19
- response: {
20
- 200: loginResponseSchema,
14
+ export function createLoginRoute(workspaces: WorkspacesInterModuleClient) {
15
+ return defineRoute({
16
+ method: 'POST',
17
+ path: '/login',
18
+ description: 'Sign in with an email address and password.',
19
+ schema: {
20
+ body: loginBodySchema,
21
+ response: {
22
+ 200: loginResponseSchema,
23
+ },
21
24
  },
22
- },
23
- preHandler: createAuthRateLimitPreHandler('login'),
24
- errorHandler: (error) => {
25
- if (error instanceof InvalidCredentialsError) {
26
- throw new ClientError('Invalid credentials', 'invalid-credentials', {status: 401});
27
- }
28
- if (error instanceof EmailNotVerifiedError) {
29
- throw new ClientError('Email not verified', 'email-not-verified', {status: 403});
30
- }
31
- if (error instanceof AuthDependencyUnavailableError) {
32
- throw new ClientError(
33
- 'Authentication dependency unavailable',
34
- 'auth-dependency-unavailable',
35
- {
36
- status: 503,
37
- },
38
- );
39
- }
40
- throw error;
41
- },
42
- handler: async (request, reply) => {
43
- const {email, password} = request.body;
25
+ preHandler: createAuthRateLimitPreHandler('login'),
26
+ errorHandler: (error) => {
27
+ if (error instanceof InvalidCredentialsError) {
28
+ throw new ClientError('Invalid credentials', 'invalid-credentials', {status: 401});
29
+ }
30
+ if (error instanceof EmailNotVerifiedError) {
31
+ throw new ClientError('Email not verified', 'email-not-verified', {status: 403});
32
+ }
33
+ if (error instanceof AuthDependencyUnavailableError) {
34
+ throw new ClientError(
35
+ 'Authentication dependency unavailable',
36
+ 'auth-dependency-unavailable',
37
+ {
38
+ status: 503,
39
+ },
40
+ );
41
+ }
42
+ throw error;
43
+ },
44
+ handler: async (request, reply) => {
45
+ const {email, password} = request.body;
44
46
 
45
- const result = await login({email, password});
46
- setRefreshTokenCookie(reply, result.refreshToken);
47
+ const result = await login({email, password, workspaces});
48
+ setRefreshTokenCookie(reply, result.refreshToken);
47
49
 
48
- return toAuthSessionDto(result);
49
- },
50
- });
50
+ return toAuthSessionDto(result);
51
+ },
52
+ });
53
+ }
@@ -1,4 +1,5 @@
1
1
  import {refreshResponseSchema} from '@shipfox/api-auth-dto';
2
+ import type {WorkspacesInterModuleClient} from '@shipfox/api-workspaces-dto/inter-module';
2
3
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
3
4
  import {refreshAccessToken} from '#core/auth.js';
4
5
  import {AuthDependencyUnavailableError, TokenInvalidError} from '#core/errors.js';
@@ -9,46 +10,48 @@ import {
9
10
  } from '#presentation/auth/refresh-cookie.js';
10
11
  import {toAuthSessionDto} from '#presentation/dto/user.js';
11
12
 
12
- export const refreshRoute = defineRoute({
13
- method: 'POST',
14
- path: '/refresh',
15
- description: 'Refresh the current browser session access token.',
16
- schema: {
17
- response: {
18
- 200: refreshResponseSchema,
13
+ export function createRefreshRoute(workspaces: WorkspacesInterModuleClient) {
14
+ return defineRoute({
15
+ method: 'POST',
16
+ path: '/refresh',
17
+ description: 'Refresh the current browser session access token.',
18
+ schema: {
19
+ response: {
20
+ 200: refreshResponseSchema,
21
+ },
19
22
  },
20
- },
21
- errorHandler: (error, _request, reply) => {
22
- if (error instanceof TokenInvalidError) {
23
- clearRefreshTokenCookie(reply);
24
- throw new ClientError('Refresh token is invalid or expired', 'unauthorized', {
25
- status: 401,
26
- });
27
- }
28
- if (error instanceof AuthDependencyUnavailableError) {
29
- throw new ClientError(
30
- 'Authentication dependency unavailable',
31
- 'auth-dependency-unavailable',
32
- {
33
- status: 503,
34
- },
35
- );
36
- }
37
- throw error;
38
- },
39
- handler: async (request, reply) => {
40
- const refreshToken = getRefreshTokenCookie(request);
41
- if (!refreshToken) {
42
- clearRefreshTokenCookie(reply);
43
- throw new ClientError('Refresh token is invalid or expired', 'unauthorized', {status: 401});
44
- }
23
+ errorHandler: (error, _request, reply) => {
24
+ if (error instanceof TokenInvalidError) {
25
+ clearRefreshTokenCookie(reply);
26
+ throw new ClientError('Refresh token is invalid or expired', 'unauthorized', {
27
+ status: 401,
28
+ });
29
+ }
30
+ if (error instanceof AuthDependencyUnavailableError) {
31
+ throw new ClientError(
32
+ 'Authentication dependency unavailable',
33
+ 'auth-dependency-unavailable',
34
+ {
35
+ status: 503,
36
+ },
37
+ );
38
+ }
39
+ throw error;
40
+ },
41
+ handler: async (request, reply) => {
42
+ const refreshToken = getRefreshTokenCookie(request);
43
+ if (!refreshToken) {
44
+ clearRefreshTokenCookie(reply);
45
+ throw new ClientError('Refresh token is invalid or expired', 'unauthorized', {status: 401});
46
+ }
45
47
 
46
- const result = await refreshAccessToken({refreshToken});
48
+ const result = await refreshAccessToken({refreshToken, workspaces});
47
49
 
48
- // Absent on a grace-window hit: keep the existing cookie rather than overwrite it.
49
- if (result.refreshToken) {
50
- setRefreshTokenCookie(reply, result.refreshToken);
51
- }
52
- return toAuthSessionDto(result);
53
- },
54
- });
50
+ // Absent on a grace-window hit: keep the existing cookie rather than overwrite it.
51
+ if (result.refreshToken) {
52
+ setRefreshTokenCookie(reply, result.refreshToken);
53
+ }
54
+ return toAuthSessionDto(result);
55
+ },
56
+ });
57
+ }