@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
@@ -2,71 +2,63 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Mount Better Auth at `/api/auth/*` with the catch-all route in `src/infra/http/controllers/auth.controller.ts`,
6
- which rebuilds the request for `auth.handler`, as Better Auth's Fastify guide does. Mark it
7
- `config: { public: true }`.
5
+ Keep authentication in the `auth` module, as any other module: `User` and `Session` in `entities/`, the
6
+ password hasher, the access tokens and the opaque tokens in `ports/`, one use case per action. Their adapters
7
+ live in `src/infra/auth/`. No authentication library owns a table, a route or a migration.
8
8
 
9
- Every route is protected by the global `preHandler` hook in `src/infra/http/hooks/auth.hook.ts`. Mark a route
10
- that needs no session with `config: { public: true }`; that means only *do not answer 401*, and the session is
11
- still read.
9
+ Serve `POST /api/auth/sign-up`, `sign-in`, `refresh` and `sign-out`, and `GET /api/auth/session`, from
10
+ `auth.controller.ts`. Answer their errors as every route does, in problem+json.
12
11
 
13
- Read the user in the handler with `currentUser(request)` and pass its id to the use case as an argument. A use
14
- case never reads the request or any ambient session.
12
+ Issue an access token as a JWT valid for **15 minutes**, signed with `HS256` from `JWT_SECRET`, and name the
13
+ algorithm when verifying it. Issue the refresh token as an opaque `<sessionId>.<secret>`, store only its
14
+ SHA-256, and **rotate it on every use**. A token from an earlier rotation revokes its session; the one replaced
15
+ less than ten seconds ago answers 409 instead, and revokes nothing.
15
16
 
16
- Keep Better Auth's tables in their own Postgres schema, `auth`, on their own connection, migrated by its own
17
- CLI from the compiled `dist/infra/auth/auth.cli.js`, before our migrations. `pnpm db:migrate` does both, in
18
- that order.
17
+ Hash passwords with Argon2id through `@node-rs/argon2`, at the library's defaults. When the email is unknown,
18
+ still verify the password against a decoy hash, and answer exactly as for a wrong password.
19
19
 
20
- Set `advanced.database.generateId` to `'uuid'`. Reference `auth.user(id)` from an application table with a
21
- real foreign key and `ON DELETE RESTRICT`, declared in its schema as a to-one relation to `AuthUser` with
22
- `mapToPk: true`, so the domain still holds a plain `userId`. `AuthUser`, in `auth-user.schema.ts`, maps only
23
- the id and is the one Better Auth table the ORM knows. Keep `schemaGenerator.ignoreSchema: ['auth']` and
24
- `skipTables: ['auth.user']` in `mikro-orm.factory.ts`. Better Auth's tables follow Better Auth's conventions,
25
- not `database/entities.md`.
20
+ Give the web its tokens in `httpOnly`, `SameSite=Lax` cookies: the access cookie on `/`, the refresh cookie only
21
+ on `/api/auth/refresh`, both `Secure` when `API_URL` is https. Give a native client its tokens in the body, and
22
+ only when it sends `X-Auth-Transport: bearer`; it then sends `Authorization: Bearer`. Refuse with 403 any
23
+ request that changes state, carries a cookie and no bearer, and does not come from `WEB_ORIGIN`.
26
24
 
27
- Create what the application keeps about a user in `databaseHooks.user.create.after`, through a use case:
28
- `src/infra/di/index.ts` passes the hook into `createAuth`.
25
+ Every route is protected by the global `preHandler` hook in `auth.hook.ts`. Mark a route that needs no session
26
+ with `config: { public: true }`; that means only *do not answer 401*, and the token is still read. Read the
27
+ user in the handler with `currentUser(request)` and pass its id to the use case as an argument. A use case
28
+ never reads the request.
29
29
 
30
- Pin every Better Auth package to one version, and declare `@better-auth/core` as a direct dependency at that
31
- version: `better-auth`, `auth` and every `@better-auth/*` plugin, in the api and in every client. Upgrade them in
32
- one change, never one package at a time.
30
+ Create a user and the application's own row for it in one use case, inside one transaction: `SignUpUseCase`
31
+ creates the `User` and calls `CreateProfileUseCase`.
33
32
 
34
- ## Rationale
35
-
36
- The route sits inside Fastify, so the request id and the logs cover the authentication flow. **The error
37
- handler does not:** Better Auth writes its own responses, so an authentication error arrives as
38
- `{ message, code }`, not as problem+json. The client converts it; the server does not rewrite another
39
- library's responses.
33
+ Limit the routes that take a password with `@fastify/rate-limit`, through the route's own
34
+ `config.rateLimit`. The plugin is registered with `global: false`.
40
35
 
