@stjoseph/prumo 0.0.1

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 (220) hide show
  1. package/.prumo-templates/api/auth.md +130 -0
  2. package/.prumo-templates/api/config.md +85 -0
  3. package/.prumo-templates/api/errors.md +82 -0
  4. package/.prumo-templates/api/logging.md +79 -0
  5. package/.prumo-templates/api/modules.md +117 -0
  6. package/.prumo-templates/api/observability.md +91 -0
  7. package/.prumo-templates/api/pagination.md +71 -0
  8. package/.prumo-templates/api/routes.md +89 -0
  9. package/.prumo-templates/client/data.md +124 -0
  10. package/.prumo-templates/client/forms.md +88 -0
  11. package/.prumo-templates/core/conventions.md +74 -0
  12. package/.prumo-templates/core/testing.md +100 -0
  13. package/.prumo-templates/core/tooling.md +118 -0
  14. package/.prumo-templates/database/entities.md +97 -0
  15. package/.prumo-templates/database/migrations.md +74 -0
  16. package/.prumo-templates/database/queries.md +79 -0
  17. package/.prumo-templates/database/testing.md +82 -0
  18. package/.prumo-templates/database/transactions.md +68 -0
  19. package/.prumo-templates/mobile/components.md +83 -0
  20. package/.prumo-templates/mobile/config.md +84 -0
  21. package/.prumo-templates/mobile/routing.md +74 -0
  22. package/.prumo-templates/mobile/storage.md +84 -0
  23. package/.prumo-templates/mobile/structure.md +79 -0
  24. package/.prumo-templates/monorepo/dependencies.md +84 -0
  25. package/.prumo-templates/monorepo/layout.md +89 -0
  26. package/.prumo-templates/monorepo/tasks.md +76 -0
  27. package/.prumo-templates/multi-tenancy/client.md +83 -0
  28. package/.prumo-templates/multi-tenancy/isolation.md +104 -0
  29. package/.prumo-templates/site/rendering.md +83 -0
  30. package/.prumo-templates/site/structure.md +68 -0
  31. package/.prumo-templates/web/components.md +106 -0
  32. package/.prumo-templates/web/routing.md +82 -0
  33. package/.prumo-templates/web/structure.md +92 -0
  34. package/LICENSE +21 -0
  35. package/README.md +110 -0
  36. package/dist/asker.js +28 -0
  37. package/dist/cli.js +62 -0
  38. package/dist/compose.js +222 -0
  39. package/dist/context.js +38 -0
  40. package/dist/generate.js +60 -0
  41. package/dist/jsonc.js +76 -0
  42. package/dist/names.js +10 -0
  43. package/dist/questions.js +63 -0
  44. package/package.json +55 -0
  45. package/templates/api/.env.example +12 -0
  46. package/templates/api/.githooks/install.mjs +18 -0
  47. package/templates/api/.githooks/pre-commit +2 -0
  48. package/templates/api/.githooks/pre-push +2 -0
  49. package/templates/api/README.md +35 -0
  50. package/templates/api/biome.jsonc +18 -0
  51. package/templates/api/docker/init/01-auth-schema.sql +1 -0
  52. package/templates/api/docker-compose.yml +20 -0
  53. package/templates/api/gitignore +5 -0
  54. package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
  55. package/templates/api/nest-cli.json +10 -0
  56. package/templates/api/package.json +69 -0
  57. package/templates/api/pnpm-workspace.yaml +9 -0
  58. package/templates/api/src/app.module.ts +46 -0
  59. package/templates/api/src/auth/auth.constants.ts +2 -0
  60. package/templates/api/src/auth/auth.controller.ts +19 -0
  61. package/templates/api/src/auth/auth.factory.spec.ts +29 -0
  62. package/templates/api/src/auth/auth.factory.ts +52 -0
  63. package/templates/api/src/auth/auth.guard.ts +43 -0
  64. package/templates/api/src/auth/auth.module.ts +30 -0
  65. package/templates/api/src/auth/auth.ts +4 -0
  66. package/templates/api/src/auth/current-user.decorator.ts +9 -0
  67. package/templates/api/src/auth/express.d.ts +10 -0
  68. package/templates/api/src/auth/public.decorator.ts +4 -0
  69. package/templates/api/src/config/env.module.ts +11 -0
  70. package/templates/api/src/config/env.ts +81 -0
  71. package/templates/api/src/errors/problem-details.filter.ts +86 -0
  72. package/templates/api/src/errors/validation-exception.factory.ts +30 -0
  73. package/templates/api/src/health/health.controller.ts +32 -0
  74. package/templates/api/src/health/health.module.ts +9 -0
  75. package/templates/api/src/main.ts +43 -0
  76. package/templates/api/src/mikro-orm.config.ts +4 -0
  77. package/templates/api/src/mikro-orm.factory.ts +14 -0
  78. package/templates/api/src/request-context/request-context.module.ts +9 -0
  79. package/templates/api/src/request-context/request-context.service.ts +17 -0
  80. package/templates/api/src/request-context/request-id.middleware.ts +19 -0
  81. package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
  82. package/templates/api/src/users/entities/profile.entity.ts +27 -0
  83. package/templates/api/src/users/users.controller.ts +23 -0
  84. package/templates/api/src/users/users.module.ts +10 -0
  85. package/templates/api/src/users/users.service.spec.ts +44 -0
  86. package/templates/api/src/users/users.service.ts +36 -0
  87. package/templates/api/test/factories/profile.factory.ts +26 -0
  88. package/templates/api/test/factories/user.factory.ts +21 -0
  89. package/templates/api/test/global-setup.ts +20 -0
  90. package/templates/api/test/setup.ts +80 -0
  91. package/templates/api/tsconfig.build.json +8 -0
  92. package/templates/api/tsconfig.json +29 -0
  93. package/templates/api/vitest.config.mts +17 -0
  94. package/templates/mobile/.env.example +1 -0
  95. package/templates/mobile/.githooks/install.mjs +18 -0
  96. package/templates/mobile/.githooks/pre-commit +2 -0
  97. package/templates/mobile/.githooks/pre-push +2 -0
  98. package/templates/mobile/README.md +30 -0
  99. package/templates/mobile/app.config.ts +13 -0
  100. package/templates/mobile/app.json +39 -0
  101. package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
  102. package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
  103. package/templates/mobile/assets/expo.icon/icon.json +35 -0
  104. package/templates/mobile/assets/images/android-icon-background.png +0 -0
  105. package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
  106. package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
  107. package/templates/mobile/assets/images/icon.png +0 -0
  108. package/templates/mobile/assets/images/splash-icon.png +0 -0
  109. package/templates/mobile/babel.config.js +6 -0
  110. package/templates/mobile/biome.jsonc +21 -0
  111. package/templates/mobile/gitignore +11 -0
  112. package/templates/mobile/global.d.ts +1 -0
  113. package/templates/mobile/jest.config.js +21 -0
  114. package/templates/mobile/metro.config.js +4 -0
  115. package/templates/mobile/nativewind-env.d.ts +1 -0
  116. package/templates/mobile/package.json +63 -0
  117. package/templates/mobile/src/api-contract/api-error.ts +51 -0
  118. package/templates/mobile/src/api-contract/client.ts +26 -0
  119. package/templates/mobile/src/api-contract/index.ts +10 -0
  120. package/templates/mobile/src/api-contract/profile.ts +27 -0
  121. package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
  122. package/templates/mobile/src/app/(app)/index.tsx +18 -0
  123. package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
  124. package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
  125. package/templates/mobile/src/app/_layout.tsx +54 -0
  126. package/templates/mobile/src/components/ui/button.tsx +36 -0
  127. package/templates/mobile/src/components/ui/field.tsx +30 -0
  128. package/templates/mobile/src/config.ts +3 -0
  129. package/templates/mobile/src/features/auth/auth-client.ts +27 -0
  130. package/templates/mobile/src/features/auth/intended-route.ts +11 -0
  131. package/templates/mobile/src/features/auth/session.ts +17 -0
  132. package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
  133. package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
  134. package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
  135. package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
  136. package/templates/mobile/src/features/forms/form-error.tsx +16 -0
  137. package/templates/mobile/src/features/forms/server-errors.ts +33 -0
  138. package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
  139. package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
  140. package/templates/mobile/src/features/storage/query-persister.ts +15 -0
  141. package/templates/mobile/src/features/storage/storage.ts +11 -0
  142. package/templates/mobile/src/global.css +3 -0
  143. package/templates/mobile/tailwind.config.js +9 -0
  144. package/templates/mobile/test/fake-transport.ts +23 -0
  145. package/templates/mobile/tsconfig.json +21 -0
  146. package/templates/site/.githooks/install.mjs +18 -0
  147. package/templates/site/.githooks/pre-commit +2 -0
  148. package/templates/site/.githooks/pre-push +2 -0
  149. package/templates/site/README.md +28 -0
  150. package/templates/site/biome.jsonc +12 -0
  151. package/templates/site/components.json +25 -0
  152. package/templates/site/gitignore +7 -0
  153. package/templates/site/next.config.ts +7 -0
  154. package/templates/site/package.json +40 -0
  155. package/templates/site/pnpm-workspace.yaml +5 -0
  156. package/templates/site/postcss.config.mjs +7 -0
  157. package/templates/site/src/app/globals.css +129 -0
  158. package/templates/site/src/app/layout.tsx +13 -0
  159. package/templates/site/src/app/page.spec.ts +10 -0
  160. package/templates/site/src/app/page.tsx +17 -0
  161. package/templates/site/src/lib/utils.ts +1 -0
  162. package/templates/site/tsconfig.json +34 -0
  163. package/templates/site/vitest.config.mts +8 -0
  164. package/templates/web/.env.example +1 -0
  165. package/templates/web/.githooks/install.mjs +18 -0
  166. package/templates/web/.githooks/pre-commit +2 -0
  167. package/templates/web/.githooks/pre-push +2 -0
  168. package/templates/web/README.md +29 -0
  169. package/templates/web/biome.jsonc +24 -0
  170. package/templates/web/components.json +25 -0
  171. package/templates/web/gitignore +6 -0
  172. package/templates/web/index.html +12 -0
  173. package/templates/web/package.json +55 -0
  174. package/templates/web/src/api-contract/api-error.ts +51 -0
  175. package/templates/web/src/api-contract/client.ts +26 -0
  176. package/templates/web/src/api-contract/index.ts +10 -0
  177. package/templates/web/src/api-contract/profile.ts +27 -0
  178. package/templates/web/src/app.spec.tsx +12 -0
  179. package/templates/web/src/app.tsx +14 -0
  180. package/templates/web/src/components/ui/button.tsx +57 -0
  181. package/templates/web/src/components/ui/field.tsx +224 -0
  182. package/templates/web/src/components/ui/input.tsx +19 -0
  183. package/templates/web/src/components/ui/label.tsx +17 -0
  184. package/templates/web/src/components/ui/separator.tsx +20 -0
  185. package/templates/web/src/config.ts +7 -0
  186. package/templates/web/src/features/auth/auth-client.ts +12 -0
  187. package/templates/web/src/features/auth/session.ts +17 -0
  188. package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
  189. package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
  190. package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
  191. package/templates/web/src/features/forms/form-error.tsx +15 -0
  192. package/templates/web/src/features/forms/server-errors.ts +33 -0
  193. package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
  194. package/templates/web/src/features/profile/profile-form.tsx +74 -0
  195. package/templates/web/src/index.css +130 -0
  196. package/templates/web/src/lib/utils.ts +1 -0
  197. package/templates/web/src/main.tsx +28 -0
  198. package/templates/web/src/routeTree.gen.ts +125 -0
  199. package/templates/web/src/router.tsx +21 -0
  200. package/templates/web/src/routes/__root.tsx +10 -0
  201. package/templates/web/src/routes/_authenticated/index.tsx +21 -0
  202. package/templates/web/src/routes/_authenticated.tsx +15 -0
  203. package/templates/web/src/routes/sign-in.tsx +20 -0
  204. package/templates/web/src/routes/sign-up.tsx +17 -0
  205. package/templates/web/test/fake-transport.ts +23 -0
  206. package/templates/web/test/render-app.tsx +17 -0
  207. package/templates/web/test/setup.ts +7 -0
  208. package/templates/web/tsconfig.json +21 -0
  209. package/templates/web/vite.config.ts +18 -0
  210. package/templates/workspace/.githooks/install.mjs +18 -0
  211. package/templates/workspace/.githooks/pre-commit +2 -0
  212. package/templates/workspace/.githooks/pre-push +2 -0
  213. package/templates/workspace/README.md +25 -0
  214. package/templates/workspace/biome.jsonc +8 -0
  215. package/templates/workspace/gitignore +5 -0
  216. package/templates/workspace/package.json +17 -0
  217. package/templates/workspace/packages/api-contract/package.json +20 -0
  218. package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
  219. package/templates/workspace/pnpm-workspace.yaml +3 -0
  220. package/templates/workspace/tsconfig.base.json +10 -0
