@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
package/dist/questions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateProjectName } from "./names.js";
|
|
1
|
+
import { projectAt, targetProblem, validateProjectName } from "./names.js";
|
|
2
2
|
import { CliError } from "./output.js";
|
|
3
3
|
const TYPES = ['api', 'web', 'mobile', 'site'];
|
|
4
4
|
const TENANT_AWARE = ['api', 'web', 'mobile'];
|
|
@@ -6,14 +6,20 @@ function missing(flag) {
|
|
|
6
6
|
throw new CliError('needs_input', `Missing ${flag}. Outside an interactive terminal every answer must come from a flag.`);
|
|
7
7
|
}
|
|
8
8
|
function parseTypes(value) {
|
|
9
|
-
const types = value
|
|
9
|
+
const types = value
|
|
10
|
+
.split(',')
|
|
11
|
+
.map((type) => type.trim())
|
|
12
|
+
.filter((type) => type !== '');
|
|
10
13
|
const unknown = types.filter((type) => !TYPES.includes(type));
|
|
11
|
-
if (
|
|
14
|
+
if (types.length === 0) {
|
|
15
|
+
throw new CliError('invalid_input', `--types is empty. Choose from ${TYPES.join(', ')}.`);
|
|
16
|
+
}
|
|
17
|
+
if (unknown.length > 0) {
|
|
12
18
|
throw new CliError('invalid_input', `Unknown type: ${unknown.join(', ')}. Choose from ${TYPES.join(', ')}.`);
|
|
13
19
|
}
|
|
14
20
|
return [...new Set(types)];
|
|
15
21
|
}
|
|
16
|
-
export async function resolveAnswers(flags, asker) {
|
|
22
|
+
export async function resolveAnswers(flags, asker, cwd = process.cwd()) {
|
|
17
23
|
if (flags.alone && flags.monorepo) {
|
|
18
24
|
throw new CliError('invalid_input', 'Choose --alone or --monorepo, not both.');
|
|
19
25
|
}
|
|
@@ -23,10 +29,17 @@ export async function resolveAnswers(flags, asker) {
|
|
|
23
29
|
if (flags.multiTenant && flags.singleTenant) {
|
|
24
30
|
throw new CliError('invalid_input', 'Choose --multi-tenant or --single-tenant, not both.');
|
|
25
31
|
}
|
|
26
|
-
const
|
|
32
|
+
const input = flags.name ?? (asker === undefined ? missing('the project name') : await asker.name());
|
|
33
|
+
const { name, target } = projectAt(input, cwd);
|
|
27
34
|
const invalid = validateProjectName(name);
|
|
28
35
|
if (invalid !== undefined) {
|
|
29
|
-
|
|
36
|
+
const which = input === '.' ? ', taken from this directory' : '';
|
|
37
|
+
throw new CliError('invalid_input', `Invalid project name "${name}"${which}. ${invalid}`);
|
|
38
|
+
}
|
|
39
|
+
// Before the other questions, so nobody answers them all for a directory that cannot take the project.
|
|
40
|
+
const occupied = targetProblem(target);
|
|
41
|
+
if (occupied !== undefined) {
|
|
42
|
+
throw new CliError('target_not_empty', occupied);
|
|
30
43
|
}
|
|
31
44
|
let types;
|
|
32
45
|
if (flags.types !== undefined) {
|
|
@@ -52,17 +65,20 @@ export async function resolveAnswers(flags, asker) {
|
|
|
52
65
|
architecture = await asker.architecture();
|
|
53
66
|
}
|
|
54
67
|
let multiTenant = false;
|
|
55
|
-
if (types.some((type) => TENANT_AWARE.includes(type))) {
|
|
56
|
-
if (flags.multiTenant
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
else if (asker !== undefined) {
|
|
60
|
-
multiTenant = await asker.multiTenant();
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
missing('--multi-tenant or --single-tenant');
|
|
68
|
+
if (!types.some((type) => TENANT_AWARE.includes(type))) {
|
|
69
|
+
if (flags.multiTenant) {
|
|
70
|
+
throw new CliError('invalid_input', `--multi-tenant needs one of ${TENANT_AWARE.join(', ')} in --types.`);
|
|
64
71
|
}
|
|
65
72
|
}
|
|
73
|
+
else if (flags.multiTenant || flags.singleTenant) {
|
|
74
|
+
multiTenant = flags.multiTenant;
|
|
75
|
+
}
|
|
76
|
+
else if (asker !== undefined) {
|
|
77
|
+
multiTenant = await asker.multiTenant();
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
missing('--multi-tenant or --single-tenant');
|
|
81
|
+
}
|
|
66
82
|
// MCP authorizes through a sign-in and a consent page, which only a web app can serve.
|
|
67
83
|
const mcpCapable = types.includes('api') && types.includes('web');
|
|
68
84
|
let mcp = false;
|
|
@@ -80,5 +96,5 @@ export async function resolveAnswers(flags, asker) {
|
|
|
80
96
|
else {
|
|
81
97
|
missing('--mcp or --no-mcp');
|
|
82
98
|
}
|
|
83
|
-
return { name, types, architecture, multiTenant, mcp };
|
|
99
|
+
return { name, types, architecture, multiTenant, mcp, target };
|
|
84
100
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stjoseph/prumo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Scaffolds a TypeScript project and ships it the conventions it follows, in a .prumo/ folder any AI assistant reads.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"scaffolding",
|
|
@@ -52,6 +52,6 @@
|
|
|
52
52
|
"vitest": "4.1.11"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
|
55
|
-
"node": ">=22.
|
|
55
|
+
"node": ">=22.18.0"
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Requirements
|
|
4
4
|
|
|
5
|
-
Node 22.
|
|
5
|
+
Node 22.18 or later, pnpm 10.26 or later, and Docker Desktop running: the database and the tests both run in it.
|
|
6
6
|
|
|
7
7
|
## Running it
|
|
8
8
|
|
|
@@ -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,25 +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/expo": "1.7.6",
|
|
23
|
-
"@better-auth/mcp": "1.7.6",
|
|
21
|
+
"@fastify/cookie": "11.1.2",
|
|
24
22
|
"@fastify/cors": "11.3.0",
|
|
23
|
+
"@fastify/rate-limit": "11.2.0",
|
|
25
24
|
"@fastify/swagger": "9.9.0",
|
|
26
25
|
"@fastify/swagger-ui": "6.1.1",
|
|
27
26
|
"@mikro-orm/core": "7.2.0",
|
|
28
27
|
"@mikro-orm/migrations": "7.2.0",
|
|
29
28
|
"@mikro-orm/postgresql": "7.2.0",
|
|
30
29
|
"@modelcontextprotocol/server": "2.2.0",
|
|
31
|
-
"
|
|
30
|
+
"@node-rs/argon2": "2.2.1",
|
|
32
31
|
"fastify": "5.12.5",
|
|
33
32
|
"fastify-type-provider-zod": "7.0.0",
|
|
34
|
-
"
|
|
33
|
+
"jose": "6.2.12",
|
|
35
34
|
"reflect-metadata": "0.2.2",
|
|
36
35
|
"tsyringe": "4.10.0",
|
|
37
36
|
"zod": "4.6.4"
|
|
@@ -47,14 +46,14 @@
|
|
|
47
46
|
"@types/node": "24.13.4",
|
|
48
47
|
"@types/pg": "8.23.1",
|
|
49
48
|
"@vitest/coverage-v8": "4.1.11",
|
|
50
|
-
"
|
|
49
|
+
"pg": "8.23.0",
|
|
51
50
|
"pino-pretty": "13.1.3",
|
|
52
51
|
"typescript": "6.0.3",
|
|
53
52
|
"unplugin-swc": "1.6.0",
|
|
54
53
|
"vitest": "4.1.11"
|
|
55
54
|
},
|
|
56
55
|
"engines": {
|
|
57
|
-
"node": ">=22.
|
|
56
|
+
"node": ">=22.18.0"
|
|
58
57
|
},
|
|
59
58
|
"mikro-orm": {
|
|
60
59
|
"configPaths": [
|
|
@@ -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')
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { User } from '@/domain/auth/entities/user.entity'
|
|
2
|
+
|
|
3
|
+
export interface UserRepository {
|
|
4
|
+
findById(id: string): Promise<User | null>
|
|
5
|
+
findByEmail(email: string): Promise<User | null>
|
|
6
|
+
save(user: User): Promise<void>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const USER_REPOSITORY = Symbol('UserRepository')
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import type { User } from '@/domain/auth/entities/user.entity'
|
|
3
|
+
import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
|
|
4
|
+
import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
|
|
5
|
+
|
|
6
|
+
@injectable()
|
|
7
|
+
export class FindUserUseCase {
|
|
8
|
+
constructor(@inject(USER_REPOSITORY) private readonly users: UserRepository) {}
|
|
9
|
+
|
|
10
|
+
async execute(userId: string): Promise<User> {
|
|
11
|
+
const user = await this.users.findById(userId)
|
|
12
|
+
|
|
13
|
+
// A valid token for a user who no longer exists is a session that should have ended.
|
|
14
|
+
if (user === null) {
|
|
15
|
+
throw new InvalidSessionError()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return user
|
|
19
|
+
}
|
|
20
|
+
}
|