@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,86 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
+ import { memoryTokenStore } from '@test/support/memory-token-store'
4
+ import { userEvent } from '@testing-library/react-native'
5
+ import { renderRouter, screen } from 'expo-router/testing-library'
6
+ import type { ReactNode } from 'react'
7
+ import { Text } from 'react-native'
8
+ import { createAuthClient, createClient } from '@/api-contract'
9
+ import ForgotPasswordScreen from '@/app/(auth)/forgot-password'
10
+ import ResetPasswordScreen from '@/app/(auth)/reset-password'
11
+ import SignUpScreen from '@/app/(auth)/sign-up'
12
+ import VerifyEmailScreen from '@/app/(auth)/verify-email'
13
+ import { ClientsProvider } from '@/features/clients/clients-context'
14
+
15
+ const TOKENS = { accessToken: 'access-1', refreshToken: 'refresh-1', expiresIn: 900 }
16
+
17
+ async function renderAt(initialUrl: string, routes: Parameters<typeof fakeTransport>[0]) {
18
+ const transport = fakeTransport(routes)
19
+ const auth = createAuthClient({ baseUrl: API_URL, fetch: transport, tokens: memoryTokenStore() })
20
+ const clients = { auth, api: createClient({ baseUrl: API_URL, fetch: auth.fetch }) }
21
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
22
+
23
+ function Providers({ children }: { children: ReactNode }) {
24
+ return (
25
+ <QueryClientProvider client={queryClient}>
26
+ <ClientsProvider clients={clients}>{children}</ClientsProvider>
27
+ </QueryClientProvider>
28
+ )
29
+ }
30
+
31
+ await renderRouter(
32
+ {
33
+ index: () => <Text>Your profile</Text>,
34
+ '(auth)/sign-up': SignUpScreen,
35
+ '(auth)/verify-email': VerifyEmailScreen,
36
+ '(auth)/forgot-password': ForgotPasswordScreen,
37
+ '(auth)/reset-password': ResetPasswordScreen,
38
+ },
39
+ { initialUrl, wrapper: Providers },
40
+ )
41
+ }
42
+
43
+ describe('email verification on mobile', () => {
44
+ it('takes a new account to the code, and in once it is entered', async () => {
45
+ const user = userEvent.setup()
46
+
47
+ await renderAt('/sign-up', {
48
+ 'POST /api/auth/sign-up': () => json(201, { verificationRequired: true }),
49
+ 'POST /api/auth/email/verify': () => json(200, TOKENS),
50
+ })
51
+
52
+ await user.type(screen.getByLabelText('Name'), 'Ana')
53
+ await user.type(screen.getByLabelText('Email'), 'ana@example.com')
54
+ await user.type(screen.getByLabelText('Password'), 'correct-horse-battery')
55
+ await user.press(screen.getByRole('button', { name: 'Create account' }))
56
+
57
+ expect(await screen.findByText('Confirm your email')).toBeOnTheScreen()
58
+
59
+ await user.type(screen.getByLabelText('Code'), '123456')
60
+ await user.press(screen.getByRole('button', { name: 'Confirm email' }))
61
+
62
+ expect(await screen.findByText('Your profile')).toBeOnTheScreen()
63
+ })
64
+ })
65
+
66
+ describe('password reset on mobile', () => {
67
+ it('goes from forgot to reset and in', async () => {
68
+ const user = userEvent.setup()
69
+
70
+ await renderAt('/forgot-password', {
71
+ 'POST /api/auth/password/forgot': () => new Response(null, { status: 204 }),
72
+ 'POST /api/auth/password/reset': () => json(200, TOKENS),
73
+ })
74
+
75
+ await user.type(screen.getByLabelText('Email'), 'ana@example.com')
76
+ await user.press(screen.getByRole('button', { name: 'Send a reset code' }))
77
+
78
+ expect(await screen.findByText('Choose a new password')).toBeOnTheScreen()
79
+
80
+ await user.type(screen.getByLabelText('Code'), '123456')
81
+ await user.type(screen.getByLabelText('New password'), 'another-good-password')
82
+ await user.press(screen.getByRole('button', { name: 'Set the new password' }))
83
+
84
+ expect(await screen.findByText('Your profile')).toBeOnTheScreen()
85
+ })
86
+ })
@@ -1,23 +1,24 @@
1
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
2
  import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
