@stjoseph/prumo 0.1.1 → 0.3.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 (173) hide show
  1. package/.prumo-templates/api/auth.md +50 -48
  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 -5
  8. package/.prumo-templates/database/testing.md +6 -8
  9. package/.prumo-templates/mcp/server.md +39 -12
  10. package/.prumo-templates/mobile/routing.md +1 -1
  11. package/.prumo-templates/mobile/storage.md +7 -6
  12. package/.prumo-templates/monorepo/layout.md +4 -4
  13. package/.prumo-templates/multi-tenancy/client.md +6 -6
  14. package/.prumo-templates/multi-tenancy/isolation.md +17 -10
  15. package/README.md +19 -14
  16. package/dist/clean.js +13 -3
  17. package/dist/cli.js +38 -9
  18. package/dist/commands.js +7 -4
  19. package/dist/compose.js +4 -12
  20. package/dist/doctor.js +31 -17
  21. package/dist/generate.js +56 -9
  22. package/dist/mcp.js +15 -8
  23. package/dist/names.js +42 -0
  24. package/dist/questions.js +32 -16
  25. package/package.json +2 -2
  26. package/templates/api/.env.example +3 -5
  27. package/templates/api/README.md +5 -6
  28. package/templates/api/docker-compose.yml +0 -1
  29. package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
  30. package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
  31. package/templates/api/package.json +7 -8
  32. package/templates/api/scripts/database.mjs +3 -11
  33. package/templates/api/scripts/ports.mjs +1 -1
  34. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  35. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  36. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  37. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  38. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  39. package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
  40. package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
  41. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  42. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  43. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  44. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  45. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  46. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  47. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  48. package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
  49. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  50. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  51. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
  52. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
  53. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  54. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
  55. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
  56. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  57. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
  58. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
  59. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  60. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
  61. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  62. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  63. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  64. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  65. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  66. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  67. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
  68. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  69. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
  70. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  71. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  72. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
  73. package/templates/api/src/infra/config/env.ts +3 -7
  74. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
  75. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  76. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
  77. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
  78. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
  79. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  80. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
  81. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  82. package/templates/api/src/infra/di/auth.di.ts +21 -0
  83. package/templates/api/src/infra/di/index.ts +4 -10
  84. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  85. package/templates/api/src/infra/http/app.ts +11 -1
  86. package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
  87. package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
  88. package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
  89. package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
  90. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  91. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  92. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  93. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  94. package/templates/api/src/infra/http/server.ts +1 -4
  95. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  96. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  97. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  98. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  99. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  100. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  101. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
  102. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
  103. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  104. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  105. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  106. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  107. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
  108. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
  109. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
  110. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  111. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  112. package/templates/api/test/support/factories/user.factory.ts +9 -11
  113. package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
  114. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  115. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  116. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  117. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  118. package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
  119. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  120. package/templates/api/test/support/setup.ts +6 -28
  121. package/templates/mobile/README.md +1 -1
  122. package/templates/mobile/jest.config.js +0 -17
  123. package/templates/mobile/package.json +1 -4
  124. package/templates/mobile/scripts/ports.mjs +1 -1
  125. package/templates/mobile/src/api-contract/api-error.ts +0 -11
  126. package/templates/mobile/src/api-contract/auth.ts +161 -0
  127. package/templates/mobile/src/api-contract/index.ts +10 -0
  128. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  129. package/templates/mobile/src/app/_layout.tsx +5 -5
  130. package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
  131. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  132. package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
  133. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  134. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  135. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  136. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  137. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  138. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  139. package/templates/site/README.md +1 -1
  140. package/templates/site/package.json +1 -1
  141. package/templates/site/scripts/ports.mjs +1 -1
  142. package/templates/web/README.md +1 -1
  143. package/templates/web/package.json +1 -3
  144. package/templates/web/scripts/ports.mjs +1 -1
  145. package/templates/web/src/api-contract/api-error.ts +0 -11
  146. package/templates/web/src/api-contract/auth.ts +161 -0
  147. package/templates/web/src/api-contract/index.ts +10 -0
  148. package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
  149. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  150. package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
  151. package/templates/web/src/features/oauth/consent-form.tsx +66 -35
  152. package/templates/web/src/main.tsx +6 -4
  153. package/templates/web/src/router.tsx +1 -2
  154. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  155. package/templates/web/src/routes/_authenticated.tsx +1 -1
  156. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  157. package/templates/web/test/app.spec.tsx +47 -22
  158. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  159. package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
  160. package/templates/web/test/support/render-app.tsx +4 -4
  161. package/templates/workspace/README.md +1 -1
  162. package/templates/workspace/package.json +1 -1
  163. package/templates/workspace/scripts/ports.mjs +1 -1
  164. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  165. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  166. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  167. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  168. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  169. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  170. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  171. package/templates/mobile/src/features/auth/session.ts +0 -17
  172. package/templates/web/src/features/auth/auth-client.ts +0 -17
  173. package/templates/web/src/features/auth/session.ts +0 -17
