@shipfox/api-auth 2.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 (319) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +116 -0
  5. package/LICENSE +21 -0
  6. package/README.md +336 -0
  7. package/dist/config.d.ts +22 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +95 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/core/auth.d.ts +108 -0
  12. package/dist/core/auth.d.ts.map +1 -0
  13. package/dist/core/auth.js +476 -0
  14. package/dist/core/auth.js.map +1 -0
  15. package/dist/core/entities/email-verification.d.ts +9 -0
  16. package/dist/core/entities/email-verification.d.ts.map +1 -0
  17. package/dist/core/entities/email-verification.js +3 -0
  18. package/dist/core/entities/email-verification.js.map +1 -0
  19. package/dist/core/entities/password-reset.d.ts +9 -0
  20. package/dist/core/entities/password-reset.d.ts.map +1 -0
  21. package/dist/core/entities/password-reset.js +3 -0
  22. package/dist/core/entities/password-reset.js.map +1 -0
  23. package/dist/core/entities/refresh-token.d.ts +12 -0
  24. package/dist/core/entities/refresh-token.d.ts.map +1 -0
  25. package/dist/core/entities/refresh-token.js +3 -0
  26. package/dist/core/entities/refresh-token.js.map +1 -0
  27. package/dist/core/entities/user.d.ts +12 -0
  28. package/dist/core/entities/user.d.ts.map +1 -0
  29. package/dist/core/entities/user.js +3 -0
  30. package/dist/core/entities/user.js.map +1 -0
  31. package/dist/core/errors.d.ts +54 -0
  32. package/dist/core/errors.d.ts.map +1 -0
  33. package/dist/core/errors.js +106 -0
  34. package/dist/core/errors.js.map +1 -0
  35. package/dist/core/job-lease-token.d.ts +12 -0
  36. package/dist/core/job-lease-token.d.ts.map +1 -0
  37. package/dist/core/job-lease-token.js +58 -0
  38. package/dist/core/job-lease-token.js.map +1 -0
  39. package/dist/core/jwt.d.ts +37 -0
  40. package/dist/core/jwt.d.ts.map +1 -0
  41. package/dist/core/jwt.js +46 -0
  42. package/dist/core/jwt.js.map +1 -0
  43. package/dist/core/password.d.ts +10 -0
  44. package/dist/core/password.d.ts.map +1 -0
  45. package/dist/core/password.js +13 -0
  46. package/dist/core/password.js.map +1 -0
  47. package/dist/core/rate-limit.d.ts +34 -0
  48. package/dist/core/rate-limit.d.ts.map +1 -0
  49. package/dist/core/rate-limit.js +76 -0
  50. package/dist/core/rate-limit.js.map +1 -0
  51. package/dist/core/runner-session-token.d.ts +6 -0
  52. package/dist/core/runner-session-token.d.ts.map +1 -0
  53. package/dist/core/runner-session-token.js +37 -0
  54. package/dist/core/runner-session-token.js.map +1 -0
  55. package/dist/db/db.d.ts +1723 -0
  56. package/dist/db/db.d.ts.map +1 -0
  57. package/dist/db/db.js +28 -0
  58. package/dist/db/db.js.map +1 -0
  59. package/dist/db/email-verifications.d.ts +39 -0
  60. package/dist/db/email-verifications.d.ts.map +1 -0
  61. package/dist/db/email-verifications.js +95 -0
  62. package/dist/db/email-verifications.js.map +1 -0
  63. package/dist/db/migrations.d.ts +2 -0
  64. package/dist/db/migrations.d.ts.map +1 -0
  65. package/dist/db/migrations.js +5 -0
  66. package/dist/db/migrations.js.map +1 -0
  67. package/dist/db/password-resets.d.ts +23 -0
  68. package/dist/db/password-resets.d.ts.map +1 -0
  69. package/dist/db/password-resets.js +37 -0
  70. package/dist/db/password-resets.js.map +1 -0
  71. package/dist/db/rate-limits.d.ts +18 -0
  72. package/dist/db/rate-limits.d.ts.map +1 -0
  73. package/dist/db/rate-limits.js +44 -0
  74. package/dist/db/rate-limits.js.map +1 -0
  75. package/dist/db/refresh-tokens.d.ts +44 -0
  76. package/dist/db/refresh-tokens.d.ts.map +1 -0
  77. package/dist/db/refresh-tokens.js +71 -0
  78. package/dist/db/refresh-tokens.js.map +1 -0
  79. package/dist/db/schema/common.d.ts +2 -0
  80. package/dist/db/schema/common.d.ts.map +1 -0
  81. package/dist/db/schema/common.js +4 -0
  82. package/dist/db/schema/common.js.map +1 -0
  83. package/dist/db/schema/email-verifications.d.ts +114 -0
  84. package/dist/db/schema/email-verifications.d.ts.map +1 -0
  85. package/dist/db/schema/email-verifications.js +35 -0
  86. package/dist/db/schema/email-verifications.js.map +1 -0
  87. package/dist/db/schema/outbox.d.ts +195 -0
  88. package/dist/db/schema/outbox.d.ts.map +1 -0
  89. package/dist/db/schema/outbox.js +5 -0
  90. package/dist/db/schema/outbox.js.map +1 -0
  91. package/dist/db/schema/password-resets.d.ts +114 -0
  92. package/dist/db/schema/password-resets.d.ts.map +1 -0
  93. package/dist/db/schema/password-resets.js +35 -0
  94. package/dist/db/schema/password-resets.js.map +1 -0
  95. package/dist/db/schema/rate-limits.d.ts +145 -0
  96. package/dist/db/schema/rate-limits.d.ts.map +1 -0
  97. package/dist/db/schema/rate-limits.js +25 -0
  98. package/dist/db/schema/rate-limits.js.map +1 -0
  99. package/dist/db/schema/refresh-tokens.d.ts +165 -0
  100. package/dist/db/schema/refresh-tokens.d.ts.map +1 -0
  101. package/dist/db/schema/refresh-tokens.js +47 -0
  102. package/dist/db/schema/refresh-tokens.js.map +1 -0
  103. package/dist/db/schema/users.d.ts +149 -0
  104. package/dist/db/schema/users.d.ts.map +1 -0
  105. package/dist/db/schema/users.js +40 -0
  106. package/dist/db/schema/users.js.map +1 -0
  107. package/dist/db/users.d.ts +28 -0
  108. package/dist/db/users.d.ts.map +1 -0
  109. package/dist/db/users.js +83 -0
  110. package/dist/db/users.js.map +1 -0
  111. package/dist/index.d.ts +14 -0
  112. package/dist/index.d.ts.map +1 -0
  113. package/dist/index.js +52 -0
  114. package/dist/index.js.map +1 -0
  115. package/dist/metrics/index.d.ts +2 -0
  116. package/dist/metrics/index.d.ts.map +1 -0
  117. package/dist/metrics/index.js +3 -0
  118. package/dist/metrics/index.js.map +1 -0
  119. package/dist/metrics/instance.d.ts +16 -0
  120. package/dist/metrics/instance.d.ts.map +1 -0
  121. package/dist/metrics/instance.js +52 -0
  122. package/dist/metrics/instance.js.map +1 -0
  123. package/dist/presentation/auth/bearer-token-auth.d.ts +15 -0
  124. package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -0
  125. package/dist/presentation/auth/bearer-token-auth.js +31 -0
  126. package/dist/presentation/auth/bearer-token-auth.js.map +1 -0
  127. package/dist/presentation/auth/jwt-auth.d.ts +10 -0
  128. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -0
  129. package/dist/presentation/auth/jwt-auth.js +31 -0
  130. package/dist/presentation/auth/jwt-auth.js.map +1 -0
  131. package/dist/presentation/auth/lease-token-auth.d.ts +15 -0
  132. package/dist/presentation/auth/lease-token-auth.d.ts.map +1 -0
  133. package/dist/presentation/auth/lease-token-auth.js +27 -0
  134. package/dist/presentation/auth/lease-token-auth.js.map +1 -0
  135. package/dist/presentation/auth/refresh-cookie.d.ts +7 -0
  136. package/dist/presentation/auth/refresh-cookie.d.ts.map +1 -0
  137. package/dist/presentation/auth/refresh-cookie.js +29 -0
  138. package/dist/presentation/auth/refresh-cookie.js.map +1 -0
  139. package/dist/presentation/auth/runner-session-auth.d.ts +3 -0
  140. package/dist/presentation/auth/runner-session-auth.d.ts.map +1 -0
  141. package/dist/presentation/auth/runner-session-auth.js +16 -0
  142. package/dist/presentation/auth/runner-session-auth.js.map +1 -0
  143. package/dist/presentation/dto/user.d.ts +8 -0
  144. package/dist/presentation/dto/user.d.ts.map +1 -0
  145. package/dist/presentation/dto/user.js +19 -0
  146. package/dist/presentation/dto/user.js.map +1 -0
  147. package/dist/presentation/e2eRoutes/create-session.d.ts +2 -0
  148. package/dist/presentation/e2eRoutes/create-session.d.ts.map +1 -0
  149. package/dist/presentation/e2eRoutes/create-session.js +49 -0
  150. package/dist/presentation/e2eRoutes/create-session.js.map +1 -0
  151. package/dist/presentation/e2eRoutes/create-user.d.ts +2 -0
  152. package/dist/presentation/e2eRoutes/create-user.d.ts.map +1 -0
  153. package/dist/presentation/e2eRoutes/create-user.js +42 -0
  154. package/dist/presentation/e2eRoutes/create-user.js.map +1 -0
  155. package/dist/presentation/e2eRoutes/index.d.ts +3 -0
  156. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  157. package/dist/presentation/e2eRoutes/index.js +15 -0
  158. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  159. package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts +2 -0
  160. package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts.map +1 -0
  161. package/dist/presentation/routes/email-verification/verify-email-confirm.js +35 -0
  162. package/dist/presentation/routes/email-verification/verify-email-confirm.js.map +1 -0
  163. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts +2 -0
  164. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -0
  165. package/dist/presentation/routes/email-verification/verify-email-resend.js +27 -0
  166. package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -0
  167. package/dist/presentation/routes/index.d.ts +3 -0
  168. package/dist/presentation/routes/index.d.ts.map +1 -0
  169. package/dist/presentation/routes/index.js +31 -0
  170. package/dist/presentation/routes/index.js.map +1 -0
  171. package/dist/presentation/routes/password/change-password.d.ts +2 -0
  172. package/dist/presentation/routes/password/change-password.d.ts.map +1 -0
  173. package/dist/presentation/routes/password/change-password.js +50 -0
  174. package/dist/presentation/routes/password/change-password.js.map +1 -0
  175. package/dist/presentation/routes/password/password-reset-confirm.d.ts +2 -0
  176. package/dist/presentation/routes/password/password-reset-confirm.d.ts.map +1 -0
  177. package/dist/presentation/routes/password/password-reset-confirm.js +36 -0
  178. package/dist/presentation/routes/password/password-reset-confirm.js.map +1 -0
  179. package/dist/presentation/routes/password/password-reset-request.d.ts +2 -0
  180. package/dist/presentation/routes/password/password-reset-request.d.ts.map +1 -0
  181. package/dist/presentation/routes/password/password-reset-request.js +26 -0
  182. package/dist/presentation/routes/password/password-reset-request.js.map +1 -0
  183. package/dist/presentation/routes/rate-limit.d.ts +10 -0
  184. package/dist/presentation/routes/rate-limit.d.ts.map +1 -0
  185. package/dist/presentation/routes/rate-limit.js +101 -0
  186. package/dist/presentation/routes/rate-limit.js.map +1 -0
  187. package/dist/presentation/routes/registration/signup.d.ts +2 -0
  188. package/dist/presentation/routes/registration/signup.d.ts.map +1 -0
  189. package/dist/presentation/routes/registration/signup.js +82 -0
  190. package/dist/presentation/routes/registration/signup.js.map +1 -0
  191. package/dist/presentation/routes/session/login.d.ts +2 -0
  192. package/dist/presentation/routes/session/login.d.ts.map +1 -0
  193. package/dist/presentation/routes/session/login.js +48 -0
  194. package/dist/presentation/routes/session/login.js.map +1 -0
  195. package/dist/presentation/routes/session/logout.d.ts +2 -0
  196. package/dist/presentation/routes/session/logout.d.ts.map +1 -0
  197. package/dist/presentation/routes/session/logout.js +25 -0
  198. package/dist/presentation/routes/session/logout.js.map +1 -0
  199. package/dist/presentation/routes/session/me.d.ts +2 -0
  200. package/dist/presentation/routes/session/me.d.ts.map +1 -0
  201. package/dist/presentation/routes/session/me.js +42 -0
  202. package/dist/presentation/routes/session/me.js.map +1 -0
  203. package/dist/presentation/routes/session/refresh.d.ts +2 -0
  204. package/dist/presentation/routes/session/refresh.d.ts.map +1 -0
  205. package/dist/presentation/routes/session/refresh.js +49 -0
  206. package/dist/presentation/routes/session/refresh.js.map +1 -0
  207. package/dist/presentation/subscribers/index.d.ts +3 -0
  208. package/dist/presentation/subscribers/index.d.ts.map +1 -0
  209. package/dist/presentation/subscribers/index.js +4 -0
  210. package/dist/presentation/subscribers/index.js.map +1 -0
  211. package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts +3 -0
  212. package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts.map +1 -0
  213. package/dist/presentation/subscribers/on-email-verification-send-requested.js +13 -0
  214. package/dist/presentation/subscribers/on-email-verification-send-requested.js.map +1 -0
  215. package/dist/presentation/subscribers/on-password-reset-send-requested.d.ts +3 -0
  216. package/dist/presentation/subscribers/on-password-reset-send-requested.d.ts.map +1 -0
  217. package/dist/presentation/subscribers/on-password-reset-send-requested.js +14 -0
  218. package/dist/presentation/subscribers/on-password-reset-send-requested.js.map +1 -0
  219. package/dist/tsconfig.test.tsbuildinfo +1 -0
  220. package/drizzle/0000_initial.sql +81 -0
  221. package/drizzle/meta/0000_snapshot.json +643 -0
  222. package/drizzle/meta/_journal.json +13 -0
  223. package/drizzle.config.ts +7 -0
  224. package/package.json +88 -0
  225. package/src/config.ts +88 -0
  226. package/src/core/auth.test.ts +675 -0
  227. package/src/core/auth.ts +560 -0
  228. package/src/core/entities/email-verification.ts +8 -0
  229. package/src/core/entities/password-reset.ts +8 -0
  230. package/src/core/entities/refresh-token.ts +11 -0
  231. package/src/core/entities/user.ts +12 -0
  232. package/src/core/errors.ts +123 -0
  233. package/src/core/job-lease-token.test.ts +201 -0
  234. package/src/core/job-lease-token.ts +83 -0
  235. package/src/core/jwt.test.ts +180 -0
  236. package/src/core/jwt.ts +66 -0
  237. package/src/core/password.test.ts +28 -0
  238. package/src/core/password.ts +21 -0
  239. package/src/core/rate-limit.test.ts +462 -0
  240. package/src/core/rate-limit.ts +130 -0
  241. package/src/core/runner-session-token.test.ts +126 -0
  242. package/src/core/runner-session-token.ts +49 -0
  243. package/src/db/db.ts +28 -0
  244. package/src/db/email-verifications.test.ts +61 -0
  245. package/src/db/email-verifications.ts +160 -0
  246. package/src/db/migrations.ts +4 -0
  247. package/src/db/password-resets.test.ts +61 -0
  248. package/src/db/password-resets.ts +69 -0
  249. package/src/db/rate-limits.ts +69 -0
  250. package/src/db/refresh-tokens.test.ts +164 -0
  251. package/src/db/refresh-tokens.ts +149 -0
  252. package/src/db/schema/common.ts +3 -0
  253. package/src/db/schema/email-verifications.ts +37 -0
  254. package/src/db/schema/outbox.ts +4 -0
  255. package/src/db/schema/password-resets.ts +37 -0
  256. package/src/db/schema/rate-limits.ts +27 -0
  257. package/src/db/schema/refresh-tokens.ts +43 -0
  258. package/src/db/schema/users.ts +37 -0
  259. package/src/db/users.test.ts +75 -0
  260. package/src/db/users.ts +121 -0
  261. package/src/index.test.ts +39 -0
  262. package/src/index.ts +69 -0
  263. package/src/metrics/index.ts +13 -0
  264. package/src/metrics/instance.test.ts +69 -0
  265. package/src/metrics/instance.ts +77 -0
  266. package/src/presentation/auth/bearer-token-auth.test.ts +99 -0
  267. package/src/presentation/auth/bearer-token-auth.ts +51 -0
  268. package/src/presentation/auth/jwt-auth.test.ts +136 -0
  269. package/src/presentation/auth/jwt-auth.ts +39 -0
  270. package/src/presentation/auth/lease-token-auth.test.ts +56 -0
  271. package/src/presentation/auth/lease-token-auth.ts +25 -0
  272. package/src/presentation/auth/refresh-cookie.test.ts +39 -0
  273. package/src/presentation/auth/refresh-cookie.ts +34 -0
  274. package/src/presentation/auth/runner-session-auth.test.ts +54 -0
  275. package/src/presentation/auth/runner-session-auth.ts +13 -0
  276. package/src/presentation/dto/user.ts +21 -0
  277. package/src/presentation/e2eRoutes/create-session.ts +39 -0
  278. package/src/presentation/e2eRoutes/create-user.ts +38 -0
  279. package/src/presentation/e2eRoutes/index.test.ts +102 -0
  280. package/src/presentation/e2eRoutes/index.ts +10 -0
  281. package/src/presentation/routes/email-verification/verify-email-confirm.test.ts +66 -0
  282. package/src/presentation/routes/email-verification/verify-email-confirm.ts +37 -0
  283. package/src/presentation/routes/email-verification/verify-email-resend.test.ts +100 -0
  284. package/src/presentation/routes/email-verification/verify-email-resend.ts +26 -0
  285. package/src/presentation/routes/index.ts +29 -0
  286. package/src/presentation/routes/password/change-password.test.ts +106 -0
  287. package/src/presentation/routes/password/change-password.ts +45 -0
  288. package/src/presentation/routes/password/password-reset-confirm.test.ts +63 -0
  289. package/src/presentation/routes/password/password-reset-confirm.ts +35 -0
  290. package/src/presentation/routes/password/password-reset-request.test.ts +56 -0
  291. package/src/presentation/routes/password/password-reset-request.ts +25 -0
  292. package/src/presentation/routes/rate-limit.test.ts +341 -0
  293. package/src/presentation/routes/rate-limit.ts +119 -0
  294. package/src/presentation/routes/registration/signup.test.ts +284 -0
  295. package/src/presentation/routes/registration/signup.ts +81 -0
  296. package/src/presentation/routes/session/login.test.ts +77 -0
  297. package/src/presentation/routes/session/login.ts +50 -0
  298. package/src/presentation/routes/session/logout.test.ts +55 -0
  299. package/src/presentation/routes/session/logout.ts +23 -0
  300. package/src/presentation/routes/session/me.test.ts +56 -0
  301. package/src/presentation/routes/session/me.ts +35 -0
  302. package/src/presentation/routes/session/refresh.test.ts +89 -0
  303. package/src/presentation/routes/session/refresh.ts +54 -0
  304. package/src/presentation/subscribers/email-send-requested.test.ts +77 -0
  305. package/src/presentation/subscribers/index.ts +2 -0
  306. package/src/presentation/subscribers/on-email-verification-send-requested.ts +10 -0
  307. package/src/presentation/subscribers/on-password-reset-send-requested.ts +13 -0
  308. package/test/env.ts +10 -0
  309. package/test/factories/index.ts +1 -0
  310. package/test/factories/user.ts +36 -0
  311. package/test/globalSetup.ts +18 -0
  312. package/test/index.ts +1 -0
  313. package/test/routes.ts +275 -0
  314. package/test/setup.ts +17 -0
  315. package/tsconfig.build.json +9 -0
  316. package/tsconfig.build.tsbuildinfo +1 -0
  317. package/tsconfig.json +4 -0
  318. package/tsconfig.test.json +8 -0
  319. package/vitest.config.ts +11 -0