41
- Protected is the default because of the asymmetry between the two mistakes. A route someone forgot to
42
- protect is open, and nothing breaks. A route someone forgot to mark public answers 401 on the first call and
43
- is fixed in seconds.
36
+ ## Rationale
44
37
 
45
- The user arrives as an argument so a use case's signature says what it needs, and its test passes an id
46
- instead of building a request.
38
+ Authentication is part of the product's data, so it follows the product's rules: one database, one connection,
39
+ one migration tool, and a domain that names what it needs through ports. Nothing has to be adapted to a
40
+ library's tables, ids or responses, and the user is an entity the ORM knows like any other.
47
41
 
48
- Better Auth's ids are `text` by default, which no `uuid` column can reference; `generateId: 'uuid'` fixes
49
- that. What stays different is not ours to fix: camelCase columns and `gen_random_uuid()`. Rewriting another
50
- library's schema fights its CLI on every upgrade. Better Auth's migrations run first because an application
51
- table references its tables.
42
+ An access token is checked by its signature alone, so no request touches the database to accept one, and
43
+ nothing can revoke one before it expires. Fifteen minutes bounds that. The refresh token is what can be
44
+ revoked, so it lives in the database, and rotation makes a stolen copy detectable: whoever presents an old one
45
+ is not the client that rotated it, and the session ends for both. The ten seconds exist because two tabs
46
+ refreshing at once both present the same token, and the second must not sign the user out.
52
47
 
53
- The ORM compares its entities with everything in the database, so without `ignoreSchema` it reads Better
54
- Auth's tables as leftovers and every generated migration drops them, with every login in them. The foreign
55
- key is declared rather than written by hand in a migration because the ORM would otherwise drop that too,
56
- every time. `AuthUser` exists only as that anchor.
48
+ The decoy hash is there because the time a sign-in takes would otherwise say which emails have an account.
57
49
 
58
- `better-auth` pins its core exactly, but each plugin only asks for a compatible core as a peer. Without a
59
- direct `@better-auth/core`, pnpm satisfies that peer with the newest core on the registry, so a core release
60
- installs a second core beside the pinned one, and the plugins stop typechecking against it. One version and
61
- a declared core leave pnpm a single core to install.
50
+ A cookie no script can read is a token an injected script cannot take, which is why the web never receives
51
+ one in a body. A cookie is also sent by the browser whoever wrote the page, which is what the Origin check
52
+ answers: a request leaning on a cookie must come from the web app. A native app has no cookie jar and no
53
+ Origin, so it carries a bearer and passes.
62
54
 
63
- **The sign-up hook is not atomic:** the user and the application's row are written over two connections, so
64
- a failure between them leaves a user without a profile. The result is a visible 404, not corrupted data. And
65
- the foreign key means deleting a user fails while rows reference it, so the application removes them first.
55
+ **What this costs:** the security of sign-in is this code's. There is no upstream fix to wait for. Email
56
+ verification, password reset and social sign-in are questions `prumo new` asks; a project that answered no to
57
+ them has none of them until they are written.
66
58
 
67
59
  ## Applies to
68
60
 
69
- Every route, every use case that acts for a user, and `src/infra/auth/`, where Better Auth is built.
61
+ Every route, every use case that acts for a user, `src/domain/auth/`, and `src/infra/auth/`.
70
62
 
71
63
  ## Examples
72
64
 
@@ -77,22 +69,21 @@ Every route, every use case that acts for a user, and `src/infra/auth/`, where B
77
69
  ✅ execute(currentUser(request).id)
78
70
  ❌ execute(request)
79
71
 