@@ -2,62 +2,62 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Mount Better Auth at `/api/auth/*` with the catch-all route in `src/infra/http/controllers/auth.controller.ts`,
6
- which rebuilds the request for `auth.handler`, as Better Auth's Fastify guide does. Mark it
7
- `config: { public: true }`.
5
+ Keep authentication in the `auth` module, as any other module: `User` and `Session` in `entities/`, the
6
+ password hasher, the access tokens and the opaque tokens in `ports/`, one use case per action. Their adapters
7
+ live in `src/infra/auth/`. No authentication library owns a table, a route or a migration.
8
8
 
9
- Every route is protected by the global `preHandler` hook in `src/infra/http/hooks/auth.hook.ts`. Mark a route
10
- that needs no session with `config: { public: true }`; that means only *do not answer 401*, and the session is
11
- still read.
9
+ Serve `POST /api/auth/sign-up`, `sign-in`, `refresh` and `sign-out`, and `GET /api/auth/session`, from
10
+ `auth.controller.ts`. Answer their errors as every route does, in problem+json.
12
11
 
13
- Read the user in the handler with `currentUser(request)` and pass its id to the use case as an argument. A use
14
- case never reads the request or any ambient session.
12
+ Issue an access token as a JWT valid for **15 minutes**, signed with `HS256` from `JWT_SECRET`, and name the
13
+ algorithm when verifying it. Issue the refresh token as an opaque `<sessionId>.<secret>`, store only its
14
+ SHA-256, and **rotate it on every use**. A token from an earlier rotation revokes its session; the one replaced
15
+ less than ten seconds ago answers 409 instead, and revokes nothing.
15
16
 
16
- Keep Better Auth's tables in their own Postgres schema, `auth`, on their own connection, migrated by its own
17
- CLI from the compiled `dist/infra/auth/auth.cli.js`, before our migrations. `pnpm db:migrate` does both, in
18
- that order.
17
+ Hash passwords with Argon2id through `@node-rs/argon2`, at the library's defaults. When the email is unknown,
18
+ still verify the password against a decoy hash, and answer exactly as for a wrong password.
19
19
 
20
- Set `advanced.database.generateId` to `'uuid'`. Reference `auth.user(id)` from an application table with a
21
- real foreign key and `ON DELETE RESTRICT`, declared in its schema as a to-one relation to `AuthUser` with
22
- `mapToPk: true`, so the domain still holds a plain `userId`. `AuthUser`, in `auth-user.schema.ts`, maps only
23
- the id and is the one Better Auth table the ORM knows. Keep `schemaGenerator.ignoreSchema: ['auth']` and
24
- `skipTables: ['auth.user']` in `mikro-orm.factory.ts`. Better Auth's tables follow Better Auth's conventions,
25
- not `database/entities.md`.
20
+ Give the web its tokens in `httpOnly`, `SameSite=Lax` cookies: the access cookie on `/`, the refresh cookie only
21
+ on `/api/auth/refresh`, both `Secure` when `API_URL` is https. Give a native client its tokens in the body, and
22
+ only when it sends `X-Auth-Transport: bearer`; it then sends `Authorization: Bearer`. Refuse with 403 any
23
+ request that changes state, carries a cookie and no bearer, and does not come from `WEB_ORIGIN`.
26
24
 
