@stjoseph/prumo 0.0.6 → 0.1.1

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 (140) 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 +7 -8
  22. package/dist/commands.js +2 -0
  23. package/dist/compose.js +6 -2
  24. package/dist/context.js +5 -0
  25. package/dist/generate.js +8 -0
  26. package/dist/mcp.js +100 -0
  27. package/dist/questions.js +21 -1
  28. package/dist/version.js +7 -0
  29. package/package.json +4 -3
  30. package/templates/api/.swcrc +13 -0
  31. package/templates/api/biome.jsonc +5 -3
  32. package/templates/api/package.json +21 -27
  33. package/templates/api/pnpm-workspace.yaml +0 -2
  34. package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
  35. package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
  36. package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
  37. package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
  38. package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
  39. package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
  40. package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
  41. package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
  42. package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
  43. package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
  44. package/templates/api/src/infra/auth/auth.cli.ts +5 -0
  45. package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
  46. package/templates/api/src/infra/config/env.ts +47 -0
  47. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
  48. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
  49. package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
  50. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
  51. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
  52. package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
  53. package/templates/api/src/infra/di/index.ts +32 -0
  54. package/templates/api/src/infra/di/users.di.ts +7 -0
  55. package/templates/api/src/infra/http/app.ts +99 -0
  56. package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
  57. package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
  58. package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
  59. package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
  60. package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
  61. package/templates/api/src/infra/http/errors/http-error.ts +8 -0
  62. package/templates/api/src/infra/http/fastify.d.ts +17 -0
  63. package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
  64. package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
  65. package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
  66. package/templates/api/src/infra/http/server.ts +34 -0
  67. package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
  68. package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
  69. package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
  70. package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
  71. package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
  72. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
  73. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
  74. package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
  75. package/templates/api/test/infra/http/app.spec.ts +21 -0
  76. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
  77. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
  78. package/templates/api/test/support/factories/profile.factory.ts +25 -0
  79. package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
  80. package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
  81. package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
  82. package/templates/api/tsconfig.json +3 -6
  83. package/templates/api/vitest.config.mts +4 -4
  84. package/templates/mobile/jest.config.js +1 -1
  85. package/templates/mobile/package.json +2 -2
  86. package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
  87. package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  88. package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
  89. package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  90. package/templates/mobile/tsconfig.json +2 -1
  91. package/templates/site/{src → test}/app/page.spec.ts +1 -1
  92. package/templates/site/tsconfig.json +2 -1
  93. package/templates/site/vitest.config.mts +4 -2
  94. package/templates/web/package.json +4 -2
  95. package/templates/web/src/features/auth/auth-client.ts +5 -0
  96. package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
  97. package/templates/web/src/features/oauth/consent-form.tsx +63 -0
  98. package/templates/web/src/routeTree.gen.ts +21 -2
  99. package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
  100. package/templates/web/{src → test}/app.spec.tsx +2 -2
  101. package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  102. package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
  103. package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  104. package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
  105. package/templates/web/tsconfig.json +2 -1
  106. package/templates/web/vite.config.ts +3 -3
  107. package/.prumo-templates/api/modules.md +0 -117
  108. package/templates/api/nest-cli.json +0 -10
  109. package/templates/api/src/app.module.ts +0 -46
  110. package/templates/api/src/auth/auth.constants.ts +0 -2
  111. package/templates/api/src/auth/auth.controller.ts +0 -19
  112. package/templates/api/src/auth/auth.guard.ts +0 -43
  113. package/templates/api/src/auth/auth.module.ts +0 -30
  114. package/templates/api/src/auth/auth.ts +0 -4
  115. package/templates/api/src/auth/current-user.decorator.ts +0 -9
  116. package/templates/api/src/auth/express.d.ts +0 -10
  117. package/templates/api/src/auth/public.decorator.ts +0 -4
  118. package/templates/api/src/config/env.module.ts +0 -11
  119. package/templates/api/src/config/env.ts +0 -81
  120. package/templates/api/src/errors/problem-details.filter.ts +0 -86
  121. package/templates/api/src/errors/validation-exception.factory.ts +0 -30
  122. package/templates/api/src/health/health.controller.ts +0 -32
  123. package/templates/api/src/health/health.module.ts +0 -9
  124. package/templates/api/src/main.ts +0 -43
  125. package/templates/api/src/mikro-orm.factory.ts +0 -14
  126. package/templates/api/src/request-context/request-context.module.ts +0 -9
  127. package/templates/api/src/request-context/request-context.service.ts +0 -17
  128. package/templates/api/src/request-context/request-id.middleware.ts +0 -19
  129. package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
  130. package/templates/api/src/users/entities/profile.entity.ts +0 -27
  131. package/templates/api/src/users/users.controller.ts +0 -23
  132. package/templates/api/src/users/users.module.ts +0 -10
  133. package/templates/api/src/users/users.service.spec.ts +0 -44
  134. package/templates/api/src/users/users.service.ts +0 -36
  135. package/templates/api/test/factories/profile.factory.ts +0 -26
  136. package/templates/api/tsconfig.build.json +0 -8
  137. /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
  138. /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  139. /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  140. /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
