@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,14 +2,15 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Put in `expo-secure-store` anything that **grants access if stolen**: the session cookie and any token.
5
+ Put in `expo-secure-store` anything that **grants access if stolen**: the access and refresh tokens, and any
6
+ other token.
6
7
  Put everything else in MMKV.
7
8
 
8
9
  Do not use MMKV's own encryption.
9
10
 
10
11
  Persist the TanStack Query cache to MMKV with a maximum age.
11
12
 
12
- On logout, clear the session, the persisted cache, and every MMKV key holding that user's data. Leave
13
+ On logout, clear the tokens, the persisted cache, and every MMKV key holding that user's data. Leave
13
14
  device preferences (theme, language) untouched.
14
15
 
15
16
  Decide when writing each key whether it belongs to the user or to the device.
@@ -33,7 +34,7 @@ from one that opens blank with a spinner, which is what happens every time someb
33
34
  maximum age exists because a cache without one presents months-old data as current with no way for the
34
35
  user to tell.
35
36
 
36
- That persistence is also why logout must clear more than the session: switching users on one device would
37
+ That persistence is also why logout must clear more than the tokens: switching users on one device would
37
38
  otherwise show the previous user's data. Wiping MMKV wholesale instead would take theme and language with
38
39
  it, so the same person returning finds the app as if it were new; security that punishes the common case
39
40
  gets switched off by somebody.
@@ -47,7 +48,7 @@ Every write to persistent storage, and the logout path.
47
48
  Choosing a store:
48
49
 
49
50
  ```
50
- ✅ SecureStore: the session cookie
51
+ ✅ SecureStore: the access and refresh tokens
51
52
  MMKV: theme, last opened tab, cached responses
52
53
  ❌ MMKV: the session token // works perfectly, protects nothing
53
54
  ```
@@ -62,8 +63,8 @@ Opening MMKV (version 4):
62
63
  Logout:
63
64
 
64
65
  ```
65
- ✅ clear session + query cache + user-scoped MMKV keys, kept as one list the logout walks
66
- ❌ clear session only
66
+ ✅ clear tokens + query cache + user-scoped MMKV keys, kept as one list the logout walks
67
+ ❌ clear tokens only
67
68
  ❌ MMKV.clearAll() // takes theme and language too
68
69
  ```
69
70
 
@@ -30,9 +30,9 @@ Dockerfile.
30
30
  The integration package exists because the rest of the sharing was already refused: contract types are
31
31
  hand-written rather than generated, validation differs on each side, and shadcn copies components rather
32
32
  than sharing them. What genuinely repeats across clients is how a URL is built, how an error body becomes