27
- Create what the application keeps about a user in `databaseHooks.user.create.after`, through a use case:
28
- `src/infra/di/index.ts` passes the hook into `createAuth`.
25
+ Every route is protected by the global `preHandler` hook in `auth.hook.ts`. Mark a route that needs no session
26
+ with `config: { public: true }`; that means only *do not answer 401*, and the token is still read. Read the
27
+ user in the handler with `currentUser(request)` and pass its id to the use case as an argument. A use case
28
+ never reads the request.
29
29
 
30
- ## Rationale
30
+ Create a user and the application's own row for it in one use case, inside one transaction: `SignUpUseCase`
31
+ creates the `User` and calls `CreateProfileUseCase`.
32
+
33
+ Limit the routes that take a password with `@fastify/rate-limit`, through the route's own
34
+ `config.rateLimit`. The plugin is registered with `global: false`.
31
35
 
32
- The route sits inside Fastify, so the request id and the logs cover the authentication flow. **The error
33
- handler does not:** Better Auth writes its own responses, so an authentication error arrives as
34
- `{ message, code }`, not as problem+json. The client converts it; the server does not rewrite another
35
- library's responses.
36
+ ## Rationale
36
37
 
37
- Protected is the default because of the asymmetry between the two mistakes. A route someone forgot to
38
- protect is open, and nothing breaks. A route someone forgot to mark public answers 401 on the first call and
39
- is fixed in seconds.
38
+ Authentication is part of the product's data, so it follows the product's rules: one database, one connection,
39
+ one migration tool, and a domain that names what it needs through ports. Nothing has to be adapted to a
40
+ library's tables, ids or responses, and the user is an entity the ORM knows like any other.
40
41
 
41
- The user arrives as an argument so a use case's signature says what it needs, and its test passes an id
42
- instead of building a request.
42
+ An access token is checked by its signature alone, so no request touches the database to accept one, and
43
+ nothing can revoke one before it expires. Fifteen minutes bounds that. The refresh token is what can be
44
+ revoked, so it lives in the database, and rotation makes a stolen copy detectable: whoever presents an old one
45
+ is not the client that rotated it, and the session ends for both. The ten seconds exist because two tabs
46
+ refreshing at once both present the same token, and the second must not sign the user out.
43
47
 
44
- Better Auth's ids are `text` by default, which no `uuid` column can reference; `generateId: 'uuid'` fixes
45
- that. What stays different is not ours to fix: camelCase columns and `gen_random_uuid()`. Rewriting another
46
- library's schema fights its CLI on every upgrade. Better Auth's migrations run first because an application
47
- table references its tables.
48
+ The decoy hash is there because the time a sign-in takes would otherwise say which emails have an account.
48
49
 
49
- The ORM compares its entities with everything in the database, so without `ignoreSchema` it reads Better
50
- Auth's tables as leftovers and every generated migration drops them, with every login in them. The foreign
51
- key is declared rather than written by hand in a migration because the ORM would otherwise drop that too,
52
- every time. `AuthUser` exists only as that anchor.
50
+ A cookie no script can read is a token an injected script cannot take, which is why the web never receives
51
+ one in a body. A cookie is also sent by the browser whoever wrote the page, which is what the Origin check
52
+ answers: a request leaning on a cookie must come from the web app. A native app has no cookie jar and no
53
+ Origin, so it carries a bearer and passes.
53
54
 
54
- **The sign-up hook is not atomic:** the user and the application's row are written over two connections, so
55
- a failure between them leaves a user without a profile. The result is a visible 404, not corrupted data. And
56
- the foreign key means deleting a user fails while rows reference it, so the application removes them first.
55
+ **What this costs:** the security of sign-in is this code's. There is no upstream fix to wait for, and no
56
+ reset of a password, verification of an email or social sign-in until one is written.
57
57
 
58
58
  ## Applies to
59
59
 
60
- Every route, every use case that acts for a user, and `src/infra/auth/`, where Better Auth is built.
60
+ Every route, every use case that acts for a user, `src/domain/auth/`, and `src/infra/auth/`.
61
61
 