@@ -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
+ }
@@ -0,0 +1,17 @@
1
+ import type { DependencyContainer } from 'tsyringe'
2
+ import type { AuthSession, AuthUser } from '@/infra/auth/auth.factory'
3
+
4
+ declare module 'fastify' {
5
+ interface FastifyInstance {
6
+ container: DependencyContainer
7
+ }
8
+
9
+ interface FastifyContextConfig {
10
+ public?: boolean
11
+ }
12
+
13
+ interface FastifyRequest {
14
+ user?: AuthUser
15
+ session?: NonNullable<AuthSession>['session']
16
+ }
17
+ }
@@ -0,0 +1,42 @@
1
+ import { fromNodeHeaders } from 'better-auth/node'
2
+ import type { FastifyReply, FastifyRequest } from 'fastify'
3
+
4
+ function bodyOf(body: unknown): string | undefined {
5
+ if (body === undefined) {
6
+ return undefined
7
+ }
8
+
9
+ return typeof body === 'string' ? body : JSON.stringify(body)
10
+ }
11
+
12
+ // Better Auth and the MCP handler answer Fetch requests. Fastify has already parsed a JSON body, so the
13
+ // request is rebuilt rather than handed over raw, as Better Auth's Fastify guide does; a body kept as a
14
+ // string, such as a form, passes unchanged.
15
+ export function toFetchRequest(request: FastifyRequest): Request {
16
+ const body = bodyOf(request.body)
17
+
18
+ return new Request(new URL(request.url, `${request.protocol}://${request.host}`), {
19
+ method: request.method,
20
+ headers: fromNodeHeaders(request.headers),
21
+ ...(body === undefined ? {} : { body }),
22
+ })
23
+ }
24
+
25
+ export async function sendFetchResponse(
26
+ reply: FastifyReply,
27
+ response: Response,
28
+ ): Promise<FastifyReply> {
29
+ reply.status(response.status)
30
+
31
+ for (const [key, value] of response.headers) {
32
+ if (key !== 'set-cookie') {
33
+ reply.header(key, value)
34
+ }
35
+ }
36
+
37
+ for (const cookie of response.headers.getSetCookie()) {
38
+ reply.header('set-cookie', cookie)
39
+ }
40
+
41
+ return reply.send(response.body === null ? null : await response.text())
42
+ }
@@ -0,0 +1,32 @@
1
+ import { fromNodeHeaders } from 'better-auth/node'
2
+ import type { FastifyInstance, FastifyRequest } from 'fastify'
3
+ import { AUTH, type Auth, type AuthUser } from '@/infra/auth/auth.factory'
4
+ import { HttpError } from '@/infra/http/errors/http-error'
5
+
6
+ export function registerAuthHook(app: FastifyInstance): void {
7
+ const auth = app.container.resolve<Auth>(AUTH)
8
+
9
+ app.addHook('preHandler', async (request) => {
10
+ // An unknown route answers 404, not 401: there is nothing there to protect.
11
+ if (request.is404) {
12
+ return
13
+ }
14
+
15
+ const session = await auth.api.getSession({ headers: fromNodeHeaders(request.headers) })
16
+
17
+ request.user = session?.user
18
+ request.session = session?.session
19
+
20
+ if (request.routeOptions.config.public !== true && session === null) {
21
+ throw new HttpError(401, 'Unauthorized')
22
+ }
23
+ })
24
+ }
25
+
26
+ export function currentUser(request: FastifyRequest): AuthUser {
27
+ if (request.user === undefined) {
28
+ throw new HttpError(401, 'Unauthorized')
29
+ }
30
+
31
+ return request.user
32
+ }
@@ -0,0 +1,9 @@
1
+ import { type EntityManager, RequestContext } from '@mikro-orm/postgresql'
2
+ import type { FastifyInstance } from 'fastify'
3
+
4
+ export function registerRequestContext(app: FastifyInstance, em: EntityManager): void {
5
+ app.addHook('onRequest', (request, reply, done) => {
6
+ reply.header('x-request-id', request.id)
7
+ RequestContext.create(em, done)
8
+ })
9
+ }
@@ -0,0 +1,34 @@
1
+ // tsyringe reads decorator metadata as each class is loaded, so this must come before any other import.
2
+ import 'reflect-metadata'
3
+ import { MikroORM } from '@mikro-orm/postgresql'
4
+ import type { Pool } from 'pg'
5
+ import { AUTH, type Auth } from '@/infra/auth/auth.factory'
6
+ import { loadEnv } from '@/infra/config/env'
7
+ import { createOrmConfig } from '@/infra/database/mikroorm/mikro-orm.factory'
8
+ import { createContainer } from '@/infra/di'
9
+ import { buildApp } from './app'
10
+
11
+ async function main(): Promise<void> {
12
+ const env = loadEnv()
13
+ const orm = await MikroORM.init(createOrmConfig(env))
14
+
15
+ // MikroORM 7 no longer connects on init, so readiness would report the database down until a first query.
16
+ await orm.connect()
17
+
18
+ const container = createContainer({ env, orm })
19
+ const app = await buildApp(container)
20
+
21
+ // Handling the signals takes away Node's default exit, so every open pool must close for the process to end.
22
+ app.addHook('onClose', async () => {
23
+ await orm.close()
24
+ await (container.resolve<Auth>(AUTH).options.database as Pool).end()
25
+ })
26
+
27
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
28
+ process.once(signal, () => void app.close())
29
+ }
30
+
31
+ await app.listen({ port: env.PORT, host: '::' })
32
+ }
33
+
34
+ void main()
@@ -0,0 +1,20 @@
1
+ import { createMcpHandler, type McpHttpHandler, McpServer } from '@modelcontextprotocol/server'
2
+ import type { DependencyContainer } from 'tsyringe'
3
+ import { registerUserTools } from './tools/users.tool'
4
+
5
+ // One server per request: the protocol is stateless, and each instance knows only its caller.
6
+ export function createMcpServerHandler(container: DependencyContainer): McpHttpHandler {
7
+ return createMcpHandler(
8
+ ({ authInfo }) => {
9
+ const server = new McpServer({ name: 'api', version: '1.0.0' })
10
+ const userId = authInfo?.extra?.userId
11
+
12
+ if (typeof userId === 'string') {
13
+ registerUserTools(server, container, userId)
14
+ }
15
+
16
+ return server
17
+ },
18
+ { legacy: 'reject' },
19
+ )
20
+ }
@@ -0,0 +1,39 @@
1
+ import type { McpServer } from '@modelcontextprotocol/server'
2
+ import type { DependencyContainer } from 'tsyringe'
3
+ import { profileResponseSchema } from '@/domain/users/dto/profile-response.dto'
4
+ import { updateProfileSchema } from '@/domain/users/dto/update-profile.dto'
5
+ import type { Profile } from '@/domain/users/entities/profile.entity'
6
+ import { FindProfileUseCase } from '@/domain/users/use-cases/find-profile.use-case'
7
+ import { UpdateProfileUseCase } from '@/domain/users/use-cases/update-profile.use-case'
8
+
9
+ // MCP sends whatever a tool returns, so the output schema is applied here: nothing drops unlisted fields.
10
+ function asResult(profile: Profile) {
11
+ const data = JSON.parse(JSON.stringify(profileResponseSchema.parse(profile)))
12
+
13
+ return {
14
+ content: [{ type: 'text' as const, text: JSON.stringify(data) }],
15
+ structuredContent: data,
16
+ }
17
+ }
18
+
19
+ export function registerUserTools(
20
+ server: McpServer,
21
+ container: DependencyContainer,
22
+ userId: string,
23
+ ): void {
24
+ server.registerTool(
25
+ 'get_profile',
26
+ { description: "Read the signed-in user's profile." },
27
+ async () => asResult(await container.resolve(FindProfileUseCase).execute(userId)),
28
+ )
29
+
30
+ server.registerTool(
31
+ 'update_profile',
32
+ {
33
+ description: "Change the signed-in user's display name, locale or timezone.",
34
+ inputSchema: updateProfileSchema,
35
+ },
36
+ async (changes) =>
37
+ asResult(await container.resolve(UpdateProfileUseCase).execute(userId, changes)),
38
+ )
39
+ }
@@ -0,0 +1,22 @@
1
+ import { InMemoryProfileRepository } from '@test/support/fakes/in-memory-profile.repository'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Profile } from '@/domain/users/entities/profile.entity'
4
+ import { ProfileNotFoundError } from '@/domain/users/errors/profile-not-found.error'
5
+ import { FindProfileUseCase } from '@/domain/users/use-cases/find-profile.use-case'
6
+
7
+ describe('FindProfileUseCase', () => {
8
+ it('finds the profile belonging to a user', async () => {
9
+ const profiles = new InMemoryProfileRepository()
10
+ await profiles.save(new Profile('user-1', 'Ana'))
11
+
12
+ await expect(new FindProfileUseCase(profiles).execute('user-1')).resolves.toMatchObject({
13
+ displayName: 'Ana',
14
+ })
15
+ })
16
+
17
+ it('refuses a user without a profile', async () => {
18
+ const findProfile = new FindProfileUseCase(new InMemoryProfileRepository())
19
+
20
+ await expect(findProfile.execute('nobody')).rejects.toBeInstanceOf(ProfileNotFoundError)
21
+ })
22
+ })
@@ -0,0 +1,26 @@
1
+ import { InMemoryProfileRepository } from '@test/support/fakes/in-memory-profile.repository'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Profile } from '@/domain/users/entities/profile.entity'
4
+ import { ProfileNotFoundError } from '@/domain/users/errors/profile-not-found.error'
5
+ import { UpdateProfileUseCase } from '@/domain/users/use-cases/update-profile.use-case'
6
+
7
+ describe('UpdateProfileUseCase', () => {
8
+ it('applies only the fields the request carries', async () => {
9
+ const profiles = new InMemoryProfileRepository()
10
+ const profile = new Profile('user-1', 'Ana')
11
+ profile.update({ timezone: 'UTC' })
12
+ await profiles.save(profile)
13
+
14
+ const updated = await new UpdateProfileUseCase(profiles).execute('user-1', {
15
+ timezone: 'America/Sao_Paulo',
16
+ })
17
+
18
+ expect(updated).toMatchObject({ displayName: 'Ana', timezone: 'America/Sao_Paulo' })
19
+ })
20
+
21
+ it('refuses a user without a profile', async () => {
22
+ const updateProfile = new UpdateProfileUseCase(new InMemoryProfileRepository())
23
+
24
+ await expect(updateProfile.execute('nobody', {})).rejects.toBeInstanceOf(ProfileNotFoundError)
25
+ })
26
+ })
@@ -1,26 +1,19 @@
1
+ import { testEnv } from '@test/support/setup'
1
2
  import { describe, expect, it } from 'vitest'
