@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,112 @@
1
+ import { authFakes } from '@test/support/fakes/auth-fakes'
2
+ import { FakeOpaqueTokens } from '@test/support/fakes/fake-opaque-tokens'
3
+ import { ImmediateTransactionManager } from '@test/support/fakes/immediate-transaction-manager'
4
+ import type { Identity } from '@/domain/auth/entities/identity.entity'
5
+ import type { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
6
+ import type {
7
+ IdentityProviders,
8
+ ProviderCallback,
9
+ ProviderName,
10
+ ProviderProfile,
11
+ ProviderRedirect,
12
+ } from '@/domain/auth/ports/identity-providers.port'
13
+ import type { IdentityRepository } from '@/domain/auth/repositories/identity.repository'
14
+ import type { SocialSignInRepository } from '@/domain/auth/repositories/social-sign-in.repository'
15
+ import { CompleteSocialSignInUseCase } from '@/domain/auth/use-cases/complete-social-sign-in.use-case'
16
+ import { ExchangeSocialCodeUseCase } from '@/domain/auth/use-cases/exchange-social-code.use-case'
17
+ import { LinkIdentityUseCase } from '@/domain/auth/use-cases/link-identity.use-case'
18
+ import { StartSessionUseCase } from '@/domain/auth/use-cases/start-session.use-case'
19
+ import { StartSocialSignInUseCase } from '@/domain/auth/use-cases/start-social-sign-in.use-case'
20
+ import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
21
+ import { FakeAccessTokens } from './fake-access-tokens'
22
+
23
+ export class InMemoryIdentityRepository implements IdentityRepository {
24
+ readonly rows: Identity[] = []
25
+
26
+ async findBySubject(provider: ProviderName, subject: string): Promise<Identity | null> {
27
+ return this.rows.find((row) => row.provider === provider && row.subject === subject) ?? null
28
+ }
29
+
30
+ async save(identity: Identity): Promise<void> {
31
+ identity.id ??= crypto.randomUUID()
32
+ this.rows.push(identity)
33
+ }
34
+ }
35
+
36
+ export class InMemorySocialSignInRepository implements SocialSignInRepository {
37
+ readonly rows = new Map<string, SocialSignIn>()
38
+
39
+ async lockById(id: string): Promise<SocialSignIn | null> {
40
+ return this.rows.get(id) ?? null
41
+ }
42
+
43
+ async save(signIn: SocialSignIn): Promise<void> {
44
+ signIn.id ??= crypto.randomUUID()
45
+ this.rows.set(signIn.id, signIn)
46
+ }
47
+ }
48
+
49
+ // A provider that answers what the test tells it to, and remembers what it was asked.
50
+ export class FakeIdentityProviders implements IdentityProviders {
51
+ next: ProviderProfile = {
52
+ subject: 'google-sub-1',
53
+ email: 'ana@example.com',
54
+ emailVerified: true,
55
+ name: 'Ana',
56
+ }
57
+ lastCallback: ProviderCallback | undefined
58
+
59
+ constructor(private readonly configured: ProviderName[] = ['google', 'apple']) {}
60
+
61
+ isConfigured(provider: ProviderName): boolean {
62
+ return this.configured.includes(provider)
63
+ }
64
+
65
+ async redirect(provider: ProviderName, state: string): Promise<ProviderRedirect> {
66
+ return {
67
+ url: `https://${provider}.test/authorize?state=${state}`,
68
+ codeVerifier: 'verifier',
69
+ nonce: 'nonce',
70
+ }
71
+ }
72
+
73
+ async profile(_provider: ProviderName, callback: ProviderCallback): Promise<ProviderProfile> {
74
+ this.lastCallback = callback
75
+ return this.next
76
+ }
77
+ }
78
+
79
+ export function socialFakes() {
80
+ const auth = authFakes()
81
+ const identities = new InMemoryIdentityRepository()
82
+ const signIns = new InMemorySocialSignInRepository()
83
+ const providers = new FakeIdentityProviders()
84
+ const opaque = new FakeOpaqueTokens()
85
+ const transactions = new ImmediateTransactionManager()
86
+ const startSession = new StartSessionUseCase(auth.sessions, new FakeAccessTokens(), opaque)
87
+ const linkIdentity = new LinkIdentityUseCase(
88
+ auth.users,
89
+ identities,
90
+ auth.sessions,
91
+ transactions,
92
+ new CreateProfileUseCase(auth.profiles),
93
+ )
94
+
95
+ return {
96
+ ...auth,
97
+ identities,
98
+ signIns,
99
+ providers,
100
+ linkIdentity,
101
+ start: new StartSocialSignInUseCase(providers, signIns, opaque),
102
+ complete: new CompleteSocialSignInUseCase(
103
+ providers,
104
+ signIns,
105
+ opaque,
106
+ transactions,
107
+ linkIdentity,
108
+ startSession,
109
+ ),
110
+ exchange: new ExchangeSocialCodeUseCase(signIns, opaque, transactions, startSession),
111
+ }
112
+ }
@@ -1,10 +1,8 @@
1
1
  // tsyringe reads decorator metadata as each class is loaded, so this must come before any other import.
2
2
  import 'reflect-metadata'
3
3
  import { MikroORM } from '@mikro-orm/postgresql'
4
- import { getMigrations } from 'better-auth/db/migration'
5
- import { Client, type Pool } from 'pg'
4
+ import { Client } from 'pg'
6
5
  import { afterAll, beforeAll, beforeEach, inject } from 'vitest'
7
- import { createAuth } from '@/infra/auth/auth.factory'
8
6
  import type { Env } from '@/infra/config/env'
9
7
  import { createOrmConfig } from '@/infra/database/mikroorm/mikro-orm.factory'
10
8
 
@@ -25,9 +23,8 @@ export function testEnv(): Env {
25
23
  NODE_ENV: 'test',
26
24
  PORT: 3000,
27
25
  DATABASE_URL: databaseUrl,
28
- AUTH_DATABASE_URL: databaseUrl,
29
- BETTER_AUTH_SECRET: 'test-secret-that-is-at-least-32-characters',
30
- BETTER_AUTH_URL: 'http://localhost:3000',
26
+ JWT_SECRET: 'test-secret-that-is-at-least-32-characters',
27
+ API_URL: 'http://localhost:3000',
31
28
  WEB_ORIGIN: 'http://localhost:5173',
32
29
  LOG_LEVEL: 'error',
33
30
  }
@@ -44,41 +41,22 @@ async function createWorkerDatabase(baseUrl: string, name: string): Promise<stri
44
41
  const url = new URL(baseUrl)
45
42
  url.pathname = `/${name}`
46
43
 
47
- const worker = new Client({ connectionString: url.toString() })
48
-
49
- await worker.connect()
50
- await worker.query('CREATE SCHEMA IF NOT EXISTS auth')
51
- await worker.end()
52
-
53
44
  return url.toString()
54
45
  }