62
62
  ## Examples
63
63
 
@@ -68,19 +68,21 @@ Every route, every use case that acts for a user, and `src/infra/auth/`, where B
68
68
  ✅ execute(currentUser(request).id)
69
69
  ❌ execute(request)
70
70
 
71
- ✅ userId: { kind: 'm:1', entity: () => 'AuthUser' as never, mapToPk: true, deleteRule: 'restrict' }
72
- ❌ userId: { type: 'uuid' } nothing stops an orphan
71
+ ✅ jwtVerify(token, key, { algorithms: ['HS256'], issuer, audience })
72
+ ❌ jwtVerify(token, key) the token chooses its own algorithm
73
+
74
+ ✅ sessions store sha256(secret)
75
+ ❌ sessions store the refresh token
73
76
  ```
74
77
 
75
78
  ## Enforcement
76
79
 
77
80
  **Boot.** The hook is global, so a route is unprotected only by an explicit `public: true`.
78
81
 
79
- **Tests.** `users.controller.spec.ts` signs up through the real route and proves 401 without a session.
80
- `mikro-orm.factory.spec.ts` asserts that a migrated database leaves the schema generator nothing to change,
81
- so a configuration that would drop Better Auth's schema, or the foreign key, fails the suite.
82
-
83
- **The database.** A row referencing a user that does not exist is refused.
82
+ **Tests.** `auth.controller.spec.ts` covers both transports, rotation, the Origin check, 401, 409, 400 and the
83
+ rate limit. `refresh-session.use-case.spec.ts` proves that a replayed token revokes the session and that one
84
+ just rotated does not. `jose-access-tokens.adapter.spec.ts` refuses an expired token, another secret, another
85
+ issuer, an unsigned token and another algorithm.
84
86
 
85
87
  **Review only.** That each `public: true` is deliberate: the one mistake that is invisible at runtime, because
86
88
  the route works, for everyone.
@@ -33,7 +33,7 @@ bodies; a process environment legitimately carries `PATH`, `HOME` and whatever t
33
33
  exception is written down so nobody harmonises it later.
34
34
 
35
35
  **The file is loaded by `loadEnv()` rather than by the server** because the server is not the only entry
36
- point: the ORM's CLI and Better Auth's CLI both read the configuration without starting it. Node's
36
+ point: the ORM's CLI reads the configuration without starting it. Node's
37
37
  `--env-file` would work without code, but it has to be typed on every command. One function every entry
38
38
  point calls keeps one policy, including *never in production*.
39
39
 
@@ -47,7 +47,7 @@ point calls keeps one policy, including *never in production*.
47
47
  ✅ constructor(@inject(ENV) private readonly env: Env) {}
48
48
  ❌ const url = process.env.DATABASE_URL
49
49
 
50
- ✅ Invalid environment: DATABASE_URL, BETTER_AUTH_SECRET
50
+ ✅ Invalid environment: DATABASE_URL, JWT_SECRET
51
51
  ❌ DATABASE_URL is invalid: postgres://admin:hunter2@prod-db/app
52
52
  ```
53
53
 
@@ -3,8 +3,9 @@
3
3
  ## Rule
4
4
 
5
5
  Serve every route at `/api/v<n>/<resource>`: register each controller with
6
- `prefix: '/api/v<n>/<resource>'`. Better Auth's `/api/auth/*`, `/api/health/*` and `/api/docs` are the only
7
- routes outside a version.
6
+ `prefix: '/api/v<n>/<resource>'`. `/api/auth/*`, `/api/health/*` and `/api/docs`, and with MCP `/api/mcp`,
7
+ `/api/oauth/*` and `/.well-known/*`, are the only routes outside a version. They answer with the statuses their
8
+ own contracts set.
8
9
 
9
10
  Name resources in the plural, kebab-case for compound words. Use REST verbs. A `POST` answers `201`
10
11
  through `reply.code(201)`; every other method answers `200`.
@@ -28,8 +29,9 @@ Serve the documentation UI outside production only.
28
29
 
29
30
  ## Rationale
30
31
 
