@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.
- package/.prumo-templates/api/auth.md +130 -0
- package/.prumo-templates/api/config.md +85 -0
- package/.prumo-templates/api/errors.md +82 -0
- package/.prumo-templates/api/logging.md +79 -0
- package/.prumo-templates/api/modules.md +117 -0
- package/.prumo-templates/api/observability.md +91 -0
- package/.prumo-templates/api/pagination.md +71 -0
- package/.prumo-templates/api/routes.md +89 -0
- package/.prumo-templates/client/data.md +124 -0
- package/.prumo-templates/client/forms.md +88 -0
- package/.prumo-templates/core/conventions.md +74 -0
- package/.prumo-templates/core/testing.md +100 -0
- package/.prumo-templates/core/tooling.md +118 -0
- package/.prumo-templates/database/entities.md +97 -0
- package/.prumo-templates/database/migrations.md +74 -0
- package/.prumo-templates/database/queries.md +79 -0
- package/.prumo-templates/database/testing.md +82 -0
- package/.prumo-templates/database/transactions.md +68 -0
- package/.prumo-templates/mobile/components.md +83 -0
- package/.prumo-templates/mobile/config.md +84 -0
- package/.prumo-templates/mobile/routing.md +74 -0
- package/.prumo-templates/mobile/storage.md +84 -0
- package/.prumo-templates/mobile/structure.md +79 -0
- package/.prumo-templates/monorepo/dependencies.md +84 -0
- package/.prumo-templates/monorepo/layout.md +89 -0
- package/.prumo-templates/monorepo/tasks.md +76 -0
- package/.prumo-templates/multi-tenancy/client.md +83 -0
- package/.prumo-templates/multi-tenancy/isolation.md +104 -0
- package/.prumo-templates/site/rendering.md +83 -0
- package/.prumo-templates/site/structure.md +68 -0
- package/.prumo-templates/web/components.md +106 -0
- package/.prumo-templates/web/routing.md +82 -0
- package/.prumo-templates/web/structure.md +92 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/asker.js +28 -0
- package/dist/cli.js +62 -0
- package/dist/compose.js +222 -0
- package/dist/context.js +38 -0
- package/dist/generate.js +60 -0
- package/dist/jsonc.js +76 -0
- package/dist/names.js +10 -0
- package/dist/questions.js +63 -0
- package/package.json +55 -0
- package/templates/api/.env.example +12 -0
- package/templates/api/.githooks/install.mjs +18 -0
- package/templates/api/.githooks/pre-commit +2 -0
- package/templates/api/.githooks/pre-push +2 -0
- package/templates/api/README.md +35 -0
- package/templates/api/biome.jsonc +18 -0
- package/templates/api/docker/init/01-auth-schema.sql +1 -0
- package/templates/api/docker-compose.yml +20 -0
- package/templates/api/gitignore +5 -0
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
- package/templates/api/nest-cli.json +10 -0
- package/templates/api/package.json +69 -0
- package/templates/api/pnpm-workspace.yaml +9 -0
- package/templates/api/src/app.module.ts +46 -0
- package/templates/api/src/auth/auth.constants.ts +2 -0
- package/templates/api/src/auth/auth.controller.ts +19 -0
- package/templates/api/src/auth/auth.factory.spec.ts +29 -0
- package/templates/api/src/auth/auth.factory.ts +52 -0
- package/templates/api/src/auth/auth.guard.ts +43 -0
- package/templates/api/src/auth/auth.module.ts +30 -0
- package/templates/api/src/auth/auth.ts +4 -0
- package/templates/api/src/auth/current-user.decorator.ts +9 -0
- package/templates/api/src/auth/express.d.ts +10 -0
- package/templates/api/src/auth/public.decorator.ts +4 -0
- package/templates/api/src/config/env.module.ts +11 -0
- package/templates/api/src/config/env.ts +81 -0
- package/templates/api/src/errors/problem-details.filter.ts +86 -0
- package/templates/api/src/errors/validation-exception.factory.ts +30 -0
- package/templates/api/src/health/health.controller.ts +32 -0
- package/templates/api/src/health/health.module.ts +9 -0
- package/templates/api/src/main.ts +43 -0
- package/templates/api/src/mikro-orm.config.ts +4 -0
- package/templates/api/src/mikro-orm.factory.ts +14 -0
- package/templates/api/src/request-context/request-context.module.ts +9 -0
- package/templates/api/src/request-context/request-context.service.ts +17 -0
- package/templates/api/src/request-context/request-id.middleware.ts +19 -0
- package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
- package/templates/api/src/users/entities/profile.entity.ts +27 -0
- package/templates/api/src/users/users.controller.ts +23 -0
- package/templates/api/src/users/users.module.ts +10 -0
- package/templates/api/src/users/users.service.spec.ts +44 -0
- package/templates/api/src/users/users.service.ts +36 -0
- package/templates/api/test/factories/profile.factory.ts +26 -0
- package/templates/api/test/factories/user.factory.ts +21 -0
- package/templates/api/test/global-setup.ts +20 -0
- package/templates/api/test/setup.ts +80 -0
- package/templates/api/tsconfig.build.json +8 -0
- package/templates/api/tsconfig.json +29 -0
- package/templates/api/vitest.config.mts +17 -0
- package/templates/mobile/.env.example +1 -0
- package/templates/mobile/.githooks/install.mjs +18 -0
- package/templates/mobile/.githooks/pre-commit +2 -0
- package/templates/mobile/.githooks/pre-push +2 -0
- package/templates/mobile/README.md +30 -0
- package/templates/mobile/app.config.ts +13 -0
- package/templates/mobile/app.json +39 -0
- package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
- package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
- package/templates/mobile/assets/expo.icon/icon.json +35 -0
- package/templates/mobile/assets/images/android-icon-background.png +0 -0
- package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
- package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
- package/templates/mobile/assets/images/icon.png +0 -0
- package/templates/mobile/assets/images/splash-icon.png +0 -0
- package/templates/mobile/babel.config.js +6 -0
- package/templates/mobile/biome.jsonc +21 -0
- package/templates/mobile/gitignore +11 -0
- package/templates/mobile/global.d.ts +1 -0
- package/templates/mobile/jest.config.js +21 -0
- package/templates/mobile/metro.config.js +4 -0
- package/templates/mobile/nativewind-env.d.ts +1 -0
- package/templates/mobile/package.json +63 -0
- package/templates/mobile/src/api-contract/api-error.ts +51 -0
- package/templates/mobile/src/api-contract/client.ts +26 -0
- package/templates/mobile/src/api-contract/index.ts +10 -0
- package/templates/mobile/src/api-contract/profile.ts +27 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
- package/templates/mobile/src/app/(app)/index.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
- package/templates/mobile/src/app/_layout.tsx +54 -0
- package/templates/mobile/src/components/ui/button.tsx +36 -0
- package/templates/mobile/src/components/ui/field.tsx +30 -0
- package/templates/mobile/src/config.ts +3 -0
- package/templates/mobile/src/features/auth/auth-client.ts +27 -0
- package/templates/mobile/src/features/auth/intended-route.ts +11 -0
- package/templates/mobile/src/features/auth/session.ts +17 -0
- package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
- package/templates/mobile/src/features/forms/form-error.tsx +16 -0
- package/templates/mobile/src/features/forms/server-errors.ts +33 -0
- package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
- package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
- package/templates/mobile/src/features/storage/query-persister.ts +15 -0
- package/templates/mobile/src/features/storage/storage.ts +11 -0
- package/templates/mobile/src/global.css +3 -0
- package/templates/mobile/tailwind.config.js +9 -0
- package/templates/mobile/test/fake-transport.ts +23 -0
- package/templates/mobile/tsconfig.json +21 -0
- package/templates/site/.githooks/install.mjs +18 -0
- package/templates/site/.githooks/pre-commit +2 -0
- package/templates/site/.githooks/pre-push +2 -0
- package/templates/site/README.md +28 -0
- package/templates/site/biome.jsonc +12 -0
- package/templates/site/components.json +25 -0
- package/templates/site/gitignore +7 -0
- package/templates/site/next.config.ts +7 -0
- package/templates/site/package.json +40 -0
- package/templates/site/pnpm-workspace.yaml +5 -0
- package/templates/site/postcss.config.mjs +7 -0
- package/templates/site/src/app/globals.css +129 -0
- package/templates/site/src/app/layout.tsx +13 -0
- package/templates/site/src/app/page.spec.ts +10 -0
- package/templates/site/src/app/page.tsx +17 -0
- package/templates/site/src/lib/utils.ts +1 -0
- package/templates/site/tsconfig.json +34 -0
- package/templates/site/vitest.config.mts +8 -0
- package/templates/web/.env.example +1 -0
- package/templates/web/.githooks/install.mjs +18 -0
- package/templates/web/.githooks/pre-commit +2 -0
- package/templates/web/.githooks/pre-push +2 -0
- package/templates/web/README.md +29 -0
- package/templates/web/biome.jsonc +24 -0
- package/templates/web/components.json +25 -0
- package/templates/web/gitignore +6 -0
- package/templates/web/index.html +12 -0
- package/templates/web/package.json +55 -0
- package/templates/web/src/api-contract/api-error.ts +51 -0
- package/templates/web/src/api-contract/client.ts +26 -0
- package/templates/web/src/api-contract/index.ts +10 -0
- package/templates/web/src/api-contract/profile.ts +27 -0
- package/templates/web/src/app.spec.tsx +12 -0
- package/templates/web/src/app.tsx +14 -0
- package/templates/web/src/components/ui/button.tsx +57 -0
- package/templates/web/src/components/ui/field.tsx +224 -0
- package/templates/web/src/components/ui/input.tsx +19 -0
- package/templates/web/src/components/ui/label.tsx +17 -0
- package/templates/web/src/components/ui/separator.tsx +20 -0
- package/templates/web/src/config.ts +7 -0
- package/templates/web/src/features/auth/auth-client.ts +12 -0
- package/templates/web/src/features/auth/session.ts +17 -0
- package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
- package/templates/web/src/features/forms/form-error.tsx +15 -0
- package/templates/web/src/features/forms/server-errors.ts +33 -0
- package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
- package/templates/web/src/features/profile/profile-form.tsx +74 -0
- package/templates/web/src/index.css +130 -0
- package/templates/web/src/lib/utils.ts +1 -0
- package/templates/web/src/main.tsx +28 -0
- package/templates/web/src/routeTree.gen.ts +125 -0
- package/templates/web/src/router.tsx +21 -0
- package/templates/web/src/routes/__root.tsx +10 -0
- package/templates/web/src/routes/_authenticated/index.tsx +21 -0
- package/templates/web/src/routes/_authenticated.tsx +15 -0
- package/templates/web/src/routes/sign-in.tsx +20 -0
- package/templates/web/src/routes/sign-up.tsx +17 -0
- package/templates/web/test/fake-transport.ts +23 -0
- package/templates/web/test/render-app.tsx +17 -0
- package/templates/web/test/setup.ts +7 -0
- package/templates/web/tsconfig.json +21 -0
- package/templates/web/vite.config.ts +18 -0
- package/templates/workspace/.githooks/install.mjs +18 -0
- package/templates/workspace/.githooks/pre-commit +2 -0
- package/templates/workspace/.githooks/pre-push +2 -0
- package/templates/workspace/README.md +25 -0
- package/templates/workspace/biome.jsonc +8 -0
- package/templates/workspace/gitignore +5 -0
- package/templates/workspace/package.json +17 -0
- package/templates/workspace/packages/api-contract/package.json +20 -0
- package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
- package/templates/workspace/pnpm-workspace.yaml +3 -0
- 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.
|