@stjoseph/prumo 0.0.5 → 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 (155) 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 +12 -6
  20. package/dist/asker.js +4 -0
  21. package/dist/clean.js +2 -2
  22. package/dist/cli.js +4 -0
  23. package/dist/commands.js +2 -0
  24. package/dist/compose.js +12 -8
  25. package/dist/context.js +2 -0
  26. package/dist/generate.js +8 -0
  27. package/dist/mcp.js +100 -0
  28. package/dist/questions.js +21 -1
  29. package/package.json +4 -3
  30. package/templates/api/.swcrc +13 -0
  31. package/templates/api/README.md +10 -1
  32. package/templates/api/biome.jsonc +5 -3
  33. package/templates/api/package.json +22 -28
  34. package/templates/api/pnpm-workspace.yaml +0 -2
  35. package/templates/api/scripts/ports.mjs +508 -0
  36. package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
  37. package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
  38. package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
  39. package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
  40. package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
  41. package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
  42. package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
  43. package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
  44. package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
  45. package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
  46. package/templates/api/src/infra/auth/auth.cli.ts +5 -0
  47. package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
  48. package/templates/api/src/infra/config/env.ts +47 -0
  49. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
  50. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
  51. package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
  52. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
  53. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
  54. package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
  55. package/templates/api/src/infra/di/index.ts +32 -0
  56. package/templates/api/src/infra/di/users.di.ts +7 -0
  57. package/templates/api/src/infra/http/app.ts +99 -0
  58. package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
  59. package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
  60. package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
  61. package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
  62. package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
  63. package/templates/api/src/infra/http/errors/http-error.ts +8 -0
  64. package/templates/api/src/infra/http/fastify.d.ts +17 -0
  65. package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
  66. package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
  67. package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
  68. package/templates/api/src/infra/http/server.ts +34 -0
  69. package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
  70. package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
  71. package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
  72. package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
  73. package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
  74. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
  75. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
  76. package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
  77. package/templates/api/test/infra/http/app.spec.ts +21 -0
  78. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
  79. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
  80. package/templates/api/test/support/factories/profile.factory.ts +25 -0
  81. package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
  82. package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
  83. package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
  84. package/templates/api/tsconfig.json +3 -6
  85. package/templates/api/vitest.config.mts +4 -4
  86. package/templates/mobile/.env.example +3 -0
  87. package/templates/mobile/README.md +10 -0
  88. package/templates/mobile/jest.config.js +5 -1
  89. package/templates/mobile/package.json +3 -3
  90. package/templates/mobile/scripts/ports.mjs +508 -0
  91. package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
  92. package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  93. package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
  94. package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  95. package/templates/mobile/tsconfig.json +2 -1
  96. package/templates/site/.env.example +2 -0
  97. package/templates/site/README.md +12 -1
  98. package/templates/site/gitignore +1 -0
  99. package/templates/site/package.json +1 -1
  100. package/templates/site/scripts/ports.mjs +508 -0
  101. package/templates/site/{src → test}/app/page.spec.ts +1 -1
  102. package/templates/site/tsconfig.json +2 -1
  103. package/templates/site/vitest.config.mts +4 -2
  104. package/templates/web/.env.example +3 -0
  105. package/templates/web/README.md +10 -1
  106. package/templates/web/package.json +5 -3
  107. package/templates/web/scripts/ports.mjs +508 -0
  108. package/templates/web/src/features/auth/auth-client.ts +5 -0
  109. package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
  110. package/templates/web/src/features/oauth/consent-form.tsx +63 -0
  111. package/templates/web/src/routeTree.gen.ts +21 -2
  112. package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
  113. package/templates/web/{src → test}/app.spec.tsx +2 -2
  114. package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  115. package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
  116. package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  117. package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
  118. package/templates/web/tsconfig.json +2 -1
  119. package/templates/web/vite.config.ts +3 -3
  120. package/templates/workspace/README.md +7 -3
  121. package/templates/workspace/scripts/ports.mjs +508 -0
  122. package/.prumo-templates/api/modules.md +0 -117
  123. package/templates/api/nest-cli.json +0 -10
  124. package/templates/api/src/app.module.ts +0 -46
  125. package/templates/api/src/auth/auth.constants.ts +0 -2
  126. package/templates/api/src/auth/auth.controller.ts +0 -19
  127. package/templates/api/src/auth/auth.guard.ts +0 -43
  128. package/templates/api/src/auth/auth.module.ts +0 -30
  129. package/templates/api/src/auth/auth.ts +0 -4
  130. package/templates/api/src/auth/current-user.decorator.ts +0 -9
  131. package/templates/api/src/auth/express.d.ts +0 -10
  132. package/templates/api/src/auth/public.decorator.ts +0 -4
  133. package/templates/api/src/config/env.module.ts +0 -11
  134. package/templates/api/src/config/env.ts +0 -81
  135. package/templates/api/src/errors/problem-details.filter.ts +0 -86
  136. package/templates/api/src/errors/validation-exception.factory.ts +0 -30
  137. package/templates/api/src/health/health.controller.ts +0 -32
  138. package/templates/api/src/health/health.module.ts +0 -9
  139. package/templates/api/src/main.ts +0 -43
  140. package/templates/api/src/mikro-orm.factory.ts +0 -14
  141. package/templates/api/src/request-context/request-context.module.ts +0 -9
  142. package/templates/api/src/request-context/request-context.service.ts +0 -17
  143. package/templates/api/src/request-context/request-id.middleware.ts +0 -19
  144. package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
  145. package/templates/api/src/users/entities/profile.entity.ts +0 -27
  146. package/templates/api/src/users/users.controller.ts +0 -23
  147. package/templates/api/src/users/users.module.ts +0 -10
  148. package/templates/api/src/users/users.service.spec.ts +0 -44
  149. package/templates/api/src/users/users.service.ts +0 -36
  150. package/templates/api/test/factories/profile.factory.ts +0 -26
  151. package/templates/api/tsconfig.build.json +0 -8
  152. /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
  153. /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  154. /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  155. /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