33
- an `ApiError`, and what the contract's shapes are. Only transport differs (the web sends its cookie
34
- automatically, mobile attaches it from secure storage, a site's server calls anonymously), so transport
35
- comes from outside.
33
+ an `ApiError`, what the contract's shapes are, and how an expired access token is refreshed. Only transport
34
+ differs (the web sends its cookies automatically, mobile hands the auth client a store over secure storage, a
35
+ site's server calls anonymously), so transport comes from outside.
36
36
 
37
37
  **This is also what recovers most of what hand-written types gave up.** That decision accepted, as its
38
38
  cost, that a renamed field leaves each client compiling against its own copy. One copy instead of three
@@ -65,7 +65,7 @@ The package's boundary:
65
65
 
66
66
  ```
67
67
  ✅ createClient({ fetch: myTransport })
68
- ❌ the package calling fetch and reading a cookie itself
68
+ ❌ the package calling fetch and reading secure storage itself
69
69
  ```
70
70
 
71
71
  ## Enforcement
@@ -17,7 +17,7 @@ Never read the tenant from local storage or from a cached response to decide wha
17
17
 
18
18
  The query-key rule already says a key includes everything that changes the result, and the tenant changes
19
19
  the result. **But the tenant is invisible to whoever writes the query:** the server derives it from the
20
- session, so the client never sends it, never passes it as a filter, and never sees it in the calling code.
20
+ access token, so the client never sends it, never passes it as a filter, and never sees it in the calling code.
21
21
  Nobody remembers to put it in a key, because nothing in the file suggests it exists. Adding it everywhere
22
22
  would be correct when remembered and silent when not; clearing once, in one place, does not depend on
23
23
  memory.
@@ -45,10 +45,10 @@ Every client of a multi-tenant project: `web`, `mobile` and `site` alike.
45
45
  Switching:
46
46
 
47
47
  ```
48
- ✅ await organization.setActive({ organizationId })
48
+ ✅ await switchTenant(organizationId) // the API re-issues the access token
49
49
  queryClient.clear()
50
50
  router.navigate({ to: '/' })
51
- ❌ await organization.setActive({ organizationId }) // cache and route untouched
51
+ ❌ await switchTenant(organizationId) // cache and route untouched
52
52
  ```
53
53
 
54
54
  Query keys:
@@ -78,6 +78,6 @@ client may choose.
78
78
  That the active tenant is on screen. Its absence breaks nothing and costs somebody a wrong action in the
79
79
  wrong account.
80
80
 
81
- **Known limit:** if the session's tenant changes without this app knowing (another tab switching), the
82
- cache is not cleared. That follows from the active organization living in the session, and is the cost that
83
- decision accepted.
81
+ **Known limit:** if the session's tenant changes without this app knowing (another tab switching), this tab
82
+ keeps its access token, and its tenant, until the next refresh, and then its cache is not cleared. That follows
83
+ from the active organization living in the session, and is the cost that decision accepted.
@@ -5,14 +5,16 @@
5
5
  Derive the active tenant from the session. **Never from anything the client sends**: not a header, not a
6
6
  path parameter, not the `Host`.
7
7
 
8
- Use Better Auth's `organization` plugin with `activeOrganizationId` **persisted in the session**. Do not
9
- manage the active organization client-side.
8
+ Keep organizations in the `auth` module: an `Organization` entity, and a `Membership` joining a user to one.
9
+ Store the active tenant on the `Session` row, as `active_organization_id`, and copy it into the access token as
10
+ a claim each time one is issued or refreshed. Switch tenants through one route that checks the membership,
11
+ updates the session and issues a new access token. Do not manage the active organization client-side.
10
12
 
11
13
  Give every tenant-scoped table a `tenant_id` that is `NOT NULL` and carries a foreign key into
12
14
  `organization`, with `ON DELETE RESTRICT`.
13
15
 
14
16
  Register the MikroORM filter with `default: true` and set its parameter once per request, from the
15
- session.
17
+ verified access token's claim.
16
18
 
17
19
  **Never accept `tenant_id` as a method argument.** Read it where the filter's parameter is read.
18
20
 
@@ -22,7 +24,8 @@ a comment saying it was checked.
22
24
  Declare a route that has no tenant. **Treat an absent filter parameter as an error**, never as "no
23
25
  filter".
24
26
 
25
- Run Better Auth's migrations before ours.
27
+ When a membership ends, revoke that user's sessions with it, or the departed member keeps the tenant until the
28
+ access token expires.
26
29
 
27
30
  ## Rationale
28
31
 
@@ -32,9 +35,13 @@ forgetting is invisible, because the route works perfectly for someone entitled
32
35
  someone who is not. A subdomain is input too: it arrives in a header a client controls and that proxies
33
36
  rewrite, so the defence would rest on infrastructure configuration.
34
37
 
35
- Better Auth's own documentation offers managing the active organization client-side, for multiple tabs. For
36
- a multi-tenant system that means the **tab** decides isolation, so it is refused here by name: somebody
37
- will find that sentence.
38
+ Keeping the active organization in the client, per tab, is a common suggestion for working in two tenants at
39
+ once. For a multi-tenant system that means the **tab** decides isolation, so it is refused here by name:
40
+ somebody will find that suggestion.
41
+
42
+ The claim is read from a token whose signature was just verified, so no request touches the database to learn
43
+ its tenant. The cost is the token's lifetime: a membership removed now still reads for up to fifteen minutes,
44
+ unless the sessions go with it, which is why the rule asks for both.
38
45
 
39
46
  The filter is an application-level guard, and its own documentation says a raw query or a forgotten
40
47
  `filters` option reads past it. It leaves three holes and they get different answers, because the problems
@@ -48,8 +55,8 @@ did not load, middleware in the wrong order) into a query that returns the whole
48
55
 