31
- The prefix makes Better Auth's `/api/auth/*` fall out of the ordinary convention instead of becoming the
32
- one route with an exception in its path.
32
+ The prefix makes the authentication and protocol routes fall out of the ordinary convention instead of each
33
+ becoming the one route with an exception in its path. They stay outside a version because what shapes them is
34
+ what they implement, OAuth's discovery above all, whose addresses are fixed by its RFCs.
33
35
 
34
36
  Versioning costs five characters today and a coordinated migration of every consumer later, including a
35
37
  published mobile app that does not update when you want it to. It goes in the URI rather than a header
@@ -17,7 +17,8 @@ folders, each created with its first file:
17
17
  | Folder | Holds | File |
18
18
  |---|---|---|
19
19
  | `entities/` | A plain class with the data and the behaviour of one entity | `<name>.entity.ts` |
20
- | `repositories/` | The port: an interface and its `Symbol` token, in one file | `<name>.repository.ts` |
20
+ | `repositories/` | The port to persistence: an interface and its `Symbol` token, in one file | `<name>.repository.ts` |
21
+ | `ports/` | Any other port, such as a password hasher or a token signer: an interface and its `Symbol` token | `<name>.port.ts` |
21
22
  | `use-cases/` | One class per use case, with one `execute()` method | `<verb>-<noun>.use-case.ts` |
22
23
  | `dto/` | Zod schemas and their inferred types, input and output | `<name>.dto.ts` |
23
24
  | `errors/` | Classes extending `DomainError` | `<name>.error.ts` |
@@ -94,6 +95,8 @@ A module:
94
95
  domain/users/repositories/profile.repository.ts interface + PROFILE_REPOSITORY
95
96
  domain/users/use-cases/update-profile.use-case.ts
96
97
  domain/users/dto/update-profile.dto.ts
98
+ ✅ domain/auth/ports/password-hasher.port.ts interface + PASSWORD_HASHER
99
+ ❌ domain/auth/repositories/password-hasher.repository.ts stores nothing
97
100
  ❌ domain/users/users.service.ts every case in one class
98
101
  ❌ domain/users/profile.ts no role suffix
99
102
  ```
@@ -3,15 +3,15 @@
3
3
  ## Rule
4
4
 
5
5
  Keep the wire contract with the project's API in `src/api-contract/`: `createClient`, which builds every URL
6
- including the `/api/v1` prefix, the `ApiError`, the hand-written request and response types, and the query
7
- factories. **Let the app inject the transport** (`createClient({ baseUrl, fetch })`) with
8
- `credentials: 'include'` on the web configured there, once. In a monorepo the same directory is
9
- `packages/api-contract`.
6
+ including the `/api/v1` prefix, the `ApiError`, the hand-written request and response types, the query
7
+ factories, and `createAuthClient` with `sessionQuery`. **Let the app inject the transport**
8
+ (`createClient({ baseUrl, fetch })`) with `credentials: 'include'` on the web configured there, once. In a
9
+ monorepo the same directory is `packages/api-contract`.
10
10
 
11
- Call Better Auth's routes through Better Auth's own client, `createAuthClient`, configured once. In `mobile`, give it
12
- the `expoClient` plugin, and let the transport for the project's API attach the session with `auth.getCookie()` and
13
- send `credentials: 'omit'`. Do not write
14
- its routes, bodies or responses by hand, and do not send them through the wrapper.
11
+ Build the auth client once, and give the API client its `fetch`: `createClient({ baseUrl, fetch: auth.fetch })`.
12
+ That fetch refreshes once on a 401 and repeats the request, with one refresh for every request refused at
13
+ once. The web passes no token store and lives on its cookies. `mobile` passes a `TokenStore` over
14
+ `expo-secure-store`, which makes the client ask for bearer tokens and send them.
15
15
 
16
16
  Declare request and response types by hand, in `api-contract/`. Do not generate a client
17
17
  from the OpenAPI document, and **do not `import type` an entity from the API package**; see below.
@@ -21,8 +21,8 @@ function travel together. Include in the key everything that changes the result:
21
21
  ordering.
22
22
 
23
23
  Let the client parse every `application/problem+json` body into one `ApiError` carrying `status`,
24
- `title`, `detail`, `requestId` and the `errors` map, and throw it. Convert the auth client's `error` into the
25
- same `ApiError` in one adapter. No component reads a raw error body.
24
+ `title`, `detail`, `requestId` and the `errors` map, and throw it. The auth routes answer the same way. No
25
+ component reads a raw error body.
26
26
 
27
27
  Use `useInfiniteQuery` for paginated lists, with `getNextPageParam` returning `nextCursor`, or `undefined`
28
28
  once it is null.
@@ -55,12 +55,10 @@ difference between their keys fills one cache entry while the other is read, pay
55
55
  nothing, with no warning. A key that omits a filter is the same class of fault: two screens share one
56
56
  entry and the second shows the first's data.
57
57
 
58
- **There are two doors because there are two contracts.** The project's API is ours, so its types are written
59
- here. Better Auth's routes are Better Auth's: hand-writing them would copy a contract we do not control, which
60
- drifts on every upgrade and grows with every plugin. Its client already carries those types and follows the
61
- library, and `multi-tenancy/client.md` depends on it. **Its errors are not problem+json**: `toNodeHandler`
62
- writes the response itself, past the exception filter, so the body is `{ message, code }` with no `requestId`.
63
- The adapter is what keeps that difference out of every form.
58
+ **The auth client is the one door every request goes through.** An access token lasts fifteen minutes, so any
59
+ request can meet a 401 that a refresh would answer. Doing that once, in the transport, means no screen knows
60
+ tokens expire. Several requests refused together share one refresh, because a second would present a token the
61
+ first has already rotated, and the API would read that as theft.
64
62
 
65
63
  One `ApiError` keeps the shape of problem+json in a single file instead of in every component that
66
64
  displays an error. It also puts `requestId` on every failure without anyone asking, which is what makes a
@@ -92,8 +90,8 @@ The query factory:
92
90
  Reaching the server:
93
91
 
94
92
  ```
