@stjoseph/prumo 0.0.6 → 0.1.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 (139) hide show
  1. package/.prumo-templates/api/auth.md +51 -95
  2. package/.prumo-templates/api/config.md +30 -56
  3. package/.prumo-templates/api/errors.md +64 -44
  4. package/.prumo-templates/api/logging.md +27 -44
  5. package/.prumo-templates/api/observability.md +36 -62
  6. package/.prumo-templates/api/pagination.md +23 -38
  7. package/.prumo-templates/api/routes.md +67 -48
  8. package/.prumo-templates/api/structure.md +130 -0
  9. package/.prumo-templates/client/data.md +4 -3
  10. package/.prumo-templates/core/conventions.md +10 -7
  11. package/.prumo-templates/core/testing.md +43 -26
  12. package/.prumo-templates/core/tooling.md +33 -30
  13. package/.prumo-templates/database/entities.md +53 -61
  14. package/.prumo-templates/database/queries.md +34 -47
  15. package/.prumo-templates/database/transactions.md +28 -38
  16. package/.prumo-templates/mcp/server.md +54 -0
  17. package/.prumo-templates/monorepo/dependencies.md +3 -3
  18. package/.prumo-templates/monorepo/layout.md +1 -1
  19. package/README.md +6 -6
  20. package/dist/asker.js +4 -0
  21. package/dist/cli.js +4 -0
  22. package/dist/commands.js +2 -0
  23. package/dist/compose.js +6 -2
  24. package/dist/context.js +2 -0
  25. package/dist/generate.js +8 -0
  26. package/dist/mcp.js +100 -0
  27. package/dist/questions.js +21 -1
  28. package/package.json +4 -3
  29. package/templates/api/.swcrc +13 -0
  30. package/templates/api/biome.jsonc +5 -3
  31. package/templates/api/package.json +21 -27
  32. package/templates/api/pnpm-workspace.yaml +0 -2
  33. package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
  34. package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
  35. package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
  36. package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
  37. package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
  38. package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
  39. package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
  40. package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
  41. package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
  42. package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
  43. package/templates/api/src/infra/auth/auth.cli.ts +5 -0
  44. package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
  45. package/templates/api/src/infra/config/env.ts +47 -0
  46. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
  47. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
  48. package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
  49. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
  50. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
  51. package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
  52. package/templates/api/src/infra/di/index.ts +32 -0
  53. package/templates/api/src/infra/di/users.di.ts +7 -0
  54. package/templates/api/src/infra/http/app.ts +99 -0
  55. package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
  56. package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
  57. package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
  58. package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
  59. package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
  60. package/templates/api/src/infra/http/errors/http-error.ts +8 -0
  61. package/templates/api/src/infra/http/fastify.d.ts +17 -0
  62. package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
  63. package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
  64. package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
  65. package/templates/api/src/infra/http/server.ts +34 -0
  66. package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
  67. package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
  68. package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
  69. package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
  70. package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
  71. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
  72. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
  73. package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
  74. package/templates/api/test/infra/http/app.spec.ts +21 -0
  75. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
  76. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
  77. package/templates/api/test/support/factories/profile.factory.ts +25 -0
  78. package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
  79. package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
  80. package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
  81. package/templates/api/tsconfig.json +3 -6
  82. package/templates/api/vitest.config.mts +4 -4
  83. package/templates/mobile/jest.config.js +1 -1
  84. package/templates/mobile/package.json +2 -2
  85. package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
  86. package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  87. package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
  88. package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  89. package/templates/mobile/tsconfig.json +2 -1
  90. package/templates/site/{src → test}/app/page.spec.ts +1 -1
  91. package/templates/site/tsconfig.json +2 -1
  92. package/templates/site/vitest.config.mts +4 -2
  93. package/templates/web/package.json +4 -2
  94. package/templates/web/src/features/auth/auth-client.ts +5 -0
  95. package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
  96. package/templates/web/src/features/oauth/consent-form.tsx +63 -0
  97. package/templates/web/src/routeTree.gen.ts +21 -2
  98. package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
  99. package/templates/web/{src → test}/app.spec.tsx +2 -2
  100. package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  101. package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
  102. package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  103. package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
  104. package/templates/web/tsconfig.json +2 -1
  105. package/templates/web/vite.config.ts +3 -3
  106. package/.prumo-templates/api/modules.md +0 -117
  107. package/templates/api/nest-cli.json +0 -10
  108. package/templates/api/src/app.module.ts +0 -46
  109. package/templates/api/src/auth/auth.constants.ts +0 -2
  110. package/templates/api/src/auth/auth.controller.ts +0 -19
  111. package/templates/api/src/auth/auth.guard.ts +0 -43
  112. package/templates/api/src/auth/auth.module.ts +0 -30
  113. package/templates/api/src/auth/auth.ts +0 -4
  114. package/templates/api/src/auth/current-user.decorator.ts +0 -9
  115. package/templates/api/src/auth/express.d.ts +0 -10
  116. package/templates/api/src/auth/public.decorator.ts +0 -4
  117. package/templates/api/src/config/env.module.ts +0 -11
  118. package/templates/api/src/config/env.ts +0 -81
  119. package/templates/api/src/errors/problem-details.filter.ts +0 -86
  120. package/templates/api/src/errors/validation-exception.factory.ts +0 -30
  121. package/templates/api/src/health/health.controller.ts +0 -32
  122. package/templates/api/src/health/health.module.ts +0 -9
  123. package/templates/api/src/main.ts +0 -43
  124. package/templates/api/src/mikro-orm.factory.ts +0 -14
  125. package/templates/api/src/request-context/request-context.module.ts +0 -9
  126. package/templates/api/src/request-context/request-context.service.ts +0 -17
  127. package/templates/api/src/request-context/request-id.middleware.ts +0 -19
  128. package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
  129. package/templates/api/src/users/entities/profile.entity.ts +0 -27
  130. package/templates/api/src/users/users.controller.ts +0 -23
  131. package/templates/api/src/users/users.module.ts +0 -10
  132. package/templates/api/src/users/users.service.spec.ts +0 -44
  133. package/templates/api/src/users/users.service.ts +0 -36
  134. package/templates/api/test/factories/profile.factory.ts +0 -26
  135. package/templates/api/tsconfig.build.json +0 -8
  136. /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
  137. /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  138. /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  139. /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
