@stjoseph/prumo 0.2.0 → 0.4.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 -60
- package/.prumo-templates/api/config.md +2 -2
- package/.prumo-templates/api/routes.md +6 -4
- package/.prumo-templates/api/structure.md +4 -1
- package/.prumo-templates/client/data.md +16 -18
- package/.prumo-templates/client/forms.md +2 -2
- package/.prumo-templates/core/testing.md +4 -4
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +55 -14
- package/.prumo-templates/mobile/routing.md +1 -1
- package/.prumo-templates/mobile/storage.md +7 -6
- package/.prumo-templates/monorepo/layout.md +4 -4
- package/.prumo-templates/multi-tenancy/client.md +6 -6
- package/.prumo-templates/multi-tenancy/isolation.md +17 -10
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +8 -6
- package/dist/asker.js +13 -0
- package/dist/clean.js +2 -2
- package/dist/cli.js +16 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +3 -11
- package/dist/context.js +4 -0
- package/dist/features.js +230 -0
- package/dist/generate.js +20 -7
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +21 -5
- package/templates/api/README.md +4 -5
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261005111545_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261005111547_create_authorization.ts +18 -0
- 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 +7 -8
- package/templates/api/scripts/database.mjs +3 -11
- package/templates/api/scripts/ports.mjs +1 -1
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
- package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
- package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -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 +78 -0
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +34 -0
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -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/errors/superseded-token.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
- 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/ports/opaque-tokens.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -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 +12 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/user.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/find-user.use-case.ts +20 -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 +94 -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 +38 -0
- package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +57 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +55 -0
- 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-decision.dto.ts +7 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +14 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +87 -0
- package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +18 -0
- package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
- package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
- package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
- package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
- package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
- package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +97 -0
- package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +89 -0
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
- package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +64 -0
- package/templates/api/src/infra/config/env.ts +17 -3
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +38 -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/profile.schema.ts +1 -1
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +34 -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 +19 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +15 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -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 +25 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
- package/templates/api/src/infra/di/auth.di.ts +21 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +8 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -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 +15 -1
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +108 -29
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +46 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +267 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +5 -0
- package/templates/api/src/infra/http/fastify.d.ts +2 -3
- package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
- package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
- package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
- package/templates/api/src/infra/http/server.ts +1 -4
- 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/mcp.server.ts +10 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
- package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -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 +66 -0
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +41 -0
- 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 +53 -0
- package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
- package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -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 +168 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +309 -67
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +13 -35
- package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
- package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +94 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
- 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/fake-opaque-tokens.ts +14 -0
- package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
- package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -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 +27 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/setup.ts +6 -28
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/jest.config.js +0 -17
- package/templates/mobile/package.json +0 -4
- package/templates/mobile/scripts/ports.mjs +1 -1
- package/templates/mobile/src/api-contract/api-error.ts +4 -11
- package/templates/mobile/src/api-contract/auth.ts +221 -0
- package/templates/mobile/src/api-contract/index.ts +14 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- 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/app/_layout.tsx +5 -5
- 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 +21 -7
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +24 -8
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/token-store.ts +21 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
- package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/mobile/test/support/memory-token-store.ts +15 -0
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/package.json +0 -3
- package/templates/web/scripts/ports.mjs +1 -1
- package/templates/web/src/api-contract/api-error.ts +4 -11
- package/templates/web/src/api-contract/auth.ts +221 -0
- package/templates/web/src/api-contract/index.ts +14 -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 +24 -14
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +16 -8
- 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 +72 -35
- package/templates/web/src/main.tsx +6 -4
- package/templates/web/src/routeTree.gen.ts +73 -2
- package/templates/web/src/router.tsx +1 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
- package/templates/web/src/routes/_authenticated.tsx +1 -1
- 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/api-contract/auth.spec.ts +155 -0
- package/templates/web/test/app.spec.tsx +47 -22
- package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +90 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/scripts/ports.mjs +1 -1
- package/dist/mcp.js +0 -100
- package/templates/api/docker/init/01-auth-schema.sql +0 -1
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
- package/templates/api/src/infra/auth/auth.cli.ts +0 -5
- package/templates/api/src/infra/auth/auth.factory.ts +0 -77
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
- package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
- package/templates/mobile/src/features/auth/auth-client.ts +0 -27
- package/templates/mobile/src/features/auth/session.ts +0 -17
- package/templates/web/src/features/auth/auth-client.ts +0 -17
- package/templates/web/src/features/auth/session.ts +0 -17
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { fakeTransport, json } from '@test/support/fake-transport'
|
|
2
|
+
import { renderApp } from '@test/support/render-app'
|
|
3
|
+
import { screen } from '@testing-library/react'
|
|
4
|
+
import userEvent from '@testing-library/user-event'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import type { Profile } from '@/api-contract'
|
|
7
|
+
|
|
8
|
+
const user = { id: 'user-1', email: 'ana@example.com' }
|
|
9
|
+
|
|
10
|
+
const profile: Profile = {
|
|
11
|
+
id: 'profile-1',
|
|
12
|
+
userId: user.id,
|
|
13
|
+
displayName: 'Ana',
|
|
14
|
+
locale: 'en',
|
|
15
|
+
timezone: 'UTC',
|
|
16
|
+
createdAt: '2026-09-14T12:57:24.057Z',
|
|
17
|
+
updatedAt: '2026-09-14T12:57:24.057Z',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const empty = (status: number) => new Response(null, { status })
|
|
21
|
+
|
|
22
|
+
const problem = (status: number, body: Record<string, unknown>) =>
|
|
23
|
+
json(status, { status, title: 'Error', ...body }, 'application/problem+json')
|
|
24
|
+
|
|
25
|
+
// A visitor whose session appears once `signedIn` flips, as cookies would.
|
|
26
|
+
function app(routes: Parameters<typeof fakeTransport>[0]) {
|
|
27
|
+
const state = { signedIn: false }
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
state,
|
|
31
|
+
transport: fakeTransport({
|
|
32
|
+
'GET /api/auth/session': () => (state.signedIn ? json(200, { user }) : json(401, {})),
|
|
33
|
+
'POST /api/auth/refresh': () => json(401, {}),
|
|
34
|
+
'GET /api/v1/users/me': () => json(200, profile),
|
|
35
|
+
...routes,
|
|
36
|
+
}),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('email verification on the web', () => {
|
|
41
|
+
it('takes a new account to the code, and into the app once it is entered', async () => {
|
|
42
|
+
const visitor = userEvent.setup()
|
|
43
|
+
const { state, transport } = app({
|
|
44
|
+
'POST /api/auth/sign-up': () => json(201, { verificationRequired: true }),
|
|
45
|
+
'POST /api/auth/email/verify': () => {
|
|
46
|
+
state.signedIn = true
|
|
47
|
+
return empty(204)
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
renderApp('/sign-up', transport)
|
|
52
|
+
|
|
53
|
+
await visitor.type(await screen.findByLabelText('Name'), 'Ana')
|
|
54
|
+
await visitor.type(screen.getByLabelText('Email'), user.email)
|
|
55
|
+
await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
|
|
56
|
+
await visitor.click(screen.getByRole('button', { name: 'Create account' }))
|
|
57
|
+
|
|
58
|
+
expect(await screen.findByRole('heading', { name: 'Confirm your email' })).toBeInTheDocument()
|
|
59
|
+
|
|
60
|
+
await visitor.type(screen.getByLabelText('Code'), '123456')
|
|
61
|
+
await visitor.click(screen.getByRole('button', { name: 'Confirm email' }))
|
|
62
|
+
|
|
63
|
+
expect(await screen.findByRole('heading', { name: 'Your profile' })).toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('sends an unconfirmed account that signs in to the code page', async () => {
|
|
67
|
+
const visitor = userEvent.setup()
|
|
68
|
+
const { transport } = app({
|
|
69
|
+
'POST /api/auth/sign-in': () => problem(403, { code: 'email_not_verified' }),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
renderApp('/sign-in', transport)
|
|
73
|
+
|
|
74
|
+
await visitor.type(await screen.findByLabelText('Email'), user.email)
|
|
75
|
+
await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
|
|
76
|
+
await visitor.click(screen.getByRole('button', { name: 'Sign in' }))
|
|
77
|
+
|
|
78
|
+
expect(await screen.findByRole('heading', { name: 'Confirm your email' })).toBeInTheDocument()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('shows a wrong code on the code field', async () => {
|
|
82
|
+
const visitor = userEvent.setup()
|
|
83
|
+
const { transport } = app({
|
|
84
|
+
'POST /api/auth/email/verify': () =>
|
|
85
|
+
problem(422, { code: 'invalid_code', detail: 'The code is wrong or has expired' }),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
renderApp(`/verify-email?email=${user.email}`, transport)
|
|
89
|
+
|
|
90
|
+
await visitor.type(await screen.findByLabelText('Code'), '000000')
|
|
91
|
+
await visitor.click(screen.getByRole('button', { name: 'Confirm email' }))
|
|
92
|
+
|
|
93
|
+
expect(await screen.findByText(/The code is wrong or has expired/)).toBeInTheDocument()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('password reset on the web', () => {
|
|
98
|
+
it('goes from forgot to reset to the app', async () => {
|
|
99
|
+
const visitor = userEvent.setup()
|
|
100
|
+
const { state, transport } = app({
|
|
101
|
+
'POST /api/auth/password/forgot': () => empty(204),
|
|
102
|
+
'POST /api/auth/password/reset': () => {
|
|
103
|
+
state.signedIn = true
|
|
104
|
+
return empty(204)
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
renderApp('/sign-in', transport)
|
|
109
|
+
|
|
110
|
+
await visitor.click(await screen.findByRole('link', { name: 'Forgot your password?' }))
|
|
111
|
+
await screen.findByRole('heading', { name: 'Reset your password' })
|
|
112
|
+
await visitor.type(screen.getByLabelText('Email'), user.email)
|
|
113
|
+
await visitor.click(screen.getByRole('button', { name: 'Send a reset code' }))
|
|
114
|
+
await screen.findByRole('heading', { name: 'Choose a new password' })
|
|
115
|
+
await visitor.type(screen.getByLabelText('Code'), '123456')
|
|
116
|
+
await visitor.type(screen.getByLabelText('New password'), 'another-good-password')
|
|
117
|
+
await visitor.click(screen.getByRole('button', { name: 'Set the new password' }))
|
|
118
|
+
|
|
119
|
+
expect(await screen.findByRole('heading', { name: 'Your profile' })).toBeInTheDocument()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -11,8 +11,12 @@ describe('SignInForm', () => {
|
|
|
11
11
|
renderApp(
|
|
12
12
|
'/sign-in',
|
|
13
13
|
fakeTransport({
|
|
14
|
-
'POST /api/auth/sign-in
|
|
15
|
-
json(
|
|
14
|
+
'POST /api/auth/sign-in': () =>
|
|
15
|
+
json(
|
|
16
|
+
401,
|
|
17
|
+
{ title: 'Unauthorized', status: 401, detail: 'Invalid email or password' },
|
|
18
|
+
'application/problem+json',
|
|
19
|
+
),
|
|
16
20
|
}),
|
|
17
21
|
)
|
|
18
22
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
2
|
+
import { renderApp } from '@test/support/render-app'
|
|
3
|
+
import { screen } from '@testing-library/react'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const signedOut = fakeTransport({
|
|
7
|
+
'GET /api/auth/session': () => json(401, {}),
|
|
8
|
+
'POST /api/auth/refresh': () => json(401, {}),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('SocialButtons', () => {
|
|
12
|
+
it('send the browser to the API, which returns it to the page it was turned away from', async () => {
|
|
13
|
+
renderApp('/sign-in?redirect=%2Forders', signedOut)
|
|
14
|
+
|
|
15
|
+
for (const name of [
|
|
16
|
+
'Continue with Google', // prumo:google
|
|
17
|
+
'Continue with Apple', // prumo:apple
|
|
18
|
+
]) {
|
|
19
|
+
const link = await screen.findByRole('link', { name })
|
|
20
|
+
const href = new URL(link.getAttribute('href') ?? '')
|
|
21
|
+
|
|
22
|
+
expect(`${href.origin}${href.pathname}`).toMatch(
|
|
23
|
+
new RegExp(`^${API_URL}/api/auth/social/(google|apple)$`),
|
|
24
|
+
)
|
|
25
|
+
expect(href.searchParams.get('client')).toBe('web')
|
|
26
|
+
expect(href.searchParams.get('returnTo')).toBe('/orders')
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('says why a provider sign-in came back without a session', async () => {
|
|
31
|
+
renderApp('/sign-in?error=provider_email_unverified', signedOut)
|
|
32
|
+
|
|
33
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('not verified with the provider')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -1,45 +1,109 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
2
3
|
import { renderApp } from '@test/support/render-app'
|
|
3
|
-
import { screen } from '@testing-library/react'
|
|
4
|
+
import { render, screen } from '@testing-library/react'
|
|
4
5
|
import userEvent from '@testing-library/user-event'
|
|
5
|
-
import {
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import { createAuthClient } from '@/api-contract'
|
|
8
|
+
import { ConsentForm } from '@/features/oauth/consent-form'
|
|
6
9
|
|
|
7
|
-
const
|
|
10
|
+
const REQUEST = '0199a7e2-0000-7000-8000-000000000000'
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
'
|
|
12
|
+
const view = {
|
|
13
|
+
clientId: 'https://claude.example.com/client.json',
|
|
14
|
+
clientName: 'Claude',
|
|
15
|
+
clientHost: 'claude.example.com',
|
|
16
|
+
redirectHost: 'claude.example.com',
|
|
17
|
+
redirectsToThisDevice: false,
|
|
18
|
+
scopes: [
|
|
19
|
+
{ scope: 'profile:read', description: 'Read your profile' },
|
|
20
|
+
{ scope: 'profile:write', description: 'Change your display name, language and timezone' },
|
|
21
|
+
],
|
|
22
|
+
}
|
|
12
23
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
})
|
|
24
|
+
function renderForm(routes: Parameters<typeof fakeTransport>[0]) {
|
|
25
|
+
const leave = vi.fn()
|
|
26
|
+
const auth = createAuthClient({ baseUrl: API_URL, fetch: fakeTransport(routes) })
|
|
27
|
+
|
|
28
|
+
render(
|
|
29
|
+
<QueryClientProvider client={new QueryClient()}>
|
|
30
|
+
<ConsentForm auth={auth} requestId={REQUEST} leave={leave} />
|
|
31
|
+
</QueryClientProvider>,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return leave
|
|
35
|
+
}
|
|
16
36
|
|
|
17
37
|
describe('ConsentForm', () => {
|
|
18
|
-
it('names the client
|
|
38
|
+
it('names the client and both hosts, and sends the browser where the API says', async () => {
|
|
19
39
|
const visitor = userEvent.setup()
|
|
20
|
-
let
|
|
40
|
+
let decision: unknown
|
|
41
|
+
const leave = renderForm({
|
|
42
|
+
[`GET /api/oauth/authorizations/${REQUEST}`]: () => json(200, view),
|
|
43
|
+
[`POST /api/oauth/authorizations/${REQUEST}/decision`]: async (request) => {
|
|
44
|
+
decision = await request.json()
|
|
45
|
+
return json(200, { redirectTo: 'https://claude.example.com/callback?code=c' })
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
expect(await screen.findByText('Claude')).toBeInTheDocument()
|
|
50
|
+
expect(screen.getByText(/claude\.example\.com\)/)).toBeInTheDocument()
|
|
51
|
+
expect(screen.getByRole('list', { name: 'What it will be able to do' })).toHaveTextContent(
|
|
52
|
+
'Read your profile',
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
await visitor.click(screen.getByRole('button', { name: 'Allow' }))
|
|
56
|
+
|
|
57
|
+
expect(decision).toEqual({ accept: true })
|
|
58
|
+
expect(leave).toHaveBeenCalledWith('https://claude.example.com/callback?code=c')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('warns when the code returns to a program on this device', async () => {
|
|
62
|
+
renderForm({
|
|
63
|
+
[`GET /api/oauth/authorizations/${REQUEST}`]: () =>
|
|
64
|
+
json(200, { ...view, redirectHost: 'localhost:33418', redirectsToThisDevice: true }),
|
|
65
|
+
})
|
|
21
66
|
|
|
22
|
-
|
|
23
|
-
|
|
67
|
+
expect(await screen.findByRole('note')).toHaveTextContent('a program on this device')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('says so when the request has expired', async () => {
|
|
71
|
+
renderForm({
|
|
72
|
+
[`GET /api/oauth/authorizations/${REQUEST}`]: () =>
|
|
73
|
+
json(404, { title: 'Not Found', status: 404 }, 'application/problem+json'),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('expired')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('the consent route', () => {
|
|
81
|
+
// The MCP client opened the browser at the API, which sent it here; without a session, sign-in comes first.
|
|
82
|
+
it('sends a visitor to sign in first, and back to the same request after', async () => {
|
|
83
|
+
const visitor = userEvent.setup()
|
|
84
|
+
let signedIn = false
|
|
24
85
|
|
|
25
86
|
renderApp(
|
|
26
|
-
`/consent
|
|
87
|
+
`/consent?request=${REQUEST}`,
|
|
27
88
|
fakeTransport({
|
|
28
|
-
'GET /api/auth/
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
89
|
+
'GET /api/auth/session': () =>
|
|
90
|
+
signedIn
|
|
91
|
+
? json(200, { user: { id: 'user-1', email: 'ana@example.com' } })
|
|
92
|
+
: json(401, {}),
|
|
93
|
+
'POST /api/auth/refresh': () => json(401, {}),
|
|
94
|
+
'POST /api/auth/sign-in': () => {
|
|
95
|
+
signedIn = true
|
|
96
|
+
return new Response(null, { status: 204 })
|
|
34
97
|
},
|
|
98
|
+
[`GET /api/oauth/authorizations/${REQUEST}`]: () => json(200, view),
|
|
35
99
|
}),
|
|
36
100
|
)
|
|
37
101
|
|
|
38
|
-
|
|
39
|
-
|
|
102
|
+
await visitor.type(await screen.findByLabelText('Email'), 'ana@example.com')
|
|
103
|
+
await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
|
|
104
|
+
await visitor.click(screen.getByRole('button', { name: 'Sign in' }))
|
|
40
105
|
|
|
41
|
-
await
|
|
42
|
-
|
|
43
|
-
expect(consent).toMatchObject({ accept: true, oauth_query: SIGNED })
|
|
106
|
+
expect(await screen.findByRole('heading', { name: 'Allow access' })).toBeInTheDocument()
|
|
107
|
+
expect(await screen.findByText('Claude')).toBeInTheDocument()
|
|
44
108
|
})
|
|
45
109
|
})
|
|
@@ -2,15 +2,15 @@ import { QueryClient } from '@tanstack/react-query'
|
|
|
2
2
|
import { createMemoryHistory } from '@tanstack/react-router'
|
|
3
3
|
import { API_URL } from '@test/support/fake-transport'
|
|
4
4
|
import { render } from '@testing-library/react'
|
|
5
|
-
import { createClient, type Transport } from '@/api-contract'
|
|
5
|
+
import { createAuthClient, createClient, type Transport } from '@/api-contract'
|
|
6
6
|
import { App } from '@/app'
|
|
7
|
-
import { createAuth } from '@/features/auth/auth-client'
|
|
8
7
|
|
|
9
8
|
export function renderApp(path: string, transport: Transport) {
|
|
9
|
+
const auth = createAuthClient({ baseUrl: API_URL, fetch: transport })
|
|
10
10
|
const context = {
|
|
11
11
|
queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }),
|
|
12
|
-
api: createClient({ baseUrl: API_URL, fetch:
|
|
13
|
-
auth
|
|
12
|
+
api: createClient({ baseUrl: API_URL, fetch: auth.fetch }),
|
|
13
|
+
auth,
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
return render(<App context={context} history={createMemoryHistory({ initialEntries: [path] })} />)
|
|
@@ -25,7 +25,7 @@ const APPS = {
|
|
|
25
25
|
// A port that moves takes these with it: the key to write, in which app, from which app's port.
|
|
26
26
|
const FOLLOWERS = {
|
|
27
27
|
api: [
|
|
28
|
-
{ app: 'api', key: '
|
|
28
|
+
{ app: 'api', key: 'API_URL' },
|
|
29
29
|
{ app: 'web', key: 'VITE_API_URL' },
|
|
30
30
|
{ app: 'mobile', key: 'EXPO_PUBLIC_API_URL' },
|
|
31
31
|
],
|
package/dist/mcp.js
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
2
|
-
import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { setJsonc } from "./jsonc.js";
|
|
5
|
-
// The api and web templates ship with MCP. A project that answered no receives them without it: these files are
|
|
6
|
-
// deleted, these dependencies removed, and every marked line or block cut. Every other type has nothing to cut.
|
|
7
|
-
const FILES = {
|
|
8
|
-
api: [
|
|
9
|
-
'src/infra/mcp',
|
|
10
|
-
'src/infra/http/controllers/mcp.controller.ts',
|
|
11
|
-
'test/infra/http/controllers/mcp.controller.spec.ts',
|
|
12
|
-
],
|
|
13
|
-
web: ['src/features/oauth', 'src/routes/_authenticated/consent.tsx', 'test/features/oauth'],
|
|
14
|
-
};
|
|
15
|
-
const DEPENDENCIES = {
|
|
16
|
-
api: [
|
|
17
|
-
'@better-auth/cimd',
|
|
18
|
-
'@better-auth/mcp',
|
|
19
|
-
'@modelcontextprotocol/client',
|
|
20
|
-
'@modelcontextprotocol/server',
|
|
21
|
-
],
|
|
22
|
-
web: ['@better-auth/oauth-provider'],
|
|
23
|
-
};
|
|
24
|
-
const BLOCK_START = /^\s*\/\/ prumo:mcp$/;
|
|
25
|
-
const BLOCK_END = /^\s*\/\/ prumo:end-mcp$/;
|
|
26
|
-
// A marked import keeps its marker as a trailing comment, which the import sorter carries along.
|
|
27
|
-
const MARKED_LINE = /\s*\/\/ prumo:mcp$/;
|
|
28
|
-
export function cutMcp(text, keep, file = 'a file') {
|
|
29
|
-
const kept = [];
|
|
30
|
-
let inBlock = false;
|
|
31
|
-
for (const line of text.split('\n')) {
|
|
32
|
-
if (BLOCK_START.test(line)) {
|
|
33
|
-
if (inBlock) {
|
|
34
|
-
throw new Error(`${file} opens a prumo:mcp block inside another`);
|
|
35
|
-
}
|
|
36
|
-
inBlock = true;
|
|
37
|
-
}
|
|
38
|
-
else if (BLOCK_END.test(line)) {
|
|
39
|
-
if (!inBlock) {
|
|
40
|
-
throw new Error(`${file} closes a prumo:mcp block it never opened`);
|
|
41
|
-
}
|
|
42
|
-
inBlock = false;
|
|
43
|
-
}
|
|
44
|
-
else if (MARKED_LINE.test(line)) {
|
|
45
|
-
if (keep) {
|
|
46
|
-
kept.push(line.replace(MARKED_LINE, ''));
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
else if (keep || !inBlock) {
|
|
50
|
-
kept.push(line);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
if (inBlock) {
|
|
54
|
-
throw new Error(`${file} leaves a prumo:mcp block open`);
|
|
55
|
-
}
|
|
56
|
-
return kept.join('\n');
|
|
57
|
-
}
|
|
58
|
-
async function sourceFiles(directory) {
|
|
59
|
-
if (!existsSync(directory)) {
|
|
60
|
-
return [];
|
|
61
|
-
}
|
|
62
|
-
const entries = await readdir(directory, { withFileTypes: true, recursive: true });
|
|
63
|
-
return entries
|
|
64
|
-
.filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name))
|
|
65
|
-
.map((entry) => join(entry.parentPath, entry.name));
|
|
66
|
-
}
|
|
67
|
-
export async function applyMcp(app, type, enabled) {
|
|
68
|
-
const files = FILES[type];
|
|
69
|
-
const dependencies = DEPENDENCIES[type];
|
|
70
|
-
if (files === undefined || dependencies === undefined) {
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
if (!enabled) {
|
|
74
|
-
for (const file of files) {
|
|
75
|
-
await rm(join(app, file), { recursive: true, force: true });
|
|
76
|
-
}
|
|
77
|
-
const manifest = join(app, 'package.json');
|
|
78
|
-
let text = await readFile(manifest, 'utf8');
|
|
79
|
-
for (const section of ['dependencies', 'devDependencies']) {
|
|
80
|
-
for (const dependency of dependencies) {
|
|
81
|
-
text = setJsonc(text, [section, dependency], undefined);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
await writeFile(manifest, text);
|
|
85
|
-
}
|
|
86
|
-
const sources = [
|
|
87
|
-
...(await sourceFiles(join(app, 'src'))),
|
|
88
|
-
...(await sourceFiles(join(app, 'test'))),
|
|
89
|
-
];
|
|
90
|
-
for (const source of sources) {
|
|
91
|
-
const text = await readFile(source, 'utf8');
|
|
92
|
-
const cut = cutMcp(text, enabled, source);
|
|
93
|
-
if (!enabled && dependencies.some((dependency) => cut.includes(`'${dependency}`))) {
|
|
94
|
-
throw new Error(`${source} still imports an MCP package outside a prumo:mcp marker`);
|
|
95
|
-
}
|
|
96
|
-
if (cut !== text) {
|
|
97
|
-
await writeFile(source, cut);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
CREATE SCHEMA IF NOT EXISTS auth;
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
-
|
|
3
|
-
export class Migration20260914123239_create_profile extends Migration {
|
|
4
|
-
|
|
5
|
-
override name = 'Migration20260914123239_create_profile';
|
|
6
|
-
|
|
7
|
-
override up(): void | Promise<void> {
|
|
8
|
-
this.addSql(`create table "profile" ("id" uuid not null default uuidv7(), "user_id" uuid not null, "display_name" text not null, "locale" text not null default 'en', "timezone" text not null default 'UTC', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), primary key ("id"));`);
|
|
9
|
-
this.addSql(`alter table "profile" add constraint "profile_user_id_unique" unique ("user_id");`);
|
|
10
|
-
this.addSql(`alter table "profile" add constraint "profile_user_id_foreign" foreign key ("user_id") references "auth"."user" ("id") on delete restrict;`);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { cimd } from '@better-auth/cimd' // prumo:mcp
|
|
2
|
-
import { fetchClientMetadataResource } from '@better-auth/cimd/node' // prumo:mcp
|
|
3
|
-
import { expo } from '@better-auth/expo'
|
|
4
|
-
import { mcp } from '@better-auth/mcp' // prumo:mcp
|
|
5
|
-
import { type BetterAuthPlugin, betterAuth } from 'better-auth'
|
|
6
|
-
import { jwt } from 'better-auth/plugins' // prumo:mcp
|
|
7
|
-
import { Pool } from 'pg'
|
|
8
|
-
import type { Env } from '@/infra/config/env'
|
|
9
|
-
|
|
10
|
-
type NewUser = { id: string; name: string }
|
|
11
|
-
|
|
12
|
-
export type AuthHooks = {
|
|
13
|
-
onUserCreated?: (user: NewUser) => Promise<void>
|
|
14
|
-
}
|
|
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
|
|
25
|
-
export function createAuth(
|
|
26
|
-
env: Pick<
|
|
27
|
-
Env,
|
|
28
|
-
| 'AUTH_DATABASE_URL'
|
|
29
|
-
| 'BETTER_AUTH_SECRET'
|
|
30
|
-
| 'BETTER_AUTH_URL'
|
|
31
|
-
| 'WEB_ORIGIN'
|
|
32
|
-
| 'MOBILE_APP_SCHEME'
|
|
33
|
-
>,
|
|
34
|
-
hooks: AuthHooks = {},
|
|
35
|
-
) {
|
|
36
|
-
const mobile = env.MOBILE_APP_SCHEME
|
|
37
|
-
const plugins: BetterAuthPlugin[] = mobile === undefined ? [] : [expo()]
|
|
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
|
|
51
|
-
return betterAuth({
|
|
52
|
-
baseURL: env.BETTER_AUTH_URL,
|
|
53
|
-
basePath: '/api/auth',
|
|
54
|
-
secret: env.BETTER_AUTH_SECRET,
|
|
55
|
-
trustedOrigins: mobile === undefined ? [env.WEB_ORIGIN] : [env.WEB_ORIGIN, `${mobile}://`],
|
|
56
|
-
plugins,
|
|
57
|
-
database: new Pool({
|
|
58
|
-
connectionString: env.AUTH_DATABASE_URL,
|
|
59
|
-
options: '-c search_path=auth',
|
|
60
|
-
}),
|
|
61
|
-
emailAndPassword: { enabled: true },
|
|
62
|
-
advanced: { database: { generateId: 'uuid' } },
|
|
63
|
-
databaseHooks: {
|
|
64
|
-
user: {
|
|
65
|
-
create: {
|
|
66
|
-
after: async (user) => {
|
|
67
|
-
await hooks.onUserCreated?.(user)
|
|
68
|
-
},
|
|
69
|
-
},
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
})
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export type Auth = ReturnType<typeof createAuth>
|
|
76
|
-
export type AuthSession = Awaited<ReturnType<Auth['api']['getSession']>>
|
|
77
|
-
export type AuthUser = NonNullable<AuthSession>['user']
|
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { testEnv } from '@test/support/setup'
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import { createAuth } from '@/infra/auth/auth.factory'
|
|
4
|
-
|
|
5
|
-
const proxy =
|
|
6
|
-
'http://localhost:3000/api/auth/expo-authorization-proxy?authorizationURL=https://example.com/authorize&oauthState=s'
|
|
7
|
-
|
|
8
|
-
describe('createAuth', () => {
|
|
9
|
-
it('exposes no Expo redirect endpoint without a mobile scheme', async () => {
|
|
10
|
-
const response = await createAuth(testEnv()).handler(new Request(proxy))
|
|
11
|
-
|
|
12
|
-
expect(response.status).toBe(404)
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
it('exposes the Expo endpoint and trusts the scheme once a mobile scheme is set', async () => {
|
|
16
|
-
const auth = createAuth({ ...testEnv(), MOBILE_APP_SCHEME: 'app' })
|
|
17
|
-
const response = await auth.handler(new Request(proxy))
|
|
18
|
-
|
|
19
|
-
expect(response.status).toBe(302)
|
|
20
|
-
expect(auth.options.trustedOrigins).toContain('app://')
|
|
21
|
-
})
|
|
22
|
-
})
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { expoClient } from '@better-auth/expo/client'
|
|
2
|
-
import { createAuthClient } from 'better-auth/react'
|
|
3
|
-
import * as SecureStore from 'expo-secure-store'
|
|
4
|
-
import type { Transport } from '@/api-contract'
|
|
5
|
-
|
|
6
|
-
export type AuthClient = ReturnType<typeof createAuth>
|
|
7
|
-
|
|
8
|
-
export type SessionAuth = Pick<AuthClient, 'signIn' | 'signUp' | 'getSession'>
|
|
9
|
-
|
|
10
|
-
export function createAuth({ baseUrl, fetch }: { baseUrl: string; fetch: Transport }) {
|
|
11
|
-
return createAuthClient({
|
|
12
|
-
baseURL: baseUrl,
|
|
13
|
-
basePath: '/api/auth',
|
|
14
|
-
fetchOptions: { customFetchImpl: fetch },
|
|
15
|
-
plugins: [expoClient({ storage: SecureStore })],
|
|
16
|
-
})
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function withSessionCookie(auth: AuthClient, fetch: Transport): Transport {
|
|
20
|
-
return async (input, init) => {
|
|
21
|
-
const headers = new Headers(init?.headers)
|
|
22
|
-
|
|
23
|
-
headers.set('cookie', await auth.getCookie())
|
|
24
|
-
|
|
25
|
-
return fetch(input, { ...init, headers, credentials: 'omit' })
|
|
26
|
-
}
|
|
27
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { queryOptions } from '@tanstack/react-query'
|
|
2
|
-
import { ApiError } from '@/api-contract'
|
|
3
|
-
import type { SessionAuth } from './auth-client'
|
|
4
|
-
|
|
5
|
-
export const sessionQuery = (auth: SessionAuth) =>
|
|
6
|
-
queryOptions({
|
|
7
|
-
queryKey: ['session'],
|
|
8
|
-
queryFn: async () => {
|
|
9
|
-
const { data, error } = await auth.getSession()
|
|
10
|
-
|
|
11
|
-
if (error !== null) {
|
|
12
|
-
throw ApiError.fromAuthError(error)
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
return data
|
|
16
|
-
},
|
|
17
|
-
})
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { oauthProviderClient } from '@better-auth/oauth-provider/client' // prumo:mcp
|
|
2
|
-
import { createAuthClient } from 'better-auth/react'
|
|
3
|
-
import type { Transport } from '@/api-contract'
|
|
4
|
-
|
|
5
|
-
export type AuthClient = ReturnType<typeof createAuth>
|
|
6
|
-
|
|
7
|
-
export function createAuth({ baseUrl, fetch }: { baseUrl: string; fetch: Transport }) {
|
|
8
|
-
return createAuthClient({
|
|
9
|
-
baseURL: baseUrl,
|
|
10
|
-
basePath: '/api/auth',
|
|
11
|
-
fetchOptions: { customFetchImpl: fetch },
|
|
12
|
-
// prumo:mcp
|
|
13
|
-
// Carries the signed query of an MCP client's authorization through sign-in and consent.
|
|
14
|
-
plugins: [oauthProviderClient()],
|
|
15
|
-
// prumo:end-mcp
|
|
16
|
-
})
|
|
17
|
-
}
|