@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.
- package/.prumo-templates/api/auth.md +50 -48
- package/.prumo-templates/api/config.md +2 -2
- package/.prumo-templates/api/routes.md +6 -4
- package/.prumo-templates/api/structure.md +4 -1
- package/.prumo-templates/client/data.md +16 -18
- package/.prumo-templates/client/forms.md +2 -2
- package/.prumo-templates/core/testing.md +4 -5
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/mcp/server.md +39 -12
- package/.prumo-templates/mobile/routing.md +1 -1
- package/.prumo-templates/mobile/storage.md +7 -6
- package/.prumo-templates/monorepo/layout.md +4 -4
- package/.prumo-templates/multi-tenancy/client.md +6 -6
- package/.prumo-templates/multi-tenancy/isolation.md +17 -10
- package/README.md +19 -14
- package/dist/clean.js +13 -3
- package/dist/cli.js +38 -9
- package/dist/commands.js +7 -4
- package/dist/compose.js +4 -12
- package/dist/doctor.js +31 -17
- package/dist/generate.js +56 -9
- package/dist/mcp.js +15 -8
- package/dist/names.js +42 -0
- package/dist/questions.js +32 -16
- package/package.json +2 -2
- package/templates/api/.env.example +3 -5
- package/templates/api/README.md +5 -6
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
- package/templates/api/package.json +7 -8
- package/templates/api/scripts/database.mjs +3 -11
- package/templates/api/scripts/ports.mjs +1 -1
- package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
- package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
- package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
- package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
- package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
- package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
- package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
- package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
- package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
- package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
- package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
- package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
- package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
- package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
- package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
- package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
- package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
- package/templates/api/src/infra/config/env.ts +3 -7
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
- package/templates/api/src/infra/di/auth.di.ts +21 -0
- package/templates/api/src/infra/di/index.ts +4 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -0
- package/templates/api/src/infra/http/app.ts +11 -1
- package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
- package/templates/api/src/infra/http/fastify.d.ts +2 -3
- package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
- package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
- package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
- package/templates/api/src/infra/http/server.ts +1 -4
- package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
- package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
- package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
- package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
- package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
- package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
- package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
- package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
- package/templates/api/test/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
- package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
- package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
- package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/setup.ts +6 -28
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/jest.config.js +0 -17
- package/templates/mobile/package.json +1 -4
- package/templates/mobile/scripts/ports.mjs +1 -1
- package/templates/mobile/src/api-contract/api-error.ts +0 -11
- package/templates/mobile/src/api-contract/auth.ts +161 -0
- package/templates/mobile/src/api-contract/index.ts +10 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- package/templates/mobile/src/app/_layout.tsx +5 -5
- package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
- package/templates/mobile/src/features/auth/token-store.ts +21 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
- package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
- package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
- package/templates/mobile/test/support/memory-token-store.ts +15 -0
- package/templates/site/README.md +1 -1
- package/templates/site/package.json +1 -1
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/README.md +1 -1
- package/templates/web/package.json +1 -3
- package/templates/web/scripts/ports.mjs +1 -1
- package/templates/web/src/api-contract/api-error.ts +0 -11
- package/templates/web/src/api-contract/auth.ts +161 -0
- package/templates/web/src/api-contract/index.ts +10 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
- package/templates/web/src/features/oauth/consent-form.tsx +66 -35
- package/templates/web/src/main.tsx +6 -4
- package/templates/web/src/router.tsx +1 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
- package/templates/web/src/routes/_authenticated.tsx +1 -1
- package/templates/web/test/api-contract/auth.spec.ts +155 -0
- package/templates/web/test/app.spec.tsx +47 -22
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/README.md +1 -1
- package/templates/workspace/package.json +1 -1
- package/templates/workspace/scripts/ports.mjs +1 -1
- package/templates/api/docker/init/01-auth-schema.sql +0 -1
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
- package/templates/api/src/infra/auth/auth.cli.ts +0 -5
- package/templates/api/src/infra/auth/auth.factory.ts +0 -77
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
- package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
- package/templates/mobile/src/features/auth/auth-client.ts +0 -27
- package/templates/mobile/src/features/auth/session.ts +0 -17
- package/templates/web/src/features/auth/auth-client.ts +0 -17
- package/templates/web/src/features/auth/session.ts +0 -17
|
@@ -2,62 +2,62 @@
|
|
|
2
2
|
|
|
3
3
|
## Rule
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
`
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
is
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
**
|
|
55
|
-
|
|
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
|
|
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
|
-
✅
|
|
72
|
-
❌
|
|
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.** `
|
|
80
|
-
`
|
|
81
|
-
|
|
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
|
|
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,
|
|
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>'`.
|
|
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
|
|
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,
|
|
7
|
-
factories
|
|
8
|
-
`credentials: 'include'` on the web configured there, once. In a
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
25
|
-
|
|
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
|
-
**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
✅
|
|
96
|
-
❌
|
|
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`.
|
|
17
|
-
|
|
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
|
-
**
|
|
59
|
-
is **asynchronous** and its matchers need no setup. `
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
12
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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`.
|
|
6
|
-
`
|
|
7
|
-
`
|
|
8
|
-
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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`,
|
|
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
|
|
52
|
-
own client
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
66
|
-
❌ clear
|
|
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`,
|
|
34
|
-
automatically, mobile
|
|
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
|
|
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
|
-
|
|
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
|
|
48
|
+
✅ await switchTenant(organizationId) // the API re-issues the access token
|
|
49
49
|
queryClient.clear()
|
|
50
50
|
router.navigate({ to: '/' })
|
|
51
|
-
❌ await
|
|
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),
|
|
82
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
a multi-tenant system that means the **tab** decides isolation, so it is refused here by name:
|
|
37
|
-
will find that
|
|
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
|
|
52
|
-
|
|
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 =
|
|
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
|
```
|