@@ -0,0 +1,22 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { Profile } from '@/domain/users/entities/profile.entity'
3
+ import { ProfileNotFoundError } from '@/domain/users/errors/profile-not-found.error'
4
+ import {
5
+ PROFILE_REPOSITORY,
6
+ type ProfileRepository,
7
+ } from '@/domain/users/repositories/profile.repository'
8
+
9
+ @injectable()
10
+ export class FindProfileUseCase {
11
+ constructor(@inject(PROFILE_REPOSITORY) private readonly profiles: ProfileRepository) {}
12
+
13
+ async execute(userId: string): Promise<Profile> {
14
+ const profile = await this.profiles.findByUserId(userId)
15
+
16
+ if (profile === null) {
17
+ throw new ProfileNotFoundError()
18
+ }
19
+
20
+ return profile
21
+ }
22
+ }
@@ -0,0 +1,26 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { UpdateProfileDto } from '@/domain/users/dto/update-profile.dto'
3
+ import type { Profile } from '@/domain/users/entities/profile.entity'
4
+ import { ProfileNotFoundError } from '@/domain/users/errors/profile-not-found.error'
5
+ import {
6
+ PROFILE_REPOSITORY,
7
+ type ProfileRepository,
8
+ } from '@/domain/users/repositories/profile.repository'
9
+
10
+ @injectable()
11
+ export class UpdateProfileUseCase {
12
+ constructor(@inject(PROFILE_REPOSITORY) private readonly profiles: ProfileRepository) {}
13
+
14
+ async execute(userId: string, changes: UpdateProfileDto): Promise<Profile> {
15
+ const profile = await this.profiles.findByUserId(userId)
16
+
17
+ if (profile === null) {
18
+ throw new ProfileNotFoundError()
19
+ }
20
+
21
+ profile.update(changes)
22
+ await this.profiles.save(profile)
23
+
24
+ return profile
25
+ }
26
+ }
@@ -0,0 +1,5 @@
1
+ import { loadEnv } from '@/infra/config/env'
2
+ import { createAuth } from './auth.factory'
3
+
4
+ // Read by Better Auth's CLI (`auth migrate --config`), which only needs the schema, not the hooks.
5
+ export const auth = createAuth(loadEnv())
@@ -1,7 +1,11 @@
1
+ import { cimd } from '@better-auth/cimd' // prumo:mcp
2
+ import { fetchClientMetadataResource } from '@better-auth/cimd/node' // prumo:mcp
1
3
  import { expo } from '@better-auth/expo'
