@stjoseph/prumo 0.2.0 → 0.4.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 (252) hide show
  1. package/.prumo-templates/api/auth.md +51 -60
  2. package/.prumo-templates/api/config.md +2 -2
  3. package/.prumo-templates/api/routes.md +6 -4
  4. package/.prumo-templates/api/structure.md +4 -1
  5. package/.prumo-templates/client/data.md +16 -18
  6. package/.prumo-templates/client/forms.md +2 -2
  7. package/.prumo-templates/core/testing.md +4 -4
  8. package/.prumo-templates/database/testing.md +6 -8
  9. package/.prumo-templates/email/codes.md +62 -0
  10. package/.prumo-templates/email/mailer.md +47 -0
  11. package/.prumo-templates/mcp/server.md +55 -14
  12. package/.prumo-templates/mobile/routing.md +1 -1
  13. package/.prumo-templates/mobile/storage.md +7 -6
  14. package/.prumo-templates/monorepo/layout.md +4 -4
  15. package/.prumo-templates/multi-tenancy/client.md +6 -6
  16. package/.prumo-templates/multi-tenancy/isolation.md +17 -10
  17. package/.prumo-templates/social/sign-in.md +77 -0
  18. package/README.md +8 -6
  19. package/dist/asker.js +13 -0
  20. package/dist/clean.js +2 -2
  21. package/dist/cli.js +16 -2
  22. package/dist/commands.js +7 -0
  23. package/dist/compose.js +3 -11
  24. package/dist/context.js +4 -0
  25. package/dist/features.js +230 -0
  26. package/dist/generate.js +20 -7
  27. package/dist/questions.js +64 -1
  28. package/package.json +1 -1
  29. package/templates/api/.env.example +21 -5
  30. package/templates/api/README.md +4 -5
  31. package/templates/api/docker-compose.yml +0 -1
  32. package/templates/api/migrations/Migration20261005111545_create_user_profile_session.ts +31 -0
  33. package/templates/api/migrations/Migration20261005111547_create_authorization.ts +18 -0
  34. package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
  35. package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
  36. package/templates/api/package.json +7 -8
  37. package/templates/api/scripts/database.mjs +3 -11
  38. package/templates/api/scripts/ports.mjs +1 -1
  39. package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
  40. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  41. package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
  42. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  43. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  44. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  45. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  46. package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
  47. package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
  48. package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
  49. package/templates/api/src/domain/auth/entities/session.entity.ts +78 -0
  50. package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
  51. package/templates/api/src/domain/auth/entities/user.entity.ts +34 -0
  52. package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
  53. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  54. package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
  55. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  56. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  57. package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
  58. package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
  59. package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
  60. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  61. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  62. package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
  63. package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
  64. package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
  65. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  66. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  67. package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
  68. package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
  69. package/templates/api/src/domain/auth/repositories/session.repository.ts +12 -0
  70. package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
  71. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  72. package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
  73. package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
  74. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  75. package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
  76. package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
  77. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +94 -0
  78. package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
  79. package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
  80. package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
  81. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +38 -0
  82. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  83. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +57 -0
  84. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +55 -0
  85. package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
  86. package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
  87. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  88. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +14 -0
  89. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +87 -0
  90. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  91. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +18 -0
  92. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  93. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  94. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  95. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  96. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  97. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  98. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +97 -0
  99. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  100. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +89 -0
  101. package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
  102. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  103. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  104. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +64 -0
  105. package/templates/api/src/infra/config/env.ts +17 -3
  106. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +38 -0
  107. package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
  108. package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
  109. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  110. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +34 -0
  111. package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
  112. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +19 -0
  113. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +15 -4
  114. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  115. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
  116. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
  117. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +25 -0
  118. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
  119. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  120. package/templates/api/src/infra/di/auth.di.ts +21 -0
  121. package/templates/api/src/infra/di/email.di.ts +23 -0
  122. package/templates/api/src/infra/di/index.ts +8 -10
  123. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  124. package/templates/api/src/infra/di/social.di.ts +17 -0
  125. package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
  126. package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
  127. package/templates/api/src/infra/http/app.ts +15 -1
  128. package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
  129. package/templates/api/src/infra/http/controllers/auth.controller.ts +108 -29
  130. package/templates/api/src/infra/http/controllers/mcp.controller.ts +46 -27
  131. package/templates/api/src/infra/http/controllers/oauth.controller.ts +267 -0
  132. package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
  133. package/templates/api/src/infra/http/errors/error-handler.ts +5 -0
  134. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  135. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  136. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  137. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  138. package/templates/api/src/infra/http/server.ts +1 -4
  139. package/templates/api/src/infra/http/session-delivery.ts +69 -0
  140. package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
  141. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  142. package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
  143. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  144. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  145. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
  146. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  147. package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
  148. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  149. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  150. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +41 -0
  151. package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
  152. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +53 -0
  153. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  154. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  155. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  156. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  157. package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
  158. package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
  159. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +168 -0
  160. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +309 -67
  161. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
  162. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +13 -35
  163. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  164. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  165. package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
  166. package/templates/api/test/support/factories/user.factory.ts +9 -11
  167. package/templates/api/test/support/fake-oidc-server.ts +96 -0
  168. package/templates/api/test/support/fakes/auth-fakes.ts +94 -0
  169. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  170. package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
  171. package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
  172. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  173. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  174. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  175. package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
  176. package/templates/api/test/support/fakes/in-memory-session.repository.ts +27 -0
  177. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  178. package/templates/api/test/support/fakes/social-fakes.ts +112 -0
  179. package/templates/api/test/support/setup.ts +6 -28
  180. package/templates/api/test/support/test-app.ts +65 -0
  181. package/templates/mobile/README.md +1 -1
  182. package/templates/mobile/jest.config.js +0 -17
  183. package/templates/mobile/package.json +0 -4
  184. package/templates/mobile/scripts/ports.mjs +1 -1
  185. package/templates/mobile/src/api-contract/api-error.ts +4 -11
  186. package/templates/mobile/src/api-contract/auth.ts +221 -0
  187. package/templates/mobile/src/api-contract/index.ts +14 -0
  188. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  189. package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
  190. package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
  191. package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
  192. package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
  193. package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
  194. package/templates/mobile/src/app/_layout.tsx +5 -5
  195. package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
  196. package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
  197. package/templates/mobile/src/features/auth/sign-in-form.tsx +21 -7
  198. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  199. package/templates/mobile/src/features/auth/sign-up-form.tsx +24 -8
  200. package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
  201. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  202. package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
  203. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  204. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  205. package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
  206. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  207. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  208. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
  209. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  210. package/templates/site/scripts/ports.mjs +1 -1
  211. package/templates/web/package.json +0 -3
  212. package/templates/web/scripts/ports.mjs +1 -1
  213. package/templates/web/src/api-contract/api-error.ts +4 -11
  214. package/templates/web/src/api-contract/auth.ts +221 -0
  215. package/templates/web/src/api-contract/index.ts +14 -0
  216. package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
  217. package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
  218. package/templates/web/src/features/auth/sign-in-form.tsx +24 -14
  219. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  220. package/templates/web/src/features/auth/sign-up-form.tsx +16 -8
  221. package/templates/web/src/features/auth/social-buttons.tsx +45 -0
  222. package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
  223. package/templates/web/src/features/oauth/consent-form.tsx +72 -35
  224. package/templates/web/src/main.tsx +6 -4
  225. package/templates/web/src/routeTree.gen.ts +73 -2
  226. package/templates/web/src/router.tsx +1 -2
  227. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  228. package/templates/web/src/routes/_authenticated.tsx +1 -1
  229. package/templates/web/src/routes/forgot-password.tsx +17 -0
  230. package/templates/web/src/routes/reset-password.tsx +20 -0
  231. package/templates/web/src/routes/sign-in.tsx +10 -3
  232. package/templates/web/src/routes/sign-up.tsx +4 -0
  233. package/templates/web/src/routes/verify-email.tsx +20 -0
  234. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  235. package/templates/web/test/app.spec.tsx +47 -22
  236. package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
  237. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  238. package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
  239. package/templates/web/test/features/oauth/consent-form.spec.tsx +90 -26
  240. package/templates/web/test/support/render-app.tsx +4 -4
  241. package/templates/workspace/scripts/ports.mjs +1 -1
  242. package/dist/mcp.js +0 -100
  243. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  244. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  245. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  246. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  247. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  248. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  249. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  250. package/templates/mobile/src/features/auth/session.ts +0 -17
  251. package/templates/web/src/features/auth/auth-client.ts +0 -17
  252. package/templates/web/src/features/auth/session.ts +0 -17
