@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.
- package/.prumo-templates/api/auth.md +51 -95
- package/.prumo-templates/api/config.md +30 -56
- package/.prumo-templates/api/errors.md +64 -44
- package/.prumo-templates/api/logging.md +27 -44
- package/.prumo-templates/api/observability.md +36 -62
- package/.prumo-templates/api/pagination.md +23 -38
- package/.prumo-templates/api/routes.md +67 -48
- package/.prumo-templates/api/structure.md +130 -0
- package/.prumo-templates/client/data.md +4 -3
- package/.prumo-templates/core/conventions.md +10 -7
- package/.prumo-templates/core/testing.md +43 -26
- package/.prumo-templates/core/tooling.md +33 -30
- package/.prumo-templates/database/entities.md +53 -61
- package/.prumo-templates/database/queries.md +34 -47
- package/.prumo-templates/database/transactions.md +28 -38
- package/.prumo-templates/mcp/server.md +54 -0
- package/.prumo-templates/monorepo/dependencies.md +3 -3
- package/.prumo-templates/monorepo/layout.md +1 -1
- package/README.md +6 -6
- package/dist/asker.js +4 -0
- package/dist/cli.js +4 -0
- package/dist/commands.js +2 -0
- package/dist/compose.js +6 -2
- package/dist/context.js +2 -0
- package/dist/generate.js +8 -0
- package/dist/mcp.js +100 -0
- package/dist/questions.js +21 -1
- package/package.json +4 -3
- package/templates/api/.swcrc +13 -0
- package/templates/api/biome.jsonc +5 -3
- package/templates/api/package.json +21 -27
- package/templates/api/pnpm-workspace.yaml +0 -2
- package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
- package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
- package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
- package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
- package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
- package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
- package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
- package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
- package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
- package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
- package/templates/api/src/infra/auth/auth.cli.ts +5 -0
- package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
- package/templates/api/src/infra/config/env.ts +47 -0
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
- package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
- package/templates/api/src/infra/di/index.ts +32 -0
- package/templates/api/src/infra/di/users.di.ts +7 -0
- package/templates/api/src/infra/http/app.ts +99 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
- package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
- package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
- package/templates/api/src/infra/http/errors/http-error.ts +8 -0
- package/templates/api/src/infra/http/fastify.d.ts +17 -0
- package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
- package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
- package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
- package/templates/api/src/infra/http/server.ts +34 -0
- package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
- package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
- package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
- package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
- package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
- package/templates/api/test/infra/http/app.spec.ts +21 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
- package/templates/api/test/support/factories/profile.factory.ts +25 -0
- package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
- package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
- package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
- package/templates/api/tsconfig.json +3 -6
- package/templates/api/vitest.config.mts +4 -4
- package/templates/mobile/jest.config.js +1 -1
- package/templates/mobile/package.json +2 -2
- package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
- package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/mobile/tsconfig.json +2 -1
- package/templates/site/{src → test}/app/page.spec.ts +1 -1
- package/templates/site/tsconfig.json +2 -1
- package/templates/site/vitest.config.mts +4 -2
- package/templates/web/package.json +4 -2
- package/templates/web/src/features/auth/auth-client.ts +5 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
- package/templates/web/src/features/oauth/consent-form.tsx +63 -0
- package/templates/web/src/routeTree.gen.ts +21 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
- package/templates/web/{src → test}/app.spec.tsx +2 -2
- package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
- package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
- package/templates/web/tsconfig.json +2 -1
- package/templates/web/vite.config.ts +3 -3
- package/.prumo-templates/api/modules.md +0 -117
- package/templates/api/nest-cli.json +0 -10
- package/templates/api/src/app.module.ts +0 -46
- package/templates/api/src/auth/auth.constants.ts +0 -2
- package/templates/api/src/auth/auth.controller.ts +0 -19
- package/templates/api/src/auth/auth.guard.ts +0 -43
- package/templates/api/src/auth/auth.module.ts +0 -30
- package/templates/api/src/auth/auth.ts +0 -4
- package/templates/api/src/auth/current-user.decorator.ts +0 -9
- package/templates/api/src/auth/express.d.ts +0 -10
- package/templates/api/src/auth/public.decorator.ts +0 -4
- package/templates/api/src/config/env.module.ts +0 -11
- package/templates/api/src/config/env.ts +0 -81
- package/templates/api/src/errors/problem-details.filter.ts +0 -86
- package/templates/api/src/errors/validation-exception.factory.ts +0 -30
- package/templates/api/src/health/health.controller.ts +0 -32
- package/templates/api/src/health/health.module.ts +0 -9
- package/templates/api/src/main.ts +0 -43
- package/templates/api/src/mikro-orm.factory.ts +0 -14
- package/templates/api/src/request-context/request-context.module.ts +0 -9
- package/templates/api/src/request-context/request-context.service.ts +0 -17
- package/templates/api/src/request-context/request-id.middleware.ts +0 -19
- package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
- package/templates/api/src/users/entities/profile.entity.ts +0 -27
- package/templates/api/src/users/users.controller.ts +0 -23
- package/templates/api/src/users/users.module.ts +0 -10
- package/templates/api/src/users/users.service.spec.ts +0 -44
- package/templates/api/src/users/users.service.ts +0 -36
- package/templates/api/test/factories/profile.factory.ts +0 -26
- package/templates/api/tsconfig.build.json +0 -8
- /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
- /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
|
@@ -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 {
|
|
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(
|
|
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({ ...
|
|
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
|
+
})
|
package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts
ADDED
|
@@ -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
|
+
})
|
package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts
ADDED
|
@@ -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
|
+
})
|