@stjoseph/prumo 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/.prumo-templates/api/auth.md +50 -48
  2. package/.prumo-templates/api/config.md +2 -2
  3. package/.prumo-templates/api/routes.md +6 -4
  4. package/.prumo-templates/api/structure.md +4 -1
  5. package/.prumo-templates/client/data.md +16 -18
  6. package/.prumo-templates/client/forms.md +2 -2
  7. package/.prumo-templates/core/testing.md +4 -5
  8. package/.prumo-templates/database/testing.md +6 -8
  9. package/.prumo-templates/mcp/server.md +39 -12
  10. package/.prumo-templates/mobile/routing.md +1 -1
  11. package/.prumo-templates/mobile/storage.md +7 -6
  12. package/.prumo-templates/monorepo/layout.md +4 -4
  13. package/.prumo-templates/multi-tenancy/client.md +6 -6
  14. package/.prumo-templates/multi-tenancy/isolation.md +17 -10
  15. package/README.md +19 -14
  16. package/dist/clean.js +13 -3
  17. package/dist/cli.js +38 -9
  18. package/dist/commands.js +7 -4
  19. package/dist/compose.js +4 -12
  20. package/dist/doctor.js +31 -17
  21. package/dist/generate.js +56 -9
  22. package/dist/mcp.js +15 -8
  23. package/dist/names.js +42 -0
  24. package/dist/questions.js +32 -16
  25. package/package.json +2 -2
  26. package/templates/api/.env.example +3 -5
  27. package/templates/api/README.md +5 -6
  28. package/templates/api/docker-compose.yml +0 -1
  29. package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
  30. package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
  31. package/templates/api/package.json +7 -8
  32. package/templates/api/scripts/database.mjs +3 -11
  33. package/templates/api/scripts/ports.mjs +1 -1
  34. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  35. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  36. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  37. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  38. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  39. package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
  40. package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
  41. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  42. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  43. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  44. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  45. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  46. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  47. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  48. package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
  49. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  50. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  51. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
  52. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
  53. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  54. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
  55. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
  56. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  57. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
  58. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
  59. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  60. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
  61. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  62. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  63. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  64. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  65. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  66. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  67. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
  68. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  69. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
  70. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  71. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  72. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
  73. package/templates/api/src/infra/config/env.ts +3 -7
  74. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
  75. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  76. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
  77. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
  78. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
  79. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  80. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
  81. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  82. package/templates/api/src/infra/di/auth.di.ts +21 -0
  83. package/templates/api/src/infra/di/index.ts +4 -10
  84. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  85. package/templates/api/src/infra/http/app.ts +11 -1
  86. package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
  87. package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
  88. package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
  89. package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
  90. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  91. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  92. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  93. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  94. package/templates/api/src/infra/http/server.ts +1 -4
  95. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  96. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  97. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  98. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  99. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  100. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  101. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
  102. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
  103. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  104. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  105. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  106. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  107. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
  108. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
  109. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
  110. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  111. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  112. package/templates/api/test/support/factories/user.factory.ts +9 -11
  113. package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
  114. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  115. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  116. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  117. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  118. package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
  119. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  120. package/templates/api/test/support/setup.ts +6 -28
  121. package/templates/mobile/README.md +1 -1
  122. package/templates/mobile/jest.config.js +0 -17
  123. package/templates/mobile/package.json +1 -4
  124. package/templates/mobile/scripts/ports.mjs +1 -1
  125. package/templates/mobile/src/api-contract/api-error.ts +0 -11
  126. package/templates/mobile/src/api-contract/auth.ts +161 -0
  127. package/templates/mobile/src/api-contract/index.ts +10 -0
  128. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  129. package/templates/mobile/src/app/_layout.tsx +5 -5
  130. package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
  131. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  132. package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
  133. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  134. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  135. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  136. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  137. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  138. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  139. package/templates/site/README.md +1 -1
  140. package/templates/site/package.json +1 -1
  141. package/templates/site/scripts/ports.mjs +1 -1
  142. package/templates/web/README.md +1 -1
  143. package/templates/web/package.json +1 -3
  144. package/templates/web/scripts/ports.mjs +1 -1
  145. package/templates/web/src/api-contract/api-error.ts +0 -11
  146. package/templates/web/src/api-contract/auth.ts +161 -0
  147. package/templates/web/src/api-contract/index.ts +10 -0
  148. package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
  149. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  150. package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
  151. package/templates/web/src/features/oauth/consent-form.tsx +66 -35
  152. package/templates/web/src/main.tsx +6 -4
  153. package/templates/web/src/router.tsx +1 -2
  154. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  155. package/templates/web/src/routes/_authenticated.tsx +1 -1
  156. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  157. package/templates/web/test/app.spec.tsx +47 -22
  158. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  159. package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
  160. package/templates/web/test/support/render-app.tsx +4 -4
  161. package/templates/workspace/README.md +1 -1
  162. package/templates/workspace/package.json +1 -1
  163. package/templates/workspace/scripts/ports.mjs +1 -1
  164. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  165. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  166. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  167. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  168. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  169. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  170. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  171. package/templates/mobile/src/features/auth/session.ts +0 -17
  172. package/templates/web/src/features/auth/auth-client.ts +0 -17
  173. package/templates/web/src/features/auth/session.ts +0 -17
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.split(',').map((type) => type.trim());
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 (unknown.length > 0 || types.length === 0) {
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 name = flags.name ?? (asker === undefined ? missing('the project name') : await asker.name());
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
- throw new CliError('invalid_input', `Invalid project name "${name}". ${invalid}`);
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 || flags.singleTenant) {
57
- multiTenant = flags.multiTenant;
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.1.1",
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.17.0"
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
- BETTER_AUTH_SECRET=replace-this-with-at-least-32-characters
12
- BETTER_AUTH_URL=http://localhost:3000
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Requirements
4
4
 
5
- Node 22.17 or later, pnpm, and Docker Desktop running: the database and the tests both run in it.
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 `BETTER_AUTH_SECRET`. It is not
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
- `pnpm db:migrate` builds first, then runs Better Auth's migrations and then the application's. Before
34
- migrating, Better Auth logs `Database schema mismatch` because its tables do not exist yet; the migration
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` | Builds, then runs Better Auth's migrations and the application's |
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
@@ -12,7 +12,6 @@ services:
12
12
  - '${POSTGRES_PORT:-5432}:5432'
13
13
  volumes:
14
14
  - postgres:/var/lib/postgresql
15
- - ./docker/init:/docker-entrypoint-initdb.d:ro
16
15
  healthcheck:
17
16
  test: ['CMD-SHELL', 'pg_isready -U app']
18
17
  interval: 5s
@@ -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": "pnpm build && auth migrate --config dist/infra/auth/auth.cli.js --yes && mikro-orm migration:up",
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
- "@better-auth/cimd": "1.7.6",
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
- "better-auth": "1.7.6",
30
+ "@node-rs/argon2": "2.2.1",
32
31
  "fastify": "5.12.5",
33
32
  "fastify-type-provider-zod": "7.0.0",
34
- "pg": "8.23.0",
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
- "auth": "1.7.6",
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.17.0"
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, readdirSync, readFileSync, writeFileSync } from 'node:fs'
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, AUTH_DATABASE_URL: url })
272
- say('Wrote DATABASE_URL and AUTH_DATABASE_URL to .env.')
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: 'BETTER_AUTH_URL' },
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,7 @@
1
+ import { z } from 'zod'
2
+
3
+ export const sessionResponseSchema = z.object({
4
+ user: z.object({ id: z.uuid(), email: z.string() }),
5
+ })
6
+
7
+ export type SessionResponseDto = z.infer<typeof sessionResponseSchema>
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod'
2
+
3
+ export const signInSchema = z.strictObject({
4
+ email: z.email().max(254),
5
+ password: z.string().min(1).max(128),
6
+ })
7
+
8
+ export type SignInDto = z.infer<typeof signInSchema>
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod'
2
+
3
+ export const signUpSchema = z.strictObject({
4
+ name: z.string().trim().min(1).max(80),
5
+ email: z.email().max(254),
6
+ password: z.string().min(8).max(128),
7
+ })
8
+
9
+ export type SignUpDto = z.infer<typeof signUpSchema>
@@ -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,9 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ export class EmailTakenError extends DomainError {
4
+ readonly kind = 'conflict'
5
+
6
+ constructor() {
7
+ super('An account with this email already exists')
8
+ }
9
+ }
@@ -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,9 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ export class InvalidSessionError extends DomainError {
4
+ readonly kind = 'unauthorized'
5
+
6
+ constructor(message = 'Session expired or revoked') {
7
+ super(message)
8
+ }
9
+ }
@@ -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,7 @@
1
+ // Random values that are handed out once and stored only as a digest.
2
+ export interface OpaqueTokens {
3
+ create(): string
4
+ digest(token: string): string
5
+ }
6
+
7
+ export const OPAQUE_TOKENS = Symbol('OpaqueTokens')
@@ -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
+ }