@stjoseph/prumo 0.2.0 → 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 -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 -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 +2 -2
- package/dist/clean.js +2 -2
- package/dist/compose.js +1 -9
- package/dist/generate.js +3 -4
- package/dist/mcp.js +15 -8
- package/package.json +1 -1
- package/templates/api/.env.example +3 -5
- package/templates/api/README.md +4 -5
- 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 +6 -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 +0 -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/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 +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/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
|
@@ -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
|
```
|
package/README.md
CHANGED
|
@@ -88,9 +88,9 @@ is asked, and the exit code is 0 only when `ok` is true.
|
|
|
88
88
|
|
|
89
89
|
| Type | What you get |
|
|
90
90
|
|---|---|
|
|
91
|
-
| `api` | Fastify with a domain/infra structure, tsyringe, Zod, MikroORM on PostgreSQL,
|
|
91
|
+
| `api` | Fastify with a domain/infra structure, tsyringe, Zod, MikroORM on PostgreSQL, its own JWT authentication, Swagger UI, Vitest with Testcontainers |
|
|
92
92
|
| `web` | Vite, React, TanStack Router and Query, Tailwind, shadcn/ui, forms validated with Zod |
|
|
93
|
-
| `mobile` | Expo with Expo Router, NativeWind, MMKV, and the
|
|
93
|
+
| `mobile` | Expo with Expo Router, NativeWind, MMKV, and the tokens kept in `expo-secure-store` |
|
|
94
94
|
| `site` | Next on the App Router, static by default, for pages that have to be indexed |
|
|
95
95
|
|
|
96
96
|
One type produces that project on its own. Several produce a pnpm workspace: each application under
|
package/dist/clean.js
CHANGED
|
@@ -10,7 +10,7 @@ export const API_DEV_PREFIX = 'node scripts/database.mjs --check && ';
|
|
|
10
10
|
export const ROOT_DEV_PREFIX = 'node apps/api/scripts/database.mjs --check && ';
|
|
11
11
|
export const SETUP_SCRIPT = 'node scripts/database.mjs';
|
|
12
12
|
export const EXAMPLE_BLOCK = '# MISSING until `pnpm db:setup` creates a database; `pnpm dev` offers to run it.\n' +
|
|
13
|
-
'DATABASE_URL=MISSING\
|
|
13
|
+
'DATABASE_URL=MISSING\n';
|
|
14
14
|
export const README_PARAGRAPH = '`DATABASE_URL` starts as `MISSING`. While it is, `pnpm dev` offers to create the database first: it asks for a name,\n' +
|
|
15
15
|
'starts Postgres from `docker-compose.yml` on port 5432 or the next free one (kept in `POSTGRES_PORT`), writes the URL\n' +
|
|
16
16
|
'into `.env`, and migrates. `pnpm db:setup` does the same at any time. Without a terminal every answer comes from a\n' +
|
|
@@ -51,7 +51,7 @@ function rulesFor(root, api, templates) {
|
|
|
51
51
|
const rules = [
|
|
52
52
|
devRule('api-dev-check', at('package.json'), API_DEV_PREFIX),
|
|
53
53
|
scriptRule('api-db-setup', at('package.json'), 'Remove the `db:setup` script', 'db:setup', (value) => value === SETUP_SCRIPT ? { status: 'pending' } : { status: 'modified', next: value }),
|
|
54
|
-
textRule('api-env-example', at('.env.example'), 'Put a sample database URL back in `.env.example`', EXAMPLE_BLOCK, `DATABASE_URL=${SAMPLE_URL}\
|
|
54
|
+
textRule('api-env-example', at('.env.example'), 'Put a sample database URL back in `.env.example`', EXAMPLE_BLOCK, `DATABASE_URL=${SAMPLE_URL}\n`, '=MISSING'),
|
|
55
55
|
textRule('api-readme-setup', at('README.md'), 'Replace the README paragraph about `MISSING`', README_PARAGRAPH, README_REPLACEMENT, 'db:setup'),
|
|
56
56
|
textRule('api-readme-row', at('README.md'), 'Remove `db:setup` from the README', README_ROW, '', '`pnpm db:setup`'),
|
|
57
57
|
];
|
package/dist/compose.js
CHANGED
|
@@ -182,7 +182,7 @@ async function assertCatalogCovers(target, catalog) {
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
|
-
export async function composeWorkspace({ templates, target, types,
|
|
185
|
+
export async function composeWorkspace({ templates, target, types, mcp = false, }) {
|
|
186
186
|
await mkdir(target, { recursive: true });
|
|
187
187
|
await copyTemplate(join(templates, 'workspace'), target);
|
|
188
188
|
const apps = [];
|
|
@@ -199,14 +199,6 @@ export async function composeWorkspace({ templates, target, types, mobileScheme
|
|
|
199
199
|
else {
|
|
200
200
|
await rm(join(target, 'packages'), { recursive: true, force: true });
|
|
201
201
|
}
|
|
202
|
-
if (types.includes('api') && types.includes('mobile')) {
|
|
203
|
-
await rewriteText(join(target, 'apps', 'api', '.env.example'), (text) => {
|
|
204
|
-
if (!text.includes('# MOBILE_APP_SCHEME=app\n')) {
|
|
205
|
-
throw new Error('apps/api/.env.example no longer carries the commented MOBILE_APP_SCHEME line');
|
|
206
|
-
}
|
|
207
|
-
return text.replace('# MOBILE_APP_SCHEME=app\n', `MOBILE_APP_SCHEME=${mobileScheme}\n`);
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
202
|
// One command for everything and one per app. Each app answers to `dev`, so the root never needs to know how.
|
|
211
203
|
// The database and the ports are the exception: both may ask a question, which the interleaved output of a
|
|
212
204
|
// parallel run would bury, so the root asks first and each app's own check then finds nothing to do.
|
package/dist/generate.js
CHANGED
|
@@ -24,7 +24,7 @@ async function nameMobileApp(app, name) {
|
|
|
24
24
|
return setJsonc(result, ['expo', 'scheme'], schemeFor(name));
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
|
-
const SECRET_LINE = /^
|
|
27
|
+
const SECRET_LINE = /^JWT_SECRET=.*$/m;
|
|
28
28
|
// `.env` stays out of git, so the example is the committed truth and a fresh project gets a copy it can start with.
|
|
29
29
|
// Only the secret differs: a sample value is public, so every generated API draws its own.
|
|
30
30
|
async function writeLocalEnv(app, type) {
|
|
@@ -35,9 +35,9 @@ async function writeLocalEnv(app, type) {
|
|
|
35
35
|
let env = await readFile(example, 'utf8');
|
|
36
36
|
if (type === 'api') {
|
|
37
37
|
if (!SECRET_LINE.test(env)) {
|
|
38
|
-
throw new Error(`${example} no longer carries a
|
|
38
|
+
throw new Error(`${example} no longer carries a JWT_SECRET line`);
|
|
39
39
|
}
|
|
40
|
-
env = env.replace(SECRET_LINE, `
|
|
40
|
+
env = env.replace(SECRET_LINE, `JWT_SECRET=${randomBytes(32).toString('base64url')}`);
|
|
41
41
|
}
|
|
42
42
|
await writeFile(join(app, '.env'), env);
|
|
43
43
|
}
|
|
@@ -130,7 +130,6 @@ async function write({ templates, knowledge, target, answers, install, childOutp
|
|
|
130
130
|
templates,
|
|
131
131
|
target,
|
|
132
132
|
types: answers.types,
|
|
133
|
-
mobileScheme: schemeFor(answers.name),
|
|
134
133
|
mcp: answers.mcp,
|
|
135
134
|
});
|
|
136
135
|
await nameProject(target, answers.name);
|
package/dist/mcp.js
CHANGED
|
@@ -4,22 +4,29 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { setJsonc } from "./jsonc.js";
|
|
5
5
|
// The api and web templates ship with MCP. A project that answered no receives them without it: these files are
|
|
6
6
|
// deleted, these dependencies removed, and every marked line or block cut. Every other type has nothing to cut.
|
|
7
|
-
|
|
7
|
+
// The API's MCP is its server and the OAuth authorization server that issues its tokens, with the table that
|
|
8
|
+
// holds an authorization in a migration of its own, so a project without MCP never creates it.
|
|
9
|
+
export const FILES = {
|
|
8
10
|
api: [
|
|
9
11
|
'src/infra/mcp',
|
|
12
|
+
'src/domain/oauth',
|
|
13
|
+
'src/infra/oauth',
|
|
14
|
+
'src/infra/di/oauth.di.ts',
|
|
15
|
+
'src/infra/http/fetch-bridge.ts',
|
|
10
16
|
'src/infra/http/controllers/mcp.controller.ts',
|
|
17
|
+
'src/infra/http/controllers/oauth.controller.ts',
|
|
18
|
+
'src/infra/database/mikroorm/entities/authorization.schema.ts',
|
|
19
|
+
'src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts',
|
|
20
|
+
'migrations/Migration20261002210631_create_authorization.ts',
|
|
21
|
+
'test/domain/oauth',
|
|
22
|
+
'test/infra/oauth',
|
|
11
23
|
'test/infra/http/controllers/mcp.controller.spec.ts',
|
|
12
24
|
],
|
|
13
25
|
web: ['src/features/oauth', 'src/routes/_authenticated/consent.tsx', 'test/features/oauth'],
|
|
14
26
|
};
|
|
15
27
|
const DEPENDENCIES = {
|
|
16
|
-
api: [
|
|
17
|
-
|
|
18
|
-
'@better-auth/mcp',
|
|
19
|
-
'@modelcontextprotocol/client',
|
|
20
|
-
'@modelcontextprotocol/server',
|
|
21
|
-
],
|
|
22
|
-
web: ['@better-auth/oauth-provider'],
|
|
28
|
+
api: ['@modelcontextprotocol/client', '@modelcontextprotocol/server'],
|
|
29
|
+
web: [],
|
|
23
30
|
};
|
|
24
31
|
const BLOCK_START = /^\s*\/\/ prumo:mcp$/;
|
|
25
32
|
const BLOCK_END = /^\s*\/\/ prumo:end-mcp$/;
|
package/package.json
CHANGED
|
@@ -4,12 +4,10 @@ LOG_LEVEL=info
|
|
|
4
4
|
|
|
5
5
|
# MISSING until `pnpm db:setup` creates a database; `pnpm dev` offers to run it.
|
|
6
6
|
DATABASE_URL=MISSING
|
|
7
|
-
AUTH_DATABASE_URL=MISSING
|
|
8
7
|
# Read by docker-compose.yml, not by the API: the port Postgres is published on.
|
|
9
8
|
POSTGRES_PORT=5432
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
# Signs the access tokens. `prumo new` writes a random one into .env; never reuse it between environments.
|
|
11
|
+
JWT_SECRET=replace-this-with-at-least-32-characters
|
|
12
|
+
API_URL=http://localhost:3000
|
|
13
13
|
WEB_ORIGIN=http://localhost:5173
|
|
14
|
-
# Set only when a mobile app talks to this API; it enables Better Auth's Expo plugin.
|
|
15
|
-
# MOBILE_APP_SCHEME=app
|
package/templates/api/README.md
CHANGED
|
@@ -11,7 +11,7 @@ pnpm install
|
|
|
11
11
|
pnpm dev
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
`.env` comes with the generated project, copied from `.env.example` with a random `
|
|
14
|
+
`.env` comes with the generated project, copied from `.env.example` with a random `JWT_SECRET`. It is not
|
|
15
15
|
committed, so on a fresh clone run `cp .env.example .env` and replace the secret with 32 or more random characters.
|
|
16
16
|
|
|
17
17
|
`DATABASE_URL` starts as `MISSING`. While it is, `pnpm dev` offers to create the database first: it asks for a name,
|
|
@@ -30,9 +30,8 @@ new port to `.env`, along with every URL that pointed at the old one. Without a
|
|
|
30
30
|
Ctrl+C stops everything `pnpm dev` started, not only what the terminal signals: the script remembers the processes
|
|
31
31
|
it spawned, stops them, and frees the port. Anything it did not start is named and left alone.
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
still completes.
|
|
33
|
+
Authentication is the API's own: `/api/auth/sign-up`, `sign-in`, `refresh`, `sign-out` and `session`. The web
|
|
34
|
+
receives httpOnly cookies; a native client sends `X-Auth-Transport: bearer` and receives the tokens in the body.
|
|
36
35
|
|
|
37
36
|
## Everyday commands
|
|
38
37
|
|
|
@@ -42,7 +41,7 @@ still completes.
|
|
|
42
41
|
| `pnpm lint` | Biome |
|
|
43
42
|
| `pnpm typecheck` | `tsc --noEmit`, also run before every push |
|
|
44
43
|
| `pnpm db:setup` | Creates a development database in Docker, writes its URL into `.env`, and migrates |
|
|
45
|
-
| `pnpm db:migrate` |
|
|
44
|
+
| `pnpm db:migrate` | Runs the migrations |
|
|
46
45
|
| `pnpm db:migration:create` | Generates a migration from the entity diff, to be read and edited before committing |
|
|
47
46
|
|
|
48
47
|
## Conventions
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20261002210629_create_user_profile_session extends Migration {
|
|
4
|
+
|
|
5
|
+
override name = 'Migration20261002210629_create_user_profile_session';
|
|
6
|
+
|
|
7
|
+
override up(): void | Promise<void> {
|
|
8
|
+
this.addSql(`create table "user" ("id" uuid not null default uuidv7(), "email" text not null, "password_hash" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
|
|
9
|
+
this.addSql(`alter table "user" add constraint "user_email_unique" unique ("email");`);
|
|
10
|
+
|
|
11
|
+
this.addSql(`create table "session" ("id" uuid not null default uuidv7(), "user_id" uuid not null, "token_hash" text not null, "client_id" text null, "resource" text null, "previous_token_hash" text null, "rotated_at" timestamptz null, "expires_at" timestamptz not null, "revoked_at" timestamptz null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
|
|
12
|
+
this.addSql(`create index "session_user_id_index" on "session" ("user_id");`);
|
|
13
|
+
|
|
14
|
+
this.addSql(`create table "profile" ("id" uuid not null default uuidv7(), "user_id" uuid not null, "display_name" text not null, "locale" text not null default 'en', "timezone" text not null default 'UTC', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
|
|
15
|
+
this.addSql(`alter table "profile" add constraint "profile_user_id_unique" unique ("user_id");`);
|
|
16
|
+
|
|
17
|
+
this.addSql(`alter table "session" add constraint "session_user_id_foreign" foreign key ("user_id") references "user" ("id") on delete cascade;`);
|
|
18
|
+
|
|
19
|
+
this.addSql(`alter table "profile" add constraint "profile_user_id_foreign" foreign key ("user_id") references "user" ("id") on delete restrict;`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override down(): void | Promise<void> {
|
|
23
|
+
this.addSql(`alter table "session" drop constraint "session_user_id_foreign";`);
|
|
24
|
+
this.addSql(`alter table "profile" drop constraint "profile_user_id_foreign";`);
|
|
25
|
+
|
|
26
|
+
this.addSql(`drop table if exists "user" cascade;`);
|
|
27
|
+
this.addSql(`drop table if exists "session" cascade;`);
|
|
28
|
+
this.addSql(`drop table if exists "profile" cascade;`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20261002210631_create_authorization extends Migration {
|
|
4
|
+
|
|
5
|
+
override name = 'Migration20261002210631_create_authorization';
|
|
6
|
+
|
|
7
|
+
override up(): void | Promise<void> {
|
|
8
|
+
this.addSql(`create table "authorization" ("id" uuid not null default uuidv7(), "client_id" text not null, "client_name" text not null, "redirect_uri" text not null, "code_challenge" text not null, "client_state" text null, "resource" text not null, "status" text not null, "user_id" uuid null, "code_hash" text null, "session_id" uuid null, "expires_at" timestamptz not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
|
|
9
|
+
this.addSql(`create index "authorization_user_id_index" on "authorization" ("user_id");`);
|
|
10
|
+
|
|
11
|
+
this.addSql(`alter table "authorization" add constraint "authorization_user_id_foreign" foreign key ("user_id") references "user" ("id") on delete cascade;`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
override down(): void | Promise<void> {
|
|
15
|
+
this.addSql(`drop table if exists "authorization" cascade;`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
}
|
|
@@ -13,26 +13,24 @@
|
|
|
13
13
|
"test:watch": "vitest",
|
|
14
14
|
"test:cov": "vitest run --coverage",
|
|
15
15
|
"db:setup": "node scripts/database.mjs",
|
|
16
|
-
"db:migrate": "
|
|
16
|
+
"db:migrate": "mikro-orm migration:up",
|
|
17
17
|
"db:migration:create": "mikro-orm migration:create",
|
|
18
18
|
"prepare": "node .githooks/install.mjs"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@
|
|
22
|
-
"@better-auth/core": "1.7.7",
|
|
23
|
-
"@better-auth/expo": "1.7.7",
|
|
24
|
-
"@better-auth/mcp": "1.7.7",
|
|
21
|
+
"@fastify/cookie": "11.1.2",
|
|
25
22
|
"@fastify/cors": "11.3.0",
|
|
23
|
+
"@fastify/rate-limit": "11.2.0",
|
|
26
24
|
"@fastify/swagger": "9.9.0",
|
|
27
25
|
"@fastify/swagger-ui": "6.1.1",
|
|
28
26
|
"@mikro-orm/core": "7.2.0",
|
|
29
27
|
"@mikro-orm/migrations": "7.2.0",
|
|
30
28
|
"@mikro-orm/postgresql": "7.2.0",
|
|
31
29
|
"@modelcontextprotocol/server": "2.2.0",
|
|
32
|
-
"
|
|
30
|
+
"@node-rs/argon2": "2.2.1",
|
|
33
31
|
"fastify": "5.12.5",
|
|
34
32
|
"fastify-type-provider-zod": "7.0.0",
|
|
35
|
-
"
|
|
33
|
+
"jose": "6.2.12",
|
|
36
34
|
"reflect-metadata": "0.2.2",
|
|
37
35
|
"tsyringe": "4.10.0",
|
|
38
36
|
"zod": "4.6.4"
|
|
@@ -48,7 +46,7 @@
|
|
|
48
46
|
"@types/node": "24.13.4",
|
|
49
47
|
"@types/pg": "8.23.1",
|
|
50
48
|
"@vitest/coverage-v8": "4.1.11",
|
|
51
|
-
"
|
|
49
|
+
"pg": "8.23.0",
|
|
52
50
|
"pino-pretty": "13.1.3",
|
|
53
51
|
"typescript": "6.0.3",
|
|
54
52
|
"unplugin-swc": "1.6.0",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// The server is always the one in docker-compose.yml, published on 5432 or, when that port is taken, the next free
|
|
7
7
|
// one, kept in POSTGRES_PORT. It needs no dependency, so it runs before `pnpm install` has installed anything.
|
|
8
8
|
import { spawnSync } from 'node:child_process'
|
|
9
|
-
import { existsSync,
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
10
10
|
import { connect } from 'node:net'
|
|
11
11
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
12
12
|
import { createInterface } from 'node:readline/promises'
|
|
@@ -259,17 +259,9 @@ async function main() {
|
|
|
259
259
|
say(`Created database ${name}.`)
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
// The image runs docker/init only for the database it creates on first start; every other one needs them too.
|
|
263
|
-
const init = join(api, 'docker', 'init')
|
|
264
|
-
for (const file of readdirSync(init)
|
|
265
|
-
.filter((entry) => entry.endsWith('.sql'))
|
|
266
|
-
.sort()) {
|
|
267
|
-
sql(name, readFileSync(join(init, file), 'utf8'))
|
|
268
|
-
}
|
|
269
|
-
|
|
270
262
|
const url = `postgresql://${USER}:${PASSWORD}@localhost:${port}/${name}`
|
|
271
|
-
writeEnv({ DATABASE_URL: url
|
|
272
|
-
say('Wrote DATABASE_URL
|
|
263
|
+
writeEnv({ DATABASE_URL: url })
|
|
264
|
+
say('Wrote DATABASE_URL to .env.')
|
|
273
265
|
|
|
274
266
|
let migrated = false
|
|
275
267
|
|
|
@@ -25,7 +25,7 @@ const APPS = {
|
|
|
25
25
|
// A port that moves takes these with it: the key to write, in which app, from which app's port.
|
|
26
26
|
const FOLLOWERS = {
|
|
27
27
|
api: [
|
|
28
|
-
{ app: 'api', key: '
|
|
28
|
+
{ app: 'api', key: 'API_URL' },
|
|
29
29
|
{ app: 'web', key: 'VITE_API_URL' },
|
|
30
30
|
{ app: 'mobile', key: 'EXPO_PUBLIC_API_URL' },
|
|
31
31
|
],
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
// The web sends its refresh token as a cookie and no body, which Fastify hands over as null; a native client
|
|
4
|
+
// sends it here.
|
|
5
|
+
export const refreshSchema = z
|
|
6
|
+
.strictObject({ refreshToken: z.string().min(1).optional() })
|
|
7
|
+
.nullish()
|
|
8
|
+
|
|
9
|
+
export type RefreshDto = z.infer<typeof refreshSchema>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
// What a native client receives instead of cookies. `expiresIn` is in seconds, as OAuth writes it.
|
|
4
|
+
export const tokensResponseSchema = z.object({
|
|
5
|
+
accessToken: z.string(),
|
|
6
|
+
refreshToken: z.string(),
|
|
7
|
+
expiresIn: z.number().int(),
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export type TokensResponseDto = z.infer<typeof tokensResponseSchema>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// A refresh token is long-lived, so it rotates on every use and is kept only as a hash.
|
|
2
|
+
export const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000
|
|
3
|
+
|
|
4
|
+
// Two tabs refreshing at once both present the same token. The second, arriving within this window, is told to
|
|
5
|
+
// retry instead of being treated as a stolen token.
|
|
6
|
+
export const ROTATION_GRACE_MS = 10 * 1000
|
|
7
|
+
|
|
8
|
+
export type RefreshOutcome = 'valid' | 'superseded' | 'reused' | 'expired'
|
|
9
|
+
|
|
10
|
+
// A session the user started in this application has neither. One an OAuth client obtained names the client and
|
|
11
|
+
// the resource its access tokens are for.
|
|
12
|
+
export type Grant = { clientId: string; resource: string }
|
|
13
|
+
|
|
14
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
15
|
+
|
|
16
|
+
export class Session {
|
|
17
|
+
id: string
|
|
18
|
+
userId: string
|
|
19
|
+
tokenHash: string
|
|
20
|
+
clientId: string | null = null
|
|
21
|
+
resource: string | null = null
|
|
22
|
+
previousTokenHash: string | null = null
|
|
23
|
+
rotatedAt: Date | null = null
|
|
24
|
+
expiresAt: Date
|
|
25
|
+
revokedAt: Date | null = null
|
|
26
|
+
createdAt: Date
|
|
27
|
+
updatedAt: Date
|
|
28
|
+
|
|
29
|
+
constructor(userId: string, tokenHash: string, now: Date, grant?: Grant) {
|
|
30
|
+
this.userId = userId
|
|
31
|
+
this.tokenHash = tokenHash
|
|
32
|
+
this.clientId = grant?.clientId ?? null
|
|
33
|
+
this.resource = grant?.resource ?? null
|
|
34
|
+
this.expiresAt = new Date(now.getTime() + SESSION_LIFETIME_MS)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The refresh token names its session, so a token from any earlier rotation is recognised as reuse.
|
|
38
|
+
static parseRefreshToken(token: string): { sessionId: string; secret: string } | null {
|
|
39
|
+
const dot = token.indexOf('.')
|
|
40
|
+
const sessionId = token.slice(0, dot)
|
|
41
|
+
const secret = token.slice(dot + 1)
|
|
42
|
+
|
|
43
|
+
return dot === -1 || !UUID.test(sessionId) || secret === '' ? null : { sessionId, secret }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
refreshToken(secret: string): string {
|
|
47
|
+
return `${this.id}.${secret}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A token that is neither the current one nor the one just replaced was replaced long ago: someone kept a copy.
|
|
51
|
+
check(tokenHash: string, now: Date): RefreshOutcome {
|
|
52
|
+
if (this.revokedAt !== null || this.expiresAt <= now) {
|
|
53
|
+
return 'expired'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (tokenHash === this.tokenHash) {
|
|
57
|
+
return 'valid'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const justRotated =
|
|
61
|
+
this.rotatedAt !== null && now.getTime() - this.rotatedAt.getTime() < ROTATION_GRACE_MS
|
|
62
|
+
|
|
63
|
+
return tokenHash === this.previousTokenHash && justRotated ? 'superseded' : 'reused'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
rotate(tokenHash: string, now: Date): void {
|
|
67
|
+
this.previousTokenHash = this.tokenHash
|
|
68
|
+
this.tokenHash = tokenHash
|
|
69
|
+
this.rotatedAt = now
|
|
70
|
+
this.expiresAt = new Date(now.getTime() + SESSION_LIFETIME_MS)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
revoke(now: Date): void {
|
|
74
|
+
this.revokedAt ??= now
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class User {
|
|
2
|
+
id: string
|
|
3
|
+
email: string
|
|
4
|
+
passwordHash: string
|
|
5
|
+
createdAt: Date
|
|
6
|
+
updatedAt: Date
|
|
7
|
+
|
|
8
|
+
constructor(email: string, passwordHash: string) {
|
|
9
|
+
this.email = User.normalizeEmail(email)
|
|
10
|
+
this.passwordHash = passwordHash
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// One address, one account: `Ana@Example.com ` and `ana@example.com` are the same person.
|
|
14
|
+
static normalizeEmail(email: string): string {
|
|
15
|
+
return email.trim().toLowerCase()
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DomainError } from '@/domain/shared/errors/domain-error'
|
|
2
|
+
|
|
3
|
+
// One message for an unknown email and a wrong password, so the answer does not say which accounts exist.
|
|
4
|
+
export class InvalidCredentialsError extends DomainError {
|
|
5
|
+
readonly kind = 'unauthorized'
|
|
6
|
+
|
|
7
|
+
constructor() {
|
|
8
|
+
super('Invalid email or password')
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { DomainError } from '@/domain/shared/errors/domain-error'
|
|
2
|
+
|
|
3
|
+
// Another request rotated this token a moment ago. The client already holds the new one, so it retries instead of
|
|
4
|
+
// signing out.
|
|
5
|
+
export class SupersededTokenError extends DomainError {
|
|
6
|
+
readonly kind = 'conflict'
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
super('Refresh token was just rotated; retry with the current one')
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type AccessClaims = { userId: string; sessionId: string; clientId?: string }
|
|
2
|
+
|
|
3
|
+
export type IssuedToken = { token: string; expiresAt: Date }
|
|
4
|
+
|
|
5
|
+
// Short-lived and checked by signature alone: nothing is looked up to accept one, so nothing can revoke one.
|
|
6
|
+
export const ACCESS_TOKEN_LIFETIME_MS = 15 * 60 * 1000
|
|
7
|
+
|
|
8
|
+
// Without an audience a token is for this API's own routes; with one, for that resource alone. Neither is accepted
|
|
9
|
+
// where the other is expected.
|
|
10
|
+
export interface AccessTokens {
|
|
11
|
+
issue(claims: AccessClaims, expiresAt: Date, audience?: string): Promise<IssuedToken>
|
|
12
|
+
verify(token: string, audience?: string): Promise<AccessClaims | null>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const ACCESS_TOKENS = Symbol('AccessTokens')
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface PasswordHasher {
|
|
2
|
+
hash(password: string): Promise<string>
|
|
3
|
+
// Without a hash it still spends the time of a real check and answers false, so an unknown email and a wrong
|
|
4
|
+
// password take as long as each other.
|
|
5
|
+
verify(hash: string | undefined, password: string): Promise<boolean>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const PASSWORD_HASHER = Symbol('PasswordHasher')
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Session } from '@/domain/auth/entities/session.entity'
|
|
2
|
+
|
|
3
|
+
export interface SessionRepository {
|
|
4
|
+
findById(id: string): Promise<Session | null>
|
|
5
|
+
// Reads the row and holds it until the surrounding transaction ends, so two rotations cannot interleave.
|
|
6
|
+
lockById(id: string): Promise<Session | null>
|
|
7
|
+
save(session: Session): Promise<void>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const SESSION_REPOSITORY = Symbol('SessionRepository')
|