@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
@@ -0,0 +1,75 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { Authorization } from '@/domain/oauth/entities/authorization.entity'
3
+ import { AuthorizationRequestError } from '@/domain/oauth/errors/authorization-request.error'
4
+ import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
5
+ import {
6
+ CLIENT_METADATA,
7
+ type ClientMetadataSource,
8
+ } from '@/domain/oauth/ports/client-metadata.port'
9
+ import {
10
+ AUTHORIZATION_REPOSITORY,
11
+ type AuthorizationRepository,
12
+ } from '@/domain/oauth/repositories/authorization.repository'
13
+
14
+ // RFC 7636: 43 to 128 characters from the unreserved set.
15
+ const CODE_CHALLENGE = /^[A-Za-z0-9\-._~]{43,128}$/
16
+
17
+ export type AuthorizeRequest = {
18
+ responseType: string | undefined
19
+ clientId: string
20
+ redirectUri: string
21
+ codeChallenge: string | undefined
22
+ codeChallengeMethod: string | undefined
23
+ clientState: string | null
24
+ resource: string | undefined
25
+ }
26
+
27
+ @injectable()
28
+ export class StartAuthorizationUseCase {
29
+ constructor(
30
+ @inject(CLIENT_METADATA) private readonly clients: ClientMetadataSource,
31
+ @inject(AUTHORIZATION_REPOSITORY) private readonly authorizations: AuthorizationRepository,
32
+ ) {}
33
+
34
+ // The client and its redirect URI are checked first: until both are, no error may be sent anywhere.
35
+ async execute(request: AuthorizeRequest, expectedResource: string): Promise<Authorization> {
36
+ const client = await this.clients.read(request.clientId)
37
+
38
+ // Exact match only: a prefix or a pattern is how an authorization code ends up somewhere else.
39
+ if (!client.redirectUris.includes(request.redirectUri)) {
40
+ throw new InvalidClientError('redirect_uri is not one the client registered')
41
+ }
42
+
43
+ if (request.responseType !== 'code') {
44
+ throw new AuthorizationRequestError('unsupported_response_type', 'Only response_type=code')
45
+ }
46
+
47
+ if (
48
+ request.codeChallengeMethod !== 'S256' ||
49
+ !CODE_CHALLENGE.test(request.codeChallenge ?? '')
50
+ ) {
51
+ throw new AuthorizationRequestError('invalid_request', 'PKCE with S256 is required')
52
+ }
53
+
54
+ // RFC 8707: the token is for one resource, and this server has one.
55
+ if (request.resource !== expectedResource) {
56
+ throw new AuthorizationRequestError('invalid_target', `resource must be ${expectedResource}`)
57
+ }
58
+
59
+ const authorization = new Authorization(
60
+ {
61
+ clientId: request.clientId,
62
+ clientName: client.clientName,
63
+ redirectUri: request.redirectUri,
64
+ codeChallenge: request.codeChallenge as string,
65
+ clientState: request.clientState,
66
+ resource: expectedResource,
67
+ },
68
+ new Date(),
69
+ )
70
+
71
+ await this.authorizations.save(authorization)
72
+
73
+ return authorization
74
+ }
75
+ }
@@ -0,0 +1,21 @@
1
+ import { hash, verify } from '@node-rs/argon2'
2
+ import type { PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
3
+
4
+ // The library's defaults are Argon2id with 19 MiB, two passes and one lane: OWASP's minimum, so none is restated.
5
+ export class Argon2PasswordHasher implements PasswordHasher {
6
+ // A real hash of nothing in particular, so an unknown email costs one verification like any other.
7
+ private readonly decoy = hash('decoy password that nobody holds')
8
+
9
+ hash(password: string): Promise<string> {
10
+ return hash(password)
11
+ }
12
+
13
+ async verify(stored: string | undefined, password: string): Promise<boolean> {
14
+ if (stored === undefined) {
15
+ await verify(await this.decoy, password)
16
+ return false
17
+ }
18
+
19
+ return verify(stored, password)
20
+ }
21
+ }
@@ -0,0 +1,14 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import type { OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
3
+
4
+ // 256 random bits need no slow hash: nothing can be guessed, so a plain SHA-256 is enough to keep a stolen
5
+ // database from handing out working tokens.
6
+ export class CryptoOpaqueTokens implements OpaqueTokens {
7
+ create(): string {
8
+ return randomBytes(32).toString('base64url')
9
+ }
10
+
11
+ digest(token: string): string {
12
+ return createHash('sha256').update(token).digest('base64url')
13
+ }
14
+ }
@@ -0,0 +1,62 @@
1
+ import { jwtVerify, SignJWT } from 'jose'
2
+ import type {
3
+ AccessClaims,
4
+ AccessTokens,
5
+ IssuedToken,
6
+ } from '@/domain/auth/ports/access-tokens.port'
7
+ import type { Env } from '@/infra/config/env'
8
+
9
+ // One algorithm, named on both sides: a token that declares any other, `none` included, is refused.
10
+ const ALGORITHM = 'HS256'
11
+
12
+ export class JoseAccessTokens implements AccessTokens {
13
+ private readonly key: Uint8Array
14
+ private readonly issuer: string
15
+
16
+ constructor(env: Pick<Env, 'JWT_SECRET' | 'API_URL'>) {
17
+ this.key = new TextEncoder().encode(env.JWT_SECRET)
18
+ this.issuer = env.API_URL
19
+ }
20
+
21
+ async issue(
22
+ { userId, sessionId, clientId }: AccessClaims,
23
+ expiresAt: Date,
24
+ audience = this.issuer,
25
+ ): Promise<IssuedToken> {
26
+ const token = await new SignJWT({
27
+ sid: sessionId,
28
+ ...(clientId !== undefined && { client_id: clientId }),
29
+ })
30
+ .setProtectedHeader({ alg: ALGORITHM })
31
+ .setSubject(userId)
32
+ .setIssuer(this.issuer)
33
+ .setAudience(audience)
34
+ .setIssuedAt()
35
+ .setExpirationTime(Math.floor(expiresAt.getTime() / 1000))
36
+ .sign(this.key)
37
+
38
+ return { token, expiresAt }
39
+ }
40
+
41
+ async verify(token: string, audience = this.issuer): Promise<AccessClaims | null> {
42
+ try {
43
+ const { payload } = await jwtVerify(token, this.key, {
44
+ algorithms: [ALGORITHM],
45
+ issuer: this.issuer,
46
+ audience,
47
+ })
48
+
49
+ if (typeof payload.sub !== 'string' || typeof payload.sid !== 'string') {
50
+ return null
51
+ }
52
+
53
+ return {
54
+ userId: payload.sub,
55
+ sessionId: payload.sid,
56
+ ...(typeof payload.client_id === 'string' && { clientId: payload.client_id }),
57
+ }
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+ }
@@ -5,14 +5,10 @@ const envSchema = z.object({
5
5
  NODE_ENV: z.enum(['development', 'test', 'production']),
6
6
  PORT: z.coerce.number().int().min(1).max(65535),
7
7
  DATABASE_URL: z.string().min(1),
8
- AUTH_DATABASE_URL: z.string().min(1),
9
- BETTER_AUTH_SECRET: z.string().min(32),
10
- BETTER_AUTH_URL: z.string().min(1),
8
+ JWT_SECRET: z.string().min(32),
9
+ // Where this API is reached from outside: the issuer of its tokens, and what decides whether cookies are Secure.
10
+ API_URL: z.url(),
11
11
  WEB_ORIGIN: z.string().min(1),
12
- MOBILE_APP_SCHEME: z
13
- .string()
14
- .regex(/^[a-z][a-z0-9+.-]*$/)
15
- .optional(),
16
12
  LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']),
17
13
  })
18
14
 
@@ -0,0 +1,37 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { Authorization } from '@/domain/oauth/entities/authorization.entity'
3
+
4
+ export const AuthorizationSchema = new EntitySchema({
5
+ class: Authorization,
6
+ properties: {
7
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
8
+ clientId: { type: 'text' },
9
+ clientName: { type: 'text' },
10
+ redirectUri: { type: 'text' },
11
+ codeChallenge: { type: 'text' },
12
+ clientState: { type: 'text', nullable: true },
13
+ resource: { type: 'text' },
14
+ status: { type: 'text' },
15
+ userId: {
16
+ kind: 'm:1',
17
+ // EntitySchema cannot type a to-one relation held as its primary key on a string property.
18
+ entity: () => 'User' as never,
19
+ mapToPk: true,
20
+ fieldName: 'user_id',
21
+ nullable: true,
22
+ index: true,
23
+ // An authorization means nothing without the user who gave it.
24
+ deleteRule: 'cascade',
25
+ },
26
+ codeHash: { type: 'text', nullable: true },
27
+ sessionId: { type: 'uuid', nullable: true },
28
+ expiresAt: { type: 'datetime' },
29
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
30
+ updatedAt: {
31
+ type: 'datetime',
32
+ onCreate: () => new Date(),
33
+ onUpdate: () => new Date(),
34
+ defaultRaw: 'now()',
35
+ },
36
+ },
37
+ })
@@ -8,7 +8,7 @@ export const ProfileSchema = new EntitySchema({
8
8
  userId: {
9
9
  kind: 'm:1',
10
10
  // EntitySchema cannot type a to-one relation held as its primary key on a string property.
11
- entity: () => 'AuthUser' as never,
11
+ entity: () => 'User' as never,
12
12
  mapToPk: true,
13
13
  fieldName: 'user_id',
14
14
  unique: true,
@@ -0,0 +1,33 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { Session } from '@/domain/auth/entities/session.entity'
3
+
4
+ export const SessionSchema = new EntitySchema({
5
+ class: Session,
6
+ properties: {
7
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
8
+ userId: {
9
+ kind: 'm:1',
10
+ // EntitySchema cannot type a to-one relation held as its primary key on a string property.
11
+ entity: () => 'User' as never,
12
+ mapToPk: true,
13
+ fieldName: 'user_id',
14
+ index: true,
15
+ // A session means nothing without its user.
16
+ deleteRule: 'cascade',
17
+ },
18
+ tokenHash: { type: 'text' },
19
+ clientId: { type: 'text', nullable: true },
20
+ resource: { type: 'text', nullable: true },
21
+ previousTokenHash: { type: 'text', nullable: true },
22
+ rotatedAt: { type: 'datetime', nullable: true },
23
+ expiresAt: { type: 'datetime' },
24
+ revokedAt: { type: 'datetime', nullable: true },
25
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
26
+ updatedAt: {
27
+ type: 'datetime',
28
+ onCreate: () => new Date(),
29
+ onUpdate: () => new Date(),
30
+ defaultRaw: 'now()',
31
+ },
32
+ },
33
+ })
@@ -0,0 +1,18 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { User } from '@/domain/auth/entities/user.entity'
3
+
4
+ export const UserSchema = new EntitySchema({
5
+ class: User,
6
+ properties: {
7
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
8
+ email: { type: 'text', unique: true },
9
+ passwordHash: { type: 'text' },
10
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
11
+ updatedAt: {
12
+ type: 'datetime',
13
+ onCreate: () => new Date(),
14
+ onUpdate: () => new Date(),
15
+ defaultRaw: 'now()',
16
+ },
17
+ },
18
+ })
@@ -1,17 +1,22 @@
1
1
  import { Migrator } from '@mikro-orm/migrations'
2
2
  import { defineConfig } from '@mikro-orm/postgresql'
3
3
  import type { Env } from '@/infra/config/env'
4
- import { AuthUserSchema } from './entities/auth-user.schema'
4
+ import { AuthorizationSchema } from './entities/authorization.schema' // prumo:mcp
5
5
  import { ProfileSchema } from './entities/profile.schema'
6
+ import { SessionSchema } from './entities/session.schema'
7
+ import { UserSchema } from './entities/user.schema'
6
8
 
7
9
  export function createOrmConfig(env: Pick<Env, 'DATABASE_URL' | 'NODE_ENV'>) {
8
10
  return defineConfig({
9
11
  clientUrl: env.DATABASE_URL,
10
- entities: [ProfileSchema, AuthUserSchema],
12
+ entities: [
13
+ UserSchema,
14
+ ProfileSchema,
15
+ SessionSchema,
16
+ AuthorizationSchema, // prumo:mcp
17
+ ],
11
18
  extensions: [Migrator],
12
19
  migrations: { path: './migrations', snapshot: false },
13
- // Better Auth migrates its own schema; without this, every generated migration drops it.
14
- schemaGenerator: { ignoreSchema: ['auth'], skipTables: ['auth.user'] },
15
20
  debug: env.NODE_ENV === 'development',
16
21
  })
17
22
  }
@@ -0,0 +1,21 @@
1
+ import { EntityManager, LockMode } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { Authorization } from '@/domain/oauth/entities/authorization.entity'
4
+ import type { AuthorizationRepository } from '@/domain/oauth/repositories/authorization.repository'
5
+
6
+ @injectable()
7
+ export class MikroOrmAuthorizationRepository implements AuthorizationRepository {
8
+ constructor(private readonly em: EntityManager) {}
9
+
10
+ findById(id: string): Promise<Authorization | null> {
11
+ return this.em.findOne(Authorization, { id })
12
+ }
13
+
14
+ lockById(id: string): Promise<Authorization | null> {
15
+ return this.em.findOne(Authorization, { id }, { lockMode: LockMode.PESSIMISTIC_WRITE })
16
+ }
17
+
18
+ async save(authorization: Authorization): Promise<void> {
19
+ await this.em.persist(authorization).flush()
20
+ }
21
+ }
@@ -0,0 +1,21 @@
1
+ import { EntityManager, LockMode } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { Session } from '@/domain/auth/entities/session.entity'
4
+ import type { SessionRepository } from '@/domain/auth/repositories/session.repository'
5
+
6
+ @injectable()
7
+ export class MikroOrmSessionRepository implements SessionRepository {
8
+ constructor(private readonly em: EntityManager) {}
9
+
10
+ findById(id: string): Promise<Session | null> {
11
+ return this.em.findOne(Session, { id })
12
+ }
13
+
14
+ lockById(id: string): Promise<Session | null> {
15
+ return this.em.findOne(Session, { id }, { lockMode: LockMode.PESSIMISTIC_WRITE })
16
+ }
17
+
18
+ async save(session: Session): Promise<void> {
19
+ await this.em.persist(session).flush()
20
+ }
21
+ }
@@ -0,0 +1,31 @@
1
+ import { EntityManager, UniqueConstraintViolationException } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
5
+ import type { UserRepository } from '@/domain/auth/repositories/user.repository'
6
+
7
+ @injectable()
8
+ export class MikroOrmUserRepository implements UserRepository {
9
+ constructor(private readonly em: EntityManager) {}
10
+
11
+ findById(id: string): Promise<User | null> {
12
+ return this.em.findOne(User, { id })
13
+ }
14
+
15
+ findByEmail(email: string): Promise<User | null> {
16
+ return this.em.findOne(User, { email })
17
+ }
18
+
19
+ async save(user: User): Promise<void> {
20
+ try {
21
+ await this.em.persist(user).flush()
22
+ } catch (error) {
23
+ // Two sign-ups with one email can both pass the check; the unique index settles which one exists.
24
+ if (error instanceof UniqueConstraintViolationException) {
25
+ throw new EmailTakenError()
26
+ }
27
+
28
+ throw error
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,21 @@
1
+ import type { DependencyContainer } from 'tsyringe'
2
+ import { ACCESS_TOKENS } from '@/domain/auth/ports/access-tokens.port'
3
+ import { OPAQUE_TOKENS } from '@/domain/auth/ports/opaque-tokens.port'
4
+ import { PASSWORD_HASHER } from '@/domain/auth/ports/password-hasher.port'
5
+ import { SESSION_REPOSITORY } from '@/domain/auth/repositories/session.repository'
6
+ import { USER_REPOSITORY } from '@/domain/auth/repositories/user.repository'
7
+ import { Argon2PasswordHasher } from '@/infra/auth/argon2-password-hasher.adapter'
8
+ import { CryptoOpaqueTokens } from '@/infra/auth/crypto-opaque-tokens.adapter'
9
+ import { JoseAccessTokens } from '@/infra/auth/jose-access-tokens.adapter'
10
+ import { ENV, type Env } from '@/infra/config/env'
11
+ import { MikroOrmSessionRepository } from '@/infra/database/mikroorm/repositories/mikroorm-session.repository'
12
+ import { MikroOrmUserRepository } from '@/infra/database/mikroorm/repositories/mikroorm-user.repository'
13
+
14
+ export function registerAuth(container: DependencyContainer): void {
15
+ container.register(USER_REPOSITORY, { useClass: MikroOrmUserRepository })
16
+ container.register(SESSION_REPOSITORY, { useClass: MikroOrmSessionRepository })
17
+ // One instance each: the hasher keeps its decoy hash, the signer its key.
18
+ container.register(PASSWORD_HASHER, { useValue: new Argon2PasswordHasher() })
19
+ container.register(ACCESS_TOKENS, { useValue: new JoseAccessTokens(container.resolve<Env>(ENV)) })
20
+ container.register(OPAQUE_TOKENS, { useValue: new CryptoOpaqueTokens() })
21
+ }
@@ -1,10 +1,10 @@
1
1
  import { EntityManager, MikroORM } from '@mikro-orm/postgresql'
2
2
  import { type DependencyContainer, container as root } from 'tsyringe'
3
3
  import { TRANSACTION_MANAGER } from '@/domain/shared/transactions/transaction-manager'
4
- import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
5
- import { AUTH, createAuth } from '@/infra/auth/auth.factory'
6
4
  import { ENV, type Env } from '@/infra/config/env'
7
5
  import { MikroOrmTransactionManager } from '@/infra/database/mikroorm/transactions/mikroorm-transaction-manager'
6
+ import { registerAuth } from './auth.di'
7
+ import { registerOauth } from './oauth.di' // prumo:mcp
8
8
  import { registerUsers } from './users.di'
9
9
 
10
10
  export type Dependencies = { env: Env; orm: MikroORM }
@@ -18,15 +18,9 @@ export function createContainer({ env, orm }: Dependencies): DependencyContainer
18
18
  container.register(EntityManager, { useValue: orm.em })
19
19
  container.register(TRANSACTION_MANAGER, { useClass: MikroOrmTransactionManager })
20
20
 
21
+ registerAuth(container)
21
22
  registerUsers(container)
22
-
23
- container.register(AUTH, {
24
- useValue: createAuth(env, {
25
- onUserCreated: async (user) => {
26
- await container.resolve(CreateProfileUseCase).execute(user.id, user.name)
27
- },
28
- }),
29
- })
23
+ registerOauth(container) // prumo:mcp
30
24
 
31
25
  return container
32
26
  }
@@ -0,0 +1,14 @@
1
+ import type { DependencyContainer } from 'tsyringe'
2
+ import { AUTHORIZATION_CODES } from '@/domain/oauth/ports/authorization-codes.port'
3
+ import { CLIENT_METADATA } from '@/domain/oauth/ports/client-metadata.port'
4
+ import { AUTHORIZATION_REPOSITORY } from '@/domain/oauth/repositories/authorization.repository'
5
+ import { MikroOrmAuthorizationRepository } from '@/infra/database/mikroorm/repositories/mikroorm-authorization.repository'
6
+ import { CryptoAuthorizationCodes } from '@/infra/oauth/crypto-authorization-codes.adapter'
7
+ import { HttpsClientMetadata } from '@/infra/oauth/https-client-metadata.adapter'
8
+
9
+ export function registerOauth(container: DependencyContainer): void {
10
+ container.register(AUTHORIZATION_REPOSITORY, { useClass: MikroOrmAuthorizationRepository })
11
+ container.register(AUTHORIZATION_CODES, { useValue: new CryptoAuthorizationCodes() })
12
+ // One instance, so its cache of client documents outlives a request.
13
+ container.register(CLIENT_METADATA, { useValue: new HttpsClientMetadata() })
14
+ }
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto'
2
+ import cookie from '@fastify/cookie'
2
3
  import cors from '@fastify/cors'
4
+ import rateLimit from '@fastify/rate-limit'
3
5
  import swagger from '@fastify/swagger'
4
6
  import swaggerUi from '@fastify/swagger-ui'
5
7
  import { EntityManager } from '@mikro-orm/postgresql'
@@ -14,9 +16,11 @@ import { ENV, type Env } from '@/infra/config/env'
14
16
  import { authController } from '@/infra/http/controllers/auth.controller'
15
17
  import { healthController } from '@/infra/http/controllers/health.controller'
16
18
  import { mcpController } from '@/infra/http/controllers/mcp.controller' // prumo:mcp
19
+ import { oauthController } from '@/infra/http/controllers/oauth.controller' // prumo:mcp
17
20
  import { usersController } from '@/infra/http/controllers/users.controller'
18
21
  import { registerErrorHandler } from '@/infra/http/errors/error-handler'
19
22
  import { registerAuthHook } from '@/infra/http/hooks/auth.hook'
23
+ import { registerOriginCheck } from '@/infra/http/hooks/origin.hook'
20
24
  import { registerRequestContext } from '@/infra/http/hooks/request-context.hook'
21
25
 
22
26
  const DOCS = '/api/docs'
@@ -82,16 +86,22 @@ export async function buildApp(container: DependencyContainer): Promise<FastifyI
82
86
  exposedHeaders: ['X-Request-Id'],
83
87
  })
84
88
 
89
+ await app.register(cookie)
90
+ // Off by default: a route opts in through its config, so only the routes that take a password are limited.
91
+ await app.register(rateLimit, { global: false })
92
+ registerOriginCheck(app, env.WEB_ORIGIN)
93
+
85
94
  if (env.NODE_ENV !== 'production') {
86
95
  await registerDocs(app)
87
96
  }
88
97
 
89
98
  registerAuthHook(app)
90
99
 
91
- await app.register(authController)
100
+ await app.register(authController, { prefix: '/api/auth' })
92
101
  await app.register(healthController, { prefix: '/api/health' })
93
102
  await app.register(usersController, { prefix: '/api/v1/users' })
94
103
  // prumo:mcp
104
+ await app.register(oauthController)
95
105
  await app.register(mcpController, { prefix: '/api/mcp' })
96
106
  // prumo:end-mcp
97
107