+ import { memoryTokenStore } from '@test/support/memory-token-store'
3
4
  import { render, screen, userEvent } from '@testing-library/react-native'
4
- import { createAuthClient } from 'better-auth/react'
5
+ import { createAuthClient } from '@/api-contract'
5
6
  import { SignInForm } from '@/features/auth/sign-in-form'
6
7
 
7
8
  describe('SignInForm', () => {
8
9
  it('shows a rejected sign-in as a form-level error', async () => {
9
10
  const onSignedIn = jest.fn()
10
- // jest-expo leaves the app manifest empty, and the Expo plugin needs it to build an origin; the form under test
11
- // depends only on the error Better Auth returns, which the plugin does not change.
12
11
  const auth = createAuthClient({
13
- baseURL: API_URL,
14
- basePath: '/api/auth',
15
- fetchOptions: {
16
- customFetchImpl: fakeTransport({
17
- 'POST /api/auth/sign-in/email': () =>
18
- json(401, { message: 'Invalid email or password', code: 'INVALID_EMAIL_OR_PASSWORD' }),
19
- }),
20
- },
12
+ baseUrl: API_URL,
13
+ tokens: memoryTokenStore(),
14
+ fetch: fakeTransport({
15
+ 'POST /api/auth/sign-in': () =>
16
+ json(
17
+ 401,
18
+ { title: 'Unauthorized', status: 401, detail: 'Invalid email or password' },
19
+ 'application/problem+json',
20
+ ),
21
+ }),
21
22
  })
22
23
  const user = userEvent.setup()
23
24
 
@@ -1,7 +1,8 @@
1
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
- import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
2
+ import { API_URL, fakeTransport } from '@test/support/fake-transport'
3
+ import { memoryTokenStore } from '@test/support/memory-token-store'
3
4
  import { render, screen, userEvent, waitFor } from '@testing-library/react-native'
4
- import { createAuthClient } from 'better-auth/react'
5
+ import { createAuthClient } from '@/api-contract'
5
6
  import { SignOutButton } from '@/features/auth/sign-out-button'
6
7
  import { clearUserData } from '@/features/storage/storage'
7
8
 
@@ -11,15 +12,13 @@ jest.mock('@/features/storage/storage', () => ({ clearUserData: jest.fn() }))
11
12
  async function signOutWith(signOut: () => Response | Promise<Response>) {
12
13
  const queryClient = new QueryClient()
13
14
  const onSignedOut = jest.fn()
14
- // jest-expo leaves the app manifest empty, and the Expo plugin needs it to build an origin; the button depends only on
15
- // signOut resolving or failing, which the plugin does not change.
16
15
  const auth = createAuthClient({
17
- baseURL: API_URL,
18
- basePath: '/api/auth',
19
- fetchOptions: { customFetchImpl: fakeTransport({ 'POST /api/auth/sign-out': signOut }) },
16
+ baseUrl: API_URL,
17
+ tokens: memoryTokenStore({ accessToken: 'a', refreshToken: 'r', expiresIn: 900 }),
18
+ fetch: fakeTransport({ 'POST /api/auth/sign-out': signOut }),
20
19
  })
21
20
 
22
- queryClient.setQueryData(['session'], { session: { id: 'session-1' }, user: { id: 'user-1' } })
21
+ queryClient.setQueryData(['session'], { user: { id: 'user-1', email: 'ana@example.com' } })
23
22
  queryClient.setQueryData(['profile', 'me'], { id: 'profile-1', displayName: 'Ana' })
24
23
 
25
24
  await render(
@@ -38,7 +37,7 @@ describe('SignOutButton', () => {
38
37
  beforeEach(() => jest.mocked(clearUserData).mockClear())
39
38
 
40
39
  it('leaves nothing of the user behind for the next person on the device', async () => {
41
- const queryClient = await signOutWith(() => json(200, { success: true }))
40
+ const queryClient = await signOutWith(() => new Response(null, { status: 204 }))
42
41
 
43
42
  expect(queryClient.getQueryCache().getAll()).toHaveLength(0)
44
43
  expect(clearUserData).toHaveBeenCalledTimes(1)
@@ -0,0 +1,77 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
+ import { memoryTokenStore } from '@test/support/memory-token-store'
4
+ import { render, screen, userEvent, waitFor } from '@testing-library/react-native'
5
+ import * as WebBrowser from 'expo-web-browser'
6
+ import { createAuthClient } from '@/api-contract'
7
+ import { SocialButtons } from '@/features/auth/social-buttons'
8
+
9
+ jest.mock('expo-web-browser', () => ({ openAuthSessionAsync: jest.fn() }))
10
+ // jest-expo leaves the app manifest empty, so expo-linking cannot know the app's scheme; this one stands in for it.
11
+ jest.mock('expo-linking', () => ({
12
+ ...jest.requireActual('expo-linking'),
13
+ createURL: (path: string) => `acme://${path}`,
14
+ }))
15
+
16
+ // Whichever provider the project kept: each button runs the same trip.
17
+ const firstProvider = () => screen.getAllByRole('button', { name: /^Continue with / })[0] as never
18
+
19
+ const TOKENS = { accessToken: 'access-1', refreshToken: 'refresh-1', expiresIn: 900 }
20
+
21
+ async function renderButtons(exchange: () => Response) {
22
+ const tokens = memoryTokenStore()
23
+ const onSignedIn = jest.fn()
24
+ const auth = createAuthClient({
25
+ baseUrl: API_URL,
26
+ tokens,
27
+ fetch: fakeTransport({ 'POST /api/auth/social/exchange': exchange }),
28
+ })
29
+
30
+ await render(
31
+ <QueryClientProvider client={new QueryClient()}>
32
+ <SocialButtons auth={auth} onSignedIn={onSignedIn} />
33
+ </QueryClientProvider>,
34
+ )
35
+
36
+ return { tokens, onSignedIn }
37
+ }
38
+
39
+ describe('SocialButtons', () => {
40
+ beforeEach(() => jest.mocked(WebBrowser.openAuthSessionAsync).mockReset())
41
+
42
+ it('opens the provider through the API and trades the code it comes back with', async () => {
43
+ jest
44
+ .mocked(WebBrowser.openAuthSessionAsync)
45
+ .mockResolvedValue({ type: 'success', url: 'acme://social?code=flow.secret' })
46
+ const { tokens, onSignedIn } = await renderButtons(() => json(200, TOKENS))
47
+
48
+ await userEvent.setup().press(firstProvider())
49
+
50
+ await waitFor(() => expect(onSignedIn).toHaveBeenCalled())
51
+ expect(jest.mocked(WebBrowser.openAuthSessionAsync).mock.calls[0]?.[0]).toMatch(
52
+ new RegExp(`^${API_URL}/api/auth/social/\\w+\\?client=mobile&returnTo=%2F$`),
53
+ )
54
+ expect(await tokens.read()).toEqual(TOKENS)
55
+ })
56
+
57
+ it('does nothing when the browser is closed, and says so when the code is refused', async () => {
58
+ jest
59
+ .mocked(WebBrowser.openAuthSessionAsync)
60
+ .mockResolvedValueOnce({ type: 'cancel' } as Awaited<
61
+ ReturnType<typeof WebBrowser.openAuthSessionAsync>
62
+ >)
63
+ const { onSignedIn } = await renderButtons(() => json(401, {}))
64
+ const user = userEvent.setup()
65
+
66
+ await user.press(firstProvider())
67
+ expect(screen.queryByRole('alert')).toBeNull()
68
+
69
+ jest
70
+ .mocked(WebBrowser.openAuthSessionAsync)
71
+ .mockResolvedValueOnce({ type: 'success', url: 'acme://social?code=used' })
72
+ await user.press(firstProvider())
73
+
74
+ expect(await screen.findByRole('alert')).toHaveTextContent(/did not complete/)
75
+ expect(onSignedIn).not.toHaveBeenCalled()
76
+ })
77
+ })
@@ -0,0 +1,15 @@
1
+ import type { TokenStore, Tokens } from '@/api-contract'
2
+
3
+ // expo-secure-store needs the native Keychain or Keystore, which Jest does not have.
4
+ export function memoryTokenStore(initial: Tokens | null = null): TokenStore {
5
+ let current = initial
6
+
7
+ return {
8
+ async read() {
9
+ return current
10
+ },
11
+ async write(tokens) {
12
+ current = tokens
13
+ },
14
+ }
15
+ }
@@ -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
  ],
@@ -16,13 +16,10 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@base-ui/react": "1.8.0",
19
- "@better-auth/core": "1.7.7",
20
- "@better-auth/oauth-provider": "1.7.7",
21
19
  "@fontsource-variable/geist": "5.3.0",
22
20
  "@hookform/resolvers": "5.9.1",
23
21
  "@tanstack/react-query": "5.102.8",
24
22
  "@tanstack/react-router": "1.170.36",
25
- "better-auth": "1.7.7",
26
23
  "class-variance-authority": "0.7.1",
27
24
  "cn": "0.3.0",
28
25
  "lucide-react": "1.45.0",
@@ -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,
@@ -0,0 +1,52 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import { useNavigate } from '@tanstack/react-router'
3
+ import { useForm } from 'react-hook-form'
4
+ import { z } from 'zod'
5
+ import type { AuthClient } from '@/api-contract'
6
+ import { Button } from '@/components/ui/button'
7
+ import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
8
+ import { Input } from '@/components/ui/input'
9
+ import { FormError } from '@/features/forms/form-error'
10
+ import { applyServerError } from '@/features/forms/server-errors'
11
+
12
+ const schema = z.object({ email: z.email() })
13
+
14
+ type ForgotPasswordValues = z.infer<typeof schema>
15
+
16
+ export function ForgotPasswordForm({ auth }: { auth: Pick<AuthClient, 'requestPasswordReset'> }) {
17
+ const navigate = useNavigate()
18
+ const form = useForm<ForgotPasswordValues>({
19
+ resolver: zodResolver(schema),
20
+ mode: 'onTouched',
21
+ defaultValues: { email: '' },
22
+ })
23
+ const { errors, isSubmitting } = form.formState
24
+
25
+ async function onSubmit(values: ForgotPasswordValues) {
26
+ try {
27
+ await auth.requestPasswordReset(values.email)
28
+ } catch (error) {
29
+ applyServerError(error, ['email'], form.setError)
30
+ return
31
+ }
32
+
33
+ // The API answers alike whether the account exists, so the next page cannot tell either.
34
+ await navigate({ to: '/reset-password', search: { email: values.email } })
35
+ }
36
+
37
+ return (
38
+ <form onSubmit={form.handleSubmit(onSubmit)} noValidate className="flex flex-col gap-6">
39
+ <FormError errors={errors} />
40
+ <FieldGroup>
41
+ <Field data-invalid={errors.email !== undefined}>
42
+ <FieldLabel htmlFor="email">Email</FieldLabel>
43
+ <Input id="email" type="email" autoComplete="email" {...form.register('email')} />
44
+ <FieldError errors={[errors.email]} />
45
+ </Field>
46
+ </FieldGroup>
47
+ <Button type="submit" disabled={isSubmitting}>
48
+ Send a reset code
49
+ </Button>
50
+ </form>
51
+ )
52
+ }