49
56
  The foreign key exists because without it a `tenant_id` pointing nowhere inserts cleanly and passes tests,
50
57
  leaving a row invisible to every filtered query. `RESTRICT` means deleting an organization with business
51
- data fails, which is wanted: the application should decide what happens to that data rather than a library
52
- CLI cascading it away.
58
+ data fails, which is wanted: the application should decide what happens to that data rather than a cascade
59
+ deciding for it.
53
60
 
54
61
  ## Applies to
55
62
 
@@ -64,7 +71,7 @@ decision rather than configuration.
64
71
  Where the tenant comes from:
65
72
 
66
73
  ```
67
- ✅ const tenantId = session.activeOrganizationId
74
+ ✅ const tenantId = claims.organizationId // from the verified access token
68
75
  ❌ const tenantId = req.headers['x-tenant-id']
69
76
  ❌ const tenantId = subdomainOf(req.headers.host)
70
77
  ```
@@ -0,0 +1,77 @@
1
+ # Social sign-in
2
+
3
+ ## Rule
4
+
5
+ Sign in with a provider through the API, never from a client: the client opens
6
+ `/api/auth/social/<provider>?client=web|mobile`, the API runs OpenID Connect with `openid-client` behind the
7
+ `IdentityProviders` port, and the client comes back signed in. Add a provider in the adapter's `settings()`, its
8
+ variables in `env.ts`, and its button on each client.
9
+
10
+ Store each trip in `social_sign_in`: its id is OAuth's `state`, and the browser that started it holds a secret
11
+ in the `social_sign_in` cookie that the callback must present. Use PKCE wherever the provider's metadata announces
12
+ it, and a nonce always. Turn Apple's `form_post` callback into a redirect to the same address, so the cookie
13
+ comes with it.
14
+
15
+ Return the web to the path it asked for, only a path on the web app, with session cookies. Return a mobile app
16
+ to `MOBILE_APP_SCHEME://social` with a one-time code it exchanges at `/api/auth/social/exchange` for its tokens,
17
+ within two minutes. A mobile app opens the trip with `expo-web-browser`'s `openAuthSessionAsync`.
18
+
19
+ Recognise a returning user by the provider's subject, kept in `identity`. For a first sign-in, link by email
20
+ only when the provider says `email_verified`:
21
+
22
+ - an account whose email was confirmed is the same person: link it;
23
+ - an account whose email was never confirmed loses its password and every session, then is linked: the
24
+ provider's proof wins over whoever chose that password;
25
+ - no account: create one, confirmed and without a password, with its profile.
26
+
27
+ Refuse a provider email that is not verified.
28
+
29
+ When the project has a mobile app and offers Google, offer Apple too before publishing on iOS.
30
+
31
+ ## Rationale
32
+
33
+ The API keeps the client secret and validates the ID token, so neither a browser nor an app ever holds a
34
+ provider's secret or decides what an ID token says.
35
+
36
+ The cookie binds the trip to one browser. Without it, someone could start a sign-in with their own provider
37
+ account and get a victim to open the callback, signing the victim into the attacker's account, which then
38
+ records whatever the victim does. The mobile code exists so that tokens never travel in a URL, where logs and
39
+ history keep them.
40
+
41
+ The linking rules close a known attack: register a password account with somebody's email before they arrive,
42
+ then wait for them to sign in with a provider. A library this project once used shipped exactly that hole. An
43
+ account nobody confirmed belongs to whoever proves the inbox, and that proof is what the provider gives.
44
+
45
+ The App Store asks an app offering a third-party login for its primary account to offer an equivalent
46
+ privacy-preserving login too (App Review Guideline 4.8); Sign in with Apple is one.
47
+
48
+ **What this costs:** the trip leaves the app for the system browser and comes back, rather than a native sheet.
49
+ In a project that does not verify emails, no password account is ever confirmed, so the first time its owner
50
+ signs in with a provider for that email, the password goes: the owner keeps the account through the provider,
51
+ and cannot tell them apart from whoever registered the email first.
52
+ And a provider without its variables in `.env` answers that it is not configured, until someone registers the
53
+ application with it.
54
+
55
+ ## Applies to
56
+
57
+ `src/domain/auth/` for identities and trips, `social.controller.ts`, `src/infra/social/`, and the social buttons on
58
+ the web and mobile.
59
+
60
+ ## Examples
61
+
62
+ ```
63
+ ✅ a href={auth.socialSignInUrl('google', 'web', redirect)}
64
+ ❌ window.location = 'https://accounts.google.com/o/oauth2/v2/auth?client_id=…' // the client talks to Google
65
+
66
+ ✅ link only when email_verified, and void an unconfirmed account's password
67
+ ❌ link whenever the emails match
68
+ ```
69
+
70
+ ## Enforcement
71
+
72
+ **Tests.** `social.use-case.spec.ts` covers each linking rule and refuses a callback from another browser, a
73
+ second use and another provider. `openid-identity-providers.adapter.spec.ts` runs the real adapter against a
74
+ local OpenID provider: PKCE, state, nonce, a forged code, and Apple's client secret signed with the `.p8` key.
75
+ `social.controller.spec.ts` covers both clients over HTTP.
76
+
77
+ **Review only.** That a new provider's ID token is validated by `openid-client` before anything reads it.
package/README.md CHANGED
@@ -58,7 +58,7 @@ terminal a missing answer is an error rather than a default, so nothing importan
58
58
  silence:
59
59
 
60
60
  ```sh
61
- npx @stjoseph/prumo new my-app --types api,web --single-tenant --no-mcp
61
+ npx @stjoseph/prumo new my-app --types api,web --single-tenant --no-mcp --email --no-social
62
62
  ```
63
63
 
64
64
  | Flag | What it answers |
@@ -67,6 +67,8 @@ npx @stjoseph/prumo new my-app --types api,web --single-tenant --no-mcp
67
67
  | `--alone` or `--monorepo` | How a single type is laid out |
68
68
  | `--multi-tenant` or `--single-tenant` | Whether the application serves several tenants. Multi-tenant ships the `multi-tenancy` conventions; the generated code itself is not tenant-aware yet |
69
69
  | `--mcp` or `--no-mcp` | With an api and a web: whether AI assistants reach the API through MCP, signed in as the user |
70
+ | `--email` or `--no-email` | With an api: email verification and password reset, by a 6-digit code sent through the `Mailer` port, which writes to the log until you give it a provider |
71
+ | `--social google,apple` or `--no-social` | With an api and a web or mobile: sign-in with Google, Apple, or both |
70
72
  | `--skip-install` | Stops after writing the files |
71
73
 
72
74
  ## Commands
@@ -88,9 +90,9 @@ is asked, and the exit code is 0 only when `ok` is true.
88
90
 
89
91
  | Type | What you get |
90
92
  |---|---|
91
- | `api` | Fastify with a domain/infra structure, tsyringe, Zod, MikroORM on PostgreSQL, Better Auth in a schema of its own, Swagger UI, Vitest with Testcontainers |
93
+ | `api` | Fastify with a domain/infra structure, tsyringe, Zod, MikroORM on PostgreSQL, its own JWT authentication, Swagger UI, Vitest with Testcontainers |
92
94
  | `web` | Vite, React, TanStack Router and Query, Tailwind, shadcn/ui, forms validated with Zod |