@@ -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
+ })
@@ -0,0 +1,105 @@
1
+ import { testEnv, testOrm } from '@test/support/setup'
2
+ import type { FastifyInstance } from 'fastify'
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
4
+ import { createContainer } from '@/infra/di'
5
+ import { buildApp } from '@/infra/http/app'
6
+
7
+ const ORIGIN = 'http://localhost:5173'
8
+
9
+ let app: FastifyInstance
10
+
11
+ async function signUp(): Promise<string[]> {
12
+ const response = await app.inject({
13
+ method: 'POST',
14
+ url: '/api/auth/sign-up/email',
15
+ headers: { origin: ORIGIN },
16
+ payload: {
17
+ name: 'Ana',
18
+ email: `${crypto.randomUUID()}@example.com`,
19
+ password: 'correct-horse-battery',
20
+ },
21
+ })
22
+
23
+ expect(response.statusCode).toBe(200)
24
+
25
+ const cookies = response.headers['set-cookie']
26
+ return Array.isArray(cookies) ? cookies : [cookies ?? '']
27
+ }
28
+
29
+ function cookieHeader(cookies: string[]): string {
30
+ return cookies.map((cookie) => cookie.split(';')[0]).join('; ')
31
+ }
32
+
33
+ beforeEach(async () => {
34
+ app = await buildApp(createContainer({ env: testEnv(), orm: testOrm() }))
35
+ })
36
+
37
+ afterEach(async () => {
38
+ await app.close()
39
+ })
40
+
41
+ describe('users controller', () => {
42
+ it('refuses /users/me without a session', async () => {
43
+ const response = await app.inject({ url: '/api/v1/users/me' })
44
+
45
+ expect(response.statusCode).toBe(401)
46
+ expect(response.headers['content-type']).toContain('application/problem+json')
47
+ })
48
+
49
+ it('returns the profile created at sign-up, and only its public fields', async () => {
50
+ const cookie = cookieHeader(await signUp())
51
+ const response = await app.inject({ url: '/api/v1/users/me', headers: { cookie } })
52
+
53
+ expect(response.statusCode).toBe(200)
54
+ expect(Object.keys(response.json()).sort()).toEqual(
55
+ ['createdAt', 'displayName', 'id', 'locale', 'timezone', 'updatedAt', 'userId'].sort(),
56
+ )
57
+ expect(response.json()).toMatchObject({ displayName: 'Ana', locale: 'en' })
58
+ })
59
+
60
+ it('updates the profile', async () => {
61
+ const cookie = cookieHeader(await signUp())
62
+ const response = await app.inject({
63
+ method: 'PATCH',
64
+ url: '/api/v1/users/me',
65
+ headers: { cookie, origin: ORIGIN },
66
+ payload: { timezone: 'America/Sao_Paulo' },
67
+ })
68
+
69
+ expect(response.statusCode).toBe(200)
70
+ expect(response.json()).toMatchObject({ displayName: 'Ana', timezone: 'America/Sao_Paulo' })
71
+ })
72
+
73
+ it('names every rejected field, including one the schema does not know', async () => {
74
+ const cookie = cookieHeader(await signUp())
75
+ const response = await app.inject({
76
+ method: 'PATCH',
77
+ url: '/api/v1/users/me',
78
+ headers: { cookie, origin: ORIGIN },
79
+ payload: { displayName: '', role: 'admin' },
80
+ })
81
+
82
+ expect(response.statusCode).toBe(400)
83
+ expect(response.json()).toMatchObject({
84
+ status: 400,
85
+ errors: { displayName: [expect.any(String)], role: ['Unrecognized key'] },
86
+ })
87
+ })
88
+
89
+ it('signs out', async () => {
90
+ const cookie = cookieHeader(await signUp())
91
+ const response = await app.inject({
92
+ method: 'POST',
93
+ url: '/api/auth/sign-out',
94
+ headers: { cookie, origin: ORIGIN },
95
+ })
96
+
97
+ expect(response.statusCode).toBe(200)
98
+ })
99
+
100
+ it('answers an unknown route with 404, not 401', async () => {
101
+ const response = await app.inject({ url: '/api/v1/nothing-here' })
102
+
103
+ expect(response.statusCode).toBe(404)
104
+ })
105
+ })
@@ -0,0 +1,25 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { createUser } from '@test/support/factories/user.factory'
3
+ import { type Locale, Profile } from '@/domain/users/entities/profile.entity'
4
+
5
+ type ProfileOverrides = Partial<{
6
+ userId: string
7
+ displayName: string
8
+ locale: Locale
9
+ timezone: string
10
+ }>
11
+
12
+ export async function createProfile(
13
+ em: EntityManager,
14
+ overrides: ProfileOverrides = {},
15
+ ): Promise<Profile> {
16
+ const profile = new Profile(
17
+ overrides.userId ?? (await createUser(em)),
18
+ overrides.displayName ?? 'Test Person',
19
+ )
20
+
21
+ profile.update({ locale: overrides.locale ?? 'en', timezone: overrides.timezone ?? 'UTC' })
22
+ await em.persist(profile).flush()
23
+
24
+ return profile
25
+ }
@@ -9,7 +9,7 @@ export async function createUser(
9
9
  ): Promise<string> {
10
10
  const id = randomUUID()
11
11
 
12
- // auth.user belongs to Better Auth and has no entity here, so the QueryBuilder cannot reach it.
12
+ // auth.user belongs to Better Auth, and the ORM maps only its id, so the row is written in SQL.
13
13
  await em
14
14
  .getConnection()
15
15
  .execute(
@@ -0,0 +1,14 @@
1
+ import type { Profile } from '@/domain/users/entities/profile.entity'
2
+ import type { ProfileRepository } from '@/domain/users/repositories/profile.repository'
3
+
4
+ export class InMemoryProfileRepository implements ProfileRepository {
5
+ readonly rows = new Map<string, Profile>()
6
+
7
+ async findByUserId(userId: string): Promise<Profile | null> {
8
+ return this.rows.get(userId) ?? null
9
+ }
10
+
11
+ async save(profile: Profile): Promise<void> {
12
+ this.rows.set(profile.userId, profile)
13
+ }
14
+ }