55
46
 
56
- async function migrateAuth(): Promise<void> {
57
- const auth = createAuth(testEnv())
58
- const { runMigrations } = await getMigrations(auth.options)
59
-
60
- await runMigrations()
61
- await (auth.options.database as Pool).end()
62
- }
63
-
64
47
  beforeAll(async () => {
65
48
  const name = `test_${process.env.VITEST_WORKER_ID ?? '1'}`
66
49
 
67
50
  databaseUrl = await createWorkerDatabase(inject('postgresUrl'), name)
68
- await migrateAuth()
69
51
 
70
52
  orm = await MikroORM.init(createOrmConfig(testEnv()))
71
53
 
72
54
  await orm.migrator.up()
73
55
 
74
- const metadata = orm.getMetadata().getAll()
75
- const own = Object.values(metadata)
76
- .filter(
77
- (meta) => meta.tableName !== undefined && meta.pivotTable !== true && meta.schema !== 'auth',
78
- )
56
+ // getAll() is a Map: Object.values() on it is empty, and nothing would be truncated.
57
+ tables = [...orm.getMetadata().getAll().values()]
58
+ .filter((meta) => meta.tableName !== undefined && meta.pivotTable !== true)
79
59
  .map((meta) => `"${meta.tableName}"`)
80
-
81
- tables = [...own, 'auth."user"', 'auth.session', 'auth.account', 'auth.verification']
82
60
  })