95
- ✅ api.get('/users/me') authClient.signIn.email({ email, password })
96
- ❌ api.post('/auth/sign-in/email', { … }) // Better Auth's contract, copied by hand
93
+ ✅ createClient({ baseUrl, fetch: auth.fetch }) // refreshed and repeated on a 401
94
+ ❌ createClient({ baseUrl, fetch }) // the first expired token signs the user out
97
95
  ```
98
96
 
99
97
  Reading an error:
@@ -13,8 +13,8 @@ field. **Turn anything that matches no field into a form-level error**, shown at
13
13
  `requestId`. Do it through one function in `features/forms/`, which sets the form-level error on
14
14
  `root.server`.
15
15
 
16
- Show an error carrying no field the same way: form-level, with `title` and the `requestId`. **Every
17
- authentication error is this case**: Better Auth's errors carry no field map.
16
+ Show an error carrying no field the same way: form-level, with `title` and the `requestId`. A wrong password
17
+ is this case: the API names no field, so as not to say whether the email exists.
18
18
 
19
19
  Disable the submit button while `isSubmitting`. Never clear the form on error.
20
20
 
@@ -55,11 +55,10 @@ been published since January 2024. Every other rule here (placement, no database
55
55
  behaviour) holds unchanged. **What it costs:** two runners in one workspace with near-identical APIs, so `vi.fn`
56
56
  written in a mobile test is a mistake an assistant will make.
57
57
 
58
- **Three things a mobile test meets, verified with Expo SDK 57:** `render` from `@testing-library/react-native` 14
59
- is **asynchronous** and its matchers need no setup. `jest-expo` leaves the app manifest empty, which Better Auth's
60
- Expo plugin needs to build an origin, so a component test uses a Better Auth client without that plugin, which is
61
- why a form asks only for the auth methods it calls. And Better Auth ships ESM, partly as `.mjs`, so Jest extends
62
- `jest-expo`'s own transform settings to include it rather than restating them.
58
+ **Two things a mobile test meets, verified with Expo SDK 57:** `render` from `@testing-library/react-native` 14
59
+ is **asynchronous** and its matchers need no setup. And `expo-secure-store` needs the native Keychain or Keystore,
60
+ which Jest does not have, so a test builds the auth client with an in-memory token store, which is why a form
61
+ asks only for the auth methods it calls.
63
62
 
64
63
  A coverage threshold turns a proxy into a target. Whoever is below writes tests to raise the number rather
65
64
  than to check anything; whoever is above stops thinking. With no database mocks a test costs more to write,
@@ -6,10 +6,9 @@ Start one Postgres container per test run with Testcontainers. Give each Vitest
6
6
  inside it. Never point a test at a shared or long-lived database.
7
7
 
8
8
  Build the schema by running the migrations, once per worker database. Do not use the schema generator.
9
- Run Better Auth's migrations first, through its programmatic `getMigrations`, then ours.
10
9
 
11
- Truncate every table between tests, in a single `TRUNCATE` statement listing them all, Better Auth's
12
- included. Do not wrap a test in a transaction and roll it back.
10
+ Truncate every table between tests, in a single `TRUNCATE` statement listing every entity's table, read from
11
+ the ORM's metadata. Do not wrap a test in a transaction and roll it back.
13
12
 
14
13
  Create test data with a factory per entity: a function returning a valid row from defaults and
15
14
  accepting overrides. Do not keep a shared fixture file.
@@ -35,11 +34,10 @@ inside one turns the code's transaction into a savepoint, which behaves differen
35
34
  never exercised. Testing a lock inside a wrapper that will roll back tests something adjacent to the
36
35
  truth.
37
36
 
38
- Better Auth's migrations run first for the same reason they do in production: an application table
39
- references `auth.user`, so its foreign key cannot be created before the table exists. Its tables are in the
40
- truncation for the mirror reason: leave them out and the next test's factory collides with a user the
41
- previous test created. A factory for such a table creates the user first; Better Auth's tables have no entity
42
- here, so that insert is raw SQL, with its one-line reason.
37
+ The list comes from the metadata so that a new entity is truncated without anyone remembering to add it. Read
38
+ it as the `Map` it is: `Object.values()` on it is empty, and a suite once passed for months truncating nothing,
39
+ saved only by a cascade from tables it no longer has. A factory for a table that references a user creates the
40
+ user first, through the user's own factory.
43
41
 
44
42
  Factories rather than a fixture file, because a fixture grows until a test depends on user number twelve
45
43
  having a particular plan, and then neither can be changed alone. A factory call states what the scenario
@@ -2,10 +2,10 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Serve MCP at `POST /api/mcp`, in `src/infra/http/controllers/mcp.controller.ts`. Protect it with
6
- `requireMcpAuth` from `@better-auth/mcp`, with the same `resource` as `mcp()` in `auth.factory.ts`:
7
- `mcpResource(env)`. The route is `public: true` for the session hook, because it answers a bearer access
8
- token, never a cookie.
5
+ Serve MCP at `POST /api/mcp`, in `src/infra/http/controllers/mcp.controller.ts`. Accept only a bearer access
6
+ token whose audience is `mcpResource(env)` and which names a client; answer anything else with 401 and a
7
+ `WWW-Authenticate` naming `resource_metadata`. The route is `public: true` for the session hook, because the
8
+ audience decides, not the session.
9
9
 
10
10
  Build the server per request in `src/infra/mcp/mcp.server.ts` with `createMcpHandler` from
11
11
  `@modelcontextprotocol/server`, `legacy: 'reject'`. The user is the token's `sub`, passed through
@@ -15,8 +15,21 @@ Write a module's tools in `src/infra/mcp/tools/<module>.tool.ts`. A tool resolve
15
15
  `execute()`, exactly as a route handler does. Take its input schema from the module's `dto/`, and pass its
16
16
  result through the module's output schema with `.parse()` before returning it.
17
17
 
18
- Keep Better Auth the authorization server: `jwt()`, `mcp()` and `cimd()` in `auth.factory.ts`, discovery at
19
- `/.well-known/*`, sign-in and consent on the web app. Do not enable dynamic client registration.
18
+ Keep the authorization server in the `oauth` module and `oauth.controller.ts`, following MCP's 2026-07-28
19
+ authorization profile:
20
+
21
+ - Publish protected resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource/api/mcp` and at the
22
+ root, and server metadata (RFC 8414) at `/.well-known/oauth-authorization-server`.
23
+ - Identify a client by its metadata document (CIMD) only. Do not enable dynamic client registration.
24
+ - Check the client and its redirect URI, by exact match, before any error may be redirected.
25
+ - Require PKCE with `S256`, and a `resource` equal to `mcpResource(env)`, which becomes the token's audience.
26
+ - Send the browser to the web app's `/consent`. Show the client's name, the host of its `client_id` and the
27
+ host of its redirect URI, and warn when that redirect returns to this device.
28
+ - Return the code with `iss` (RFC 9207), and spend it once: a code presented twice revokes the session its
29
+ first exchange started.
30
+ - Rotate refresh tokens, and accept one only from the client it was issued to.
31
+ - Fetch a metadata document over https only, at most 5 KB and 5 seconds, following no redirect, and never from
32
+ a private, loopback or link-local address, checked at the connection's own lookup.
20
33
 
21
34
  ## Rationale
22
35
 
@@ -28,13 +41,21 @@ obeys.
28
41
  list and MCP does not: a tool returns whatever it is handed, so an entity passed straight through publishes
29
42
  every column.
30
43
 
31
- `legacy: 'reject'` and CIMD follow the MCP 2026-07-28 profile, which Better Auth's plugin targets. The
32
- protocol is stateless, so a server per request costs nothing and knows only its caller. Dynamic client
33
- registration stays off because that profile deprecates it.
44
+ The audience keeps the two kinds of token apart. A token a client obtained for MCP cannot call the API's own
45
+ routes, and the web's own token cannot call MCP.
46
+
47
+ The client chooses the URL the server fetches, which is what makes CIMD a server-side request forgery risk:
48
+ without the address check, a client could make the API read its own network. The check runs at the
49
+ connection's lookup, so a name that resolves differently a second time cannot slip past it, and a literal
50
+ address is checked before connecting because no lookup happens for one.
51
+
52
+ **What this costs:** no scopes. A tool acts with everything the user may do, and adding scopes is a change to
53
+ this module and to the consent page.
34
54
 
35
55
  ## Applies to
36
56
 
37
- `src/infra/mcp/`, `mcp.controller.ts`, the MCP part of `auth.factory.ts`, and the web app's `/consent` route.
57
+ `src/infra/mcp/`, `mcp.controller.ts`, `oauth.controller.ts`, `src/domain/oauth/`, `src/infra/oauth/`, and the
58
+ web app's `/consent` route.
38
59
 
39
60
  ## Examples
40
61
 
@@ -44,11 +65,17 @@ registration stays off because that profile deprecates it.
44
65
 
45
66
  ✅ structuredContent: profileResponseSchema.parse(profile)
46
67
  ❌ structuredContent: profile every column leaves
68
+
69
+ ✅ redirectUris.includes(redirectUri)
70
+ ❌ redirectUri.startsWith(registeredOrigin)
47
71
  ```
48
72
 
49
73
  ## Enforcement
50
74
 
51
- **Tests.** `mcp.controller.spec.ts` walks the whole authorization, consent included, connects the SDK's
52
- own client, and asserts a tool returns only the public fields.
75
+ **Tests.** `mcp.controller.spec.ts` walks discovery from the 401, the authorization, consent and the token, then
76
+ connects the SDK's own client and asserts a tool returns only the public fields. It also refuses an unknown
77
+ client without redirecting, a missing or plain PKCE, another resource, a wrong verifier, a code spent twice,
78
+ another client's refresh token, and each token where the other belongs. `https-client-metadata.adapter.spec.ts`
79
+ refuses non-canonical client ids and private addresses.
53
80
 
54
81
  **Review only.** That a new tool goes through a use case and parses its output.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Hold the native splash screen until the session has been read from `expo-secure-store`. Only then let the
5
+ Hold the native splash screen until the session has been read, with the tokens from `expo-secure-store`. Only then let the
6
6
  router render the authenticated group or redirect to login.
7
7
 
8
8
  Express protection with an authenticated layout group. Do not check for a session inside a screen.
@@ -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
  ```