4
+ import { mcp } from '@better-auth/mcp' // prumo:mcp
2
5
  import { type BetterAuthPlugin, betterAuth } from 'better-auth'
6
+ import { jwt } from 'better-auth/plugins' // prumo:mcp
3
7
  import { Pool } from 'pg'
4
- import type { Env } from '@/config/env'
8
+ import type { Env } from '@/infra/config/env'
5
9
 
6
10
  type NewUser = { id: string; name: string }
7
11
 
@@ -9,6 +13,15 @@ export type AuthHooks = {
9
13
  onUserCreated?: (user: NewUser) => Promise<void>
10
14
  }
11
15
 
16
+ export const AUTH = Symbol('Auth')
17
+
18
+ // prumo:mcp
19
+ // The protected resource MCP clients ask a token for; access tokens carry it as their audience.
20
+ export function mcpResource(env: Pick<Env, 'BETTER_AUTH_URL'>): string {
21
+ return `${env.BETTER_AUTH_URL}/api/mcp`
22
+ }
23
+
24
+ // prumo:end-mcp
12
25
  export function createAuth(
13
26
  env: Pick<
14
27
  Env,
@@ -23,6 +36,18 @@ export function createAuth(
23
36
  const mobile = env.MOBILE_APP_SCHEME
24
37
  const plugins: BetterAuthPlugin[] = mobile === undefined ? [] : [expo()]
25
38
 
39
+ // prumo:mcp
40
+ plugins.push(
41
+ jwt(),
42
+ mcp({
43
+ loginPage: `${env.WEB_ORIGIN}/sign-in`,
44
+ consentPage: `${env.WEB_ORIGIN}/consent`,
45
+ resource: mcpResource(env),
46
+ }),
47
+ cimd({ fetchClientMetadataResource, metadataProfile: 'mcp-2026-07-28' }),
48
+ )
49
+
50
+ // prumo:end-mcp
26
51
  return betterAuth({
27
52
  baseURL: env.BETTER_AUTH_URL,
28
53
  basePath: '/api/auth',
@@ -0,0 +1,47 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { z } from 'zod'
3
+
4
+ const envSchema = z.object({
5
+ NODE_ENV: z.enum(['development', 'test', 'production']),
6
+ PORT: z.coerce.number().int().min(1).max(65535),
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),
11
+ WEB_ORIGIN: z.string().min(1),
12
+ MOBILE_APP_SCHEME: z
13
+ .string()
14
+ .regex(/^[a-z][a-z0-9+.-]*$/)
15
+ .optional(),
16
+ LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']),
17
+ })
18
+
19
+ export type Env = z.infer<typeof envSchema>
20
+
21
+ export const ENV = Symbol('Env')
22
+
23
+ export function validate(raw: Record<string, unknown>): Env {
24
+ const result = envSchema.safeParse(raw)
25
+
26
+ if (!result.success) {
27
+ const named = [...new Set(result.error.issues.map((issue) => issue.path.join('.')))].join(', ')
28
+ throw new Error(`Invalid environment: ${named}`)
29
+ }
30
+
31
+ loaded = result.data
32
+ return result.data
33
+ }
34
+
35
+ let loaded: Env | undefined
36
+
37
+ export function loadEnv(): Env {
38
+ if (loaded !== undefined) {
39
+ return loaded
40
+ }
41
+
42
+ if (process.env.NODE_ENV !== 'production' && existsSync('.env')) {
43
+ process.loadEnvFile('.env')
44
+ }
45
+
46
+ return validate(process.env)
47
+ }
@@ -0,0 +1,16 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+
3
+ // Better Auth owns auth.user. This maps only its id, so a foreign key into it is declared rather than
4
+ // hand-written; schema diffing skips the table, and nothing reads or writes it through the ORM.
5
+ export class AuthUser {
6
+ id: string
7
+ }
8
+
9
+ export const AuthUserSchema = new EntitySchema({
10
+ class: AuthUser,
11
+ schema: 'auth',
12
+ tableName: 'user',
13
+ properties: {
14
+ id: { type: 'uuid', primary: true },
15
+ },
16
+ })
@@ -0,0 +1,28 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { Profile } from '@/domain/users/entities/profile.entity'
3
+
4
+ export const ProfileSchema = new EntitySchema({
5
+ class: Profile,
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: () => 'AuthUser' as never,
12
+ mapToPk: true,
13
+ fieldName: 'user_id',
14
+ unique: true,
15
+ deleteRule: 'restrict',
16
+ },
17
+ displayName: { type: 'text' },
18
+ locale: { type: 'text', default: 'en' },
19
+ timezone: { type: 'text', default: 'UTC' },
20
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
21
+ updatedAt: {
22
+ type: 'datetime',
23
+ onCreate: () => new Date(),
24
+ onUpdate: () => new Date(),
25
+ defaultRaw: 'now()',
26
+ },
27
+ },
28
+ })
@@ -1,4 +1,4 @@
1
- import { loadEnv } from './config/env'
1
+ import { loadEnv } from '@/infra/config/env'
2
2
  import { createOrmConfig } from './mikro-orm.factory'
3
3
 
4
4
  export default createOrmConfig(loadEnv())
@@ -0,0 +1,17 @@
1
+ import { Migrator } from '@mikro-orm/migrations'
2
+ import { defineConfig } from '@mikro-orm/postgresql'
3
+ import type { Env } from '@/infra/config/env'
4
+ import { AuthUserSchema } from './entities/auth-user.schema'
5
+ import { ProfileSchema } from './entities/profile.schema'
6
+
7
+ export function createOrmConfig(env: Pick<Env, 'DATABASE_URL' | 'NODE_ENV'>) {
8
+ return defineConfig({
9
+ clientUrl: env.DATABASE_URL,
10
+ entities: [ProfileSchema, AuthUserSchema],
11
+ extensions: [Migrator],
12
+ 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
+ debug: env.NODE_ENV === 'development',
16
+ })
17
+ }
@@ -0,0 +1,17 @@
1
+ import { EntityManager } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { Profile } from '@/domain/users/entities/profile.entity'
4
+ import type { ProfileRepository } from '@/domain/users/repositories/profile.repository'
5
+
6
+ @injectable()
7
+ export class MikroOrmProfileRepository implements ProfileRepository {
8
+ constructor(private readonly em: EntityManager) {}
9
+
10
+ findByUserId(userId: string): Promise<Profile | null> {
11
+ return this.em.findOne(Profile, { userId })
12
+ }
13
+
14
+ async save(profile: Profile): Promise<void> {
15
+ await this.em.persist(profile).flush()
16
+ }
17
+ }
@@ -0,0 +1,14 @@
1
+ import { EntityManager } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import type { TransactionManager } from '@/domain/shared/transactions/transaction-manager'
4
+
5
+ @injectable()
6
+ export class MikroOrmTransactionManager implements TransactionManager {
7
+ constructor(private readonly em: EntityManager) {}
8
+
9
+ // Inside the callback the global EntityManager resolves to the transaction's fork, so every
10
+ // repository that injected it joins the transaction without being handed anything.
11
+ run<T>(work: () => Promise<T>): Promise<T> {
12
+ return this.em.transactional(() => work())
13
+ }
14
+ }
@@ -0,0 +1,32 @@
1
+ import { EntityManager, MikroORM } from '@mikro-orm/postgresql'
2
+ import { type DependencyContainer, container as root } from 'tsyringe'
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
+ import { ENV, type Env } from '@/infra/config/env'
7
+ import { MikroOrmTransactionManager } from '@/infra/database/mikroorm/transactions/mikroorm-transaction-manager'
8
+ import { registerUsers } from './users.di'
9
+
10
+ export type Dependencies = { env: Env; orm: MikroORM }
11
+
12
+ export function createContainer({ env, orm }: Dependencies): DependencyContainer {
13
+ const container = root.createChildContainer()
14
+
15
+ container.register(ENV, { useValue: env })
16
+ container.register(MikroORM, { useValue: orm })
17
+ // The global EntityManager: inside a request it resolves to that request's fork by itself.
18
+ container.register(EntityManager, { useValue: orm.em })
19
+ container.register(TRANSACTION_MANAGER, { useClass: MikroOrmTransactionManager })
20
+
21
+ 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
+ })
30
+
31
+ return container
32
+ }
@@ -0,0 +1,7 @@
1
+ import type { DependencyContainer } from 'tsyringe'
2
+ import { PROFILE_REPOSITORY } from '@/domain/users/repositories/profile.repository'
3
+ import { MikroOrmProfileRepository } from '@/infra/database/mikroorm/repositories/mikroorm-profile.repository'
4
+
5
+ export function registerUsers(container: DependencyContainer): void {
6
+ container.register(PROFILE_REPOSITORY, { useClass: MikroOrmProfileRepository })
7
+ }
@@ -0,0 +1,99 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import cors from '@fastify/cors'
3
+ import swagger from '@fastify/swagger'
4
+ import swaggerUi from '@fastify/swagger-ui'
5
+ import { EntityManager } from '@mikro-orm/postgresql'
6
+ import Fastify, { type FastifyInstance } from 'fastify'
7
+ import {
8
+ jsonSchemaTransform,
9
+ serializerCompiler,
10
+ validatorCompiler,
11
+ } from 'fastify-type-provider-zod'
12
+ import type { DependencyContainer } from 'tsyringe'
13
+ import { ENV, type Env } from '@/infra/config/env'
14
+ import { authController } from '@/infra/http/controllers/auth.controller'
15
+ import { healthController } from '@/infra/http/controllers/health.controller'
16
+ import { mcpController } from '@/infra/http/controllers/mcp.controller' // prumo:mcp
17
+ import { usersController } from '@/infra/http/controllers/users.controller'
18
+ import { registerErrorHandler } from '@/infra/http/errors/error-handler'
19
+ import { registerAuthHook } from '@/infra/http/hooks/auth.hook'
20
+ import { registerRequestContext } from '@/infra/http/hooks/request-context.hook'
21
+
22
+ const DOCS = '/api/docs'
23
+ const VERSIONED = '/api/v'
24
+
25
+ function logger(env: Env) {
26
+ if (env.NODE_ENV === 'test') {
27
+ return false
28
+ }
29
+
30
+ return {
31
+ level: env.LOG_LEVEL,
32
+ redact: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
33
+ transport: env.NODE_ENV === 'development' ? { target: 'pino-pretty' } : undefined,
34
+ }
35
+ }
36
+
37
+ function requireResponseSchemas(app: FastifyInstance): void {
38
+ // Without a response schema Fastify falls back to JSON.stringify, and every column of the entity leaves.
39
+ app.addHook('onRoute', (route) => {
40
+ if (route.url.startsWith(VERSIONED) && route.schema?.response === undefined) {
41
+ throw new Error(`${String(route.method)} ${route.url} declares no response schema`)
42
+ }
43
+ })
44
+ }
45
+
46
+ async function registerDocs(app: FastifyInstance): Promise<void> {
47
+ // The UI's own routes are declared by the plugin, which cannot be told they are public.
48
+ app.addHook('onRoute', (route) => {
49
+ if (route.url.startsWith(DOCS)) {
50
+ route.config = { ...route.config, public: true }
51
+ }
52
+ })
53
+
54
+ await app.register(swagger, {
55
+ openapi: { info: { title: 'API', version: '1' } },
56
+ transform: jsonSchemaTransform,
57
+ })
58
+ await app.register(swaggerUi, { routePrefix: DOCS })
59
+ }
60
+
61
+ export async function buildApp(container: DependencyContainer): Promise<FastifyInstance> {
62
+ const env = container.resolve<Env>(ENV)
63
+ const app = Fastify({
64
+ logger: logger(env),
65
+ requestIdHeader: 'x-request-id',
66
+ genReqId: () => randomUUID(),
67
+ })
68
+
69
+ app.decorate('container', container)
70
+ app.setValidatorCompiler(validatorCompiler)
71
+ app.setSerializerCompiler(serializerCompiler)
72
+
73
+ registerErrorHandler(app)
74
+ requireResponseSchemas(app)
75
+ registerRequestContext(app, container.resolve(EntityManager))
76
+
77
+ await app.register(cors, {
78
+ origin: env.WEB_ORIGIN,
79
+ credentials: true,
80
+ // Without a list, only the CORS-safelisted GET, HEAD and POST pass a preflight.
81
+ methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
82
+ exposedHeaders: ['X-Request-Id'],
83
+ })
84
+
85
+ if (env.NODE_ENV !== 'production') {
86
+ await registerDocs(app)
87
+ }
88
+
89
+ registerAuthHook(app)
90
+
91
+ await app.register(authController)
92
+ await app.register(healthController, { prefix: '/api/health' })
93
+ await app.register(usersController, { prefix: '/api/v1/users' })
94
+ // prumo:mcp
95
+ await app.register(mcpController, { prefix: '/api/mcp' })
96
+ // prumo:end-mcp
97
+
98
+ return app
99
+ }
@@ -0,0 +1,36 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+ import { AUTH, type Auth } from '@/infra/auth/auth.factory'
3
+ import { sendFetchResponse, toFetchRequest } from '@/infra/http/fetch-bridge'
4
+
5
+ export async function authController(fastify: FastifyInstance): Promise<void> {
6
+ const auth = fastify.container.resolve<Auth>(AUTH)
7
+ const routes = { config: { public: true }, schema: { hide: true } } as const
8
+
9
+ // prumo:mcp
10
+ // OAuth's token endpoint takes a form. It is kept as text and handed to Better Auth as sent.
11
+ fastify.addContentTypeParser(
12
+ 'application/x-www-form-urlencoded',
13
+ { parseAs: 'string' },
14
+ (_request, body, done) => done(null, body),
15
+ )
16
+ // prumo:end-mcp
17
+
18
+ fastify.route({
19
+ ...routes,
20
+ method: ['GET', 'POST'],
21
+ url: '/api/auth/*',
22
+ handler: async (request, reply) =>
23
+ sendFetchResponse(reply, await auth.handler(toFetchRequest(request))),
24
+ })
25
+
26
+ // prumo:mcp
27
+ // OAuth discovery lives at the root, outside Better Auth's base path, and still reaches its handler.
28
+ fastify.route({
29
+ ...routes,
30
+ method: 'GET',
31
+ url: '/.well-known/*',
32
+ handler: async (request, reply) =>
33
+ sendFetchResponse(reply, await auth.handler(toFetchRequest(request))),
34
+ })
35
+ // prumo:end-mcp
36
+ }
@@ -0,0 +1,19 @@
1
+ import { MikroORM } from '@mikro-orm/postgresql'
2
+ import type { FastifyInstance } from 'fastify'
3
+
4
+ export async function healthController(fastify: FastifyInstance): Promise<void> {
5
+ const orm = fastify.container.resolve(MikroORM)
6
+ const quiet = { config: { public: true }, logLevel: 'silent' } as const
7
+
8
+ fastify.get('/live', quiet, async () => ({ status: 'ok' }))
9
+
10
+ fastify.get('/ready', quiet, async (_request, reply) => {
11
+ const database = await orm.checkConnection()
12
+
13
+ if (!database.ok) {
14
+ return reply.code(503).send({ status: 'error', error: { database: { status: 'down' } } })
15
+ }
16
+
17
+ return { status: 'ok', info: { database: { status: 'up' } } }
18
+ })
19
+ }
@@ -0,0 +1,40 @@
1
+ import { requireMcpAuth } from '@better-auth/mcp'
2
+ import type { AuthInfo } from '@modelcontextprotocol/server'
3
+ import type { FastifyInstance } from 'fastify'
4
+ import { AUTH, type Auth, mcpResource } from '@/infra/auth/auth.factory'
5
+ import { ENV, type Env } from '@/infra/config/env'
6
+ import { sendFetchResponse, toFetchRequest } from '@/infra/http/fetch-bridge'
7
+ import { createMcpServerHandler } from '@/infra/mcp/mcp.server'
8
+
9
+ type AccessTokenClaims = Parameters<Parameters<typeof requireMcpAuth>[1]>[1]
10
+
11
+ function authInfoOf(request: Request, claims: AccessTokenClaims): AuthInfo {
12
+ const scope = typeof claims.scope === 'string' ? claims.scope : ''
13
+ const clientId = claims.azp ?? claims.client_id
14
+
15
+ return {
16
+ token: request.headers.get('authorization')?.replace(/^\w+\s+/, '') ?? '',
17
+ clientId: typeof clientId === 'string' ? clientId : '',
18
+ scopes: scope.split(' ').filter(Boolean),
19
+ ...(claims.exp === undefined ? {} : { expiresAt: claims.exp }),
20
+ extra: { userId: claims.sub },
21
+ }
22
+ }
23
+
24
+ // The route answers a bearer access token, never a session cookie, so the session hook stands aside.
25
+ export async function mcpController(fastify: FastifyInstance): Promise<void> {
26
+ const auth = fastify.container.resolve<Auth>(AUTH)
27
+ const env = fastify.container.resolve<Env>(ENV)
28
+ const mcp = createMcpServerHandler(fastify.container)
29
+ const handle = requireMcpAuth(
30
+ auth,
31
+ (request, claims) => mcp.fetch(request, { authInfo: authInfoOf(request, claims) }),
32
+ { resource: mcpResource(env) },
33
+ )
34
+
35
+ fastify.addHook('onClose', () => mcp.close())
36
+
37
+ fastify.post('/', { config: { public: true }, schema: { hide: true } }, async (request, reply) =>
38
+ sendFetchResponse(reply, await handle(toFetchRequest(request))),
39
+ )
40
+ }
@@ -0,0 +1,35 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+ import type { ZodTypeProvider } from 'fastify-type-provider-zod'
3
+ import { profileResponseSchema } from '@/domain/users/dto/profile-response.dto'
4
+ import { updateProfileSchema } from '@/domain/users/dto/update-profile.dto'
5
+ import { FindProfileUseCase } from '@/domain/users/use-cases/find-profile.use-case'
6
+ import { UpdateProfileUseCase } from '@/domain/users/use-cases/update-profile.use-case'
7
+ import { currentUser } from '@/infra/http/hooks/auth.hook'
8
+
9
+ export async function usersController(fastify: FastifyInstance): Promise<void> {
10
+ const app = fastify.withTypeProvider<ZodTypeProvider>()
11
+
12
+ app.get(
13
+ '/me',
14
+ { schema: { tags: ['users'], response: { 200: profileResponseSchema } } },
15
+ async (request) => {
16
+ return app.container.resolve(FindProfileUseCase).execute(currentUser(request).id)
17
+ },
18
+ )
19
+
20
+ app.patch(
21
+ '/me',
22
+ {
23
+ schema: {
24
+ tags: ['users'],
25
+ body: updateProfileSchema,
26
+ response: { 200: profileResponseSchema },
27
+ },
28
+ },
29
+ async (request) => {
30
+ return app.container
31
+ .resolve(UpdateProfileUseCase)
32
+ .execute(currentUser(request).id, request.body)
33
+ },
34
+ )
35
+ }
@@ -0,0 +1,116 @@
1
+ import type { FastifyError, FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
2
+ import { hasZodFastifySchemaValidationErrors } from 'fastify-type-provider-zod'
3
+ import { DomainError, type DomainErrorKind } from '@/domain/shared/errors/domain-error'
4
+
5
+ type FieldErrors = Record<string, string[]>
6
+
7
+ type ProblemDocument = {
8
+ type: string
9
+ title: string
10
+ status: number
11
+ detail: string
12
+ instance: string
13
+ requestId: string
14
+ errors?: FieldErrors
15
+ }
16
+
17
+ const STATUS_OF: Record<DomainErrorKind, number> = {
18
+ not_found: 404,
19
+ conflict: 409,
20
+ invalid: 422,
21
+ forbidden: 403,
22
+ unauthorized: 401,
23
+ }
24
+
25
+ const TITLES: Record<number, string> = {
26
+ 400: 'Bad Request',
27
+ 401: 'Unauthorized',
28
+ 403: 'Forbidden',
29
+ 404: 'Not Found',
30
+ 409: 'Conflict',
31
+ 413: 'Payload Too Large',
32
+ 415: 'Unsupported Media Type',
33
+ 422: 'Unprocessable Entity',
34
+ 500: 'Internal Server Error',
35
+ 503: 'Service Unavailable',
36
+ }
37
+
38
+ function fieldOf(instancePath: string, key?: string): string {
39
+ return [...instancePath.split('/').filter(Boolean), ...(key === undefined ? [] : [key])].join('.')
40
+ }
41
+
42
+ function fieldErrors(error: FastifyError): FieldErrors | undefined {
43
+ if (!hasZodFastifySchemaValidationErrors(error)) {
44
+ return undefined
45
+ }
46
+
47
+ const fields: FieldErrors = {}
48
+ const add = (field: string, message: string) => {
49
+ fields[field] = [...(fields[field] ?? []), message]
50
+ }
51
+
52
+ for (const issue of error.validation) {
53
+ if (issue.keyword === 'unrecognized_keys') {
54
+ for (const key of (issue.params as { keys: string[] }).keys) {
55
+ add(fieldOf(issue.instancePath, key), 'Unrecognized key')
56
+ }
57
+ } else {
58
+ add(fieldOf(issue.instancePath), issue.message ?? 'Invalid value')
59
+ }
60
+ }
61
+
62
+ return fields
63
+ }
64
+
65
+ export function statusOf(error: FastifyError): number {
66
+ if (error instanceof DomainError) {
67
+ return STATUS_OF[error.kind]
68
+ }
69
+
70
+ return typeof error.statusCode === 'number' ? error.statusCode : 500
71
+ }
72
+
73
+ function detailOf(error: FastifyError, status: number, errors: FieldErrors | undefined): string {
74
+ if (errors !== undefined) {
75
+ return 'Validation failed'
76
+ }
77
+
78
+ return status < 500 ? error.message : 'An unexpected error occurred.'
79
+ }
80
+
81
+ function send(
82
+ request: FastifyRequest,
83
+ reply: FastifyReply,
84
+ problem: Omit<ProblemDocument, 'type' | 'title' | 'instance' | 'requestId'>,
85
+ ) {
86
+ const document: ProblemDocument = {
87
+ type: 'about:blank',
88
+ title: TITLES[problem.status] ?? 'Error',
89
+ instance: request.url,
90
+ requestId: request.id,
91
+ ...problem,
92
+ }
93
+
94
+ return reply.code(problem.status).type('application/problem+json').send(document)
95
+ }
96
+
97
+ export function registerErrorHandler(app: FastifyInstance): void {
98
+ app.setErrorHandler((error: FastifyError, request, reply) => {
99
+ const status = statusOf(error)
100
+ const errors = fieldErrors(error)
101
+
102
+ if (status >= 500) {
103
+ request.log.error(error)
104
+ }
105
+
106
+ return send(request, reply, {
107
+ status,
108
+ detail: detailOf(error, status, errors),
109
+ ...(errors === undefined ? {} : { errors }),
110
+ })
111
+ })
112
+
113
+ app.setNotFoundHandler((request, reply) =>
114
+ send(request, reply, { status: 404, detail: `Cannot ${request.method} ${request.url}` }),
115
+ )
116
+ }
@@ -0,0 +1,8 @@
1
+ export class HttpError extends Error {
2
+ constructor(
3
+ readonly statusCode: number,
4
+ message: string,
5
+ ) {
6
+ super(message)
7
+ }
8
+ }