93
- | `mobile` | Expo with Expo Router, NativeWind, MMKV, and the session kept in `expo-secure-store` |
95
+ | `mobile` | Expo with Expo Router, NativeWind, MMKV, and the tokens kept in `expo-secure-store` |
94
96
  | `site` | Next on the App Router, static by default, for pages that have to be indexed |
95
97
 
96
98
  One type produces that project on its own. Several produce a pnpm workspace: each application under
@@ -106,13 +108,13 @@ a month apart are the same project.
106
108
 
107
109
  | File | What it is |
108
110
  |---|---|
109
- | `.prumo/` | The conventions, in ten areas. Only the areas the answers call for are copied |
111
+ | `.prumo/` | The conventions, in twelve areas. Only the areas the answers call for are copied |
110
112
  | `.prumo/INDEX.md` | One line per document. Generated by the tool, and safe to regenerate |
111
113
  | `AGENTS.md` | The pointer into `.prumo/`, read by Codex, Cursor, Copilot, Gemini CLI and others |
112
114
  | `CLAUDE.md` | An `@AGENTS.md` import, for Claude Code |
113
115
 
114
- The ten areas are `core`, `api`, `database`, `client`, `web`, `mobile`, `site`, `monorepo`,
115
- `multi-tenancy` and `mcp`. Only `core` ships unconditionally, because everything in it is true of every type;
116
+ The twelve areas are `core`, `api`, `database`, `client`, `web`, `mobile`, `site`, `monorepo`,
117
+ `multi-tenancy`, `mcp`, `email` and `social`. Only `core` ships unconditionally, because everything in it is true of every type;
116
118
  the rest follow the answers, since advice about a platform the project does not have is worse than
117
119
  no advice at all.
118
120
 
package/dist/asker.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as prompt from '@clack/prompts';
2
+ import { SOCIAL_PROVIDERS } from "./features.js";
2
3
  const TYPES = ['api', 'web', 'mobile', 'site'];
3
4
  function answered(value) {
4
5
  if (prompt.isCancel(value)) {
@@ -28,5 +29,17 @@ export function terminalAsker(validateName) {
28
29
  message: 'Let AI assistants use the API through MCP, signed in as the user?',
29
30
  initialValue: false,
30
31
  })),
32
+ email: async () => answered(await prompt.confirm({
33
+ message: 'Verify emails and let users reset their password?',
34
+ initialValue: false,
35
+ })),
36
+ social: async () => answered(await prompt.multiselect({
37
+ message: 'Social sign-in? Leave all unselected for none',
38
+ options: SOCIAL_PROVIDERS.map((provider) => ({
39
+ value: provider,
40
+ label: provider === 'google' ? 'Google' : 'Apple',
41
+ })),
42
+ required: false,
43
+ })),
31
44
  };
32
45
  }
package/dist/clean.js CHANGED
@@ -10,7 +10,7 @@ export const API_DEV_PREFIX = 'node scripts/database.mjs --check && ';
10
10
  export const ROOT_DEV_PREFIX = 'node apps/api/scripts/database.mjs --check && ';
11
11
  export const SETUP_SCRIPT = 'node scripts/database.mjs';
12
12
  export const EXAMPLE_BLOCK = '# MISSING until `pnpm db:setup` creates a database; `pnpm dev` offers to run it.\n' +
13
- 'DATABASE_URL=MISSING\nAUTH_DATABASE_URL=MISSING\n';
13
+ 'DATABASE_URL=MISSING\n';
14
14
  export const README_PARAGRAPH = '`DATABASE_URL` starts as `MISSING`. While it is, `pnpm dev` offers to create the database first: it asks for a name,\n' +
15
15
  'starts Postgres from `docker-compose.yml` on port 5432 or the next free one (kept in `POSTGRES_PORT`), writes the URL\n' +
16
16
  'into `.env`, and migrates. `pnpm db:setup` does the same at any time. Without a terminal every answer comes from a\n' +