80
- ✅ userId: { kind: 'm:1', entity: () => 'AuthUser' as never, mapToPk: true, deleteRule: 'restrict' }
81
- ❌ userId: { type: 'uuid' } nothing stops an orphan
72
+ ✅ jwtVerify(token, key, { algorithms: ['HS256'], issuer, audience })
73
+ ❌ jwtVerify(token, key) the token chooses its own algorithm
74
+
75
+ ✅ sessions store sha256(secret)
76
+ ❌ sessions store the refresh token
82
77
  ```
83
78
 
84
79
  ## Enforcement
85
80
 
86
81
  **Boot.** The hook is global, so a route is unprotected only by an explicit `public: true`.
87
82
 
88
- **Tests.** `users.controller.spec.ts` signs up through the real route and proves 401 without a session.
89
- `mikro-orm.factory.spec.ts` asserts that a migrated database leaves the schema generator nothing to change,
90
- so a configuration that would drop Better Auth's schema, or the foreign key, fails the suite.
91
-
92
- **The database.** A row referencing a user that does not exist is refused.
93
-
94
- **Typecheck.** Two cores in one project usually fail `pnpm typecheck` in `src/infra/auth/`.
83
+ **Tests.** `auth.controller.spec.ts` covers both transports, rotation, the Origin check, 401, 409, 400 and the
84
+ rate limit. `refresh-session.use-case.spec.ts` proves that a replayed token revokes the session and that one
85
+ just rotated does not. `jose-access-tokens.adapter.spec.ts` refuses an expired token, another secret, another
86
+ issuer, an unsigned token and another algorithm.
95
87
 
96
- **Review only.** That an upgrade moves the whole Better Auth family, `@better-auth/core` included. And that
97
- each `public: true` is deliberate: the one mistake that is invisible at runtime, because
88
+ **Review only.** That each `public: true` is deliberate: the one mistake that is invisible at runtime, because
98
89
  the route works, for everyone.
@@ -33,7 +33,7 @@ bodies; a process environment legitimately carries `PATH`, `HOME` and whatever t
33
33
  exception is written down so nobody harmonises it later.
34
34
 
35
35
  **The file is loaded by `loadEnv()` rather than by the server** because the server is not the only entry
36
- point: the ORM's CLI and Better Auth's CLI both read the configuration without starting it. Node's
36
+ point: the ORM's CLI reads the configuration without starting it. Node's
37
37
  `--env-file` would work without code, but it has to be typed on every command. One function every entry
38
38
  point calls keeps one policy, including *never in production*.
39
39
 
@@ -47,7 +47,7 @@ point calls keeps one policy, including *never in production*.
47
47
  ✅ constructor(@inject(ENV) private readonly env: Env) {}
48
48
  ❌ const url = process.env.DATABASE_URL
49
49
 
50
- ✅ Invalid environment: DATABASE_URL, BETTER_AUTH_SECRET
50
+ ✅ Invalid environment: DATABASE_URL, JWT_SECRET
51
51
  ❌ DATABASE_URL is invalid: postgres://admin:hunter2@prod-db/app
52
52
  ```
53
53
 
@@ -3,8 +3,9 @@
3
3
  ## Rule
4
4
 
5
5
  Serve every route at `/api/v<n>/<resource>`: register each controller with
6
- `prefix: '/api/v<n>/<resource>'`. Better Auth's `/api/auth/*`, `/api/health/*` and `/api/docs` are the only
7
- routes outside a version.
6
+ `prefix: '/api/v<n>/<resource>'`. `/api/auth/*`, `/api/health/*` and `/api/docs`, and with MCP `/api/mcp`,
7
+ `/api/oauth/*` and `/.well-known/*`, are the only routes outside a version. They answer with the statuses their
8
+ own contracts set.
8
9
 
9
10
  Name resources in the plural, kebab-case for compound words. Use REST verbs. A `POST` answers `201`
10
11
  through `reply.code(201)`; every other method answers `200`.
@@ -28,8 +29,9 @@ Serve the documentation UI outside production only.
28
29
 
29
30
  ## Rationale
30
31
 
31
- The prefix makes Better Auth's `/api/auth/*` fall out of the ordinary convention instead of becoming the
32
- one route with an exception in its path.
32
+ The prefix makes the authentication and protocol routes fall out of the ordinary convention instead of each
33
+ becoming the one route with an exception in its path. They stay outside a version because what shapes them is
34
+ what they implement, OAuth's discovery above all, whose addresses are fixed by its RFCs.
33
35
 
34
36
  Versioning costs five characters today and a coordinated migration of every consumer later, including a
35
37
  published mobile app that does not update when you want it to. It goes in the URI rather than a header
@@ -17,7 +17,8 @@ folders, each created with its first file:
17
17
  | Folder | Holds | File |
18
18
  |---|---|---|
19
19
  | `entities/` | A plain class with the data and the behaviour of one entity | `<name>.entity.ts` |
20
- | `repositories/` | The port: an interface and its `Symbol` token, in one file | `<name>.repository.ts` |
20
+ | `repositories/` | The port to persistence: an interface and its `Symbol` token, in one file | `<name>.repository.ts` |
21
+ | `ports/` | Any other port, such as a password hasher or a token signer: an interface and its `Symbol` token | `<name>.port.ts` |
21
22
  | `use-cases/` | One class per use case, with one `execute()` method | `<verb>-<noun>.use-case.ts` |