@@ -0,0 +1,284 @@
1
+ import {AUTH_EMAIL_VERIFICATION_SEND_REQUESTED} from '@shipfox/api-auth-dto';
2
+ import type {FastifyInstance} from 'fastify';
3
+ import {verifyUserToken} from '#core/jwt.js';
4
+ import {
5
+ acceptWorkspaceInvitationMock,
6
+ capturedMail,
7
+ createAuthTestApp,
8
+ getSetCookie,
9
+ latestEmailLinkTo,
10
+ listMembershipsByUserMock,
11
+ outboxEventsTo,
12
+ peekInvitationByRawTokenMock,
13
+ ROUTE_TEST_SECRET,
14
+ resetCapturedMail,
15
+ signup,
16
+ uniqueEmail,
17
+ } from '#test/routes.js';
18
+
19
+ describe('POST /auth/signup', () => {
20
+ let app: FastifyInstance;
21
+ const password = 'correct horse battery staple';
22
+
23
+ beforeAll(async () => {
24
+ app = await createAuthTestApp();
25
+ });
26
+
27
+ beforeEach(() => {
28
+ resetCapturedMail();
29
+ });
30
+
31
+ afterAll(async () => {
32
+ await app.close();
33
+ });
34
+
35
+ test('returns 201 with a pending user and sends verification mail', async () => {
36
+ const email = uniqueEmail('signup');
37
+
38
+ const res = await signup(app, {
39
+ email: email.toUpperCase(),
40
+ password,
41
+ name: ' New User ',
42
+ });
43
+
44
+ expect(res.statusCode).toBe(201);
45
+ expect(res.json().user.email).toBe(email);
46
+ expect(res.json().user.name).toBe('New User');
47
+ 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);
52
+ });
53
+
54
+ test.each([
55
+ ['missing', undefined],
56
+ ['blank after trimming', ' '],
57
+ ['with control characters', 'New\nUser'],
58
+ ['with format characters', 'New\u202eUser'],
59
+ ])('rejects a %s display name', async (_case, name) => {
60
+ const email = uniqueEmail('signup-name-invalid');
61
+ const payload: {email: string; password: string; name?: string} = {email, password};
62
+ if (name !== undefined) {
63
+ payload.name = name;
64
+ }
65
+
66
+ const res = await app.inject({
67
+ method: 'POST',
68
+ url: '/auth/signup',
69
+ payload,
70
+ });
71
+
72
+ expect(res.statusCode).toBe(400);
73
+ });
74
+
75
+ test('transforms duplicate email into 409', async () => {
76
+ const email = uniqueEmail('duplicate');
77
+ await signup(app, {email, password});
78
+
79
+ const res = await signup(app, {email, password});
80
+
81
+ expect(res.statusCode).toBe(409);
82
+ expect(res.json().code).toBe('email-taken');
83
+ });
84
+
85
+ test('accepts a valid invitation during signup and skips verification email', async () => {
86
+ const email = uniqueEmail('signup-invite');
87
+ const invitationToken = `invite-${crypto.randomUUID()}`;
88
+ const workspaceId = crypto.randomUUID();
89
+ const membershipId = crypto.randomUUID();
90
+ peekInvitationByRawTokenMock.mockResolvedValueOnce({
91
+ id: crypto.randomUUID(),
92
+ workspaceId,
93
+ email,
94
+ hashedToken: 'hashed',
95
+ expiresAt: new Date(Date.now() + 86_400_000),
96
+ acceptedAt: null,
97
+ acceptedByUserId: null,
98
+ invitedByUserId: crypto.randomUUID(),
99
+ invitedByDisplay: 'owner@example.com',
100
+ createdAt: new Date(),
101
+ updatedAt: new Date(),
102
+ });
103
+ acceptWorkspaceInvitationMock.mockImplementationOnce(async (params) => ({
104
+ invitation: {
105
+ id: crypto.randomUUID(),
106
+ workspaceId,
107
+ email,
108
+ hashedToken: 'hashed',
109
+ expiresAt: new Date(Date.now() + 86_400_000),
110
+ acceptedAt: new Date(),
111
+ acceptedByUserId: params.userId,
112
+ invitedByUserId: crypto.randomUUID(),
113
+ invitedByDisplay: 'owner@example.com',
114
+ createdAt: new Date(),
115
+ updatedAt: new Date(),
116
+ },
117
+ membership: {
118
+ id: membershipId,
119
+ userId: params.userId,
120
+ userEmail: email,
121
+ userName: 'Invitee',
122
+ workspaceId,
123
+ workspaceName: 'Acme',
124
+ createdAt: new Date(),
125
+ updatedAt: new Date(),
126
+ },
127
+ alreadyMember: false,
128
+ }));
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
+ const res = await app.inject({
142
+ method: 'POST',
143
+ url: '/auth/signup',
144
+ payload: {
145
+ email,
146
+ password,
147
+ name: 'Invitee',
148
+ invitation_token: invitationToken,
149
+ },
150
+ });
151
+ const body = res.json();
152
+ const claims = await verifyUserToken({token: body.token, secret: ROUTE_TEST_SECRET});
153
+
154
+ expect(res.statusCode).toBe(201);
155
+ expect(getSetCookie(res)).toContain('shipfox_refresh_token=');
156
+ expect(body.user.email).toBe(email);
157
+ expect(body.user.email_verified_at).toEqual(expect.any(String));
158
+ expect(body.membership).toEqual({
159
+ id: membershipId,
160
+ user_id: body.user.id,
161
+ workspace_id: workspaceId,
162
+ });
163
+ expect(body.accept_error).toBeUndefined();
164
+ expect(claims.memberships).toEqual([{workspaceId, role: 'admin'}]);
165
+ expect(await outboxEventsTo(email, AUTH_EMAIL_VERIFICATION_SEND_REQUESTED)).toHaveLength(0);
166
+ expect(capturedMail()).toHaveLength(0);
167
+ });
168
+
169
+ test('returns partial success when invitation acceptance fails after user creation', async () => {
170
+ const email = uniqueEmail('signup-invite-partial');
171
+ const workspaceId = crypto.randomUUID();
172
+ peekInvitationByRawTokenMock.mockResolvedValueOnce({
173
+ id: crypto.randomUUID(),
174
+ workspaceId,
175
+ email,
176
+ hashedToken: 'hashed',
177
+ expiresAt: new Date(Date.now() + 86_400_000),
178
+ acceptedAt: null,
179
+ acceptedByUserId: null,
180
+ invitedByUserId: crypto.randomUUID(),
181
+ invitedByDisplay: null,
182
+ createdAt: new Date(),
183
+ updatedAt: new Date(),
184
+ });
185
+ acceptWorkspaceInvitationMock.mockRejectedValueOnce(new Error('accept unavailable'));
186
+
187
+ const res = await app.inject({
188
+ method: 'POST',
189
+ url: '/auth/signup',
190
+ payload: {
191
+ email,
192
+ password,
193
+ name: 'Invitee',
194
+ invitation_token: `invite-${crypto.randomUUID()}`,
195
+ },
196
+ });
197
+
198
+ expect(res.statusCode).toBe(201);
199
+ expect(res.json().membership).toBeNull();
200
+ expect(res.json().accept_error).toEqual(
201
+ expect.objectContaining({
202
+ code: 'AcceptFailed',
203
+ }),
204
+ );
205
+ expect(res.json().token).toEqual(expect.any(String));
206
+ });
207
+
208
+ test.each([
209
+ {
210
+ name: 'invalid token',
211
+ invitation: undefined,
212
+ expectedStatus: 410,
213
+ expectedCode: 'invitation-token-invalid',
214
+ },
215
+ {
216
+ 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
+ },
232
+ expectedStatus: 410,
233
+ expectedCode: 'invitation-token-expired',
234
+ },
235
+ {
236
+ name: 'email mismatch',
237
+ invitation: {
238
+ acceptedAt: null,
239
+ expiresAt: new Date(Date.now() + 86_400_000),
240
+ email: uniqueEmail('signup-invite-other'),
241
+ },
242
+ expectedStatus: 403,
243
+ expectedCode: 'invitation-email-mismatch',
244
+ },
245
+ ])('rejects signup with invitation for $name', async ({
246
+ invitation,
247
+ expectedStatus,
248
+ expectedCode,
249
+ }) => {
250
+ 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
+ }
268
+
269
+ const res = await app.inject({
270
+ method: 'POST',
271
+ url: '/auth/signup',
272
+ payload: {
273
+ email,
274
+ password,
275
+ name: 'Invitee',
276
+ invitation_token: `invite-${crypto.randomUUID()}`,
277
+ },
278
+ });
279
+
280
+ expect(res.statusCode).toBe(expectedStatus);
281
+ expect(res.json().code).toBe(expectedCode);
282
+ expect(acceptWorkspaceInvitationMock).not.toHaveBeenCalled();
283
+ });
284
+ });
@@ -0,0 +1,81 @@
1
+ import {signupBodySchema, signupResponseSchema} from '@shipfox/api-auth-dto';
2
+ import {
3
+ InvitationEmailMismatchError,
4
+ TokenAlreadyUsedError,
5
+ 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
+ import {setRefreshTokenCookie} from '#presentation/auth/refresh-cookie.js';
12
+ import {toUserDto} from '#presentation/dto/user.js';
13
+
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,
23
+ },
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;
53
+
54
+ if (invitation_token === undefined) {
55
+ const user = await signup({email, password, name});
56
+ reply.code(201);
57
+ return {user: toUserDto(user)};
58
+ }
59
+
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
+ });
@@ -0,0 +1,77 @@
1
+ import type {FastifyInstance} from 'fastify';
2
+ import {
3
+ createAuthTestApp,
4
+ listMembershipsByUserMock,
5
+ login,
6
+ resetCapturedMail,
7
+ signup,
8
+ uniqueEmail,
9
+ verifyEmail,
10
+ } from '#test/routes.js';
11
+
12
+ describe('POST /auth/login', () => {
13
+ let app: FastifyInstance;
14
+
15
+ beforeAll(async () => {
16
+ app = await createAuthTestApp();
17
+ });
18
+
19
+ beforeEach(() => {
20
+ resetCapturedMail();
21
+ });
22
+
23
+ afterAll(async () => {
24
+ await app.close();
25
+ });
26
+
27
+ test('returns 200 with a token and user after verification', async () => {
28
+ const email = uniqueEmail('login');
29
+ const password = 'correct horse battery staple';
30
+ await signup(app, {email, password});
31
+ await verifyEmail(app, email);
32
+
33
+ const res = await login(app, {email: email.toUpperCase(), password});
34
+
35
+ expect(res.statusCode).toBe(200);
36
+ expect(res.json().token).toBeDefined();
37
+ expect(res.json().user.email).toBe(email);
38
+ expect(res.headers['set-cookie']).toContain('shipfox_refresh_token=');
39
+ expect(res.headers['set-cookie']).toContain('HttpOnly');
40
+ expect(res.headers['set-cookie']).toContain('Secure');
41
+ expect(res.headers['set-cookie']).toContain('SameSite=Lax');
42
+ expect(res.headers['set-cookie']).toContain('Path=/auth');
43
+ });
44
+
45
+ test('transforms invalid credentials into 401', async () => {
46
+ const email = uniqueEmail('bad-login');
47
+
48
+ const res = await login(app, {email, password: 'wrong password'});
49
+
50
+ expect(res.statusCode).toBe(401);
51
+ expect(res.json().code).toBe('invalid-credentials');
52
+ });
53
+
54
+ test('transforms unverified email into 403', async () => {
55
+ const email = uniqueEmail('unverified-login');
56
+ const password = 'correct horse battery staple';
57
+ await signup(app, {email, password});
58
+
59
+ const res = await login(app, {email, password});
60
+
61
+ expect(res.statusCode).toBe(403);
62
+ expect(res.json().code).toBe('email-not-verified');
63
+ });
64
+
65
+ test('transforms membership dependency outages into 503', async () => {
66
+ const email = uniqueEmail('login-workspaces-down');
67
+ const password = 'correct horse battery staple';
68
+ await signup(app, {email, password});
69
+ await verifyEmail(app, email);
70
+ listMembershipsByUserMock.mockRejectedValueOnce(new Error('workspaces DB down'));
71
+
72
+ const res = await login(app, {email, password});
73
+
74
+ expect(res.statusCode).toBe(503);
75
+ expect(res.json().code).toBe('auth-dependency-unavailable');
76
+ });
77
+ });
@@ -0,0 +1,50 @@
1
+ import {loginBodySchema, loginResponseSchema} from '@shipfox/api-auth-dto';
2
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
3
+ import {login} from '#core/auth.js';
4
+ import {
5
+ AuthDependencyUnavailableError,
6
+ EmailNotVerifiedError,
7
+ InvalidCredentialsError,
8
+ } from '#core/errors.js';
9
+ import {setRefreshTokenCookie} from '#presentation/auth/refresh-cookie.js';
10
+ import {toAuthSessionDto} from '#presentation/dto/user.js';
11
+ import {createAuthRateLimitPreHandler} from '#presentation/routes/rate-limit.js';
12
+
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,
21
+ },
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;
44
+
45
+ const result = await login({email, password});
46
+ setRefreshTokenCookie(reply, result.refreshToken);
47
+
48
+ return toAuthSessionDto(result);
49
+ },
50
+ });
@@ -0,0 +1,55 @@
1
+ import type {FastifyInstance} from 'fastify';
2
+ import {
3
+ cookieHeader,
4
+ createAuthTestApp,
5
+ resetCapturedMail,
6
+ signupVerifyLogin,
7
+ } from '#test/routes.js';
8
+
9
+ describe('POST /auth/logout', () => {
10
+ let app: FastifyInstance;
11
+
12
+ beforeAll(async () => {
13
+ app = await createAuthTestApp();
14
+ });
15
+
16
+ beforeEach(() => {
17
+ resetCapturedMail();
18
+ });
19
+
20
+ afterAll(async () => {
21
+ await app.close();
22
+ });
23
+
24
+ test('returns 204 and revokes the current refresh session', async () => {
25
+ const account = await signupVerifyLogin(app, 'logout');
26
+
27
+ const res = await app.inject({
28
+ method: 'POST',
29
+ url: '/auth/logout',
30
+ headers: {cookie: cookieHeader(account.refreshCookie)},
31
+ payload: {},
32
+ });
33
+ const refresh = await app.inject({
34
+ method: 'POST',
35
+ url: '/auth/refresh',
36
+ headers: {cookie: cookieHeader(account.refreshCookie)},
37
+ payload: {},
38
+ });
39
+
40
+ expect(res.statusCode).toBe(204);
41
+ expect(res.headers['set-cookie']).toContain('shipfox_refresh_token=;');
42
+ expect(refresh.statusCode).toBe(401);
43
+ });
44
+
45
+ test('returns 204 when the refresh cookie is missing', async () => {
46
+ const res = await app.inject({
47
+ method: 'POST',
48
+ url: '/auth/logout',
49
+ payload: {},
50
+ });
51
+
52
+ expect(res.statusCode).toBe(204);
53
+ expect(res.headers['set-cookie']).toContain('shipfox_refresh_token=;');
54
+ });
55
+ });
@@ -0,0 +1,23 @@
1
+ import {logoutBodySchema} from '@shipfox/api-auth-dto';
2
+ import {defineRoute} from '@shipfox/node-fastify';
3
+ import {z} from 'zod';
4
+ import {logout} from '#core/auth.js';
5
+ import {clearRefreshTokenCookie, getRefreshTokenCookie} from '#presentation/auth/refresh-cookie.js';
6
+
7
+ export const logoutRoute = defineRoute({
8
+ method: 'POST',
9
+ path: '/logout',
10
+ description: 'Sign out of the current session.',
11
+ schema: {
12
+ body: logoutBodySchema,
13
+ response: {
14
+ 204: z.void(),
15
+ },
16
+ },
17
+ handler: async (request, reply) => {
18
+ await logout({refreshToken: getRefreshTokenCookie(request)});
19
+ clearRefreshTokenCookie(reply);
20
+
21
+ reply.code(204);
22
+ },
23
+ });
@@ -0,0 +1,56 @@
1
+ import type {FastifyInstance} from 'fastify';
2
+ import {signUserToken} from '#core/jwt.js';
3
+ import {
4
+ createAuthTestApp,
5
+ ROUTE_TEST_SECRET,
6
+ resetCapturedMail,
7
+ signupVerifyLogin,
8
+ } from '#test/routes.js';
9
+
10
+ describe('GET /auth/me', () => {
11
+ let app: FastifyInstance;
12
+
13
+ beforeAll(async () => {
14
+ app = await createAuthTestApp();
15
+ });
16
+
17
+ beforeEach(() => {
18
+ resetCapturedMail();
19
+ });
20
+
21
+ afterAll(async () => {
22
+ await app.close();
23
+ });
24
+
25
+ test('returns the signed-in user', async () => {
26
+ const account = await signupVerifyLogin(app, 'me');
27
+
28
+ const res = await app.inject({
29
+ method: 'GET',
30
+ url: '/auth/me',
31
+ headers: {authorization: `Bearer ${account.token}`},
32
+ });
33
+
34
+ expect(res.statusCode).toBe(200);
35
+ expect(res.json().user.email).toBe(account.email);
36
+ });
37
+
38
+ test('transforms a token for a missing user into 404', async () => {
39
+ const token = await signUserToken({
40
+ userId: crypto.randomUUID(),
41
+ email: 'missing@example.com',
42
+ memberships: [],
43
+ secret: ROUTE_TEST_SECRET,
44
+ expiresIn: '15m',
45
+ });
46
+
47
+ const res = await app.inject({
48
+ method: 'GET',
49
+ url: '/auth/me',
50
+ headers: {authorization: `Bearer ${token}`},
51
+ });
52
+
53
+ expect(res.statusCode).toBe(404);
54
+ expect(res.json().code).toBe('not-found');
55
+ });
56
+ });
@@ -0,0 +1,35 @@
1
+ import {AUTH_USER} from '@shipfox/api-auth-context';
2
+ import {meResponseSchema} from '@shipfox/api-auth-dto';
3
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
4
+ import {getCurrentUser} from '#core/auth.js';
5
+ import {UserNotFoundError} from '#core/errors.js';
6
+ import {getClientContext} from '#presentation/auth/jwt-auth.js';
7
+ import {toUserDto} from '#presentation/dto/user.js';
8
+
9
+ export const meRoute = defineRoute({
10
+ method: 'GET',
11
+ path: '/me',
12
+ description: 'Get the signed-in user.',
13
+ auth: AUTH_USER,
14
+ schema: {
15
+ response: {
16
+ 200: meResponseSchema,
17
+ },
18
+ },
19
+ errorHandler: (error) => {
20
+ if (error instanceof UserNotFoundError) {
21
+ throw new ClientError('User not found', 'not-found', {status: 404});
22
+ }
23
+ throw error;
24
+ },
25
+ handler: async (request) => {
26
+ const client = getClientContext(request);
27
+ if (!client) {
28
+ throw new ClientError('Authentication required', 'unauthorized', {status: 401});
29
+ }
30
+
31
+ const result = await getCurrentUser({userId: client.userId});
32
+
33
+ return {user: toUserDto(result.user)};
34
+ },
35
+ });