@stjoseph/prumo 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prumo-templates/api/auth.md +3 -2
- package/.prumo-templates/core/testing.md +4 -3
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +19 -5
- package/.prumo-templates/monorepo/tasks.md +1 -1
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +9 -7
- package/dist/asker.js +13 -0
- package/dist/cli.js +25 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +6 -3
- package/dist/context.js +4 -0
- package/dist/features.js +234 -0
- package/dist/generate.js +17 -3
- package/dist/next-steps.js +24 -0
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +18 -0
- package/templates/api/README.md +19 -0
- package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
- package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
- package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
- package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
- package/templates/api/package.json +1 -0
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
- package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
- package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
- package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
- package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
- package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
- package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
- package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
- package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
- package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
- package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
- package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
- package/templates/api/src/infra/config/env.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
- package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +4 -0
- package/templates/api/src/infra/di/social.di.ts +17 -0
- package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
- package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
- package/templates/api/src/infra/http/app.ts +4 -0
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
- package/templates/api/src/infra/http/session-delivery.ts +69 -0
- package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
- package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
- package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
- package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
- package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
- package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
- package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/README.md +16 -3
- package/templates/mobile/package.json +3 -2
- package/templates/mobile/src/api-contract/api-error.ts +4 -0
- package/templates/mobile/src/api-contract/auth.ts +63 -3
- package/templates/mobile/src/api-contract/index.ts +4 -0
- package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
- package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
- package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
- package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
- package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/web/README.md +5 -0
- package/templates/web/src/api-contract/api-error.ts +4 -0
- package/templates/web/src/api-contract/auth.ts +63 -3
- package/templates/web/src/api-contract/index.ts +4 -0
- package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
- package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
- package/templates/web/src/features/auth/social-buttons.tsx +45 -0
- package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
- package/templates/web/src/features/oauth/consent-form.tsx +7 -1
- package/templates/web/src/routeTree.gen.ts +73 -2
- package/templates/web/src/routes/forgot-password.tsx +17 -0
- package/templates/web/src/routes/reset-password.tsx +20 -0
- package/templates/web/src/routes/sign-in.tsx +10 -3
- package/templates/web/src/routes/sign-up.tsx +4 -0
- package/templates/web/src/routes/verify-email.tsx +20 -0
- package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
- package/templates/workspace/README.md +10 -0
- package/dist/mcp.js +0 -107
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { DependencyContainer } from 'tsyringe'
|
|
2
|
+
import { MAILER } from '@/domain/auth/ports/mailer.port'
|
|
3
|
+
import { ONE_TIME_CODES } from '@/domain/auth/ports/one-time-codes.port'
|
|
4
|
+
import { EMAIL_CODE_REPOSITORY } from '@/domain/auth/repositories/email-code.repository'
|
|
5
|
+
import { ENV, type Env } from '@/infra/config/env'
|
|
6
|
+
import { MikroOrmEmailCodeRepository } from '@/infra/database/mikroorm/repositories/mikroorm-email-code.repository'
|
|
7
|
+
import { HmacOneTimeCodes } from '@/infra/email/hmac-one-time-codes.adapter'
|
|
8
|
+
import { LogMailer } from '@/infra/email/log-mailer.adapter'
|
|
9
|
+
|
|
10
|
+
export function registerEmail(container: DependencyContainer): void {
|
|
11
|
+
const env = container.resolve<Env>(ENV)
|
|
12
|
+
|
|
13
|
+
container.register(EMAIL_CODE_REPOSITORY, { useClass: MikroOrmEmailCodeRepository })
|
|
14
|
+
container.register(ONE_TIME_CODES, { useValue: new HmacOneTimeCodes(env) })
|
|
15
|
+
// Silent under test, where a test registers its own Mailer to read what was sent.
|
|
16
|
+
container.register(MAILER, {
|
|
17
|
+
useValue: new LogMailer((line) => {
|
|
18
|
+
if (env.NODE_ENV !== 'test') {
|
|
19
|
+
console.info(line)
|
|
20
|
+
}
|
|
21
|
+
}),
|
|
22
|
+
})
|
|
23
|
+
}
|
|
@@ -4,7 +4,9 @@ import { TRANSACTION_MANAGER } from '@/domain/shared/transactions/transaction-ma
|
|
|
4
4
|
import { ENV, type Env } from '@/infra/config/env'
|
|
5
5
|
import { MikroOrmTransactionManager } from '@/infra/database/mikroorm/transactions/mikroorm-transaction-manager'
|
|
6
6
|
import { registerAuth } from './auth.di'
|
|
7
|
+
import { registerEmail } from './email.di' // prumo:email
|
|
7
8
|
import { registerOauth } from './oauth.di' // prumo:mcp
|
|
9
|
+
import { registerSocial } from './social.di' // prumo:social
|
|
8
10
|
import { registerUsers } from './users.di'
|
|
9
11
|
|
|
10
12
|
export type Dependencies = { env: Env; orm: MikroORM }
|
|
@@ -21,6 +23,8 @@ export function createContainer({ env, orm }: Dependencies): DependencyContainer
|
|
|
21
23
|
registerAuth(container)
|
|
22
24
|
registerUsers(container)
|
|
23
25
|
registerOauth(container) // prumo:mcp
|
|
26
|
+
registerEmail(container) // prumo:email
|
|
27
|
+
registerSocial(container) // prumo:social
|
|
24
28
|
|
|
25
29
|
return container
|
|
26
30
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { DependencyContainer } from 'tsyringe'
|
|
2
|
+
import { IDENTITY_PROVIDERS } from '@/domain/auth/ports/identity-providers.port'
|
|
3
|
+
import { IDENTITY_REPOSITORY } from '@/domain/auth/repositories/identity.repository'
|
|
4
|
+
import { SOCIAL_SIGN_IN_REPOSITORY } from '@/domain/auth/repositories/social-sign-in.repository'
|
|
5
|
+
import { ENV, type Env } from '@/infra/config/env'
|
|
6
|
+
import { MikroOrmIdentityRepository } from '@/infra/database/mikroorm/repositories/mikroorm-identity.repository'
|
|
7
|
+
import { MikroOrmSocialSignInRepository } from '@/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository'
|
|
8
|
+
import { OpenIdIdentityProviders } from '@/infra/social/openid-identity-providers.adapter'
|
|
9
|
+
|
|
10
|
+
export function registerSocial(container: DependencyContainer): void {
|
|
11
|
+
container.register(IDENTITY_REPOSITORY, { useClass: MikroOrmIdentityRepository })
|
|
12
|
+
container.register(SOCIAL_SIGN_IN_REPOSITORY, { useClass: MikroOrmSocialSignInRepository })
|
|
13
|
+
// One instance, so each provider's discovery document is fetched once.
|
|
14
|
+
container.register(IDENTITY_PROVIDERS, {
|
|
15
|
+
useValue: new OpenIdIdentityProviders(container.resolve<Env>(ENV)),
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHmac, randomInt } from 'node:crypto'
|
|
2
|
+
import type { OneTimeCodes } from '@/domain/auth/ports/one-time-codes.port'
|
|
3
|
+
import type { Env } from '@/infra/config/env'
|
|
4
|
+
|
|
5
|
+
export class HmacOneTimeCodes implements OneTimeCodes {
|
|
6
|
+
constructor(private readonly env: Pick<Env, 'JWT_SECRET'>) {}
|
|
7
|
+
|
|
8
|
+
create(): string {
|
|
9
|
+
return randomInt(0, 1_000_000).toString().padStart(6, '0')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// A million codes hash in a moment, so the hash is keyed by a secret the database does not hold.
|
|
13
|
+
digest(code: string): string {
|
|
14
|
+
return createHmac('sha256', this.env.JWT_SECRET).update(code).digest('base64url')
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Mail, Mailer } from '@/domain/auth/ports/mailer.port'
|
|
2
|
+
|
|
3
|
+
// Writes each mail to the log instead of sending it: enough to develop against, and nothing to sign up for. A
|
|
4
|
+
// project that sends real mail gives the Mailer port another adapter; see .prumo/email/.
|
|
5
|
+
export class LogMailer implements Mailer {
|
|
6
|
+
constructor(private readonly write: (line: string) => void) {}
|
|
7
|
+
|
|
8
|
+
async send(mail: Mail): Promise<void> {
|
|
9
|
+
this.write(`[mail] to ${mail.to}: ${mail.subject}\n${mail.text}`)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -14,9 +14,11 @@ import {
|
|
|
14
14
|
import type { DependencyContainer } from 'tsyringe'
|
|
15
15
|
import { ENV, type Env } from '@/infra/config/env'
|
|
16
16
|
import { authController } from '@/infra/http/controllers/auth.controller'
|
|
17
|
+
import { authEmailController } from '@/infra/http/controllers/auth-email.controller' // prumo:email
|
|
17
18
|
import { healthController } from '@/infra/http/controllers/health.controller'
|
|
18
19
|
import { mcpController } from '@/infra/http/controllers/mcp.controller' // prumo:mcp
|
|
19
20
|
import { oauthController } from '@/infra/http/controllers/oauth.controller' // prumo:mcp
|
|
21
|
+
import { socialController } from '@/infra/http/controllers/social.controller' // prumo:social
|
|
20
22
|
import { usersController } from '@/infra/http/controllers/users.controller'
|
|
21
23
|
import { registerErrorHandler } from '@/infra/http/errors/error-handler'
|
|
22
24
|
import { registerAuthHook } from '@/infra/http/hooks/auth.hook'
|
|
@@ -98,6 +100,8 @@ export async function buildApp(container: DependencyContainer): Promise<FastifyI
|
|
|
98
100
|
registerAuthHook(app)
|
|
99
101
|
|
|
100
102
|
await app.register(authController, { prefix: '/api/auth' })
|
|
103
|
+
await app.register(authEmailController, { prefix: '/api/auth' }) // prumo:email
|
|
104
|
+
await app.register(socialController, { prefix: '/api/auth/social' }) // prumo:social
|
|
101
105
|
await app.register(healthController, { prefix: '/api/health' })
|
|
102
106
|
await app.register(usersController, { prefix: '/api/v1/users' })
|
|
103
107
|
// prumo:mcp
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify'
|
|
2
|
+
import type { ZodTypeProvider } from 'fastify-type-provider-zod'
|
|
3
|
+
import { emailRequestSchema } from '@/domain/auth/dto/email-request.dto'
|
|
4
|
+
import { resetPasswordSchema } from '@/domain/auth/dto/reset-password.dto'
|
|
5
|
+
import { tokensResponseSchema } from '@/domain/auth/dto/tokens-response.dto'
|
|
6
|
+
import { verifyEmailSchema } from '@/domain/auth/dto/verify-email.dto'
|
|
7
|
+
import { RequestEmailVerificationUseCase } from '@/domain/auth/use-cases/request-email-verification.use-case'
|
|
8
|
+
import { RequestPasswordResetUseCase } from '@/domain/auth/use-cases/request-password-reset.use-case'
|
|
9
|
+
import { ResetPasswordUseCase } from '@/domain/auth/use-cases/reset-password.use-case'
|
|
10
|
+
import { VerifyEmailUseCase } from '@/domain/auth/use-cases/verify-email.use-case'
|
|
11
|
+
import { ENV, type Env } from '@/infra/config/env'
|
|
12
|
+
import { ATTEMPTS } from '@/infra/http/controllers/auth.controller'
|
|
13
|
+
import { sessionDelivery } from '@/infra/http/session-delivery'
|
|
14
|
+
|
|
15
|
+
// Every route answers alike whether or not the email has an account: none of them can be used to find out.
|
|
16
|
+
export async function authEmailController(fastify: FastifyInstance): Promise<void> {
|
|
17
|
+
const app = fastify.withTypeProvider<ZodTypeProvider>()
|
|
18
|
+
const { deliver } = sessionDelivery(app.container.resolve<Env>(ENV))
|
|
19
|
+
|
|
20
|
+
app.post(
|
|
21
|
+
'/email/verification',
|
|
22
|
+
{ config: { public: true, ...ATTEMPTS }, schema: { tags: ['auth'], body: emailRequestSchema } },
|
|
23
|
+
async (request, reply) => {
|
|
24
|
+
await app.container.resolve(RequestEmailVerificationUseCase).execute(request.body.email)
|
|
25
|
+
|
|
26
|
+
return reply.code(204).send()
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
app.post(
|
|
31
|
+
'/email/verify',
|
|
32
|
+
{
|
|
33
|
+
config: { public: true, ...ATTEMPTS },
|
|
34
|
+
schema: { tags: ['auth'], body: verifyEmailSchema, response: { 200: tokensResponseSchema } },
|
|
35
|
+
},
|
|
36
|
+
async (request, reply) =>
|
|
37
|
+
deliver(
|
|
38
|
+
request,
|
|
39
|
+
reply,
|
|
40
|
+
await app.container.resolve(VerifyEmailUseCase).execute(request.body),
|
|
41
|
+
200,
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
app.post(
|
|
46
|
+
'/password/forgot',
|
|
47
|
+
{ config: { public: true, ...ATTEMPTS }, schema: { tags: ['auth'], body: emailRequestSchema } },
|
|
48
|
+
async (request, reply) => {
|
|
49
|
+
await app.container.resolve(RequestPasswordResetUseCase).execute(request.body.email)
|
|
50
|
+
|
|
51
|
+
return reply.code(204).send()
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
app.post(
|
|
56
|
+
'/password/reset',
|
|
57
|
+
{
|
|
58
|
+
config: { public: true, ...ATTEMPTS },
|
|
59
|
+
schema: {
|
|
60
|
+
tags: ['auth'],
|
|
61
|
+
body: resetPasswordSchema,
|
|
62
|
+
response: { 200: tokensResponseSchema },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
async (request, reply) =>
|
|
66
|
+
deliver(
|
|
67
|
+
request,
|
|
68
|
+
reply,
|
|
69
|
+
await app.container.resolve(ResetPasswordUseCase).execute(request.body),
|
|
70
|
+
200,
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -1,95 +1,51 @@
|
|
|
1
|
-
import type { FastifyInstance
|
|
1
|
+
import type { FastifyInstance } from 'fastify'
|
|
2
2
|
import type { ZodTypeProvider } from 'fastify-type-provider-zod'
|
|
3
|
+
import { z } from 'zod'
|
|
3
4
|
import { refreshSchema } from '@/domain/auth/dto/refresh.dto'
|
|
4
5
|
import { sessionResponseSchema } from '@/domain/auth/dto/session-response.dto'
|
|
5
6
|
import { signInSchema } from '@/domain/auth/dto/sign-in.dto'
|
|
6
7
|
import { signUpSchema } from '@/domain/auth/dto/sign-up.dto'
|
|
7
|
-
import {
|
|
8
|
+
import { tokensResponseSchema } from '@/domain/auth/dto/tokens-response.dto'
|
|
8
9
|
import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
|
|
9
10
|
import { FindUserUseCase } from '@/domain/auth/use-cases/find-user.use-case'
|
|
10
11
|
import { RefreshSessionUseCase } from '@/domain/auth/use-cases/refresh-session.use-case'
|
|
11
12
|
import { SignInUseCase } from '@/domain/auth/use-cases/sign-in.use-case'
|
|
12
13
|
import { SignOutUseCase } from '@/domain/auth/use-cases/sign-out.use-case'
|
|
13
14
|
import { SignUpUseCase } from '@/domain/auth/use-cases/sign-up.use-case'
|
|
14
|
-
import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
|
|
15
15
|
import { ENV, type Env } from '@/infra/config/env'
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
const REFRESH_COOKIE = 'refresh_token'
|
|
19
|
-
|
|
20
|
-
// The refresh cookie travels only to the one route that reads it, never with an ordinary request.
|
|
21
|
-
const REFRESH_PATH = '/api/auth/refresh'
|
|
16
|
+
import { currentSession, currentUser } from '@/infra/http/hooks/auth.hook'
|
|
17
|
+
import { REFRESH_COOKIE, sessionDelivery, wantsBearer } from '@/infra/http/session-delivery'
|
|
22
18
|
|
|
23
19
|
// Repeated guessing is slowed down per address; a refresh happens every quarter hour per tab, so it gets more room.
|
|
24
|
-
const ATTEMPTS = { rateLimit: { max: 10, timeWindow: '1 minute' } }
|
|
20
|
+
export const ATTEMPTS = { rateLimit: { max: 10, timeWindow: '1 minute' } }
|
|
25
21
|
const REFRESHES = { rateLimit: { max: 60, timeWindow: '1 minute' } }
|
|
26
22
|
|
|
27
|
-
|
|
28
|
-
// injected script can take.
|
|
29
|
-
function wantsBearer(request: FastifyRequest): boolean {
|
|
30
|
-
return request.headers['x-auth-transport'] === 'bearer'
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function asBody(tokens: SessionTokens): TokensResponseDto {
|
|
34
|
-
return {
|
|
35
|
-
accessToken: tokens.accessToken.token,
|
|
36
|
-
refreshToken: tokens.refreshToken,
|
|
37
|
-
expiresIn: Math.round((tokens.accessToken.expiresAt.getTime() - Date.now()) / 1000),
|
|
38
|
-
}
|
|
39
|
-
}
|
|
23
|
+
const verificationRequiredSchema = z.object({ verificationRequired: z.literal(true) })
|
|
40
24
|
|
|
41
25
|
export async function authController(fastify: FastifyInstance): Promise<void> {
|
|
42
26
|
const app = fastify.withTypeProvider<ZodTypeProvider>()
|
|
43
|
-
const
|
|
44
|
-
const secure = env.API_URL.startsWith('https://')
|
|
45
|
-
const cookie = { httpOnly: true, sameSite: 'lax', secure } as const
|
|
46
|
-
|
|
47
|
-
function setCookies(reply: FastifyReply, tokens: SessionTokens): void {
|
|
48
|
-
reply.setCookie(ACCESS_COOKIE, tokens.accessToken.token, {
|
|
49
|
-
...cookie,
|
|
50
|
-
path: '/',
|
|
51
|
-
expires: tokens.accessToken.expiresAt,
|
|
52
|
-
})
|
|
53
|
-
reply.setCookie(REFRESH_COOKIE, tokens.refreshToken, {
|
|
54
|
-
...cookie,
|
|
55
|
-
path: REFRESH_PATH,
|
|
56
|
-
expires: tokens.refreshExpiresAt,
|
|
57
|
-
})
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function clearCookies(reply: FastifyReply): void {
|
|
61
|
-
reply.clearCookie(ACCESS_COOKIE, { ...cookie, path: '/' })
|
|
62
|
-
reply.clearCookie(REFRESH_COOKIE, { ...cookie, path: REFRESH_PATH })
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// The web gets cookies and an empty body; a native client gets the tokens and no cookie.
|
|
66
|
-
function deliver(
|
|
67
|
-
request: FastifyRequest,
|
|
68
|
-
reply: FastifyReply,
|
|
69
|
-
tokens: SessionTokens,
|
|
70
|
-
status: number,
|
|
71
|
-
) {
|
|
72
|
-
if (wantsBearer(request)) {
|
|
73
|
-
return reply.code(status).send(asBody(tokens))
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
setCookies(reply, tokens)
|
|
77
|
-
return reply.code(status === 201 ? 201 : 204).send()
|
|
78
|
-
}
|
|
27
|
+
const { deliver, clearCookies } = sessionDelivery(app.container.resolve<Env>(ENV))
|
|
79
28
|
|
|
80
29
|
app.post(
|
|
81
30
|
'/sign-up',
|
|
82
31
|
{
|
|
83
32
|
config: { public: true, ...ATTEMPTS },
|
|
84
|
-
schema: {
|
|
33
|
+
schema: {
|
|
34
|
+
tags: ['auth'],
|
|
35
|
+
body: signUpSchema,
|
|
36
|
+
response: { 201: z.union([tokensResponseSchema, verificationRequiredSchema]) },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
async (request, reply) => {
|
|
40
|
+
const tokens = await app.container.resolve(SignUpUseCase).execute(request.body)
|
|
41
|
+
|
|
42
|
+
// No session until the address is confirmed: the client asks for the code next.
|
|
43
|
+
if (tokens === null) {
|
|
44
|
+
return reply.code(201).send({ verificationRequired: true })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return deliver(request, reply, tokens, 201)
|
|
85
48
|
},
|
|
86
|
-
async (request, reply) =>
|
|
87
|
-
deliver(
|
|
88
|
-
request,
|
|
89
|
-
reply,
|
|
90
|
-
await app.container.resolve(SignUpUseCase).execute(request.body),
|
|
91
|
-
201,
|
|
92
|
-
),
|
|
93
49
|
)
|
|
94
50
|
|
|
95
51
|
app.post(
|
|
@@ -4,6 +4,7 @@ import { ACCESS_TOKENS, type AccessTokens } from '@/domain/auth/ports/access-tok
|
|
|
4
4
|
import { ENV, type Env } from '@/infra/config/env'
|
|
5
5
|
import { HttpError } from '@/infra/http/errors/http-error'
|
|
6
6
|
import { sendFetchResponse, toFetchRequest } from '@/infra/http/fetch-bridge'
|
|
7
|
+
import { missingScopes, SCOPES } from '@/infra/mcp/mcp.scopes'
|
|
7
8
|
import { createMcpServerHandler, mcpResource, mcpResourceMetadataUrl } from '@/infra/mcp/mcp.server'
|
|
8
9
|
|
|
9
10
|
// The route answers an OAuth access token issued for it, never a session cookie or a first-party token: the
|
|
@@ -24,18 +25,31 @@ export async function mcpController(fastify: FastifyInstance): Promise<void> {
|
|
|
24
25
|
const claims = token === undefined ? null : await tokens.verify(token, resource)
|
|
25
26
|
|
|
26
27
|
if (token === undefined || claims === null || claims.clientId === undefined) {
|
|
27
|
-
// RFC 9728: the 401 names the document that says where to obtain a token.
|
|
28
|
+
// RFC 9728: the 401 names the document that says where to obtain a token, and the scopes worth asking for.
|
|
28
29
|
reply.header(
|
|
29
30
|
'www-authenticate',
|
|
30
|
-
`Bearer resource_metadata="${mcpResourceMetadataUrl(env)}"${token === undefined ? '' : ', error="invalid_token"'}`,
|
|
31
|
+
`Bearer resource_metadata="${mcpResourceMetadataUrl(env)}", scope="${Object.keys(SCOPES).join(' ')}"${token === undefined ? '' : ', error="invalid_token"'}`,
|
|
31
32
|
)
|
|
32
33
|
throw new HttpError(401, 'Unauthorized')
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
const scopes = (claims.scope ?? '').split(' ').filter(Boolean)
|
|
37
|
+
const missing = missingScopes(request.body, scopes)
|
|
38
|
+
|
|
39
|
+
// Checked before the server runs the call: a tool the user did not grant is refused with the scopes it needs,
|
|
40
|
+
// so the client can ask the user for them and come back.
|
|
41
|
+
if (missing.length > 0) {
|
|
42
|
+
reply.header(
|
|
43
|
+
'www-authenticate',
|
|
44
|
+
`Bearer error="insufficient_scope", scope="${missing.join(' ')}", resource_metadata="${mcpResourceMetadataUrl(env)}"`,
|
|
45
|
+
)
|
|
46
|
+
throw new HttpError(403, `This token lacks ${missing.join(' ')}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
const authInfo: AuthInfo = {
|
|
36
50
|
token,
|
|
37
51
|
clientId: claims.clientId,
|
|
38
|
-
scopes
|
|
52
|
+
scopes,
|
|
39
53
|
extra: { userId: claims.userId },
|
|
40
54
|
}
|
|
41
55
|
|
|
@@ -18,6 +18,7 @@ import { FindAuthorizationUseCase } from '@/domain/oauth/use-cases/find-authoriz
|
|
|
18
18
|
import { StartAuthorizationUseCase } from '@/domain/oauth/use-cases/start-authorization.use-case'
|
|
19
19
|
import { ENV, type Env } from '@/infra/config/env'
|
|
20
20
|
import { currentUser } from '@/infra/http/hooks/auth.hook'
|
|
21
|
+
import { SCOPES } from '@/infra/mcp/mcp.scopes'
|
|
21
22
|
import { mcpResource } from '@/infra/mcp/mcp.server'
|
|
22
23
|
|
|
23
24
|
const PUBLIC = { public: true, rateLimit: { max: 30, timeWindow: '1 minute' } }
|
|
@@ -54,6 +55,7 @@ function tokenResponse(tokens: SessionTokens) {
|
|
|
54
55
|
token_type: 'Bearer',
|
|
55
56
|
expires_in: Math.round((tokens.accessToken.expiresAt.getTime() - Date.now()) / 1000),
|
|
56
57
|
refresh_token: tokens.refreshToken,
|
|
58
|
+
scope: tokens.scope ?? '',
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -72,10 +74,12 @@ export async function oauthController(fastify: FastifyInstance): Promise<void> {
|
|
|
72
74
|
(_request, body, done) => done(null, Object.fromEntries(new URLSearchParams(body as string))),
|
|
73
75
|
)
|
|
74
76
|
|
|
77
|
+
const scopesSupported = Object.keys(SCOPES)
|
|
75
78
|
const resourceMetadata = {
|
|
76
79
|
resource,
|
|
77
80
|
authorization_servers: [issuer],
|
|
78
81
|
bearer_methods_supported: ['header'],
|
|
82
|
+
scopes_supported: scopesSupported,
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
// At the path of the resource first, then at the root: the two places an MCP client looks.
|
|
@@ -98,6 +102,7 @@ export async function oauthController(fastify: FastifyInstance): Promise<void> {
|
|
|
98
102
|
code_challenge_methods_supported: ['S256'],
|
|
99
103
|
token_endpoint_auth_methods_supported: ['none'],
|
|
100
104
|
client_id_metadata_document_supported: true,
|
|
105
|
+
scopes_supported: scopesSupported,
|
|
101
106
|
authorization_response_iss_parameter_supported: true,
|
|
102
107
|
}),
|
|
103
108
|
)
|
|
@@ -118,8 +123,10 @@ export async function oauthController(fastify: FastifyInstance): Promise<void> {
|
|
|
118
123
|
codeChallengeMethod: query.code_challenge_method,
|
|
119
124
|
clientState: query.state ?? null,
|
|
120
125
|
resource: query.resource,
|
|
126
|
+
scope: query.scope,
|
|
121
127
|
},
|
|
122
128
|
resource,
|
|
129
|
+
scopesSupported,
|
|
123
130
|
)
|
|
124
131
|
|
|
125
132
|
// Signing in, if needed, and the consent itself happen on the web app.
|
|
@@ -162,6 +169,9 @@ export async function oauthController(fastify: FastifyInstance): Promise<void> {
|
|
|
162
169
|
clientHost: new URL(authorization.clientId).host,
|
|
163
170
|
redirectHost: redirect.host,
|
|
164
171
|
redirectsToThisDevice: LOOPBACK.has(redirect.hostname),
|
|
172
|
+
scopes: authorization.scope
|
|
173
|
+
.split(' ')
|
|
174
|
+
.map((scope) => ({ scope, description: SCOPES[scope] ?? scope })),
|
|
165
175
|
}
|
|
166
176
|
},
|
|
167
177
|
)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify'
|
|
2
|
+
import type { ZodTypeProvider } from 'fastify-type-provider-zod'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { tokensResponseSchema } from '@/domain/auth/dto/tokens-response.dto'
|
|
5
|
+
import type { SocialClient } from '@/domain/auth/entities/social-sign-in.entity'
|
|
6
|
+
import { CompleteSocialSignInUseCase } from '@/domain/auth/use-cases/complete-social-sign-in.use-case'
|
|
7
|
+
import { ExchangeSocialCodeUseCase } from '@/domain/auth/use-cases/exchange-social-code.use-case'
|
|
8
|
+
import { StartSocialSignInUseCase } from '@/domain/auth/use-cases/start-social-sign-in.use-case'
|
|
9
|
+
import { DomainError } from '@/domain/shared/errors/domain-error'
|
|
10
|
+
import { ENV, type Env } from '@/infra/config/env'
|
|
11
|
+
import { ATTEMPTS } from '@/infra/http/controllers/auth.controller'
|
|
12
|
+
import { HttpError } from '@/infra/http/errors/http-error'
|
|
13
|
+
import { sessionDelivery } from '@/infra/http/session-delivery'
|
|
14
|
+
|
|
15
|
+
const BROWSER_COOKIE = 'social_sign_in'
|
|
16
|
+
const BROWSER_COOKIE_MAX_AGE_S = 10 * 60
|
|
17
|
+
|
|
18
|
+
const PROVIDERS = [
|
|
19
|
+
'google', // prumo:google
|
|
20
|
+
'apple', // prumo:apple
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
const providerParams = z.object({ provider: z.enum(PROVIDERS) })
|
|
24
|
+
|
|
25
|
+
// Only a path on the web app: an absolute URL here would turn sign-in into an open redirect.
|
|
26
|
+
function safePath(path: string | undefined): string {
|
|
27
|
+
return path?.startsWith('/') && !path.startsWith('//') ? path : '/'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Apple sends the name it shares once, as JSON beside the code.
|
|
31
|
+
function appleName(user: string | undefined): string | null {
|
|
32
|
+
try {
|
|
33
|
+
const name = (JSON.parse(user ?? '') as { name?: { firstName?: string; lastName?: string } })
|
|
34
|
+
.name
|
|
35
|
+
const full = [name?.firstName, name?.lastName].filter(Boolean).join(' ')
|
|
36
|
+
|
|
37
|
+
return full === '' ? null : full
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function socialController(fastify: FastifyInstance): Promise<void> {
|
|
44
|
+
const app = fastify.withTypeProvider<ZodTypeProvider>()
|
|
45
|
+
const env = app.container.resolve<Env>(ENV)
|
|
46
|
+
const { deliver, setCookies } = sessionDelivery(env)
|
|
47
|
+
const cookie = {
|
|
48
|
+
httpOnly: true,
|
|
49
|
+
sameSite: 'lax',
|
|
50
|
+
secure: env.API_URL.startsWith('https://'),
|
|
51
|
+
path: '/api/auth/social',
|
|
52
|
+
} as const
|
|
53
|
+
|
|
54
|
+
const back = (client: SocialClient, query: Record<string, string>) =>
|
|
55
|
+
client === 'mobile'
|
|
56
|
+
? `${env.MOBILE_APP_SCHEME}://social?${new URLSearchParams(query)}`
|
|
57
|
+
: `${env.WEB_ORIGIN}/sign-in?${new URLSearchParams(query)}`
|
|
58
|
+
|
|
59
|
+
// Apple's answer is a form.
|
|
60
|
+
app.addContentTypeParser(
|
|
61
|
+
'application/x-www-form-urlencoded',
|
|
62
|
+
{ parseAs: 'string' },
|
|
63
|
+
(_request, body, done) => done(null, Object.fromEntries(new URLSearchParams(body as string))),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
app.get(
|
|
67
|
+
'/:provider',
|
|
68
|
+
{
|
|
69
|
+
config: { public: true, ...ATTEMPTS },
|
|
70
|
+
schema: {
|
|
71
|
+
tags: ['auth'],
|
|
72
|
+
params: providerParams,
|
|
73
|
+
querystring: z.object({
|
|
74
|
+
client: z.enum(['web', 'mobile']).default('web'),
|
|
75
|
+
returnTo: z.string().max(512).optional(),
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
async (request, reply) => {
|
|
80
|
+
const { client, returnTo } = request.query
|
|
81
|
+
|
|
82
|
+
if (client === 'mobile' && env.MOBILE_APP_SCHEME === undefined) {
|
|
83
|
+
throw new HttpError(
|
|
84
|
+
422,
|
|
85
|
+
'MOBILE_APP_SCHEME is not set, so a mobile app cannot be sent back',
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const started = await app.container.resolve(StartSocialSignInUseCase).execute({
|
|
90
|
+
provider: request.params.provider,
|
|
91
|
+
client,
|
|
92
|
+
returnTo: safePath(returnTo),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// The client rides beside the token, so an error found before the flow is read still goes to the right place.
|
|
96
|
+
reply.setCookie(BROWSER_COOKIE, `${client}~${started.browserToken}`, {
|
|
97
|
+
...cookie,
|
|
98
|
+
maxAge: BROWSER_COOKIE_MAX_AGE_S,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return reply.redirect(started.url)
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
// A cross-site POST carries no SameSite=Lax cookie, and a top-level GET does: Apple's form is turned into a
|
|
106
|
+
// redirect to the same address, so the browser's token comes with it.
|
|
107
|
+
app.post(
|
|
108
|
+
'/:provider/callback',
|
|
109
|
+
{ config: { public: true }, schema: { hide: true, params: providerParams } },
|
|
110
|
+
async (request, reply) => {
|
|
111
|
+
const form = new URLSearchParams(request.body as Record<string, string>)
|
|
112
|
+
|
|
113
|
+
return reply
|
|
114
|
+
.code(303)
|
|
115
|
+
.redirect(`${env.API_URL}/api/auth/social/${request.params.provider}/callback?${form}`)
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
app.get(
|
|
120
|
+
'/:provider/callback',
|
|
121
|
+
{
|
|
122
|
+
config: { public: true },
|
|
123
|
+
schema: {
|
|
124
|
+
hide: true,
|
|
125
|
+
params: providerParams,
|
|
126
|
+
querystring: z.object({
|
|
127
|
+
state: z.string().optional(),
|
|
128
|
+
code: z.string().optional(),
|
|
129
|
+
error: z.string().optional(),
|
|
130
|
+
user: z.string().optional(),
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
async (request, reply) => {
|
|
135
|
+
const [client, browserToken] = (request.cookies[BROWSER_COOKIE] ?? 'web~').split('~') as [
|
|
136
|
+
SocialClient,
|
|
137
|
+
string,
|
|
138
|
+
]
|
|
139
|
+
const { state, code, error } = request.query
|
|
140
|
+
|
|
141
|
+
reply.clearCookie(BROWSER_COOKIE, cookie)
|
|
142
|
+
|
|
143
|
+
if (error !== undefined || state === undefined || code === undefined) {
|
|
144
|
+
return reply.redirect(back(client, { error: 'social_sign_in_failed' }))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const outcome = await app.container.resolve(CompleteSocialSignInUseCase).execute({
|
|
149
|
+
provider: request.params.provider,
|
|
150
|
+
state,
|
|
151
|
+
browserToken: browserToken === '' ? undefined : browserToken,
|
|
152
|
+
callbackUrl: new URL(request.url, env.API_URL),
|
|
153
|
+
name: appleName(request.query.user),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
if (outcome.client === 'mobile') {
|
|
157
|
+
return reply.redirect(back('mobile', { code: outcome.code }))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
setCookies(reply, outcome.tokens)
|
|
161
|
+
|
|
162
|
+
return reply.redirect(`${env.WEB_ORIGIN}${outcome.returnTo}`)
|
|
163
|
+
} catch (caught) {
|
|
164
|
+
if (caught instanceof DomainError) {
|
|
165
|
+
return reply.redirect(back(client, { error: caught.code ?? 'social_sign_in_failed' }))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
throw caught
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
// The mobile app trades the code it was sent back with for its tokens, once.
|
|
174
|
+
app.post(
|
|
175
|
+
'/exchange',
|
|
176
|
+
{
|
|
177
|
+
config: { public: true, ...ATTEMPTS },
|
|
178
|
+
schema: {
|
|
179
|
+
tags: ['auth'],
|
|
180
|
+
body: z.strictObject({ code: z.string().min(1).max(512) }),
|
|
181
|
+
response: { 200: tokensResponseSchema },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
async (request, reply) =>
|
|
185
|
+
deliver(
|
|
186
|
+
request,
|
|
187
|
+
reply,
|
|
188
|
+
await app.container.resolve(ExchangeSocialCodeUseCase).execute(request.body.code),
|
|
189
|
+
200,
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
}
|
|
@@ -11,6 +11,7 @@ type ProblemDocument = {
|
|
|
11
11
|
detail: string
|
|
12
12
|
instance: string
|
|
13
13
|
requestId: string
|
|
14
|
+
code?: string
|
|
14
15
|
errors?: FieldErrors
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -104,9 +105,12 @@ export function registerErrorHandler(app: FastifyInstance): void {
|
|
|
104
105
|
request.log.error(error)
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
const code = error instanceof DomainError ? error.code : undefined
|
|
109
|
+
|
|
107
110
|
return send(request, reply, {
|
|
108
111
|
status,
|
|
109
112
|
detail: detailOf(error, status, errors),
|
|
113
|
+
...(code === undefined ? {} : { code }),
|
|
110
114
|
...(errors === undefined ? {} : { errors }),
|
|
111
115
|
})
|
|
112
116
|
})
|