22
23
  | `dto/` | Zod schemas and their inferred types, input and output | `<name>.dto.ts` |
23
24
  | `errors/` | Classes extending `DomainError` | `<name>.error.ts` |
@@ -94,6 +95,8 @@ A module:
94
95
  domain/users/repositories/profile.repository.ts interface + PROFILE_REPOSITORY
95
96
  domain/users/use-cases/update-profile.use-case.ts
96
97
  domain/users/dto/update-profile.dto.ts
98
+ ✅ domain/auth/ports/password-hasher.port.ts interface + PASSWORD_HASHER
99
+ ❌ domain/auth/repositories/password-hasher.repository.ts stores nothing
97
100
  ❌ domain/users/users.service.ts every case in one class
98
101
  ❌ domain/users/profile.ts no role suffix
99
102
  ```
@@ -3,15 +3,15 @@
3
3
  ## Rule
4
4
 
5
5
  Keep the wire contract with the project's API in `src/api-contract/`: `createClient`, which builds every URL
6
- including the `/api/v1` prefix, the `ApiError`, the hand-written request and response types, and the query
7
- factories. **Let the app inject the transport** (`createClient({ baseUrl, fetch })`) with
8
- `credentials: 'include'` on the web configured there, once. In a monorepo the same directory is
9
- `packages/api-contract`.
6
+ including the `/api/v1` prefix, the `ApiError`, the hand-written request and response types, the query
7
+ factories, and `createAuthClient` with `sessionQuery`. **Let the app inject the transport**
8
+ (`createClient({ baseUrl, fetch })`) with `credentials: 'include'` on the web configured there, once. In a
9
+ monorepo the same directory is `packages/api-contract`.
10
10
 
11
- Call Better Auth's routes through Better Auth's own client, `createAuthClient`, configured once. In `mobile`, give it
12
- the `expoClient` plugin, and let the transport for the project's API attach the session with `auth.getCookie()` and
13
- send `credentials: 'omit'`. Do not write
14
- its routes, bodies or responses by hand, and do not send them through the wrapper.
11
+ Build the auth client once, and give the API client its `fetch`: `createClient({ baseUrl, fetch: auth.fetch })`.
12
+ That fetch refreshes once on a 401 and repeats the request, with one refresh for every request refused at
13
+ once. The web passes no token store and lives on its cookies. `mobile` passes a `TokenStore` over
14
+ `expo-secure-store`, which makes the client ask for bearer tokens and send them.
15
15
 
16
16
  Declare request and response types by hand, in `api-contract/`. Do not generate a client
17
17
  from the OpenAPI document, and **do not `import type` an entity from the API package**; see below.
@@ -21,8 +21,8 @@ function travel together. Include in the key everything that changes the result:
21
21
  ordering.
22
22
 
23
23
  Let the client parse every `application/problem+json` body into one `ApiError` carrying `status`,
24
- `title`, `detail`, `requestId` and the `errors` map, and throw it. Convert the auth client's `error` into the
25
- same `ApiError` in one adapter. No component reads a raw error body.
24
+ `title`, `detail`, `requestId` and the `errors` map, and throw it. The auth routes answer the same way. No
25
+ component reads a raw error body.
26
26
 
27
27
  Use `useInfiniteQuery` for paginated lists, with `getNextPageParam` returning `nextCursor`, or `undefined`
28
28
  once it is null.
@@ -55,12 +55,10 @@ difference between their keys fills one cache entry while the other is read, pay
55
55
  nothing, with no warning. A key that omits a filter is the same class of fault: two screens share one
56
56
  entry and the second shows the first's data.
57
57
 
58
- **There are two doors because there are two contracts.** The project's API is ours, so its types are written
59
- here. Better Auth's routes are Better Auth's: hand-writing them would copy a contract we do not control, which
60
- drifts on every upgrade and grows with every plugin. Its client already carries those types and follows the
61
- library, and `multi-tenancy/client.md` depends on it. **Its errors are not problem+json**: `toNodeHandler`
62
- writes the response itself, past the exception filter, so the body is `{ message, code }` with no `requestId`.
63
- The adapter is what keeps that difference out of every form.
58
+ **The auth client is the one door every request goes through.** An access token lasts fifteen minutes, so any
59
+ request can meet a 401 that a refresh would answer. Doing that once, in the transport, means no screen knows
60
+ tokens expire. Several requests refused together share one refresh, because a second would present a token the
61
+ first has already rotated, and the API would read that as theft.
64
62
 
65
63
  One `ApiError` keeps the shape of problem+json in a single file instead of in every component that
66
64
  displays an error. It also puts `requestId` on every failure without anyone asking, which is what makes a
@@ -92,8 +90,8 @@ The query factory:
92
90
  Reaching the server:
93
91
 
94
92
  ```
