@stjoseph/prumo 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prumo-templates/api/auth.md +51 -60
- 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 -4
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +55 -14
- 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/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +8 -6
- package/dist/asker.js +13 -0
- package/dist/clean.js +2 -2
- package/dist/cli.js +16 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +3 -11
- package/dist/context.js +4 -0
- package/dist/features.js +230 -0
- package/dist/generate.js +20 -7
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +21 -5
- package/templates/api/README.md +4 -5
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261005111545_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261005111547_create_authorization.ts +18 -0
- package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
- package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -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/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/reset-password.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/dto/verify-email.dto.ts +8 -0
- package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
- package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +78 -0
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +34 -0
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -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/provider-email-unverified.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -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/identity-providers.port.ts +29 -0
- package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
- package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -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/email-code.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +12 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
- package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
- package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
- package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +94 -0
- package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +38 -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 +57 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
- package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -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 +14 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +87 -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 +18 -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 +97 -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 +89 -0
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -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 +64 -0
- package/templates/api/src/infra/config/env.ts +17 -3
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +38 -0
- package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
- package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -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 +34 -0
- package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +19 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +15 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +25 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -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/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +8 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -0
- package/templates/api/src/infra/di/social.di.ts +17 -0
- package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
- package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
- package/templates/api/src/infra/http/app.ts +15 -1
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +108 -29
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +46 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +267 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +5 -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/http/session-delivery.ts +69 -0
- package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
- package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -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/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
- package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -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 +41 -0
- package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +53 -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/email/hmac-one-time-codes.adapter.spec.ts +15 -0
- package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +168 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +309 -67
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +13 -35
- 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/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +94 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
- package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
- package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -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-email-code.repository.ts +15 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +27 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/setup.ts +6 -28
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/jest.config.js +0 -17
- package/templates/mobile/package.json +0 -4
- package/templates/mobile/scripts/ports.mjs +1 -1
- package/templates/mobile/src/api-contract/api-error.ts +4 -11
- package/templates/mobile/src/api-contract/auth.ts +221 -0
- package/templates/mobile/src/api-contract/index.ts +14 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
- package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
- package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
- package/templates/mobile/src/app/_layout.tsx +5 -5
- package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
- package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +21 -7
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +24 -8
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/token-store.ts +21 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -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/email-flows.spec.tsx +86 -0
- 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/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/mobile/test/support/memory-token-store.ts +15 -0
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/package.json +0 -3
- package/templates/web/scripts/ports.mjs +1 -1
- package/templates/web/src/api-contract/api-error.ts +4 -11
- package/templates/web/src/api-contract/auth.ts +221 -0
- package/templates/web/src/api-contract/index.ts +14 -0
- package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
- package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +24 -14
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +16 -8
- package/templates/web/src/features/auth/social-buttons.tsx +45 -0
- package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
- package/templates/web/src/features/oauth/consent-form.tsx +72 -35
- package/templates/web/src/main.tsx +6 -4
- package/templates/web/src/routeTree.gen.ts +73 -2
- 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/src/routes/forgot-password.tsx +17 -0
- package/templates/web/src/routes/reset-password.tsx +20 -0
- package/templates/web/src/routes/sign-in.tsx +10 -3
- package/templates/web/src/routes/sign-up.tsx +4 -0
- package/templates/web/src/routes/verify-email.tsx +20 -0
- 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/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +90 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/scripts/ports.mjs +1 -1
- package/dist/mcp.js +0 -100
- 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,71 +2,63 @@
|
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
one change, never one package at a time.
|
|
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`.
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
The route sits inside Fastify, so the request id and the logs cover the authentication flow. **The error
|
|
37
|
-
handler does not:** Better Auth writes its own responses, so an authentication error arrives as
|
|
38
|
-
`{ message, code }`, not as problem+json. The client converts it; the server does not rewrite another
|
|
39
|
-
library's responses.
|
|
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`.
|
|
40
35
|
|
|
41
|
-
|
|
42
|
-
protect is open, and nothing breaks. A route someone forgot to mark public answers 401 on the first call and
|
|
43
|
-
is fixed in seconds.
|
|
36
|
+
## Rationale
|
|
44
37
|
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
47
41
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
52
47
|
|
|
53
|
-
The
|
|
54
|
-
Auth's tables as leftovers and every generated migration drops them, with every login in them. The foreign
|
|
55
|
-
key is declared rather than written by hand in a migration because the ORM would otherwise drop that too,
|
|
56
|
-
every time. `AuthUser` exists only as that anchor.
|
|
48
|
+
The decoy hash is there because the time a sign-in takes would otherwise say which emails have an account.
|
|
57
49
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
62
54
|
|
|
63
|
-
**
|
|
64
|
-
|
|
65
|
-
|
|
55
|
+
**What this costs:** the security of sign-in is this code's. There is no upstream fix to wait for. Email
|
|
56
|
+
verification, password reset and social sign-in are questions `prumo new` asks; a project that answered no to
|
|
57
|
+
them has none of them until they are written.
|
|
66
58
|
|
|
67
59
|
## Applies to
|
|
68
60
|
|
|
69
|
-
Every route, every use case that acts for a user, and `src/infra/auth
|
|
61
|
+
Every route, every use case that acts for a user, `src/domain/auth/`, and `src/infra/auth/`.
|
|
70
62
|
|
|
71
63
|
## Examples
|
|
72
64
|
|
|
@@ -77,22 +69,21 @@ Every route, every use case that acts for a user, and `src/infra/auth/`, where B
|
|
|
77
69
|
✅ execute(currentUser(request).id)
|
|
78
70
|
❌ execute(request)
|
|
79
71
|
|
|
80
|
-
✅
|
|
81
|
-
❌
|
|
72
|
+
✅ jwtVerify(token, key, { algorithms: ['HS256'], issuer, audience })
|
|
73
|
+
❌ jwtVerify(token, key) the token chooses its own algorithm
|
|
74
|
+
|
|
75
|
+
✅ sessions store sha256(secret)
|
|
76
|
+
❌ sessions store the refresh token
|
|
82
77
|
```
|
|
83
78
|
|
|
84
79
|
## Enforcement
|
|
85
80
|
|
|
86
81
|
**Boot.** The hook is global, so a route is unprotected only by an explicit `public: true`.
|
|
87
82
|
|
|
88
|
-
**Tests.** `
|
|
89
|
-
`
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
**The database.** A row referencing a user that does not exist is refused.
|
|
93
|
-
|
|
94
|
-
**Typecheck.** Two cores in one project usually fail `pnpm typecheck` in `src/infra/auth/`.
|
|
83
|
+
**Tests.** `auth.controller.spec.ts` covers both transports, rotation, the Origin check, 401, 409, 400 and the
|
|
84
|
+
rate limit. `refresh-session.use-case.spec.ts` proves that a replayed token revokes the session and that one
|
|
85
|
+
just rotated does not. `jose-access-tokens.adapter.spec.ts` refuses an expired token, another secret, another
|
|
86
|
+
issuer, an unsigned token and another algorithm.
|
|
95
87
|
|
|
96
|
-
**Review only.** That
|
|
97
|
-
each `public: true` is deliberate: the one mistake that is invisible at runtime, because
|
|
88
|
+
**Review only.** That each `public: true` is deliberate: the one mistake that is invisible at runtime, because
|
|
98
89
|
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
|
|
|
@@ -56,10 +56,10 @@ behaviour) holds unchanged. **What it costs:** two runners in one workspace with
|
|
|
56
56
|
written in a mobile test is a mistake an assistant will make.
|
|
57
57
|
|
|
58
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. `
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
is **asynchronous** and its matchers need no setup. `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. And `jest-expo` leaves the app manifest empty, so `expo-linking` cannot
|
|
62
|
+
know the app's scheme: a test that reaches `Linking.createURL` mocks it with the scheme it expects.
|
|
63
63
|
|
|
64
64
|
A coverage threshold turns a proxy into a target. Whoever is below writes tests to raise the number rather
|
|
65
65
|
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
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Email codes
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Confirm an email address and reset a password with a **6-digit code** sent by mail, never a link. One flow
|
|
6
|
+
serves the web and the mobile app alike.
|
|
7
|
+
|
|
8
|
+
Keep one live code per user and purpose (`verify-email`, `reset-password`) in `email_code`. A code lives
|
|
9
|
+
**15 minutes** and allows **5 attempts**; the fifth wrong one kills it. Sending another replaces it, and the old
|
|
10
|
+
one stops working. Store only its HMAC-SHA256, keyed with `JWT_SECRET`.
|
|
11
|
+
|
|
12
|
+
Open no session at sign-up: answer `201 { verificationRequired: true }` and send the code. Refuse sign-in to an
|
|
13
|
+
unconfirmed account with **403** and `code: 'email_not_verified'`, and only after the password matched. Sign
|
|
14
|
+
the user in when the code is confirmed.
|
|
15
|
+
|
|
16
|
+
On a password reset, set the new password, mark the email confirmed, and revoke every session of the user before
|
|
17
|
+
starting a new one.
|
|
18
|
+
|
|
19
|
+
Answer `/email/verification` and `/password/forgot` with 204 whether or not the email has an account, and a
|
|
20
|
+
wrong code, a dead one and an unknown email with the same `422 invalid_code`.
|
|
21
|
+
|
|
22
|
+
Read the outcome of a sign-up and of a refused sign-in in the client: `verificationRequired` and
|
|
23
|
+
`email_not_verified` both lead to the code screen, which offers to send a new code.
|
|
24
|
+
|
|
25
|
+
## Rationale
|
|
26
|
+
|
|
27
|
+
A link needs a page to open: easy on the web, and a deep link with universal links on mobile. A code is typed
|
|
28
|
+
the same way everywhere, so there is one flow, one set of screens and one set of tests.
|
|
29
|
+
|
|
30
|
+
Six digits are a million possibilities, which is why the attempts are counted and the lifetime short: five
|
|
31
|
+
guesses against a million cannot succeed by luck. The hash is keyed because a million codes hash in a moment,
|
|
32
|
+
so an unkeyed digest in a database copy would give every live code away.
|
|
33
|
+
|
|
34
|
+
Every answer that could say whether an email has an account says the same thing instead, so these routes cannot
|
|
35
|
+
be used to find out who signed up. Asking for confirmation only after the password matched keeps that true at
|
|
36
|
+
sign-in too.
|
|
37
|
+
|
|
38
|
+
A reset revokes every session because whoever knew the old password may have opened one, and the reset is often
|
|
39
|
+
the reaction to exactly that.
|
|
40
|
+
|
|
41
|
+
## Applies to
|
|
42
|
+
|
|
43
|
+
`src/domain/auth/` for the codes, `auth-email.controller.ts`, and the code, forgot-password and reset-password
|
|
44
|
+
screens on the web and mobile.
|
|
45
|
+
|
|
46
|
+
## Examples
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
✅ POST /api/auth/password/forgot → 204 for any email
|
|
50
|
+
❌ POST /api/auth/password/forgot → 404 "no account with this email"
|
|
51
|
+
|
|
52
|
+
✅ sign-in with the right password, unconfirmed → 403 email_not_verified
|
|
53
|
+
❌ sign-in, unconfirmed, before checking the password → 403 // tells anyone the account exists
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Enforcement
|
|
57
|
+
|
|
58
|
+
**Tests.** `email.use-case.spec.ts` covers the dead code after five guesses, the replaced code, the unknown email
|
|
59
|
+
and the reset revoking sessions. `auth-email.controller.spec.ts` covers the HTTP answers, and the clients'
|
|
60
|
+
`email-flows.spec.tsx` the screens.
|
|
61
|
+
|
|
62
|
+
**Review only.** That a new mail with a code goes through `IssueEmailCodeUseCase`, so it keeps the same limits.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Mailer
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Send every email through the `Mailer` port in `src/domain/auth/ports/mailer.port.ts`. A use case composes the
|
|
6
|
+
`Mail` (`to`, `subject`, `text`) and hands it over; nothing else in the domain knows how mail travels.
|
|
7
|
+
|
|
8
|
+
Keep `LogMailer`, which writes each mail to the API's log, as the adapter until the project sends real mail.
|
|
9
|
+
Read a code during development from that log.
|
|
10
|
+
|
|
11
|
+
To send real mail, write a second adapter under `src/infra/email/` and register it in `email.di.ts` in place of
|
|
12
|
+
`LogMailer`, for production only. Either speak SMTP, with `nodemailer` and an `SMTP_URL` any provider accepts, or
|
|
13
|
+
call a provider's HTTP API. Validate the new variable in `env.ts`.
|
|
14
|
+
|
|
15
|
+
In a test, register a `FakeMailer` and read the code from it, as `test/support/test-app.ts` does.
|
|
16
|
+
|
|
17
|
+
## Rationale
|
|
18
|
+
|
|
19
|
+
Sending mail needs an account with somebody, and choosing one is a commercial decision the project's owner
|
|
20
|
+
makes, not the template. So the template ships the port and an adapter that needs nothing, and the flows that
|
|
21
|
+
depend on mail work end to end from the first `pnpm dev`: the code is in the terminal.
|
|
22
|
+
|
|
23
|
+
The port is the whole contract on purpose. SES, Postmark, Resend, Mailgun and a company relay all accept the
|
|
24
|
+
same three fields, so changing provider is one new adapter and one line in the container.
|
|
25
|
+
|
|
26
|
+
**What this costs:** until an adapter is written, a deployed API sends nothing, and a user waiting for a code
|
|
27
|
+
never receives it. Nothing warns about that but this document.
|
|
28
|
+
|
|
29
|
+
## Applies to
|
|
30
|
+
|
|
31
|
+
Every email the API sends, now and later: codes, invitations, receipts.
|
|
32
|
+
|
|
33
|
+
## Examples
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
✅ await this.mailer.send({ to, subject, text })
|
|
37
|
+
❌ await fetch('https://api.provider.example/send', …) // inside a use case
|
|
38
|
+
|
|
39
|
+
✅ container.register(MAILER, { useValue: new SmtpMailer(env) }) // production adapter
|
|
40
|
+
❌ if (env.NODE_ENV === 'production') { … } // inside LogMailer
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Enforcement
|
|
44
|
+
|
|
45
|
+
**Tests.** The suite reads every code through `FakeMailer`, so a flow that stops sending mail fails.
|
|
46
|
+
|
|
47
|
+
**Review only.** That production registers a real adapter. `LogMailer` in production loses mail silently.
|
|
@@ -2,21 +2,41 @@
|
|
|
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
|
|
12
12
|
`authInfo.extra.userId`.
|
|
13
13
|
|
|
14
|
-
Write a module's tools in `src/infra/mcp/tools/<module>.tool.ts
|
|
15
|
-
`
|
|
14
|
+
Write a module's tools in `src/infra/mcp/tools/<module>.tool.ts`, with the scope each one needs beside it:
|
|
15
|
+
`<module>:read` to read the module's data, `<module>:write` to change it. Add the module's two scopes to
|
|
16
|
+
`SCOPES` in `mcp.scopes.ts`, worded for a person, and its tools to `TOOL_SCOPES`. A tool resolves a use case and
|
|
17
|
+
calls `execute()`, exactly as a route handler does. Take its input schema from the module's `dto/`, and pass its
|
|
16
18
|
result through the module's output schema with `.parse()` before returning it.
|
|
17
19
|
|
|
18
|
-
Keep
|
|
19
|
-
|
|
20
|
+
Keep the authorization server in the `oauth` module and `oauth.controller.ts`, following MCP's 2026-07-28
|
|
21
|
+
authorization profile:
|
|
22
|
+
|
|
23
|
+
- Publish protected resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource/api/mcp` and at the
|
|
24
|
+
root, and server metadata (RFC 8414) at `/.well-known/oauth-authorization-server`.
|
|
25
|
+
- Identify a client by its metadata document (CIMD) only. Do not enable dynamic client registration.
|
|
26
|
+
- Check the client and its redirect URI, by exact match, before any error may be redirected.
|
|
27
|
+
- Require PKCE with `S256`, and a `resource` equal to `mcpResource(env)`, which becomes the token's audience.
|
|
28
|
+
- Send the browser to the web app's `/consent`. Show the client's name, the host of its `client_id` and the
|
|
29
|
+
host of its redirect URI, and warn when that redirect returns to this device.
|
|
30
|
+
- Return the code with `iss` (RFC 9207), and spend it once: a code presented twice revokes the session its
|
|
31
|
+
first exchange started.
|
|
32
|
+
- Rotate refresh tokens, and accept one only from the client it was issued to.
|
|
33
|
+
- Grant the scopes the client asked for, all of `SCOPES` when it asked for none, and refuse an unknown one with
|
|
34
|
+
`invalid_scope`. Carry them in the session, its access token and every refresh. Advertise them in both metadata
|
|
35
|
+
documents and in the 401.
|
|
36
|
+
- Refuse a `tools/call` the token's scopes do not cover before the server runs it: 403 with
|
|
37
|
+
`WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`, every scope the call needs in one challenge.
|
|
38
|
+
- Fetch a metadata document over https only, at most 5 KB and 5 seconds, following no redirect, and never from
|
|
39
|
+
a private, loopback or link-local address, checked at the connection's own lookup.
|
|
20
40
|
|
|
21
41
|
## Rationale
|
|
22
42
|
|
|
@@ -28,13 +48,26 @@ obeys.
|
|
|
28
48
|
list and MCP does not: a tool returns whatever it is handed, so an entity passed straight through publishes
|
|
29
49
|
every column.
|
|
30
50
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
51
|
+
The audience keeps the two kinds of token apart. A token a client obtained for MCP cannot call the API's own
|
|
52
|
+
routes, and the web's own token cannot call MCP.
|
|
53
|
+
|
|
54
|
+
The client chooses the URL the server fetches, which is what makes CIMD a server-side request forgery risk:
|
|
55
|
+
without the address check, a client could make the API read its own network. The check runs at the
|
|
56
|
+
connection's lookup, so a name that resolves differently a second time cannot slip past it, and a literal
|
|
57
|
+
address is checked before connecting because no lookup happens for one.
|
|
58
|
+
|
|
59
|
+
Scopes are per module and per kind of access because a client that only reads should not be able to change
|
|
60
|
+
anything, and because a list worded per tool would grow past what a consent page can make readable. The call is
|
|
61
|
+
refused at the HTTP edge, with the scopes it needs, because that is the answer MCP's step-up flow reads: the
|
|
62
|
+
client asks the user for more and comes back.
|
|
63
|
+
|
|
64
|
+
**What this costs:** a new module's tools need their scopes written in two places, the tool file and
|
|
65
|
+
`mcp.scopes.ts`.
|
|
34
66
|
|
|
35
67
|
## Applies to
|
|
36
68
|
|
|
37
|
-
`src/infra/mcp/`, `mcp.controller.ts`,
|
|
69
|
+
`src/infra/mcp/`, `mcp.controller.ts`, `oauth.controller.ts`, `src/domain/oauth/`, `src/infra/oauth/`, and the
|
|
70
|
+
web app's `/consent` route.
|
|
38
71
|
|
|
39
72
|
## Examples
|
|
40
73
|
|
|
@@ -44,11 +77,19 @@ registration stays off because that profile deprecates it.
|
|
|
44
77
|
|
|
45
78
|
✅ structuredContent: profileResponseSchema.parse(profile)
|
|
46
79
|
❌ structuredContent: profile every column leaves
|
|
80
|
+
|
|
81
|
+
✅ redirectUris.includes(redirectUri)
|
|
82
|
+
❌ redirectUri.startsWith(registeredOrigin)
|
|
47
83
|
```
|
|
48
84
|
|
|
49
85
|
## Enforcement
|
|
50
86
|
|
|
51
|
-
**Tests.** `mcp.controller.spec.ts`
|
|
52
|
-
|
|
87
|
+
**Tests.** `mcp.controller.spec.ts` fails when a tool the server lists has no scope in `TOOL_SCOPES`, lets a
|
|
88
|
+
read-only token read and refuses its write with `insufficient_scope`,
|
|
89
|
+
keeps the scope through a refresh, and refuses an unknown scope. It also walks discovery from the 401, the authorization, consent and the token, then
|
|
90
|
+
connects the SDK's own client and asserts a tool returns only the public fields. It also refuses an unknown
|
|
91
|
+
client without redirecting, a missing or plain PKCE, another resource, a wrong verifier, a code spent twice,
|
|
92
|
+
another client's refresh token, and each token where the other belongs. `https-client-metadata.adapter.spec.ts`
|
|
93
|
+
refuses non-canonical client ids and private addresses.
|
|
53
94
|
|
|
54
95
|
**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.
|