@@ -51,7 +51,7 @@ function rulesFor(root, api, templates) {
51
51
  const rules = [
52
52
  devRule('api-dev-check', at('package.json'), API_DEV_PREFIX),
53
53
  scriptRule('api-db-setup', at('package.json'), 'Remove the `db:setup` script', 'db:setup', (value) => value === SETUP_SCRIPT ? { status: 'pending' } : { status: 'modified', next: value }),
54
- textRule('api-env-example', at('.env.example'), 'Put a sample database URL back in `.env.example`', EXAMPLE_BLOCK, `DATABASE_URL=${SAMPLE_URL}\nAUTH_DATABASE_URL=${SAMPLE_URL}\n`, '=MISSING'),
54
+ textRule('api-env-example', at('.env.example'), 'Put a sample database URL back in `.env.example`', EXAMPLE_BLOCK, `DATABASE_URL=${SAMPLE_URL}\n`, '=MISSING'),
55
55
  textRule('api-readme-setup', at('README.md'), 'Replace the README paragraph about `MISSING`', README_PARAGRAPH, README_REPLACEMENT, 'db:setup'),
56
56
  textRule('api-readme-row', at('README.md'), 'Remove `db:setup` from the README', README_ROW, '', '`pnpm db:setup`'),
57
57
  ];
package/dist/cli.js CHANGED
@@ -34,6 +34,10 @@ async function runNew(args, json) {
34
34
  'single-tenant': { type: 'boolean', default: false },
35
35
  mcp: { type: 'boolean', default: false },
36
36
  'no-mcp': { type: 'boolean', default: false },
37
+ email: { type: 'boolean', default: false },
38
+ 'no-email': { type: 'boolean', default: false },
39
+ social: { type: 'string' },
40
+ 'no-social': { type: 'boolean', default: false },
37
41
  'skip-install': { type: 'boolean', default: false },
38
42
  json: { type: 'boolean', default: false },
39
43
  },
@@ -62,11 +66,21 @@ async function runNew(args, json) {
62
66
  singleTenant: values['single-tenant'],
63
67
  mcp: values.mcp,
64
68
  noMcp: values['no-mcp'],
69
+ email: values.email,
70
+ noEmail: values['no-email'],
71
+ social: values.social,
72
+ noSocial: values['no-social'],
65
73
  }, interactive ? terminalAsker((input) => projectProblem(input, cwd)) : undefined, cwd);
66
74
  if (!json && answers.types.length > 1) {
67
75
  prompt.log.info(`${answers.types.length} types make a workspace: apps/ and packages/ under one root.`);
68
76
  }