@@ -0,0 +1,84 @@
1
+ # Storage
2
+
3
+ ## Rule
4
+
5
+ Put in `expo-secure-store` anything that **grants access if stolen**: the session cookie and any token.
6
+ Put everything else in MMKV.
7
+
8
+ Do not use MMKV's own encryption.
9
+
10
+ Persist the TanStack Query cache to MMKV with a maximum age.
11
+
12
+ On logout, clear the session, the persisted cache, and every MMKV key holding that user's data. Leave
13
+ device preferences (theme, language) untouched.
14
+
15
+ Decide when writing each key whether it belongs to the user or to the device.
16
+
17
+ ## Rationale
18
+
19
+ The criterion is *does this alone open somebody's account*. A token does. A name does not. A cached list
20
+ of orders does not: it reveals information, and revealing is not granting.
21
+
22
+ Secure storage could not hold more even if the rule wanted it to: iOS has historically refused values
23
+ above roughly 2048 bytes, so a cached profile does not fit. Expo's own documentation also warns against
24
+ relying on it as a single source of truth for irreplaceable data.
25
+
26
+ MMKV's encryption is not used because its key has to live somewhere. Embedded in the bundle it is public;
27
+ in secure storage, the secure store is being used anyway with one more step. The app sandbox already keeps
28
+ other applications out of MMKV; its encryption defends against filesystem access, a rooted device or an
29
+ extracted backup, not against the neighbouring app.
30
+
31
+ Persisting the query cache is what separates an app that opens showing yesterday's list while it refreshes
32
+ from one that opens blank with a spinner, which is what happens every time somebody opens it in a lift. A
33
+ maximum age exists because a cache without one presents months-old data as current with no way for the
34
+ user to tell.
35
+
36
+ That persistence is also why logout must clear more than the session: switching users on one device would
37
+ otherwise show the previous user's data. Wiping MMKV wholesale instead would take theme and language with
38
+ it, so the same person returning finds the app as if it were new; security that punishes the common case
39
+ gets switched off by somebody.
40
+
41
+ ## Applies to
42
+
43
+ Every write to persistent storage, and the logout path.
44
+
45
+ ## Examples
46
+
47
+ Choosing a store:
48
+
49
+ ```
50
+ ✅ SecureStore: the session cookie
51
+ MMKV: theme, last opened tab, cached responses
52
+ ❌ MMKV: the session token // works perfectly, protects nothing
53
+ ```
54
+
55
+ Opening MMKV (version 4):
56
+
57
+ ```
58
+ ✅ const storage = createMMKV({ id: 'app' }); storage.remove(key)
59
+ ❌ const storage = new MMKV(); storage.delete(key) // the version 3 API
60
+ ```
61
+
62
+ Logout:
63
+
64
+ ```
65
+ ✅ clear session + query cache + user-scoped MMKV keys, kept as one list the logout walks
66
+ ❌ clear session only
67
+ ❌ MMKV.clearAll() // takes theme and language too
68
+ ```
69
+
70
+ ## Enforcement
71
+
72
+ **Review only, and one line carries the weight:** that nothing granting access was written to MMKV.
73
+ Nothing breaks if it is: the app works exactly the same, which is what makes it the mistake worth
74
+ naming.
75
+
76
+ **Two facts worth knowing rather than discovering:**
77
+
78
+ **On iOS, secure-store data survives uninstalling and reinstalling the app** with the same bundle ID.
79
+ *Deleting the app to sign out* does not work, and a handed-on device stays signed in. Android deletes it
80
+ on uninstall.
81
+
82
+ **Clearing on logout does not address a lost device.** Nobody signs out of a phone that is gone:
83
+ invalidating the session remotely is server-side work, and wiping local storage is not the answer to
84
+ theft.
@@ -0,0 +1,79 @@
1
+ # Structure
2
+
3
+ ## Rule
4
+
5
+ Organise by feature. Put a feature's components, hooks and queries together in `features/<name>/`.
6
+
7
+ Keep files under `app/` thin: a route file renders a feature and holds no screen implementation. Let
8
+ `app/` carry only navigation: route files, `_layout.tsx`, and groups such as `(tabs)` and `(auth)`.
9
+
10
+ **Do not organise by type.** There is no root `components/`, `hooks/` or `screens/` holding every feature's
11
+ files.
12
+
13
+ Put shared code in one of four places and nowhere else:
14
+
15
+ - `components/ui/`: visual primitives, written by hand
16
+ - `api-contract/`: the wire contract with the API, as `client/data.md` describes
17
+ - `lib/`: pure functions, with no state and no React
18
+ - a named feature: anything with behaviour
19
+
20
+ Do not create a `shared/` or `common/` folder.
21
+
22
+ Keep client state in React context, one context per subject, living in the feature that owns it. Do not
23
+ create a single application context.
24
+
25
+ Resolve `@/` to the app's own `src/`.
26
+
27
+ ## Rationale
28
+
29
+ `app/` is a URL namespace: everything in it has a public path attached. Putting a screen's implementation
30
+ there mixes *how you reach this screen* with *what this screen does*, and the first changes for navigation
31
+ reasons while the second changes for product reasons. Keeping route files thin also matches the web area,
32
+ which matters in a monorepo: the same feature exists in two apps, and if one puts the screen in the route
33
+ while the other puts it in the feature, comparing them becomes translation.
34
+
35
+ `components/ui/` exists here for the same reason it does on the web, holding the same kind of thing. Only
36
+ its origin differs: on the web those files come from the shadcn CLI, and here they are written by hand
37
+ from the start. Dropping the folder would force naming a button as a feature, and a feature is a slice of
38
+ product: a button can be named, it simply is not one.
39
+
40
+ A `shared/` folder has no admission criterion: *is this shared?* answers yes for anything used twice, so
41
+ it only grows. Not everything shared is a component either: formatting a date is a function in a file.
42
+ **If it uses React, it does not belong in `lib/`.**
43
+
44
+ One context per subject exists because context re-renders **every** consumer when its value changes. A
45
+ context bundling theme, session and the current tab makes a theme toggle re-render the whole tree:
46
+ invisible with three components, expensive when the app grows, and never an error.
47
+
48
+ ## Applies to
49
+
50
+ Every file under `src/` and `app/`.
51
+
52
+ ## Examples
53
+
54
+ A screen:
55
+
56
+ ```
57
+ ✅ app/orders.tsx → renders <OrderList/>
58
+ features/orders/order-list.tsx
59
+ ❌ app/orders.tsx → the whole screen, 200 lines
60
+ ```
61
+
62
+ Shared code:
63
+
64
+ ```
65
+ ✅ components/ui/button.tsx
66
+ lib/format-currency.ts
67
+ features/notifications/
68
+ ❌ shared/components/button.tsx
69
+ features/button/
70
+ ```
71
+
72
+ ## Enforcement
73
+
74
+ **Review only.** Nothing prevents a screen from growing inside `app/`, a `shared/` folder from appearing,
75
+ or two contexts from being merged for tidiness.
76
+
77
+ **Worth knowing rather than discovering:** the web area's `components/ui/` is maintained by the shadcn
78
+ CLI, which overwrites on reinstall. Here there is no CLI, so nothing overwrites, and equally nothing
79
+ brings improvements from upstream.
@@ -0,0 +1,84 @@
1
+ # Dependencies
2
+
3
+ ## Rule
4
+
5
+ Declare every external dependency's version once, in the catalog. Reference it as `catalog:` in each
6
+ `package.json`.
7
+
8
+ Where two apps genuinely need different majors of one package, put the odd one in a **named** catalog and reference
9
+ it as `catalog:<name>`. Name the catalog after the version it holds (`tailwind3`), and write beside it the condition
10
+ that removes it.
11
+
12
+ Depend on a workspace package with `workspace:*`.
13
+
14
+ Declare in each package every dependency that package imports. Keep the root `package.json` for workspace
15
+ tooling only: nothing application code imports.
16
+
17
+ Let an app depend on packages. **Never let an app depend on another app**, not even for a type.
18
+
19
+ Never let a package depend on an app.
20
+
21
+ ## Rationale
22
+
23
+ One catalog entry per dependency means *which version of X do we use* has a single place to look, with no
24
+ exception to check first. Restricting the catalog to what more than one package uses would require a
25
+ judgement that ages: when a second package adopts something, somebody has to remember to move the version,
26
+ nothing prompts them, and the project ends up running two versions of one library until one breaks.
27
+
28
+ A named catalog is the one exception, and it is visible by construction: it has a name, it sits in one file, and
29
+ every other conflict still fails. It exists because the stable NativeWind requires Tailwind 3 while the web uses
30
+ Tailwind 4; refusing any exception would have made a workspace holding both web and mobile impossible to generate.
31
+
32
+ `workspace:*` rather than a range, because nothing here is published. A range on an internal package would
33
+ describe a choice that does not exist; `*` states what is true: the copy sitting next to it.
34
+
35
+ Declaring per package is what keeps **phantom dependencies** out. With `react` only at the root, an app
36
+ imports it and works without declaring it, harmless until somebody extracts that app, or removes `react`
37
+ elsewhere, and an import breaks with nothing having changed. The repetition this seems to cost is not real:
38
+ every entry is the identical line `"react": "catalog:"`, not a version to keep in sync.
39
+
40
+ An app depending on another app is tempting (importing a type from the API looks like the way to stop
41
+ duplicating the contract), and it is wrong twice. **The type is the wrong one:** a response is an entity
42
+ passed through `ClassSerializerInterceptor`, so every excluded property is absent from the JSON and present
43
+ on the type, promising fields the payload never carries. **And an app is deployable:** the dependency means
44
+ building one requires the other, CI needs both for either, and the boundary between two things with
45
+ separate lifecycles stops existing.
46
+
47
+ `import type` does not rescue it. It is erased at runtime and needed everywhere else (by `tsc`, by the
48
+ editor, by CI), and nothing stops somebody swapping it for a value import when the type later becomes a
49
+ constant.
50
+
51
+ If two apps need the same thing, it belongs in the package.
52
+
53
+ ## Applies to
54
+
55
+ Every `package.json` in the workspace, and the catalog.
56
+
57
+ ## Examples
58
+
59
+ Declaring:
60
+
61
+ ```
62
+ ✅ "react": "catalog:" "@app/api-contract": "workspace:*"
63
+ ❌ "react": "^19.0.0" "@app/api-contract": "^1.0.0"
64
+ ```
65
+
66
+ Direction:
67
+
68
+ ```
69
+ ✅ apps/web → packages/api-contract
70
+ ❌ apps/web → apps/api
71
+ ❌ apps/web → import type { User } from '../../api/src/…'
72
+ ```
73
+
74
+ ## Enforcement
75
+
76
+ **The installer.** pnpm's strict isolation rejects importing what a package did not declare, in every
77
+ workspace, including one holding `mobile`. Expo does **not** need `nodeLinker: hoisted`: a workspace with `api`,
78
+ `web` and `mobile` on the default isolated linker bundles iOS and Android, verified with Expo SDK 57.
79
+
80
+ **One import nobody writes still counts.** NativeWind's Babel JSX runtime injects `react-native-css-interop` into
81
+ the app's source, so the app declares that package; without the declaration the bundle fails to resolve it.
82
+
83
+ **Review only.** That no app imports from another app, and that the root `package.json` gained nothing an
84
+ app imports.
@@ -0,0 +1,89 @@
1
+ # Layout
2
+
3
+ ## Rule
4
+
5
+ Put deployable things in `apps/` and shared code in `packages/`.
6
+
7
+ Name an app directory after its type (`apps/api`, `apps/web`, `apps/mobile`, `apps/site`), never after
8
+ the product.
9
+
10
+ Keep one package from the start: the integration layer between the clients and the API. It holds URL
11
+ construction, the `ApiError` parsing, the hand-written contract types and the query factories.
12
+
13
+ **Let each app inject its own transport.** The package does not perform the fetch.
14
+
15
+ Ship the package as TypeScript source. Do not build it to `dist/`, and do not give it a build step.
16
+
17
+ Create a second package only when **two apps consume it**. While one app uses it, keep it inside that app.
18
+
19
+ Name a package for what it does. **Never `shared`, `common` or `utils`.**
20
+
21
+ Resolve `@/` to each app's own `src/`. Import a package by its name.
22
+
23
+ ## Rationale
24
+
25
+ An app and a package have different lives. An app has a deploy, environment variables, a version in a
26
+ store; a package has none of that and exists because something imports it. One directory for both erases a
27
+ distinction that reappears in every later decision: what CI builds, what a filter reaches, what has a
28
+ Dockerfile.
29
+
30
+ The integration package exists because the rest of the sharing was already refused: contract types are
31
+ hand-written rather than generated, validation differs on each side, and shadcn copies components rather
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.
36
+
37
+ **This is also what recovers most of what hand-written types gave up.** That decision accepted, as its
38
+ cost, that a renamed field leaves each client compiling against its own copy. One copy instead of three
39
+ does not remove drift from the server; it stops it multiplying, and makes fixing it one place. The package
40
+ describes the **wire shape** (what the JSON carries), not an entity whose excluded fields are absent from
41
+ it.
42
+
43
+ Source rather than a build, because a built package needs a mandatory build order and, without a watcher,
44
+ produces the standard monorepo failure: changing the package and not seeing the effect. Editing a type and
45
+ seeing the error immediately is what makes sharing better than copying.
46
+
47
+ Two consumers, because one is evidence of nothing, and extracting early into a package costs more than
48
+ extracting early into a component. A name, because `shared` cannot grow wrongly only by never being able to
49
+ grow rightly: anything fits inside it.
50
+
51
+ ## Applies to
52
+
53
+ The workspace root and everything under it.
54
+
55
+ ## Examples
56
+
57
+ Layout:
58
+
59
+ ```
60
+ ✅ apps/api apps/web packages/api-contract
61
+ ❌ apps/acme-backend packages/shared
62
+ ```
63
+
64
+ The package's boundary:
65
+
66
+ ```
67
+ ✅ createClient({ fetch: myTransport })
68
+ ❌ the package calling fetch and reading a cookie itself
69
+ ```
70
+
71
+ ## Enforcement
72
+
73
+ **Review only.** That a new package has two consumers and a real name, and that nothing crept into a
74
+ `shared` directory under another spelling.
75
+
76
+ **Two things worth knowing:**
77
+
78
+ **Moving a file between apps calls for checking its imports.** `@/` means something different in each app,
79
+ so a file moved to the same path in another app can compile while pointing elsewhere. That only happens
80
+ with generic code, which is the code that belonged in the package.
81
+
82
+ **A Vite app and a Next app consume the package as source with no configuration**: Next 16 needs no
83
+ `transpilePackages`; a page importing it builds, stays static, and renders the imported value. Its `exports` points at `src/index.ts`,
84
+ `tsc` with `moduleResolution: bundler` typechecks through it, and `vite build` bundles it, verified in a composed
85
+ workspace. The API does not import the package, so Nest never compiles it.
86
+
87
+ **Expo resolves workspace packages automatically from SDK 52**, with no `watchFolders` or
88
+ `nodeModulesPaths` configuration. Older guidance about configuring Metro by hand no longer applies. Verified with
89
+ SDK 57: Metro bundles `@app/api-contract` as source, and Jest with `jest-expo` resolves it the same way.
@@ -0,0 +1,76 @@
1
+ # Tasks and configuration
2
+
3
+ ## Rule
4
+
5
+ Give the root `package.json` three scripts (`lint`, `typecheck` and `test`), each running through
6
+ `pnpm -r`.
7
+
8
+ **Do not add a `dev` script at the root.** Start development with `pnpm --filter <app> dev`.
9
+
10
+ Keep compiler options in `tsconfig.base.json` at the workspace root. Extend it by relative path from each
11
+ app and package, overriding only what differs.
12
+
13
+ Do not use project references.
14
+
15
+ Keep Biome's shared settings (formatter and preset) in `biome.jsonc` at the root. Give each app that needs more
16
+ its own `biome.jsonc` with `"extends": "//"`, holding only what differs.
17
+
18
+ ## Rationale
19
+
20
+ `pnpm -r` runs in topological order, so the orchestration usually credited to a task runner already exists.
21
+ What does not exist is caching, and caching pays once a build hurts, which a freshly generated workspace
22
+ does not.
23
+
24
+ The missing `dev` script is deliberate. `pnpm dev` in a workspace with four apps would start four
25
+ processes, one of them interactive, when whoever typed it expected the app they are working on. **Its
26
+ absence is what teaches that `dev` is per app**; if it existed, nobody would learn.
27
+
28
+ The other three belong at the root because that is what the pre-push hook calls. Leaving them out would
29
+ mean each person assembling their own `--filter`, and several versions of one command.
30
+
31
+ A single base config keeps five files from starting identical and diverging silently: one gains a flag,
32
+ another does not, and nobody notices until behaviour differs between apps. It is a root file rather than a
33
+ package because turning configuration into a package adds a `package.json`, a workspace entry and a
34
+ declared dependency in order to deliver one JSON file.
35
+
36
+ Biome follows the same shape because the two apps genuinely differ (decorator parsing in `api`, the shadcn
37
+ exceptions in `web`) while formatting and the preset must not. `"extends": "//"` is Biome's own syntax for
38
+ *inherit from the root, wherever it is*, and running `biome` from the root respects every nested file. One
39
+ configuration with per-directory overrides was refused because every new app would edit the shared file, and
40
+ because composing it would mean merging JSON rather than moving a file.
41
+
42
+ **Project references are refused for the same reason the shared package ships source:** they need
43
+ `composite: true` and produce a build order with intermediate artefacts, which brings back the failure of
44
+ changing something and not seeing the effect.
45
+
46
+ ## Applies to
47
+
48
+ The workspace root, and every `tsconfig.json` beneath it.
49
+
50
+ ## Examples
51
+
52
+ Starting work:
53
+
54
+ ```
55
+ ✅ pnpm --filter web dev
56
+ ❌ pnpm dev // four apps, one of them interactive
57
+ ```
58
+
59
+ Extending the base:
60
+
61
+ ```
62
+ ✅ { "extends": "../../tsconfig.base.json", "compilerOptions": { "jsx": "react-jsx" } }
63
+ ❌ a complete compilerOptions block per app
64
+ ```
65
+
66
+ ## Enforcement
67
+
68
+ **The hook.** Pre-push runs the root `typecheck`, so a type error in any package stops the push.
69
+
70
+ **Review only.** That a new app extends the base rather than copying it, and that a root `dev` script has
71
+ not appeared.
72
+
73
+ **The reversal condition, stated so it is measurable rather than a feeling:** without caching,
74
+ `pnpm -r typecheck` runs `tsc` in every package from scratch every time. Turborepo belongs on the table the
75
+ day that becomes annoying enough for somebody to stop running it before pushing, not when the repository
76
+ merely feels large.
@@ -0,0 +1,83 @@
1
+ # The tenant in a client
2
+
3
+ ## Rule
4
+
5
+ Clear the **entire** query cache when the active tenant changes. Do not add the tenant to query keys.
6
+
7
+ Navigate to the tenant's root after a switch, always, even when the current route would exist in the
8
+ destination.
9
+
10
+ Show the active tenant persistently on every screen. Not inside a menu.
11
+
12
+ Never send the tenant to the API: not as a header, not as a parameter. The server derives it.
13
+
14
+ Never read the tenant from local storage or from a cached response to decide what to display.
15
+
16
+ ## Rationale
17
+
18
+ The query-key rule already says a key includes everything that changes the result, and the tenant changes
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.
21
+ Nobody remembers to put it in a key, because nothing in the file suggests it exists. Adding it everywhere
22
+ would be correct when remembered and silent when not; clearing once, in one place, does not depend on
23
+ memory.
24
+
25
+ Staying on the current route after a switch means a 404 as the first thing the user sees, answering a
26
+ question they did not ask: they asked to change company. Mapping the route back to a list works for
27
+ `/orders/abc` and lands somewhere strange for `/settings/billing/method/xyz`, and behaviour that depends
28
+ on the shape of a URL is behaviour nobody can predict. Worse, if a resource with the same id existed in
29
+ the destination, staying would open **a different record at the same URL** while the user believed nothing
30
+ had changed.
31
+
32
+ **The visible label is a safety rule, not decoration.** Whoever operates a multi-tenant system usually has
33
+ access to several tenants, and the expensive mistake is not the system leaking; it is the person acting
34
+ on the wrong tenant: deleting an order, sending an invoice, inviting somebody, in the wrong account. The
35
+ system cannot detect it, because from the API's side the request is perfectly authorised. A selector inside
36
+ a menu answers *where am I* only for someone who already suspects; whoever acts by mistake acts because
37
+ they do not.
38
+
39
+ ## Applies to
40
+
41
+ Every client of a multi-tenant project: `web`, `mobile` and `site` alike.
42
+
43
+ ## Examples
44
+
45
+ Switching:
46
+
47
+ ```
48
+ ✅ await organization.setActive({ organizationId })
49
+ queryClient.clear()
50
+ router.navigate({ to: '/' })
51
+ ❌ await organization.setActive({ organizationId }) // cache and route untouched
52
+ ```
53
+
54
+ Query keys:
55
+
56
+ ```
57
+ ✅ ['orders', filters]
58
+ ❌ ['orders', tenantId, filters] // correct when remembered, silent when not
59
+ ```
60
+
61
+ Trusting the client:
62
+
63
+ ```
64
+ ✅ GET /api/v1/orders
65
+ ❌ GET /api/v1/orders?tenantId=…
66
+ ```
67
+
68
+ ## Enforcement
69
+
70
+ **Review only, and all three fail silently:**
71
+
72
+ That a switch clears the cache. Forgetting shows the previous tenant's data on the next screen, with no
73
+ error.
74
+
75
+ That nothing sends a tenant to the API. It would be ignored today, and it teaches the habit that the
76
+ client may choose.
77
+
78
+ That the active tenant is on screen. Its absence breaks nothing and costs somebody a wrong action in the
79
+ wrong account.
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.
@@ -0,0 +1,104 @@
1
+ # Isolation
2
+
3
+ ## Rule
4
+
5
+ Derive the active tenant from the session. **Never from anything the client sends**: not a header, not a
6
+ path parameter, not the `Host`.
7
+
8
+ Use Better Auth's `organization` plugin with `activeOrganizationId` **persisted in the session**. Do not
9
+ manage the active organization client-side.
10
+
11
+ Give every tenant-scoped table a `tenant_id` that is `NOT NULL` and carries a foreign key into
12
+ `organization`, with `ON DELETE RESTRICT`.
13
+
14
+ Register the MikroORM filter with `default: true` and set its parameter once per request, from the
15
+ session.
16
+
17
+ **Never accept `tenant_id` as a method argument.** Read it where the filter's parameter is read.
18
+
19
+ Call `qb.applyFilters()` on every QueryBuilder query. Write `WHERE tenant_id = $1` in every raw query, and
20
+ a comment saying it was checked.
21
+
22
+ Declare a route that has no tenant. **Treat an absent filter parameter as an error**, never as "no
23
+ filter".
24
+
25
+ Run Better Auth's migrations before ours.
26
+
27
+ ## Rationale
28
+
29
+ The tenant is a property of who you are, not of how you asked. A client-sent tenant works, and depends on
30
+ validation existing on **every** route; forgetting once is complete cross-tenant access, and the
31
+ forgetting is invisible, because the route works perfectly for someone entitled and equally well for
32
+ someone who is not. A subdomain is input too: it arrives in a header a client controls and that proxies
33
+ rewrite, so the defence would rest on infrastructure configuration.
34
+
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
+
39
+ The filter is an application-level guard, and its own documentation says a raw query or a forgotten
40
+ `filters` option reads past it. It leaves three holes and they get different answers, because the problems
41
+ differ: creation is not filtered at all, the QueryBuilder needs an explicit call, and raw SQL is out of
42
+ reach. Reading `tenant_id` from the session rather than taking it as an argument removes the path by which
43
+ somebody passes the tenant of the record being read instead of the tenant of the user reading it.
44
+ `NOT NULL` does not prevent a wrong tenant; it prevents an absent one, which is the common failure.
45
+
46
+ A missing parameter must be an error because the alternative turns every resolution bug (a session that
47
+ did not load, middleware in the wrong order) into a query that returns the whole system, silently.
48
+
49
+ The foreign key exists because without it a `tenant_id` pointing nowhere inserts cleanly and passes tests,
50
+ 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.
53
+
54
+ ## Applies to
55
+
56
+ Every tenant-scoped table, every query against one, and the request pipeline that resolves the session.
57
+
58
+ **The tenantless list is closed:** authentication, the health route, third-party webhooks, and the route
59
+ listing a user's organizations. Every new route is born with a tenant, and adding to this list is a
60
+ decision rather than configuration.
61
+
62
+ ## Examples
63
+
64
+ Where the tenant comes from:
65
+
66
+ ```
67
+ ✅ const tenantId = session.activeOrganizationId
68
+ ❌ const tenantId = req.headers['x-tenant-id']
69
+ ❌ const tenantId = subdomainOf(req.headers.host)
70
+ ```
71
+
72
+ Creating a row:
73
+
74
+ ```
75
+ ✅ create(input: CreateOrderInput) // reads the tenant where the filter does
76
+ ❌ create(input: CreateOrderInput, tenantId: string)
77
+ ```
78
+
79
+ Stepping off `em.find`:
80
+
81
+ ```
82
+ ✅ qb.applyFilters()
83
+ // raw: tenant filter checked
84
+ WHERE tenant_id = $1 AND …
85
+ ❌ qb.where(…).getResult() // no filter: reads every tenant
86
+ ```
87
+
88
+ ## Enforcement
89
+
90
+ **Database.** `NOT NULL` rejects an absent tenant, and the foreign key rejects one that does not exist.
91
+
92
+ **Boot.** The filter is registered once with `default: true`, so `em.find` cannot opt out by omission.
93
+
94
+ **Review only, and these are the lines that matter; every one of them fails silently:**
95
+
96
+ That `applyFilters()` is present on each QueryBuilder query, and `WHERE tenant_id` on each raw one.
97
+
98
+ That no method takes a tenant as an argument.
99
+
100
+ That a route added to the tenantless list genuinely belongs there.
101
+
102
+ **Not solved here:** raw SQL remains outside the filter's reach by design. Row Level Security is the only
103
+ guarantee that survives it, and it is not adopted because its cost in this stack is unmeasured. See the
104
+ open question.