@@ -0,0 +1,64 @@
1
+ import { jwtVerify, SignJWT } from 'jose'
2
+ import type {
3
+ AccessClaims,
4
+ AccessTokens,
5
+ IssuedToken,
6
+ } from '@/domain/auth/ports/access-tokens.port'
7
+ import type { Env } from '@/infra/config/env'
8
+
9
+ // One algorithm, named on both sides: a token that declares any other, `none` included, is refused.
10
+ const ALGORITHM = 'HS256'
11
+
12
+ export class JoseAccessTokens implements AccessTokens {
13
+ private readonly key: Uint8Array
14
+ private readonly issuer: string
15
+
16
+ constructor(env: Pick<Env, 'JWT_SECRET' | 'API_URL'>) {
17
+ this.key = new TextEncoder().encode(env.JWT_SECRET)
18
+ this.issuer = env.API_URL
19
+ }
20
+
21
+ async issue(
22
+ { userId, sessionId, clientId, scope }: AccessClaims,
23
+ expiresAt: Date,
24
+ audience = this.issuer,
25
+ ): Promise<IssuedToken> {
26
+ const token = await new SignJWT({
27
+ sid: sessionId,
28
+ ...(clientId !== undefined && { client_id: clientId }),
29
+ ...(scope !== undefined && { scope }),
30
+ })
31
+ .setProtectedHeader({ alg: ALGORITHM })
32
+ .setSubject(userId)
33
+ .setIssuer(this.issuer)
34
+ .setAudience(audience)
35
+ .setIssuedAt()
36
+ .setExpirationTime(Math.floor(expiresAt.getTime() / 1000))
37
+ .sign(this.key)
38
+
39
+ return { token, expiresAt }
40
+ }
41
+
42
+ async verify(token: string, audience = this.issuer): Promise<AccessClaims | null> {
43
+ try {
44
+ const { payload } = await jwtVerify(token, this.key, {
45
+ algorithms: [ALGORITHM],
46
+ issuer: this.issuer,
47
+ audience,
48
+ })
49
+
50
+ if (typeof payload.sub !== 'string' || typeof payload.sid !== 'string') {
51
+ return null
52
+ }
53
+
54
+ return {
55
+ userId: payload.sub,
56
+ sessionId: payload.sid,
57
+ ...(typeof payload.client_id === 'string' && { clientId: payload.client_id }),
58
+ ...(typeof payload.scope === 'string' && { scope: payload.scope }),
59
+ }
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+ }
@@ -5,14 +5,28 @@ const envSchema = z.object({
5
5
  NODE_ENV: z.enum(['development', 'test', 'production']),
6
6
  PORT: z.coerce.number().int().min(1).max(65535),
7
7
  DATABASE_URL: z.string().min(1),
8
- AUTH_DATABASE_URL: z.string().min(1),
9
- BETTER_AUTH_SECRET: z.string().min(32),
10
- BETTER_AUTH_URL: z.string().min(1),
8
+ JWT_SECRET: z.string().min(32),
9
+ // Where this API is reached from outside: the issuer of its tokens, and what decides whether cookies are Secure.
10
+ API_URL: z.url(),
11
11
  WEB_ORIGIN: z.string().min(1),
12
+ // prumo:social
13
+ // Where a mobile app is sent back after signing in with a provider: its own scheme, as in app.json.
12
14
  MOBILE_APP_SCHEME: z
13
15
  .string()
14
16
  .regex(/^[a-z][a-z0-9+.-]*$/)
15
17
  .optional(),
18
+ // prumo:end-social
19
+ // prumo:google
20
+ GOOGLE_CLIENT_ID: z.string().min(1).optional(),
21
+ GOOGLE_CLIENT_SECRET: z.string().min(1).optional(),
22
+ // prumo:end-google
23
+ // prumo:apple
24
+ APPLE_CLIENT_ID: z.string().min(1).optional(),
25
+ APPLE_TEAM_ID: z.string().min(1).optional(),
26
+ APPLE_KEY_ID: z.string().min(1).optional(),
27
+ // The .p8 key's contents; on one line, with its line breaks written as \n.
28
+ APPLE_PRIVATE_KEY: z.string().min(1).optional(),
29
+ // prumo:end-apple
16
30
  LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']),
17
31
  })
18
32
 
@@ -0,0 +1,38 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { Authorization } from '@/domain/oauth/entities/authorization.entity'
3
+
4
+ export const AuthorizationSchema = new EntitySchema({
5
+ class: Authorization,
6
+ properties: {
7
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
8
+ clientId: { type: 'text' },
9
+ clientName: { type: 'text' },
10
+ redirectUri: { type: 'text' },
11
+ codeChallenge: { type: 'text' },
12
+ clientState: { type: 'text', nullable: true },
13
+ resource: { type: 'text' },
14
+ scope: { type: 'text' },
15
+ status: { type: 'text' },
16
+ userId: {
17
+ kind: 'm:1',
18
+ // EntitySchema cannot type a to-one relation held as its primary key on a string property.
19
+ entity: () => 'User' as never,
20
+ mapToPk: true,
21
+ fieldName: 'user_id',
22
+ nullable: true,
23
+ index: true,
24
+ // An authorization means nothing without the user who gave it.
25
+ deleteRule: 'cascade',
26
+ },
27
+ codeHash: { type: 'text', nullable: true },
28
+ sessionId: { type: 'uuid', nullable: true },
29
+ expiresAt: { type: 'datetime' },
30
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
31
+ updatedAt: {
32
+ type: 'datetime',
33
+ onCreate: () => new Date(),
34
+ onUpdate: () => new Date(),
35
+ defaultRaw: 'now()',
36
+ },
37
+ },
38
+ })
@@ -0,0 +1,30 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { EmailCode } from '@/domain/auth/entities/email-code.entity'
3
+
4
+ export const EmailCodeSchema = new EntitySchema({
5
+ class: EmailCode,
6
+ uniques: [{ properties: ['userId', 'purpose'] }],
7
+ properties: {
8
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
9
+ userId: {
10
+ kind: 'm:1',
11
+ // EntitySchema cannot type a to-one relation held as its primary key on a string property.
12
+ entity: () => 'User' as never,
13
+ mapToPk: true,
14
+ fieldName: 'user_id',
15
+ // A code means nothing without its user.
16
+ deleteRule: 'cascade',
17
+ },
18
+ purpose: { type: 'text' },
19
+ codeHash: { type: 'text' },
20
+ attemptsLeft: { type: 'integer' },
21
+ expiresAt: { type: 'datetime' },
22
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
23
+ updatedAt: {
24
+ type: 'datetime',
25
+ onCreate: () => new Date(),
26
+ onUpdate: () => new Date(),
27
+ defaultRaw: 'now()',
28
+ },
29
+ },
30
+ })
@@ -0,0 +1,29 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { Identity } from '@/domain/auth/entities/identity.entity'
3
+
4
+ export const IdentitySchema = new EntitySchema({
5
+ class: Identity,
6
+ uniques: [{ properties: ['provider', 'subject'] }],
7
+ properties: {
8
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
9
+ userId: {
10
+ kind: 'm:1',
11
+ // EntitySchema cannot type a to-one relation held as its primary key on a string property.
12
+ entity: () => 'User' as never,
13
+ mapToPk: true,
14
+ fieldName: 'user_id',
15
+ index: true,
16
+ // An identity means nothing without its user.
17
+ deleteRule: 'cascade',
18
+ },
19
+ provider: { type: 'text' },
20
+ subject: { type: 'text' },
21
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
22
+ updatedAt: {
23
+ type: 'datetime',
24
+ onCreate: () => new Date(),
25
+ onUpdate: () => new Date(),
26
+ defaultRaw: 'now()',
27
+ },
28
+ },
29
+ })
@@ -8,7 +8,7 @@ export const ProfileSchema = new EntitySchema({
8
8
  userId: {
9
9
  kind: 'm:1',
10
10
  // EntitySchema cannot type a to-one relation held as its primary key on a string property.
11
- entity: () => 'AuthUser' as never,
11
+ entity: () => 'User' as never,
12
12
  mapToPk: true,
13
13
  fieldName: 'user_id',
14
14
  unique: true,
@@ -0,0 +1,34 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { Session } from '@/domain/auth/entities/session.entity'
3
+
4
+ export const SessionSchema = new EntitySchema({
5
+ class: Session,
6
+ properties: {
7
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
8
+ userId: {
9
+ kind: 'm:1',
10
+ // EntitySchema cannot type a to-one relation held as its primary key on a string property.
11
+ entity: () => 'User' as never,
12
+ mapToPk: true,
13
+ fieldName: 'user_id',
14
+ index: true,
15
+ // A session means nothing without its user.
16
+ deleteRule: 'cascade',
17
+ },
18
+ tokenHash: { type: 'text' },
19
+ clientId: { type: 'text', nullable: true },
20
+ resource: { type: 'text', nullable: true },
21
+ scope: { type: 'text', nullable: true },
22
+ previousTokenHash: { type: 'text', nullable: true },
23
+ rotatedAt: { type: 'datetime', nullable: true },
24
+ expiresAt: { type: 'datetime' },
25
+ revokedAt: { type: 'datetime', nullable: true },
26
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
27
+ updatedAt: {
28
+ type: 'datetime',
29
+ onCreate: () => new Date(),
30
+ onUpdate: () => new Date(),
31
+ defaultRaw: 'now()',
32
+ },
33
+ },
34
+ })
@@ -0,0 +1,35 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
3
+
4
+ export const SocialSignInSchema = new EntitySchema({
5
+ class: SocialSignIn,
6
+ properties: {
7
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
8
+ provider: { type: 'text' },
9
+ client: { type: 'text' },
10
+ returnTo: { type: 'text' },
11
+ browserHash: { type: 'text' },
12
+ codeVerifier: { type: 'text', nullable: true },
13
+ nonce: { type: 'text' },
14
+ status: { type: 'text' },
15
+ userId: {
16
+ kind: 'm:1',
17
+ // EntitySchema cannot type a to-one relation held as its primary key on a string property.
18
+ entity: () => 'User' as never,
19
+ mapToPk: true,
20
+ fieldName: 'user_id',
21
+ nullable: true,
22
+ index: true,
23
+ deleteRule: 'cascade',
24
+ },
25
+ exchangeHash: { type: 'text', nullable: true },
26
+ expiresAt: { type: 'datetime' },
27
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
28
+ updatedAt: {
29
+ type: 'datetime',
30
+ onCreate: () => new Date(),
31
+ onUpdate: () => new Date(),
32
+ defaultRaw: 'now()',
33
+ },
34
+ },
35
+ })
@@ -0,0 +1,19 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { User } from '@/domain/auth/entities/user.entity'
3
+
4
+ export const UserSchema = new EntitySchema({
5
+ class: User,
6
+ properties: {
7
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
8
+ email: { type: 'text', unique: true },
9
+ passwordHash: { type: 'text', nullable: true },
10
+ emailVerifiedAt: { type: 'datetime', nullable: true },
11
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
12
+ updatedAt: {
13
+ type: 'datetime',
14
+ onCreate: () => new Date(),
15
+ onUpdate: () => new Date(),
16
+ defaultRaw: 'now()',
17
+ },
18
+ },
19
+ })
@@ -1,17 +1,28 @@
1
1
  import { Migrator } from '@mikro-orm/migrations'
2
2
  import { defineConfig } from '@mikro-orm/postgresql'
3
3
  import type { Env } from '@/infra/config/env'
4
- import { AuthUserSchema } from './entities/auth-user.schema'
4
+ import { AuthorizationSchema } from './entities/authorization.schema' // prumo:mcp
5
+ import { EmailCodeSchema } from './entities/email-code.schema' // prumo:email
6
+ import { IdentitySchema } from './entities/identity.schema' // prumo:social
5
7
  import { ProfileSchema } from './entities/profile.schema'
8
+ import { SessionSchema } from './entities/session.schema'
9
+ import { SocialSignInSchema } from './entities/social-sign-in.schema' // prumo:social
10
+ import { UserSchema } from './entities/user.schema'
6
11
 
7
12
  export function createOrmConfig(env: Pick<Env, 'DATABASE_URL' | 'NODE_ENV'>) {
8
13
  return defineConfig({
9
14
  clientUrl: env.DATABASE_URL,
10
- entities: [ProfileSchema, AuthUserSchema],
15
+ entities: [
16
+ UserSchema,
17
+ ProfileSchema,
18
+ SessionSchema,
19
+ AuthorizationSchema, // prumo:mcp
20
+ EmailCodeSchema, // prumo:email
21
+ IdentitySchema, // prumo:social
22
+ SocialSignInSchema, // prumo:social
23
+ ],
11
24
  extensions: [Migrator],
12
25
  migrations: { path: './migrations', snapshot: false },
13
- // Better Auth migrates its own schema; without this, every generated migration drops it.
14
- schemaGenerator: { ignoreSchema: ['auth'], skipTables: ['auth.user'] },
15
26
  debug: env.NODE_ENV === 'development',
16
27
  })
17
28
  }
@@ -0,0 +1,21 @@
1
+ import { EntityManager, LockMode } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { Authorization } from '@/domain/oauth/entities/authorization.entity'
4
+ import type { AuthorizationRepository } from '@/domain/oauth/repositories/authorization.repository'
5
+
6
+ @injectable()
7
+ export class MikroOrmAuthorizationRepository implements AuthorizationRepository {
8
+ constructor(private readonly em: EntityManager) {}
9
+
10
+ findById(id: string): Promise<Authorization | null> {
11
+ return this.em.findOne(Authorization, { id })
12
+ }
13
+
14
+ lockById(id: string): Promise<Authorization | null> {
15
+ return this.em.findOne(Authorization, { id }, { lockMode: LockMode.PESSIMISTIC_WRITE })
16
+ }
17
+
18
+ async save(authorization: Authorization): Promise<void> {
19
+ await this.em.persist(authorization).flush()
20
+ }
21
+ }
@@ -0,0 +1,17 @@
1
+ import { EntityManager, LockMode } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { EmailCode, type EmailCodePurpose } from '@/domain/auth/entities/email-code.entity'
4
+ import type { EmailCodeRepository } from '@/domain/auth/repositories/email-code.repository'
5
+
6
+ @injectable()
7
+ export class MikroOrmEmailCodeRepository implements EmailCodeRepository {
8
+ constructor(private readonly em: EntityManager) {}
9
+
10
+ lockFor(userId: string, purpose: EmailCodePurpose): Promise<EmailCode | null> {
11
+ return this.em.findOne(EmailCode, { userId, purpose }, { lockMode: LockMode.PESSIMISTIC_WRITE })
12
+ }
13
+
14
+ async save(code: EmailCode): Promise<void> {
15
+ await this.em.persist(code).flush()
16
+ }
17
+ }
@@ -0,0 +1,18 @@
1
+ import { EntityManager } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { Identity } from '@/domain/auth/entities/identity.entity'
4
+ import type { ProviderName } from '@/domain/auth/ports/identity-providers.port'
5
+ import type { IdentityRepository } from '@/domain/auth/repositories/identity.repository'
6
+
7
+ @injectable()
8
+ export class MikroOrmIdentityRepository implements IdentityRepository {
9
+ constructor(private readonly em: EntityManager) {}
10
+
11
+ findBySubject(provider: ProviderName, subject: string): Promise<Identity | null> {
12
+ return this.em.findOne(Identity, { provider, subject })
13
+ }
14
+
15
+ async save(identity: Identity): Promise<void> {
16
+ await this.em.persist(identity).flush()
17
+ }
18
+ }
@@ -0,0 +1,25 @@
1
+ import { EntityManager, LockMode } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { Session } from '@/domain/auth/entities/session.entity'
4
+ import type { SessionRepository } from '@/domain/auth/repositories/session.repository'
5
+
6
+ @injectable()
7
+ export class MikroOrmSessionRepository implements SessionRepository {
8
+ constructor(private readonly em: EntityManager) {}
9
+
10
+ findById(id: string): Promise<Session | null> {
11
+ return this.em.findOne(Session, { id })
12
+ }
13
+
14
+ lockById(id: string): Promise<Session | null> {
15
+ return this.em.findOne(Session, { id }, { lockMode: LockMode.PESSIMISTIC_WRITE })
16
+ }
17
+
18
+ async save(session: Session): Promise<void> {
19
+ await this.em.persist(session).flush()
20
+ }
21
+
22
+ async revokeAllFor(userId: string, now: Date): Promise<void> {
23
+ await this.em.nativeUpdate(Session, { userId, revokedAt: null }, { revokedAt: now })
24
+ }
25
+ }
@@ -0,0 +1,17 @@
1
+ import { EntityManager, LockMode } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
4
+ import type { SocialSignInRepository } from '@/domain/auth/repositories/social-sign-in.repository'
5
+
6
+ @injectable()
7
+ export class MikroOrmSocialSignInRepository implements SocialSignInRepository {
8
+ constructor(private readonly em: EntityManager) {}
9
+
10
+ lockById(id: string): Promise<SocialSignIn | null> {
11
+ return this.em.findOne(SocialSignIn, { id }, { lockMode: LockMode.PESSIMISTIC_WRITE })
12
+ }
13
+
14
+ async save(signIn: SocialSignIn): Promise<void> {
15
+ await this.em.persist(signIn).flush()
16
+ }
17
+ }
@@ -0,0 +1,31 @@
1
+ import { EntityManager, UniqueConstraintViolationException } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
5
+ import type { UserRepository } from '@/domain/auth/repositories/user.repository'
6
+
7
+ @injectable()
8
+ export class MikroOrmUserRepository implements UserRepository {
9
+ constructor(private readonly em: EntityManager) {}
10
+
11
+ findById(id: string): Promise<User | null> {
12
+ return this.em.findOne(User, { id })
13
+ }
14
+
15
+ findByEmail(email: string): Promise<User | null> {
16
+ return this.em.findOne(User, { email })
17
+ }
18
+
19
+ async save(user: User): Promise<void> {
20
+ try {
21
+ await this.em.persist(user).flush()
22
+ } catch (error) {
23
+ // Two sign-ups with one email can both pass the check; the unique index settles which one exists.
24
+ if (error instanceof UniqueConstraintViolationException) {
25
+ throw new EmailTakenError()
26
+ }
27
+
28
+ throw error
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,21 @@
1
+ import type { DependencyContainer } from 'tsyringe'
2
+ import { ACCESS_TOKENS } from '@/domain/auth/ports/access-tokens.port'
3
+ import { OPAQUE_TOKENS } from '@/domain/auth/ports/opaque-tokens.port'
4
+ import { PASSWORD_HASHER } from '@/domain/auth/ports/password-hasher.port'
5
+ import { SESSION_REPOSITORY } from '@/domain/auth/repositories/session.repository'
6
+ import { USER_REPOSITORY } from '@/domain/auth/repositories/user.repository'
7
+ import { Argon2PasswordHasher } from '@/infra/auth/argon2-password-hasher.adapter'
8
+ import { CryptoOpaqueTokens } from '@/infra/auth/crypto-opaque-tokens.adapter'
9
+ import { JoseAccessTokens } from '@/infra/auth/jose-access-tokens.adapter'
10
+ import { ENV, type Env } from '@/infra/config/env'
11
+ import { MikroOrmSessionRepository } from '@/infra/database/mikroorm/repositories/mikroorm-session.repository'
12
+ import { MikroOrmUserRepository } from '@/infra/database/mikroorm/repositories/mikroorm-user.repository'
13
+
14
+ export function registerAuth(container: DependencyContainer): void {
15
+ container.register(USER_REPOSITORY, { useClass: MikroOrmUserRepository })
16
+ container.register(SESSION_REPOSITORY, { useClass: MikroOrmSessionRepository })
17
+ // One instance each: the hasher keeps its decoy hash, the signer its key.
18
+ container.register(PASSWORD_HASHER, { useValue: new Argon2PasswordHasher() })
19
+ container.register(ACCESS_TOKENS, { useValue: new JoseAccessTokens(container.resolve<Env>(ENV)) })
20
+ container.register(OPAQUE_TOKENS, { useValue: new CryptoOpaqueTokens() })
21
+ }
@@ -0,0 +1,23 @@
1
+ import type { DependencyContainer } from 'tsyringe'
2
+ import { MAILER } from '@/domain/auth/ports/mailer.port'
3
+ import { ONE_TIME_CODES } from '@/domain/auth/ports/one-time-codes.port'
4
+ import { EMAIL_CODE_REPOSITORY } from '@/domain/auth/repositories/email-code.repository'
5
+ import { ENV, type Env } from '@/infra/config/env'
6
+ import { MikroOrmEmailCodeRepository } from '@/infra/database/mikroorm/repositories/mikroorm-email-code.repository'
7
+ import { HmacOneTimeCodes } from '@/infra/email/hmac-one-time-codes.adapter'
8
+ import { LogMailer } from '@/infra/email/log-mailer.adapter'
9
+
10
+ export function registerEmail(container: DependencyContainer): void {
11
+ const env = container.resolve<Env>(ENV)
12
+
13
+ container.register(EMAIL_CODE_REPOSITORY, { useClass: MikroOrmEmailCodeRepository })
14
+ container.register(ONE_TIME_CODES, { useValue: new HmacOneTimeCodes(env) })
15
+ // Silent under test, where a test registers its own Mailer to read what was sent.
16
+ container.register(MAILER, {
17
+ useValue: new LogMailer((line) => {
18
+ if (env.NODE_ENV !== 'test') {
19
+ console.info(line)
20
+ }
21
+ }),
22
+ })
23
+ }
@@ -1,10 +1,12 @@
1
1
  import { EntityManager, MikroORM } from '@mikro-orm/postgresql'
2
2
  import { type DependencyContainer, container as root } from 'tsyringe'
3
3
  import { TRANSACTION_MANAGER } from '@/domain/shared/transactions/transaction-manager'
4
- import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
5
- import { AUTH, createAuth } from '@/infra/auth/auth.factory'
6
4
  import { ENV, type Env } from '@/infra/config/env'
7
5
  import { MikroOrmTransactionManager } from '@/infra/database/mikroorm/transactions/mikroorm-transaction-manager'
6
+ import { registerAuth } from './auth.di'
7
+ import { registerEmail } from './email.di' // prumo:email
8
+ import { registerOauth } from './oauth.di' // prumo:mcp
9
+ import { registerSocial } from './social.di' // prumo:social
8
10
  import { registerUsers } from './users.di'
9
11
 
10
12
  export type Dependencies = { env: Env; orm: MikroORM }
@@ -18,15 +20,11 @@ export function createContainer({ env, orm }: Dependencies): DependencyContainer
18
20
  container.register(EntityManager, { useValue: orm.em })
19
21
  container.register(TRANSACTION_MANAGER, { useClass: MikroOrmTransactionManager })
20
22
 
23
+ registerAuth(container)
21
24
  registerUsers(container)
22
-
23
- container.register(AUTH, {
24
- useValue: createAuth(env, {
25
- onUserCreated: async (user) => {
26
- await container.resolve(CreateProfileUseCase).execute(user.id, user.name)
27
- },
28
- }),
29
- })
25
+ registerOauth(container) // prumo:mcp
26
+ registerEmail(container) // prumo:email
27
+ registerSocial(container) // prumo:social
30
28
 
31
29
  return container
32
30
  }
@@ -0,0 +1,14 @@
1
+ import type { DependencyContainer } from 'tsyringe'
2
+ import { AUTHORIZATION_CODES } from '@/domain/oauth/ports/authorization-codes.port'
3
+ import { CLIENT_METADATA } from '@/domain/oauth/ports/client-metadata.port'
4
+ import { AUTHORIZATION_REPOSITORY } from '@/domain/oauth/repositories/authorization.repository'
5
+ import { MikroOrmAuthorizationRepository } from '@/infra/database/mikroorm/repositories/mikroorm-authorization.repository'
6
+ import { CryptoAuthorizationCodes } from '@/infra/oauth/crypto-authorization-codes.adapter'
7
+ import { HttpsClientMetadata } from '@/infra/oauth/https-client-metadata.adapter'
8
+
9
+ export function registerOauth(container: DependencyContainer): void {
10
+ container.register(AUTHORIZATION_REPOSITORY, { useClass: MikroOrmAuthorizationRepository })
11
+ container.register(AUTHORIZATION_CODES, { useValue: new CryptoAuthorizationCodes() })
12
+ // One instance, so its cache of client documents outlives a request.
13
+ container.register(CLIENT_METADATA, { useValue: new HttpsClientMetadata() })
14
+ }
@@ -0,0 +1,17 @@
1
+ import type { DependencyContainer } from 'tsyringe'
2
+ import { IDENTITY_PROVIDERS } from '@/domain/auth/ports/identity-providers.port'
3
+ import { IDENTITY_REPOSITORY } from '@/domain/auth/repositories/identity.repository'
4
+ import { SOCIAL_SIGN_IN_REPOSITORY } from '@/domain/auth/repositories/social-sign-in.repository'
5
+ import { ENV, type Env } from '@/infra/config/env'
6
+ import { MikroOrmIdentityRepository } from '@/infra/database/mikroorm/repositories/mikroorm-identity.repository'
7
+ import { MikroOrmSocialSignInRepository } from '@/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository'
8
+ import { OpenIdIdentityProviders } from '@/infra/social/openid-identity-providers.adapter'
9
+
10
+ export function registerSocial(container: DependencyContainer): void {
11
+ container.register(IDENTITY_REPOSITORY, { useClass: MikroOrmIdentityRepository })
12
+ container.register(SOCIAL_SIGN_IN_REPOSITORY, { useClass: MikroOrmSocialSignInRepository })
13
+ // One instance, so each provider's discovery document is fetched once.
14
+ container.register(IDENTITY_PROVIDERS, {
15
+ useValue: new OpenIdIdentityProviders(container.resolve<Env>(ENV)),
16
+ })
17
+ }
@@ -0,0 +1,16 @@
1
+ import { createHmac, randomInt } from 'node:crypto'
2
+ import type { OneTimeCodes } from '@/domain/auth/ports/one-time-codes.port'
3
+ import type { Env } from '@/infra/config/env'
4
+
5
+ export class HmacOneTimeCodes implements OneTimeCodes {
6
+ constructor(private readonly env: Pick<Env, 'JWT_SECRET'>) {}
7
+
8
+ create(): string {
9
+ return randomInt(0, 1_000_000).toString().padStart(6, '0')
10
+ }
11
+
12
+ // A million codes hash in a moment, so the hash is keyed by a secret the database does not hold.
13
+ digest(code: string): string {
14
+ return createHmac('sha256', this.env.JWT_SECRET).update(code).digest('base64url')
15
+ }
16
+ }
@@ -0,0 +1,11 @@
1
+ import type { Mail, Mailer } from '@/domain/auth/ports/mailer.port'
2
+
3
+ // Writes each mail to the log instead of sending it: enough to develop against, and nothing to sign up for. A
4
+ // project that sends real mail gives the Mailer port another adapter; see .prumo/email/.
5
+ export class LogMailer implements Mailer {
6
+ constructor(private readonly write: (line: string) => void) {}
7
+
8
+ async send(mail: Mail): Promise<void> {
9
+ this.write(`[mail] to ${mail.to}: ${mail.subject}\n${mail.text}`)
10
+ }
11
+ }