95
- ✅ api.get('/users/me') authClient.signIn.email({ email, password })
96
- ❌ api.post('/auth/sign-in/email', { … }) // Better Auth's contract, copied by hand
93
+ ✅ createClient({ baseUrl, fetch: auth.fetch }) // refreshed and repeated on a 401
94
+ ❌ createClient({ baseUrl, fetch }) // the first expired token signs the user out
97
95
  ```
98
96
 
99
97
  Reading an error:
@@ -13,8 +13,8 @@ field. **Turn anything that matches no field into a form-level error**, shown at
13
13
  `requestId`. Do it through one function in `features/forms/`, which sets the form-level error on
14
14
  `root.server`.
15
15
 
16
- Show an error carrying no field the same way: form-level, with `title` and the `requestId`. **Every
17
- authentication error is this case**: Better Auth's errors carry no field map.
16
+ Show an error carrying no field the same way: form-level, with `title` and the `requestId`. A wrong password
17
+ is this case: the API names no field, so as not to say whether the email exists.
18
18
 
19
19
  Disable the submit button while `isSubmitting`. Never clear the form on error.
20
20
 
@@ -56,10 +56,10 @@ behaviour) holds unchanged. **What it costs:** two runners in one workspace with
56
56
  written in a mobile test is a mistake an assistant will make.
57
57
 
58
58
  **Three things a mobile test meets, verified with Expo SDK 57:** `render` from `@testing-library/react-native` 14
59
- is **asynchronous** and its matchers need no setup. `jest-expo` leaves the app manifest empty, which Better Auth's
60
- Expo plugin needs to build an origin, so a component test uses a Better Auth client without that plugin, which is
61
- why a form asks only for the auth methods it calls. And Better Auth ships ESM, partly as `.mjs`, so Jest extends
62
- `jest-expo`'s own transform settings to include it rather than restating them.
59
+ is **asynchronous** and its matchers need no setup. `expo-secure-store` needs the native Keychain or Keystore,
60
+ which Jest does not have, so a test builds the auth client with an in-memory token store, which is why a form
61
+ asks only for the auth methods it calls. And `jest-expo` leaves the app manifest empty, so `expo-linking` cannot
62
+ know the app's scheme: a test that reaches `Linking.createURL` mocks it with the scheme it expects.
63
63
 
64
64
  A coverage threshold turns a proxy into a target. Whoever is below writes tests to raise the number rather
65
65
  than to check anything; whoever is above stops thinking. With no database mocks a test costs more to write,
@@ -6,10 +6,9 @@ Start one Postgres container per test run with Testcontainers. Give each Vitest
6
6
  inside it. Never point a test at a shared or long-lived database.
7
7
 
8
8
  Build the schema by running the migrations, once per worker database. Do not use the schema generator.
9
- Run Better Auth's migrations first, through its programmatic `getMigrations`, then ours.
10
9
 
11
- Truncate every table between tests, in a single `TRUNCATE` statement listing them all, Better Auth's
12
- included. Do not wrap a test in a transaction and roll it back.
10
+ Truncate every table between tests, in a single `TRUNCATE` statement listing every entity's table, read from
11
+ the ORM's metadata. Do not wrap a test in a transaction and roll it back.
13
12
 
14
13
  Create test data with a factory per entity: a function returning a valid row from defaults and
15
14
  accepting overrides. Do not keep a shared fixture file.
@@ -35,11 +34,10 @@ inside one turns the code's transaction into a savepoint, which behaves differen
35
34
  never exercised. Testing a lock inside a wrapper that will roll back tests something adjacent to the
36
35
  truth.
37
36
 
38
- Better Auth's migrations run first for the same reason they do in production: an application table
39
- references `auth.user`, so its foreign key cannot be created before the table exists. Its tables are in the
40
- truncation for the mirror reason: leave them out and the next test's factory collides with a user the
41
- previous test created. A factory for such a table creates the user first; Better Auth's tables have no entity
42
- here, so that insert is raw SQL, with its one-line reason.
37
+ The list comes from the metadata so that a new entity is truncated without anyone remembering to add it. Read
38
+ it as the `Map` it is: `Object.values()` on it is empty, and a suite once passed for months truncating nothing,
39
+ saved only by a cascade from tables it no longer has. A factory for a table that references a user creates the
40
+ user first, through the user's own factory.
43
41
 
44
42
  Factories rather than a fixture file, because a fixture grows until a test depends on user number twelve
45
43
  having a particular plan, and then neither can be changed alone. A factory call states what the scenario
@@ -0,0 +1,62 @@
1
+ # Email codes
2
+
3
+ ## Rule
4
+
5
+ Confirm an email address and reset a password with a **6-digit code** sent by mail, never a link. One flow
6
+ serves the web and the mobile app alike.
7
+
8
+ Keep one live code per user and purpose (`verify-email`, `reset-password`) in `email_code`. A code lives
9
+ **15 minutes** and allows **5 attempts**; the fifth wrong one kills it. Sending another replaces it, and the old
10
+ one stops working. Store only its HMAC-SHA256, keyed with `JWT_SECRET`.
11
+
12
+ Open no session at sign-up: answer `201 { verificationRequired: true }` and send the code. Refuse sign-in to an
13
+ unconfirmed account with **403** and `code: 'email_not_verified'`, and only after the password matched. Sign
14
+ the user in when the code is confirmed.
15
+
16
+ On a password reset, set the new password, mark the email confirmed, and revoke every session of the user before
17
+ starting a new one.
18
+
19
+ Answer `/email/verification` and `/password/forgot` with 204 whether or not the email has an account, and a
20
+ wrong code, a dead one and an unknown email with the same `422 invalid_code`.
21
+
22
+ Read the outcome of a sign-up and of a refused sign-in in the client: `verificationRequired` and
23
+ `email_not_verified` both lead to the code screen, which offers to send a new code.
24
+
25
+ ## Rationale
26
+
27
+ A link needs a page to open: easy on the web, and a deep link with universal links on mobile. A code is typed
28
+ the same way everywhere, so there is one flow, one set of screens and one set of tests.
29
+
30
+ Six digits are a million possibilities, which is why the attempts are counted and the lifetime short: five
31
+ guesses against a million cannot succeed by luck. The hash is keyed because a million codes hash in a moment,
32
+ so an unkeyed digest in a database copy would give every live code away.
33
+
34
+ Every answer that could say whether an email has an account says the same thing instead, so these routes cannot
35
+ be used to find out who signed up. Asking for confirmation only after the password matched keeps that true at
36
+ sign-in too.
37
+
38
+ A reset revokes every session because whoever knew the old password may have opened one, and the reset is often
39
+ the reaction to exactly that.
40
+
41
+ ## Applies to
42
+
43
+ `src/domain/auth/` for the codes, `auth-email.controller.ts`, and the code, forgot-password and reset-password
44
+ screens on the web and mobile.
45
+
46
+ ## Examples
47
+
48
+ ```
49
+ ✅ POST /api/auth/password/forgot → 204 for any email
50
+ ❌ POST /api/auth/password/forgot → 404 "no account with this email"
51
+
52
+ ✅ sign-in with the right password, unconfirmed → 403 email_not_verified
53
+ ❌ sign-in, unconfirmed, before checking the password → 403 // tells anyone the account exists
54
+ ```
55
+
56
+ ## Enforcement
57
+
58
+ **Tests.** `email.use-case.spec.ts` covers the dead code after five guesses, the replaced code, the unknown email
59
+ and the reset revoking sessions. `auth-email.controller.spec.ts` covers the HTTP answers, and the clients'
60
+ `email-flows.spec.tsx` the screens.
61
+
62
+ **Review only.** That a new mail with a code goes through `IssueEmailCodeUseCase`, so it keeps the same limits.
@@ -0,0 +1,47 @@
1
+ # Mailer
2
+
3
+ ## Rule
4
+
5
+ Send every email through the `Mailer` port in `src/domain/auth/ports/mailer.port.ts`. A use case composes the
6
+ `Mail` (`to`, `subject`, `text`) and hands it over; nothing else in the domain knows how mail travels.
7
+
8
+ Keep `LogMailer`, which writes each mail to the API's log, as the adapter until the project sends real mail.
9
+ Read a code during development from that log.
10
+
11
+ To send real mail, write a second adapter under `src/infra/email/` and register it in `email.di.ts` in place of
12
+ `LogMailer`, for production only. Either speak SMTP, with `nodemailer` and an `SMTP_URL` any provider accepts, or
13
+ call a provider's HTTP API. Validate the new variable in `env.ts`.
14
+
15
+ In a test, register a `FakeMailer` and read the code from it, as `test/support/test-app.ts` does.
16
+
17
+ ## Rationale
18
+
19
+ Sending mail needs an account with somebody, and choosing one is a commercial decision the project's owner
20
+ makes, not the template. So the template ships the port and an adapter that needs nothing, and the flows that
21
+ depend on mail work end to end from the first `pnpm dev`: the code is in the terminal.
22
+
23
+ The port is the whole contract on purpose. SES, Postmark, Resend, Mailgun and a company relay all accept the
24
+ same three fields, so changing provider is one new adapter and one line in the container.
25
+
26
+ **What this costs:** until an adapter is written, a deployed API sends nothing, and a user waiting for a code
27
+ never receives it. Nothing warns about that but this document.
28
+
29
+ ## Applies to
30
+
31
+ Every email the API sends, now and later: codes, invitations, receipts.
32
+
33
+ ## Examples
34
+
35
+ ```
36
+ ✅ await this.mailer.send({ to, subject, text })
37
+ ❌ await fetch('https://api.provider.example/send', …) // inside a use case
38
+
39
+ ✅ container.register(MAILER, { useValue: new SmtpMailer(env) }) // production adapter
40
+ ❌ if (env.NODE_ENV === 'production') { … } // inside LogMailer
41
+ ```
42
+
43
+ ## Enforcement
44
+
45
+ **Tests.** The suite reads every code through `FakeMailer`, so a flow that stops sending mail fails.
46
+
47
+ **Review only.** That production registers a real adapter. `LogMailer` in production loses mail silently.
@@ -2,21 +2,41 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Serve MCP at `POST /api/mcp`, in `src/infra/http/controllers/mcp.controller.ts`. Protect it with
6
- `requireMcpAuth` from `@better-auth/mcp`, with the same `resource` as `mcp()` in `auth.factory.ts`:
7
- `mcpResource(env)`. The route is `public: true` for the session hook, because it answers a bearer access
8
- token, never a cookie.
5
+ Serve MCP at `POST /api/mcp`, in `src/infra/http/controllers/mcp.controller.ts`. Accept only a bearer access
6
+ token whose audience is `mcpResource(env)` and which names a client; answer anything else with 401 and a
7
+ `WWW-Authenticate` naming `resource_metadata`. The route is `public: true` for the session hook, because the
8
+ audience decides, not the session.
9
9
 
10
10
  Build the server per request in `src/infra/mcp/mcp.server.ts` with `createMcpHandler` from
11
11
  `@modelcontextprotocol/server`, `legacy: 'reject'`. The user is the token's `sub`, passed through
12
12
  `authInfo.extra.userId`.
13
13
 
14
- Write a module's tools in `src/infra/mcp/tools/<module>.tool.ts`. A tool resolves a use case and calls
15
- `execute()`, exactly as a route handler does. Take its input schema from the module's `dto/`, and pass its
14
+ Write a module's tools in `src/infra/mcp/tools/<module>.tool.ts`, with the scope each one needs beside it:
15
+ `<module>:read` to read the module's data, `<module>:write` to change it. Add the module's two scopes to
16
+ `SCOPES` in `mcp.scopes.ts`, worded for a person, and its tools to `TOOL_SCOPES`. A tool resolves a use case and
17
+ calls `execute()`, exactly as a route handler does. Take its input schema from the module's `dto/`, and pass its
16
18
  result through the module's output schema with `.parse()` before returning it.
17
19
 
18
- Keep Better Auth the authorization server: `jwt()`, `mcp()` and `cimd()` in `auth.factory.ts`, discovery at
19
- `/.well-known/*`, sign-in and consent on the web app. Do not enable dynamic client registration.
20
+ Keep the authorization server in the `oauth` module and `oauth.controller.ts`, following MCP's 2026-07-28
21
+ authorization profile:
22
+
23
+ - Publish protected resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource/api/mcp` and at the
24
+ root, and server metadata (RFC 8414) at `/.well-known/oauth-authorization-server`.
25
+ - Identify a client by its metadata document (CIMD) only. Do not enable dynamic client registration.
26
+ - Check the client and its redirect URI, by exact match, before any error may be redirected.
27
+ - Require PKCE with `S256`, and a `resource` equal to `mcpResource(env)`, which becomes the token's audience.
28
+ - Send the browser to the web app's `/consent`. Show the client's name, the host of its `client_id` and the
29
+ host of its redirect URI, and warn when that redirect returns to this device.
30
+ - Return the code with `iss` (RFC 9207), and spend it once: a code presented twice revokes the session its
31
+ first exchange started.
32
+ - Rotate refresh tokens, and accept one only from the client it was issued to.
33
+ - Grant the scopes the client asked for, all of `SCOPES` when it asked for none, and refuse an unknown one with
34
+ `invalid_scope`. Carry them in the session, its access token and every refresh. Advertise them in both metadata
35
+ documents and in the 401.
36
+ - Refuse a `tools/call` the token's scopes do not cover before the server runs it: 403 with
37
+ `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`, every scope the call needs in one challenge.
38
+ - Fetch a metadata document over https only, at most 5 KB and 5 seconds, following no redirect, and never from
39
+ a private, loopback or link-local address, checked at the connection's own lookup.
20
40
 
21
41
  ## Rationale
22
42
 
@@ -28,13 +48,26 @@ obeys.
28
48
  list and MCP does not: a tool returns whatever it is handed, so an entity passed straight through publishes
29
49
  every column.
30
50
 
31
- `legacy: 'reject'` and CIMD follow the MCP 2026-07-28 profile, which Better Auth's plugin targets. The
32
- protocol is stateless, so a server per request costs nothing and knows only its caller. Dynamic client
33
- registration stays off because that profile deprecates it.
51
+ The audience keeps the two kinds of token apart. A token a client obtained for MCP cannot call the API's own
52
+ routes, and the web's own token cannot call MCP.
53
+
54
+ The client chooses the URL the server fetches, which is what makes CIMD a server-side request forgery risk:
55
+ without the address check, a client could make the API read its own network. The check runs at the
56
+ connection's lookup, so a name that resolves differently a second time cannot slip past it, and a literal
57
+ address is checked before connecting because no lookup happens for one.
58
+
59
+ Scopes are per module and per kind of access because a client that only reads should not be able to change
60
+ anything, and because a list worded per tool would grow past what a consent page can make readable. The call is
61
+ refused at the HTTP edge, with the scopes it needs, because that is the answer MCP's step-up flow reads: the
62
+ client asks the user for more and comes back.
63
+
64
+ **What this costs:** a new module's tools need their scopes written in two places, the tool file and
65
+ `mcp.scopes.ts`.
34
66
 
35
67
  ## Applies to
36
68
 
37
- `src/infra/mcp/`, `mcp.controller.ts`, the MCP part of `auth.factory.ts`, and the web app's `/consent` route.
69
+ `src/infra/mcp/`, `mcp.controller.ts`, `oauth.controller.ts`, `src/domain/oauth/`, `src/infra/oauth/`, and the
70
+ web app's `/consent` route.
38
71
 
39
72
  ## Examples
40
73
 
@@ -44,11 +77,19 @@ registration stays off because that profile deprecates it.
44
77
 
45
78
  ✅ structuredContent: profileResponseSchema.parse(profile)
46
79
  ❌ structuredContent: profile every column leaves
80
+
81
+ ✅ redirectUris.includes(redirectUri)
82
+ ❌ redirectUri.startsWith(registeredOrigin)
47
83
  ```
48
84
 
49
85
  ## Enforcement
50
86
 
51
- **Tests.** `mcp.controller.spec.ts` walks the whole authorization, consent included, connects the SDK's
52
- own client, and asserts a tool returns only the public fields.
87
+ **Tests.** `mcp.controller.spec.ts` fails when a tool the server lists has no scope in `TOOL_SCOPES`, lets a
88
+ read-only token read and refuses its write with `insufficient_scope`,
89
+ keeps the scope through a refresh, and refuses an unknown scope. It also walks discovery from the 401, the authorization, consent and the token, then
90
+ connects the SDK's own client and asserts a tool returns only the public fields. It also refuses an unknown
91
+ client without redirecting, a missing or plain PKCE, another resource, a wrong verifier, a code spent twice,
92
+ another client's refresh token, and each token where the other belongs. `https-client-metadata.adapter.spec.ts`
93
+ refuses non-canonical client ids and private addresses.
53
94
 
54
95
  **Review only.** That a new tool goes through a use case and parses its output.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Hold the native splash screen until the session has been read from `expo-secure-store`. Only then let the
5
+ Hold the native splash screen until the session has been read, with the tokens from `expo-secure-store`. Only then let the
6
6
  router render the authenticated group or redirect to login.
7
7
 
8
8
  Express protection with an authenticated layout group. Do not check for a session inside a screen.