83
61
 
84
62
  beforeEach(async () => {
@@ -0,0 +1,65 @@
1
+ import { FakeMailer } from '@test/support/fakes/fake-mailer' // prumo:email
2
+ import { testEnv, testOrm } from '@test/support/setup'
3
+ import type { DependencyContainer } from 'tsyringe'
4
+ import { MAILER } from '@/domain/auth/ports/mailer.port' // prumo:email
5
+ import type { Env } from '@/infra/config/env'
6
+ import { createContainer } from '@/infra/di'
7
+ import { buildApp } from '@/infra/http/app'
8
+
9
+ export const ORIGIN = 'http://localhost:5173'
10
+ export const PASSWORD = 'correct-horse-battery'
11
+
12
+ // The application as a test meets it: the real container over the test database, with a mailer whose inbox the
13
+ // test can read.
14
+ export async function testApp({
15
+ env = testEnv(),
16
+ configure,
17
+ }: {
18
+ env?: Env
19
+ configure?: (container: DependencyContainer) => void
20
+ } = {}) {
21
+ const container = createContainer({ env, orm: testOrm() })
22
+ // prumo:email
23
+ const mailer = new FakeMailer()
24
+
25
+ container.register(MAILER, { useValue: mailer })
26
+ // prumo:end-email
27
+ configure?.(container)
28
+
29
+ const app = await buildApp(container)
30
+
31
+ // Signs up through the real routes and, where the email must be confirmed first, confirms it. Returns the
32
+ // response that delivered the session: cookies for the web, tokens in the body for a bearer client.
33
+ async function signUp({ bearer = false, email = `${crypto.randomUUID()}@example.com` } = {}) {
34
+ const headers = bearer ? { 'x-auth-transport': 'bearer' } : { origin: ORIGIN }
35
+ let response = await app.inject({
36
+ method: 'POST',
37
+ url: '/api/auth/sign-up',
38
+ headers,
39
+ payload: { name: 'Ana', email, password: PASSWORD },
40
+ })
41
+
42
+ // prumo:email
43
+ if (response.body.includes('verificationRequired')) {
44
+ response = await app.inject({
45
+ method: 'POST',
46
+ url: '/api/auth/email/verify',
47
+ headers,
48
+ payload: { email, code: mailer.lastCode(email) },
49
+ })
50
+ }
51
+
52
+ // prumo:end-email
53
+ return { response, email }
54
+ }
55
+
56
+ return {
57
+ app,
58
+ signUp,
59
+ mailer, // prumo:email
60
+ }
61
+ }
62
+
63
+ export function cookieHeader(response: { cookies: { name: string; value: string }[] }): string {
64
+ return response.cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')
65
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Requirements
4
4
 
5
- Node 22.18 or later, pnpm 10.26 or later, and the API running with `MOBILE_APP_SCHEME=app`. A simulator or a device with Expo Go
5
+ Node 22.18 or later, pnpm 10.26 or later, and the API running. A simulator or a device with Expo Go
6
6
  to open the app.
7
7
 
8
8
  ## Running it
@@ -1,14 +1,3 @@
1
- const preset = require('jest-expo/jest-preset')
2
-
3
- const [nodeModules, ...rest] = preset.transformIgnorePatterns
4
- const babel = preset.transform['\\.[jt]sx?$']
5
-
6
- if (!nodeModules.includes('(?!(') || babel === undefined) {
7
- throw new Error(
8
- 'jest-expo changed its transform settings; re-check the Better Auth entries below',
9
- )
10
- }
11
-
12
1
  module.exports = {
13
2
  preset: 'jest-expo',
14
3
  moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', '^@test/(.*)$': '<rootDir>/test/$1' },
@@ -16,10 +5,4 @@ module.exports = {
16
5
  // workspace the contract is a package to transform too, and Jest's 5s default was crossed in CI while the same
17
6
  // suite passed alone. The budget is the machine's slowness, not the test's.
18
7
  testTimeout: 30_000,
19
- // Better Auth ships ESM only, partly as .mjs, so it joins the packages jest-expo transforms, with the same Babel.
20
- transform: { '\\.mjs$': babel },
21
- transformIgnorePatterns: [
22
- nodeModules.replace('(?!(', '(?!(better-auth|@better-auth|nanostores|'),
23
- ...rest,
24
- ],
25
8
  }
@@ -15,18 +15,14 @@
15
15
  "prepare": "node .githooks/install.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "@better-auth/core": "1.7.7",
19
- "@better-auth/expo": "1.7.7",
20
18
  "@hookform/resolvers": "5.9.1",
21
19
  "@tanstack/query-sync-storage-persister": "5.102.8",
22
20
  "@tanstack/react-query": "5.102.8",
23
21
  "@tanstack/react-query-persist-client": "5.102.8",
24
- "better-auth": "1.7.7",
25
22
  "class-variance-authority": "0.7.1",
26
23
  "expo": "57.0.22",
27
24
  "expo-constants": "57.0.18",
28
25
  "expo-linking": "57.0.10",
29
- "expo-network": "57.0.2",
30
26
  "expo-router": "57.0.21",
31
27
  "expo-secure-store": "57.0.4",
32
28
  "expo-splash-screen": "57.0.9",
@@ -25,7 +25,7 @@ const APPS = {
25
25
  // A port that moves takes these with it: the key to write, in which app, from which app's port.
26
26
  const FOLLOWERS = {
27
27
  api: [
28
- { app: 'api', key: 'BETTER_AUTH_URL' },
28
+ { app: 'api', key: 'API_URL' },
29
29
  { app: 'web', key: 'VITE_API_URL' },
30
30
  { app: 'mobile', key: 'EXPO_PUBLIC_API_URL' },
31
31
  ],
@@ -5,14 +5,8 @@ type ProblemDocument = {
5
5
  status?: number
6
6
  detail?: string
7
7
  requestId?: string
8
- errors?: FieldErrors
9
- }
10
-
11
- type AuthClientError = {
12
- status: number
13
- statusText: string
14
- message?: string
15
8
  code?: string
9
+ errors?: FieldErrors
16
10
  }
17
11
 
18
12
  export class ApiError extends Error {
@@ -22,6 +16,8 @@ export class ApiError extends Error {
22
16
  readonly detail: string | undefined,
23
17
  readonly requestId: string | undefined,
24
18
  readonly errors: FieldErrors,
19
+ // A stable name for a refusal the client acts on, such as `email_not_verified`.
20
+ readonly code?: string,
25
21
  ) {
26
22
  super(detail ?? title)
27
23
  }
@@ -42,10 +38,7 @@ export class ApiError extends Error {
42
38
  problem.detail,
43
39
  problem.requestId ?? requestId,
44
40
  problem.errors ?? {},
41
+ problem.code,
45
42
  )
46
43
  }
47
-
48
- static fromAuthError(error: AuthClientError): ApiError {
49
- return new ApiError(error.status, error.code ?? error.statusText, error.message, undefined, {})
50
- }
51
44
  }
@@ -0,0 +1,221 @@
1
+ import { queryOptions } from '@tanstack/react-query'
2
+ import { ApiError } from './api-error'
3
+ import type { Transport } from './client'
4
+
5
+ export type Session = { user: { id: string; email: string } }
6
+
7
+ export type Tokens = { accessToken: string; refreshToken: string; expiresIn: number }
8
+
9
+ export type SignUpRequest = { name: string; email: string; password: string }
10
+
11
+ export type SignInRequest = { email: string; password: string }
12
+
13
+ // prumo:email
14
+ export type VerifyEmailRequest = { email: string; code: string }
15
+
16
+ export type ResetPasswordRequest = { email: string; code: string; password: string }
17
+
18
+ // prumo:end-email
19
+ // prumo:social
20
+ export type SocialProvider = 'google' | 'apple'
21
+
22
+ // prumo:end-social
23
+ // An account that must confirm its email first gets no session from signing up, and says so.
24
+ export type SignUpResult = { verificationRequired: boolean }
25
+
26
+ // Where a native client keeps its tokens. The web passes none: its tokens live in cookies no script can read.
27
+ export type TokenStore = {
28
+ read(): Promise<Tokens | null>
29
+ write(tokens: Tokens | null): Promise<void>
30
+ }
31
+
32
+ export type AuthClient = ReturnType<typeof createAuthClient>
33
+
34
+ export function createAuthClient({
35
+ baseUrl,
36
+ fetch,
37
+ tokens,
38
+ }: {
39
+ baseUrl: string
40
+ fetch: Transport
41
+ tokens?: TokenStore
42
+ }) {
43
+ const url = (path: string) => `${baseUrl}/api/auth${path}`
44
+
45
+ function send(path: string, body?: unknown): Promise<Response> {
46
+ const headers = new Headers()
47
+
48
+ // Asking for the tokens in the body is what makes the API leave cookies out.
49
+ if (tokens !== undefined) {
50
+ headers.set('x-auth-transport', 'bearer')
51
+ }
52
+
53
+ if (body !== undefined) {
54
+ headers.set('content-type', 'application/json')
55
+ }
56
+
57
+ return fetch(url(path), {
58
+ method: 'POST',
59
+ headers,
60
+ body: body === undefined ? undefined : JSON.stringify(body),
61
+ })
62
+ }
63
+
64
+ async function begin(path: string, body: unknown): Promise<SignUpResult> {
65
+ const response = await send(path, body)
66
+
67
+ if (!response.ok) {
68
+ throw await ApiError.fromResponse(response)
69
+ }
70
+
71
+ // The web's answer is empty: its session arrived as cookies.
72
+ const text = await response.text()
73
+ const answer = text === '' ? {} : (JSON.parse(text) as Partial<Tokens & SignUpResult>)
74
+
75
+ if (answer.verificationRequired === true) {
76
+ return { verificationRequired: true }
77
+ }
78
+
79
+ await tokens?.write(answer as Tokens)
80
+ return { verificationRequired: false }
81
+ }
82
+
83
+ // prumo:email
84
+ async function ask(path: string, body: unknown): Promise<void> {
85
+ const response = await send(path, body)
86
+
87
+ if (!response.ok) {
88
+ throw await ApiError.fromResponse(response)
89
+ }
90
+ }
91
+
92
+ // prumo:end-email
93
+
94
+ let refreshing: Promise<boolean> | undefined
95
+
96
+ // Every request that meets a 401 at once waits on the same refresh: a second refresh would present a token the
97
+ // first has already rotated.
98
+ function refresh(): Promise<boolean> {
99
+ refreshing ??= (async () => {
100
+ try {
101
+ // The web has no store and sends its cookie; a native client with nothing stored has nothing to send.
102
+ const stored = tokens === undefined ? undefined : await tokens.read()
103
+
104
+ if (stored === null) {
105
+ return false
106
+ }
107
+
108
+ const response = await send(
109
+ '/refresh',
110
+ stored === undefined ? undefined : { refreshToken: stored.refreshToken },
111
+ )
112
+
113
+ // Another tab rotated the token a moment ago, and the browser already holds the new one.
114
+ if (response.status === 409) {
115
+ return true
116
+ }
117
+
118
+ if (!response.ok) {
119
+ await tokens?.write(null)
120
+ return false
121
+ }
122
+
123
+ await tokens?.write((await response.json()) as Tokens)
124
+ return true
125
+ } finally {
126
+ refreshing = undefined
127
+ }
128
+ })()
129
+
130
+ return refreshing
131
+ }
132
+
133
+ // Every request to the API goes through here: with a bearer when there is one, and refreshed once on a 401.
134
+ const authorized: Transport = async (input, init) => {
135
+ const attempt = async () => {
136
+ const headers = new Headers(init?.headers)
137
+ const stored = await tokens?.read()
138
+
139
+ if (stored !== null && stored !== undefined) {
140
+ headers.set('authorization', `Bearer ${stored.accessToken}`)
141
+ }
142
+
143
+ return fetch(input, { ...init, headers })
144
+ }
145
+ const response = await attempt()
146
+
147
+ if (response.status !== 401 || !(await refresh())) {
148
+ return response
149
+ }
150
+
151
+ return attempt()
152
+ }
153
+
154
+ return {
155
+ baseUrl,
156
+ fetch: authorized,
157
+ refresh,
158
+
159
+ signUp: (request: SignUpRequest) => begin('/sign-up', request),
160
+
161
+ signIn: async (request: SignInRequest): Promise<void> => {
162
+ await begin('/sign-in', request)
163
+ },
164
+
165
+ // prumo:email
166
+ // Each answers alike for an unknown email, so a form can say "if the account exists, a code is on its way".
167
+ requestEmailVerification: (email: string) => ask('/email/verification', { email }),
168
+
169
+ verifyEmail: async (request: VerifyEmailRequest): Promise<void> => {
170
+ await begin('/email/verify', request)
171
+ },
172
+
173
+ requestPasswordReset: (email: string) => ask('/password/forgot', { email }),
174
+
175
+ resetPassword: async (request: ResetPasswordRequest): Promise<void> => {
176
+ await begin('/password/reset', request)
177
+ },
178
+ // prumo:end-email
179
+ // prumo:social
180
+ // Where to send the browser to sign in with a provider: the API takes it there and back. The web comes back
181
+ // signed in; a mobile app comes back with a code to exchange.
182
+ socialSignInUrl: (provider: SocialProvider, client: 'web' | 'mobile', returnTo = '/') =>
183
+ `${url(`/social/${provider}`)}?${new URLSearchParams({ client, returnTo })}`,
184
+
185
+ exchangeSocialCode: async (code: string): Promise<void> => {
186
+ await begin('/social/exchange', { code })
187
+ },
188
+ // prumo:end-social
189
+
190
+ // The device forgets the tokens whether or not the request arrives: signing out locally is what was asked.
191
+ async signOut(): Promise<void> {
192
+ try {
193
+ const response = await authorized(url('/sign-out'), { method: 'POST' })
194
+
195
+ if (!response.ok && response.status !== 401) {
196
+ throw await ApiError.fromResponse(response)
197
+ }
198
+ } finally {
199
+ await tokens?.write(null)
200
+ }
201
+ },
202
+
203
+ // A visitor without a session is not an error: the answer is null.
204
+ async getSession(): Promise<Session | null> {
205
+ const response = await authorized(url('/session'))
206
+
207
+ if (response.status === 401) {
208
+ return null
209
+ }
210
+
211
+ if (!response.ok) {
212
+ throw await ApiError.fromResponse(response)
213
+ }
214
+
215
+ return (await response.json()) as Session
216
+ },
217
+ }
218
+ }
219
+
220
+ export const sessionQuery = (auth: Pick<AuthClient, 'getSession'>) =>
221
+ queryOptions({ queryKey: ['session'], queryFn: () => auth.getSession() })
@@ -1,4 +1,18 @@
1
1
  export { ApiError, type FieldErrors } from './api-error'
2
+ export {
3
+ type AuthClient,
4
+ createAuthClient,
5
+ type ResetPasswordRequest, // prumo:email
6
+ type Session,
7
+ type SignInRequest,
8
+ type SignUpRequest,
9
+ type SignUpResult,
10
+ type SocialProvider, // prumo:social
11
+ sessionQuery,
12
+ type TokenStore,
13
+ type Tokens,
14
+ type VerifyEmailRequest, // prumo:email
15
+ } from './auth'
2
16
  export { type ApiClient, createClient, type Transport } from './client'
3
17
  export {
4
18
  LOCALES,
@@ -1,7 +1,7 @@
1
1
  import { useQuery } from '@tanstack/react-query'
2
2
  import { Redirect, Stack, usePathname } from 'expo-router'
3
+ import { sessionQuery } from '@/api-contract'
3
4
  import { rememberIntendedRoute } from '@/features/auth/intended-route'
4
- import { sessionQuery } from '@/features/auth/session'
5
5
  import { useClients } from '@/features/clients/clients-context'
6
6
 
7
7
  export default function AuthenticatedLayout() {
@@ -0,0 +1,20 @@
1
+ import { useRouter } from 'expo-router'
2
+ import { Text } from 'react-native'
3
+ import { SafeAreaView } from 'react-native-safe-area-context'
4
+ import { ForgotPasswordForm } from '@/features/auth/forgot-password-form'
5
+ import { useClients } from '@/features/clients/clients-context'
6
+
7
+ export default function ForgotPasswordScreen() {
8
+ const { auth } = useClients()
9
+ const router = useRouter()
10
+
11
+ return (
12
+ <SafeAreaView className="flex-1 justify-center gap-6 p-6">
13
+ <Text className="text-2xl font-semibold text-neutral-900">Reset your password</Text>
14
+ <ForgotPasswordForm
15
+ auth={auth}
16
+ onSent={(email) => router.push({ pathname: '/reset-password', params: { email } })}
17
+ />
18
+ </SafeAreaView>
19
+ )
20
+ }
@@ -0,0 +1,18 @@
1
+ import { useLocalSearchParams, useRouter } from 'expo-router'
2
+ import { Text } from 'react-native'
3
+ import { SafeAreaView } from 'react-native-safe-area-context'
4
+ import { ResetPasswordForm } from '@/features/auth/reset-password-form'
5
+ import { useClients } from '@/features/clients/clients-context'
6
+
7
+ export default function ResetPasswordScreen() {
8
+ const { auth } = useClients()
9
+ const router = useRouter()
10
+ const { email } = useLocalSearchParams<{ email: string }>()
11
+
12
+ return (
13
+ <SafeAreaView className="flex-1 justify-center gap-6 p-6">
14
+ <Text className="text-2xl font-semibold text-neutral-900">Choose a new password</Text>
15
+ <ResetPasswordForm auth={auth} email={email} onReset={() => router.replace('/')} />
16
+ </SafeAreaView>
17
+ )
18
+ }
@@ -3,6 +3,7 @@ import { Text, View } from 'react-native'
3
3
  import { SafeAreaView } from 'react-native-safe-area-context'
4
4
  import { takeIntendedRoute } from '@/features/auth/intended-route'
5
5
  import { SignInForm } from '@/features/auth/sign-in-form'
6
+ import { SocialButtons } from '@/features/auth/social-buttons' // prumo:social
6
7
  import { useClients } from '@/features/clients/clients-context'
7
8
 
8
9
  export default function SignInScreen() {
@@ -12,6 +13,9 @@ export default function SignInScreen() {
12
13
  return (
13
14
  <SafeAreaView className="flex-1 justify-center gap-6 p-6">
14
15
  <Text className="text-2xl font-semibold text-neutral-900">Sign in</Text>
16
+ {/* prumo:social */}
17
+ <SocialButtons auth={auth} onSignedIn={() => router.replace(takeIntendedRoute() ?? '/')} />
18
+ {/* prumo:end-social */}
15
19
  <SignInForm auth={auth} onSignedIn={() => router.replace(takeIntendedRoute() ?? '/')} />
16
20
  <View className="flex-row gap-1">
17
21
  <Text className="text-sm text-neutral-500">No account?</Text>
@@ -19,6 +23,11 @@ export default function SignInScreen() {
19
23
  Sign up
20
24
  </Link>
21
25
  </View>
26
+ {/* prumo:email */}
27
+ <Link href="/forgot-password" className="text-sm text-neutral-900">
28
+ Forgot your password?
29
+ </Link>
30
+ {/* prumo:end-email */}
22
31
  </SafeAreaView>
23
32
  )
24
33
  }