2
- import { testDatabaseUrl } from '../../test/setup'
3
- import { createAuth } from './auth.factory'
4
-
5
- const env = () => ({
6
- AUTH_DATABASE_URL: testDatabaseUrl(),
7
- BETTER_AUTH_SECRET: 'test-secret-that-is-at-least-32-characters',
8
- BETTER_AUTH_URL: 'http://localhost:3000',
9
- WEB_ORIGIN: 'http://localhost:5173',
10
- })
3
+ import { createAuth } from '@/infra/auth/auth.factory'
11
4
 
12
5
  const proxy =
13
6
  'http://localhost:3000/api/auth/expo-authorization-proxy?authorizationURL=https://example.com/authorize&oauthState=s'
14
7
 
15
8
  describe('createAuth', () => {
16
9
  it('exposes no Expo redirect endpoint without a mobile scheme', async () => {
17
- const response = await createAuth(env()).handler(new Request(proxy))
10
+ const response = await createAuth(testEnv()).handler(new Request(proxy))
18
11
 
19
12
  expect(response.status).toBe(404)
20
13
  })
21
14
 
22
15
  it('exposes the Expo endpoint and trusts the scheme once a mobile scheme is set', async () => {
23
- const auth = createAuth({ ...env(), MOBILE_APP_SCHEME: 'app' })
16
+ const auth = createAuth({ ...testEnv(), MOBILE_APP_SCHEME: 'app' })
24
17
  const response = await auth.handler(new Request(proxy))
25
18
 
26
19
  expect(response.status).toBe(302)
@@ -0,0 +1,8 @@
1
+ import { testOrm } from '@test/support/setup'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ describe('createOrmConfig', () => {
5
+ it('finds nothing to change once migrated, and leaves Better Auth’s schema alone', async () => {
6
+ await expect(testOrm().schema.getUpdateSchemaSQL({ wrap: false })).resolves.toBe('')
7
+ })
8
+ })
@@ -0,0 +1,51 @@
1
+ import { createProfile } from '@test/support/factories/profile.factory'
2
+ import { createUser } from '@test/support/factories/user.factory'
3
+ import { testOrm } from '@test/support/setup'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { Profile } from '@/domain/users/entities/profile.entity'
6
+ import { MikroOrmProfileRepository } from '@/infra/database/mikroorm/repositories/mikroorm-profile.repository'
7
+
8
+ describe('MikroOrmProfileRepository', () => {
9
+ it('finds a profile by its user', async () => {
10
+ const created = await createProfile(testOrm().em.fork(), { displayName: 'Ana' })
11
+ const profiles = new MikroOrmProfileRepository(testOrm().em.fork())
12
+
13
+ const found = await profiles.findByUserId(created.userId)
14
+
15
+ expect(found).toBeInstanceOf(Profile)
16
+ expect(found).toMatchObject({ displayName: 'Ana' })
17
+ })
18
+
19
+ it('answers null for a user without a profile', async () => {
20
+ const profiles = new MikroOrmProfileRepository(testOrm().em.fork())
21
+
22
+ await expect(profiles.findByUserId(crypto.randomUUID())).resolves.toBeNull()
23
+ })
24
+
25
+ it('lets the database fill the id, the defaults and the timestamps', async () => {
26
+ const userId = await createUser(testOrm().em.fork())
27
+ await new MikroOrmProfileRepository(testOrm().em.fork()).save(new Profile(userId, 'Ana'))
28
+
29
+ const stored = await new MikroOrmProfileRepository(testOrm().em.fork()).findByUserId(userId)
30
+
31
+ expect(stored).toMatchObject({ locale: 'en', timezone: 'UTC' })
32
+ // uuidv7() puts the version nibble at the start of the third group.
33
+ expect(stored?.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7/)
34
+ expect(stored?.createdAt).toBeInstanceOf(Date)
35
+ })
36
+
37
+ it('writes what the entity changed', async () => {
38
+ const created = await createProfile(testOrm().em.fork(), { timezone: 'UTC' })
39
+ const profiles = new MikroOrmProfileRepository(testOrm().em.fork())
40
+ const profile = await profiles.findByUserId(created.userId)
41
+
42
+ profile?.update({ timezone: 'America/Sao_Paulo' })
43
+ await profiles.save(profile as Profile)
44
+
45
+ const reloaded = await new MikroOrmProfileRepository(testOrm().em.fork()).findByUserId(
46
+ created.userId,
47
+ )
48
+
49
+ expect(reloaded?.timezone).toBe('America/Sao_Paulo')
50
+ })
51
+ })
@@ -0,0 +1,29 @@
1
+ import { RequestContext } from '@mikro-orm/postgresql'
2
+ import { createUser } from '@test/support/factories/user.factory'
3
+ import { testOrm } from '@test/support/setup'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { Profile } from '@/domain/users/entities/profile.entity'
6
+ import { MikroOrmProfileRepository } from '@/infra/database/mikroorm/repositories/mikroorm-profile.repository'
7
+ import { MikroOrmTransactionManager } from '@/infra/database/mikroorm/transactions/mikroorm-transaction-manager'
8
+
9
+ describe('MikroOrmTransactionManager', () => {
10
+ it('rolls back every write inside run() when one of them fails', async () => {
11
+ const orm = testOrm()
12
+ const userId = await createUser(orm.em.fork())
13
+
14
+ // The same wiring as a request: the global EntityManager, resolved through a request context.
15
+ await RequestContext.create(orm.em, async () => {
16
+ const transactions = new MikroOrmTransactionManager(orm.em)
17
+ const profiles = new MikroOrmProfileRepository(orm.em)
18
+
19
+ await expect(
20
+ transactions.run(async () => {
21
+ await profiles.save(new Profile(userId, 'first'))
22
+ await profiles.save(new Profile(userId, 'same user, refused by the unique constraint'))
23
+ }),
24
+ ).rejects.toThrow()
25
+ })
26
+
27
+ expect(await orm.em.fork().count(Profile)).toBe(0)
28
+ })
29
+ })
@@ -0,0 +1,21 @@
1
+ import { testEnv, testOrm } from '@test/support/setup'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createContainer } from '@/infra/di'
4
+ import { buildApp } from '@/infra/http/app'
5
+
6
+ describe('buildApp', () => {
7
+ it('refuses a versioned route that declares no response schema', async () => {
8
+ const app = await buildApp(createContainer({ env: testEnv(), orm: testOrm() }))
9
+
10
+ await expect(
11
+ app.register(
12
+ async (scope) => {
13
+ scope.get('/leak', async () => ({ secret: true }))
14
+ },
15
+ { prefix: '/api/v1' },
16
+ ),
17
+ ).rejects.toThrow('GET /api/v1/leak declares no response schema')
18
+
19
+ await app.close()
20
+ })
21
+ })
@@ -0,0 +1,152 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { createServer } from 'node:net'
3
+ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
4
+ import { testEnv, testOrm } from '@test/support/setup'
5
+ import type { FastifyInstance } from 'fastify'
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
7
+ import { mcpResource } from '@/infra/auth/auth.factory'
8
+ import { createContainer } from '@/infra/di'
9
+ import { buildApp } from '@/infra/http/app'
10
+
11
+ const ORIGIN = 'http://localhost:5173'
12
+ // A web client needs an https redirect off loopback; the test never follows it.
13
+ const REDIRECT = 'https://client.example.com/callback'
14
+
15
+ let app: FastifyInstance
16
+ let env: ReturnType<typeof testEnv>
17
+
18
+ function freePort(): Promise<number> {
19
+ return new Promise((resolve) => {
20
+ const server = createServer().listen(0, '127.0.0.1', () => {
21
+ const { port } = server.address() as { port: number }
22
+ server.close(() => resolve(port))
23
+ })
24
+ })
25
+ }
26
+
27
+ // requireMcpAuth fetches the JWKS from BETTER_AUTH_URL, so the application listens for real.
28
+ beforeEach(async () => {
29
+ const port = await freePort()
30
+ env = { ...testEnv(), BETTER_AUTH_URL: `http://127.0.0.1:${port}` }
31
+ app = await buildApp(createContainer({ env, orm: testOrm() }))
32
+ await app.listen({ port, host: '127.0.0.1' })
33
+ })
34
+
35
+ afterEach(async () => {
36
+ await app.close()
37
+ })
38
+
39
+ async function signUp(): Promise<string> {
40
+ const response = await app.inject({
41
+ method: 'POST',
42
+ url: '/api/auth/sign-up/email',
43
+ headers: { origin: ORIGIN },
44
+ payload: {
45
+ name: 'Ana',
46
+ email: `${crypto.randomUUID()}@example.com`,
47
+ password: 'correct-horse-battery',
48
+ },
49
+ })
50
+ const cookies = [response.headers['set-cookie']].flat().filter((c) => c !== undefined)
51
+
52
+ return cookies.map((cookie) => cookie.split(';')[0]).join('; ')
53
+ }
54
+
55
+ // The path an MCP client takes: register, authorize with PKCE, consent, exchange the code.
56
+ async function accessToken(cookie: string): Promise<string> {
57
+ const client = await app.inject({
58
+ method: 'POST',
59
+ url: '/api/auth/oauth2/create-client',
60
+ headers: { cookie, origin: ORIGIN },
61
+ payload: { redirect_uris: [REDIRECT], token_endpoint_auth_method: 'none' },
62
+ })
63
+ const clientId = client.json().client_id as string
64
+ const verifier = randomBytes(32).toString('base64url')
65
+ const query = new URLSearchParams({
66
+ response_type: 'code',
67
+ client_id: clientId,
68
+ redirect_uri: REDIRECT,
69
+ scope: 'openid',
70
+ state: 'state',
71
+ code_challenge: createHash('sha256').update(verifier).digest('base64url'),
72
+ code_challenge_method: 'S256',
73
+ resource: mcpResource(env),
74
+ })
75
+ const authorize = await app.inject({
76
+ url: `/api/auth/oauth2/authorize?${query}`,
77
+ headers: { cookie },
78
+ })
79
+ const consentPage = new URL(authorize.headers.location as string)
80
+ const consent = await app.inject({
81
+ method: 'POST',
82
+ url: '/api/auth/oauth2/consent',
83
+ headers: { cookie, origin: ORIGIN },
84
+ payload: { accept: true, oauth_query: consentPage.search.slice(1) },
85
+ })
86
+ const redirect = new URL(consent.json().url ?? consent.json().redirect_uri)
87
+ const token = await app.inject({
88
+ method: 'POST',
89
+ url: '/api/auth/oauth2/token',
90
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
91
+ payload: new URLSearchParams({
92
+ grant_type: 'authorization_code',
93
+ code: redirect.searchParams.get('code') ?? '',
94
+ redirect_uri: REDIRECT,
95
+ client_id: clientId,
96
+ code_verifier: verifier,
97
+ resource: mcpResource(env),
98
+ }).toString(),
99
+ })
100
+
101
+ return token.json().access_token as string
102
+ }
103
+
104
+ // The SDK's own client, speaking the real protocol over HTTP.
105
+ async function connect(token: string): Promise<Client> {
106
+ // The server rejects 2025-era traffic, as Better Auth's MCP profile asks.
107
+ const client = new Client(
108
+ { name: 'test', version: '1.0.0' },
109
+ { versionNegotiation: { mode: { pin: '2026-07-28' } } },
110
+ )
111
+
112
+ await client.connect(
113
+ new StreamableHTTPClientTransport(new URL(mcpResource(env)), {
114
+ requestInit: { headers: { authorization: `Bearer ${token}` } },
115
+ }),
116
+ )
117
+ return client
118
+ }
119
+
120
+ describe('mcp controller', () => {
121
+ it('tells an MCP client where to authorize', async () => {
122
+ const metadata = await app.inject({ url: '/.well-known/oauth-protected-resource' })
123
+ const refused = await app.inject({ method: 'POST', url: '/api/mcp', payload: {} })
124
+
125
+ expect(metadata.json()).toMatchObject({ resource: mcpResource(env) })
126
+ expect(refused.statusCode).toBe(401)
127
+ expect(refused.headers['www-authenticate']).toContain('resource_metadata=')
128
+ })
129
+
130
+ it('runs a tool for the user who authorized the client, with public fields only', async () => {
131
+ const client = await connect(await accessToken(await signUp()))
132
+ const result = await client.callTool({ name: 'get_profile', arguments: {} })
133
+ expect(Object.keys(result.structuredContent ?? {}).sort()).toEqual(
134
+ ['createdAt', 'displayName', 'id', 'locale', 'timezone', 'updatedAt', 'userId'].sort(),
135
+ )
136
+ expect(result.structuredContent).toMatchObject({ displayName: 'Ana' })
137
+
138
+ await client.close()
139
+ })
140
+
141
+ it('changes the profile through a tool', async () => {
142
+ const client = await connect(await accessToken(await signUp()))
143
+ const result = await client.callTool({
144
+ name: 'update_profile',
145
+ arguments: { timezone: 'America/Sao_Paulo' },
146
+ })
147
+
148
+ expect(result.structuredContent).toMatchObject({ timezone: 'America/Sao_Paulo' })
149
+
150
+ await client.close()
151
+ })
152
+ })