@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.
Files changed (163) hide show
  1. package/.prumo-templates/api/auth.md +50 -60
  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 +2 -2
  16. package/dist/clean.js +2 -2
  17. package/dist/compose.js +1 -9
  18. package/dist/generate.js +3 -4
  19. package/dist/mcp.js +15 -8
  20. package/package.json +1 -1
  21. package/templates/api/.env.example +3 -5
  22. package/templates/api/README.md +4 -5
  23. package/templates/api/docker-compose.yml +0 -1
  24. package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
  25. package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
  26. package/templates/api/package.json +6 -8
  27. package/templates/api/scripts/database.mjs +3 -11
  28. package/templates/api/scripts/ports.mjs +1 -1
  29. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  30. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  31. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  32. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  33. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  34. package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
  35. package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
  36. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  37. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  38. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  39. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  40. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  41. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  42. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  43. package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
  44. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  45. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  46. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
  47. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
  48. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  49. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
  50. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
  51. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  52. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
  53. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
  54. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  55. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
  56. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  57. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  58. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  59. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  60. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  61. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  62. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
  63. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  64. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
  65. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  66. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  67. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
  68. package/templates/api/src/infra/config/env.ts +3 -7
  69. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
  70. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  71. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
  72. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
  73. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
  74. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  75. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
  76. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  77. package/templates/api/src/infra/di/auth.di.ts +21 -0
  78. package/templates/api/src/infra/di/index.ts +4 -10
  79. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  80. package/templates/api/src/infra/http/app.ts +11 -1
  81. package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
  82. package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
  83. package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
  84. package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
  85. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  86. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  87. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  88. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  89. package/templates/api/src/infra/http/server.ts +1 -4
  90. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  91. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  92. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  93. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  94. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  95. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  96. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
  97. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
  98. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  99. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  100. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  101. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  102. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
  103. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
  104. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
  105. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  106. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  107. package/templates/api/test/support/factories/user.factory.ts +9 -11
  108. package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
  109. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  110. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  111. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  112. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  113. package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
  114. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  115. package/templates/api/test/support/setup.ts +6 -28
  116. package/templates/mobile/README.md +1 -1
  117. package/templates/mobile/jest.config.js +0 -17
  118. package/templates/mobile/package.json +0 -4
  119. package/templates/mobile/scripts/ports.mjs +1 -1
  120. package/templates/mobile/src/api-contract/api-error.ts +0 -11
  121. package/templates/mobile/src/api-contract/auth.ts +161 -0
  122. package/templates/mobile/src/api-contract/index.ts +10 -0
  123. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  124. package/templates/mobile/src/app/_layout.tsx +5 -5
  125. package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
  126. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  127. package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
  128. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  129. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  130. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  131. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  132. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  133. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  134. package/templates/site/scripts/ports.mjs +1 -1
  135. package/templates/web/package.json +0 -3
  136. package/templates/web/scripts/ports.mjs +1 -1
  137. package/templates/web/src/api-contract/api-error.ts +0 -11
  138. package/templates/web/src/api-contract/auth.ts +161 -0
  139. package/templates/web/src/api-contract/index.ts +10 -0
  140. package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
  141. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  142. package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
  143. package/templates/web/src/features/oauth/consent-form.tsx +66 -35
  144. package/templates/web/src/main.tsx +6 -4
  145. package/templates/web/src/router.tsx +1 -2
  146. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  147. package/templates/web/src/routes/_authenticated.tsx +1 -1
  148. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  149. package/templates/web/test/app.spec.tsx +47 -22
  150. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  151. package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
  152. package/templates/web/test/support/render-app.tsx +4 -4
  153. package/templates/workspace/scripts/ports.mjs +1 -1
  154. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  155. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  156. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  157. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  158. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  159. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  160. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  161. package/templates/mobile/src/features/auth/session.ts +0 -17
  162. package/templates/web/src/features/auth/auth-client.ts +0 -17
  163. 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
- Use Better Auth's `organization` plugin with `activeOrganizationId` **persisted in the session**. Do not
9
- manage the active organization client-side.
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
- session.
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
- Run Better Auth's migrations before ours.
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
- Better Auth's own documentation offers managing the active organization client-side, for multiple tabs. For
36
- a multi-tenant system that means the **tab** decides isolation, so it is refused here by name: somebody
37
- will find that sentence.
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 library
52
- CLI cascading it away.
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 = session.activeOrganizationId
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, Better Auth in a schema of its own, Swagger UI, Vitest with Testcontainers |
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 session kept in `expo-secure-store` |
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\nAUTH_DATABASE_URL=MISSING\n';
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}\nAUTH_DATABASE_URL=${SAMPLE_URL}\n`, '=MISSING'),
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, mobileScheme = 'app', mcp = false, }) {
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 = /^BETTER_AUTH_SECRET=.*$/m;
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 BETTER_AUTH_SECRET line`);
38
+ throw new Error(`${example} no longer carries a JWT_SECRET line`);
39
39
  }
40
- env = env.replace(SECRET_LINE, `BETTER_AUTH_SECRET=${randomBytes(32).toString('base64url')}`);
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
- const FILES = {
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
- '@better-auth/cimd',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stjoseph/prumo",
3
- "version": "0.2.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",
@@ -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
@@ -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,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": "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.7",
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
- "better-auth": "1.7.7",
30
+ "@node-rs/argon2": "2.2.1",
33
31
  "fastify": "5.12.5",
34
32
  "fastify-type-provider-zod": "7.0.0",
35
- "pg": "8.23.0",
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
- "auth": "1.7.7",
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, 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')