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