69
- const { target, ...chosen } = answers;
77
+ const { target, warnings, ...chosen } = answers;
78
+ // Without --json a person is reading; with it, the warnings travel in the result instead.
79
+ if (!json) {
80
+ for (const warning of warnings) {
81
+ prompt.log.warn(warning);
82
+ }
83
+ }
70
84
  await generate({
71
85
  ...requireAssets(assetsFor(import.meta.dirname)),
72
86
  target,
@@ -77,7 +91,7 @@ async function runNew(args, json) {
77
91
  if (!json) {
78
92
  prompt.outro(`Created ${answers.name}.`);
79
93
  }
80
- return { data: { ...chosen, target, installed: install }, text: '' };
94
+ return { data: { ...chosen, target, installed: install, warnings }, text: '' };
81
95
  }
82
96
  async function runClean(args, json) {
83
97
  const { values } = parseArgs({
package/dist/commands.js CHANGED
@@ -18,6 +18,13 @@ export const COMMANDS = [
18
18
  { flag: '--single-tenant', description: 'No multi-tenancy conventions' },
19
19
  { flag: '--mcp', description: 'An MCP server on the api, authorized through the web' },
20
20
  { flag: '--no-mcp', description: 'No MCP server' },
21
+ {
22
+ flag: '--email',
23
+ description: 'Email verification and password reset, by a code sent through the Mailer port',
24
+ },
25
+ { flag: '--no-email', description: 'Neither' },
26
+ { flag: '--social <list>', description: 'Comma-separated: google, apple' },
27
+ { flag: '--no-social', description: 'No social sign-in' },
21
28
  { flag: '--skip-install', description: 'Do not run pnpm install' },
22
29
  JSON_OPTION,
23
30
  ],
package/dist/compose.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join, relative, sep } from 'node:path';
4
+ import { applyFeatures, NONE } from "./features.js";
4
5
  import { prependProperty, readJsonc, removeWhatBaseDeclares, setJsonc } from "./jsonc.js";
5
- import { applyMcp } from "./mcp.js";
6
6
  const CLIENTS = ['web', 'mobile'];
7
7
  // One exception to one-version-per-dependency: stable NativeWind needs Tailwind 3.
8
8
  const NAMED_CATALOGS = [{ name: 'tailwind3', dependency: 'tailwindcss', major: '3' }];
@@ -182,14 +182,14 @@ async function assertCatalogCovers(target, catalog) {
182
182
  }
183
183
  }
184
184
  }
185
- export async function composeWorkspace({ templates, target, types, mobileScheme = 'app', mcp = false, }) {
185
+ export async function composeWorkspace({ templates, target, types, features = NONE, }) {
186
186
  await mkdir(target, { recursive: true });
187
187
  await copyTemplate(join(templates, 'workspace'), target);
188
188
  const apps = [];
189
189
  for (const type of types) {
190
190
  const app = await copyApp(templates, target, type);
191
191
  // Before the catalog is collected, so a dependency that leaves never reaches it.
192
- await applyMcp(app, type, mcp);
192
+ await applyFeatures(app, type, features);
193
193
  apps.push(app);
194
194
  }
195
195
  const clients = CLIENTS.filter((client) => types.includes(client));
@@ -199,14 +199,6 @@ export async function composeWorkspace({ templates, target, types, mobileScheme
199
199
  else {
200
200
  await rm(join(target, 'packages'), { recursive: true, force: true });
201
201
  }
202
- if (types.includes('api') && types.includes('mobile')) {
203
- await rewriteText(join(target, 'apps', 'api', '.env.example'), (text) => {
204
- if (!text.includes('# MOBILE_APP_SCHEME=app\n')) {
205
- throw new Error('apps/api/.env.example no longer carries the commented MOBILE_APP_SCHEME line');
206
- }
207
- return text.replace('# MOBILE_APP_SCHEME=app\n', `MOBILE_APP_SCHEME=${mobileScheme}\n`);
208
- });
209
- }
210
202
  // One command for everything and one per app. Each app answers to `dev`, so the root never needs to know how.
211
203
  // The database and the ports are the exception: both may ask a question, which the interleaved output of a
212
204
  // parallel run would bury, so the root asks first and each app's own check then finds nothing to do.
package/dist/context.js CHANGED
@@ -12,6 +12,8 @@ export function areasFor(answers) {
12
12
  ...(answers.architecture === 'monorepo' ? ['monorepo'] : []),
13
13
  ...(answers.multiTenant ? ['multi-tenancy'] : []),
14
14
  ...(answers.mcp ? ['mcp'] : []),
15
+ ...(answers.email ? ['email'] : []),
16
+ ...(answers.social.length > 0 ? ['social'] : []),
15
17
  ];
16
18
  }
17
19
  async function titleOf(path) {
@@ -36,6 +38,8 @@ export async function writeContext(knowledge, root, answers) {
36
38
  architecture: answers.architecture,
37
39
  multiTenant: answers.multiTenant,
38
40
  mcp: answers.mcp,
41
+ email: answers.email,
42
+ social: answers.social,
39
43
  };
40
44
  await writeFile(join(prumo, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
41
45
  await writeFile(join(root, 'AGENTS.md'), `# ${answers.name}\n\nThis project's conventions live in \`.prumo/\`. Read [\`.prumo/INDEX.md\`](.prumo/INDEX.md) before changing how something is done.\n`);