@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,25 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { createUser } from '@test/support/factories/user.factory'
|
|
3
|
+
import { type Locale, Profile } from '@/domain/users/entities/profile.entity'
|
|
4
|
+
|
|
5
|
+
type ProfileOverrides = Partial<{
|
|
6
|
+
userId: string
|
|
7
|
+
displayName: string
|
|
8
|
+
locale: Locale
|
|
9
|
+
timezone: string
|
|
10
|
+
}>
|
|
11
|
+
|
|
12
|
+
export async function createProfile(
|
|
13
|
+
em: EntityManager,
|
|
14
|
+
overrides: ProfileOverrides = {},
|
|
15
|
+
): Promise<Profile> {
|
|
16
|
+
const profile = new Profile(
|
|
17
|
+
overrides.userId ?? (await createUser(em)),
|
|
18
|
+
overrides.displayName ?? 'Test Person',
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
profile.update({ locale: overrides.locale ?? 'en', timezone: overrides.timezone ?? 'UTC' })
|
|
22
|
+
await em.persist(profile).flush()
|
|
23
|
+
|
|
24
|
+
return profile
|
|
25
|
+
}
|
|
@@ -9,7 +9,7 @@ export async function createUser(
|
|
|
9
9
|
): Promise<string> {
|
|
10
10
|
const id = randomUUID()
|
|
11
11
|
|
|
12
|
-
// auth.user belongs to Better Auth and
|
|
12
|
+
// auth.user belongs to Better Auth, and the ORM maps only its id, so the row is written in SQL.
|
|
13
13
|
await em
|
|
14
14
|
.getConnection()
|
|
15
15
|
.execute(
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Profile } from '@/domain/users/entities/profile.entity'
|
|
2
|
+
import type { ProfileRepository } from '@/domain/users/repositories/profile.repository'
|
|
3
|
+
|
|
4
|
+
export class InMemoryProfileRepository implements ProfileRepository {
|
|
5
|
+
readonly rows = new Map<string, Profile>()
|
|
6
|
+
|
|
7
|
+
async findByUserId(userId: string): Promise<Profile | null> {
|
|
8
|
+
return this.rows.get(userId) ?? null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async save(profile: Profile): Promise<void> {
|
|
12
|
+
this.rows.set(profile.userId, profile)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
// tsyringe reads decorator metadata as each class is loaded, so this must come before any other import.
|
|
2
|
+
import 'reflect-metadata'
|
|
1
3
|
import { MikroORM } from '@mikro-orm/postgresql'
|
|
2
4
|
import { getMigrations } from 'better-auth/db/migration'
|
|
3
5
|
import { Client, type Pool } from 'pg'
|
|
4
6
|
import { afterAll, beforeAll, beforeEach, inject } from 'vitest'
|
|
5
|
-
import { createAuth } from '
|
|
6
|
-
import {
|
|
7
|
+
import { createAuth } from '@/infra/auth/auth.factory'
|
|
8
|
+
import type { Env } from '@/infra/config/env'
|
|
9
|
+
import { createOrmConfig } from '@/infra/database/mikroorm/mikro-orm.factory'
|
|
7
10
|
|
|
8
11
|
let orm: MikroORM
|
|
9
12
|
let databaseUrl: string
|
|
@@ -17,6 +20,19 @@ export function testDatabaseUrl(): string {
|
|
|
17
20
|
return databaseUrl
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
export function testEnv(): Env {
|
|
24
|
+
return {
|
|
25
|
+
NODE_ENV: 'test',
|
|
26
|
+
PORT: 3000,
|
|
27
|
+
DATABASE_URL: databaseUrl,
|
|
28
|
+
AUTH_DATABASE_URL: databaseUrl,
|
|
29
|
+
BETTER_AUTH_SECRET: 'test-secret-that-is-at-least-32-characters',
|
|
30
|
+
BETTER_AUTH_URL: 'http://localhost:3000',
|
|
31
|
+
WEB_ORIGIN: 'http://localhost:5173',
|
|
32
|
+
LOG_LEVEL: 'error',
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
async function createWorkerDatabase(baseUrl: string, name: string): Promise<string> {
|
|
21
37
|
const admin = new Client({ connectionString: baseUrl })
|
|
22
38
|
|
|
@@ -37,13 +53,8 @@ async function createWorkerDatabase(baseUrl: string, name: string): Promise<stri
|
|
|
37
53
|
return url.toString()
|
|
38
54
|
}
|
|
39
55
|
|
|
40
|
-
async function migrateAuth(
|
|
41
|
-
const auth = createAuth(
|
|
42
|
-
AUTH_DATABASE_URL: databaseUrl,
|
|
43
|
-
BETTER_AUTH_SECRET: 'test-secret-that-is-at-least-32-characters',
|
|
44
|
-
BETTER_AUTH_URL: 'http://localhost',
|
|
45
|
-
WEB_ORIGIN: 'http://localhost:5173',
|
|
46
|
-
})
|
|
56
|
+
async function migrateAuth(): Promise<void> {
|
|
57
|
+
const auth = createAuth(testEnv())
|
|
47
58
|
const { runMigrations } = await getMigrations(auth.options)
|
|
48
59
|
|
|
49
60
|
await runMigrations()
|
|
@@ -52,18 +63,19 @@ async function migrateAuth(databaseUrl: string): Promise<void> {
|
|
|
52
63
|
|
|
53
64
|
beforeAll(async () => {
|
|
54
65
|
const name = `test_${process.env.VITEST_WORKER_ID ?? '1'}`
|
|
55
|
-
const clientUrl = await createWorkerDatabase(inject('postgresUrl'), name)
|
|
56
66
|
|
|
57
|
-
databaseUrl =
|
|
58
|
-
await migrateAuth(
|
|
67
|
+
databaseUrl = await createWorkerDatabase(inject('postgresUrl'), name)
|
|
68
|
+
await migrateAuth()
|
|
59
69
|
|
|
60
|
-
orm = await MikroORM.init(createOrmConfig(
|
|
70
|
+
orm = await MikroORM.init(createOrmConfig(testEnv()))
|
|
61
71
|
|
|
62
72
|
await orm.migrator.up()
|
|
63
73
|
|
|
64
74
|
const metadata = orm.getMetadata().getAll()
|
|
65
75
|
const own = Object.values(metadata)
|
|
66
|
-
.filter(
|
|
76
|
+
.filter(
|
|
77
|
+
(meta) => meta.tableName !== undefined && meta.pivotTable !== true && meta.schema !== 'auth',
|
|
78
|
+
)
|
|
67
79
|
.map((meta) => `"${meta.tableName}"`)
|
|
68
80
|
|
|
69
81
|
tables = [...own, 'auth."user"', 'auth.session', 'auth.account', 'auth.verification']
|
|
@@ -6,13 +6,9 @@
|
|
|
6
6
|
"lib": ["ES2023"],
|
|
7
7
|
"experimentalDecorators": true,
|
|
8
8
|
"emitDecoratorMetadata": true,
|
|
9
|
-
"declaration": true,
|
|
10
|
-
"removeComments": true,
|
|
11
9
|
"allowSyntheticDefaultImports": true,
|
|
12
10
|
"esModuleInterop": true,
|
|
13
|
-
"
|
|
14
|
-
"outDir": "./dist",
|
|
15
|
-
"incremental": true,
|
|
11
|
+
"noEmit": true,
|
|
16
12
|
"skipLibCheck": true,
|
|
17
13
|
"strict": true,
|
|
18
14
|
"strictPropertyInitialization": false,
|
|
@@ -20,7 +16,8 @@
|
|
|
20
16
|
"forceConsistentCasingInFileNames": true,
|
|
21
17
|
"noFallthroughCasesInSwitch": true,
|
|
22
18
|
"paths": {
|
|
23
|
-
"@/*": ["./src/*"]
|
|
19
|
+
"@/*": ["./src/*"],
|
|
20
|
+
"@test/*": ["./test/*"]
|
|
24
21
|
},
|
|
25
22
|
"types": ["vitest/globals", "node"]
|
|
26
23
|
},
|
|
@@ -4,13 +4,13 @@ import { defineConfig } from 'vitest/config'
|
|
|
4
4
|
export default defineConfig({
|
|
5
5
|
plugins: [swc.vite({ module: { type: 'es6' } })],
|
|
6
6
|
resolve: {
|
|
7
|
-
alias: { '@': `${import.meta.dirname}/src` },
|
|
7
|
+
alias: { '@': `${import.meta.dirname}/src`, '@test': `${import.meta.dirname}/test` },
|
|
8
8
|
},
|
|
9
9
|
test: {
|
|
10
10
|
globals: true,
|
|
11
|
-
include: ['
|
|
12
|
-
globalSetup: ['./test/global-setup.ts'],
|
|
13
|
-
setupFiles: ['./test/setup.ts'],
|
|
11
|
+
include: ['test/**/*.spec.ts'],
|
|
12
|
+
globalSetup: ['./test/support/global-setup.ts'],
|
|
13
|
+
setupFiles: ['./test/support/setup.ts'],
|
|
14
14
|
pool: 'forks',
|
|
15
15
|
coverage: { provider: 'v8', include: ['src/**'] },
|
|
16
16
|
},
|
|
@@ -11,7 +11,7 @@ if (!nodeModules.includes('(?!(') || babel === undefined) {
|
|
|
11
11
|
|
|
12
12
|
module.exports = {
|
|
13
13
|
preset: 'jest-expo',
|
|
14
|
-
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },
|
|
14
|
+
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', '^@test/(.*)$': '<rootDir>/test/$1' },
|
|
15
15
|
// A first render brings React Native and Babel's transform with it, which costs seconds on a cold cache: inside a
|
|
16
16
|
// workspace the contract is a package to transform too, and Jest's 5s default was crossed in CI while the same
|
|
17
17
|
// suite passed alone. The budget is the machine's slowness, not the test's.
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
"prepare": "node .githooks/install.mjs"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@better-auth/expo": "1.7.
|
|
18
|
+
"@better-auth/expo": "1.7.6",
|
|
19
19
|
"@hookform/resolvers": "5.9.1",
|
|
20
20
|
"@tanstack/query-sync-storage-persister": "5.102.8",
|
|
21
21
|
"@tanstack/react-query": "5.102.8",
|
|
22
22
|
"@tanstack/react-query-persist-client": "5.102.8",
|
|
23
|
-
"better-auth": "1.7.
|
|
23
|
+
"better-auth": "1.7.6",
|
|
24
24
|
"class-variance-authority": "0.7.1",
|
|
25
25
|
"expo": "57.0.22",
|
|
26
26
|
"expo-constants": "57.0.18",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
2
3
|
import { createAuthClient } from 'better-auth/react'
|
|
3
4
|
import { renderRouter, screen } from 'expo-router/testing-library'
|
|
4
5
|
import type { ReactNode } from 'react'
|
|
@@ -7,7 +8,6 @@ import { createClient } from '@/api-contract'
|
|
|
7
8
|
import AuthenticatedLayout from '@/app/(app)/_layout'
|
|
8
9
|
import type { AuthClient } from '@/features/auth/auth-client'
|
|
9
10
|
import { ClientsProvider } from '@/features/clients/clients-context'
|
|
10
|
-
import { API_URL, fakeTransport, json } from '../../../test/fake-transport'
|
|
11
11
|
|
|
12
12
|
const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
|
|
13
13
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
2
3
|
import { render, screen, userEvent } from '@testing-library/react-native'
|
|
3
4
|
import { createAuthClient } from 'better-auth/react'
|
|
4
|
-
import {
|
|
5
|
-
import { SignInForm } from './sign-in-form'
|
|
5
|
+
import { SignInForm } from '@/features/auth/sign-in-form'
|
|
6
6
|
|
|
7
7
|
describe('SignInForm', () => {
|
|
8
8
|
it('shows a rejected sign-in as a form-level error', async () => {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
2
3
|
import { render, screen, userEvent, waitFor } from '@testing-library/react-native'
|
|
3
4
|
import { createAuthClient } from 'better-auth/react'
|
|
5
|
+
import { SignOutButton } from '@/features/auth/sign-out-button'
|
|
4
6
|
import { clearUserData } from '@/features/storage/storage'
|
|
5
|
-
import { API_URL, fakeTransport, json } from '../../../test/fake-transport'
|
|
6
|
-
import { SignOutButton } from './sign-out-button'
|
|
7
7
|
|
|
8
8
|
// MMKV needs its native module, which Jest does not have. What clearUserData removes is the persister's own key.
|
|
9
9
|
jest.mock('@/features/storage/storage', () => ({ clearUserData: jest.fn() }))
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
2
3
|
import { render, screen, userEvent, within } from '@testing-library/react-native'
|
|
3
4
|
import { createClient, type Profile } from '@/api-contract'
|
|
4
|
-
import {
|
|
5
|
-
import { ProfileForm } from './profile-form'
|
|
5
|
+
import { ProfileForm } from '@/features/profile/profile-form'
|
|
6
6
|
|
|
7
7
|
const profile: Profile = {
|
|
8
8
|
id: '01a09ffe-635c-77e5-9aeb-d3e9d1a7ee24',
|
|
@@ -3,6 +3,8 @@ import { defineConfig } from 'vitest/config'
|
|
|
3
3
|
|
|
4
4
|
export default defineConfig({
|
|
5
5
|
plugins: [react()],
|
|
6
|
-
resolve: {
|
|
7
|
-
|
|
6
|
+
resolve: {
|
|
7
|
+
alias: { '@': `${import.meta.dirname}/src`, '@test': `${import.meta.dirname}/test` },
|
|
8
|
+
},
|
|
9
|
+
test: { globals: true, include: ['test/**/*.spec.{ts,tsx}'] },
|
|
8
10
|
})
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"build": "vite build",
|
|
9
9
|
"preview": "vite preview",
|
|
10
10
|
"lint": "biome check",
|
|
11
|
-
"typecheck": "tsc --noEmit",
|
|
11
|
+
"typecheck": "tsr generate && tsc --noEmit",
|
|
12
12
|
"test": "vitest run",
|
|
13
13
|
"test:watch": "vitest",
|
|
14
14
|
"test:cov": "vitest run --coverage",
|
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@base-ui/react": "1.8.0",
|
|
19
|
+
"@better-auth/oauth-provider": "1.7.6",
|
|
19
20
|
"@fontsource-variable/geist": "5.3.0",
|
|
20
21
|
"@hookform/resolvers": "5.9.1",
|
|
21
22
|
"@tanstack/react-query": "5.102.8",
|
|
22
23
|
"@tanstack/react-router": "1.170.36",
|
|
23
|
-
"better-auth": "1.7.
|
|
24
|
+
"better-auth": "1.7.6",
|
|
24
25
|
"class-variance-authority": "0.7.1",
|
|
25
26
|
"cn": "0.3.0",
|
|
26
27
|
"lucide-react": "1.45.0",
|
|
@@ -34,6 +35,7 @@
|
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"@biomejs/biome": "2.5.13",
|
|
36
37
|
"@tailwindcss/vite": "4.3.3",
|
|
38
|
+
"@tanstack/router-cli": "1.167.36",
|
|
37
39
|
"@tanstack/router-plugin": "1.168.38",
|
|
38
40
|
"@testing-library/jest-dom": "7.0.1",
|
|
39
41
|
"@testing-library/react": "16.3.3",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { oauthProviderClient } from '@better-auth/oauth-provider/client' // prumo:mcp
|
|
1
2
|
import { createAuthClient } from 'better-auth/react'
|
|
2
3
|
import type { Transport } from '@/api-contract'
|
|
3
4
|
|
|
@@ -8,5 +9,9 @@ export function createAuth({ baseUrl, fetch }: { baseUrl: string; fetch: Transpo
|
|
|
8
9
|
baseURL: baseUrl,
|
|
9
10
|
basePath: '/api/auth',
|
|
10
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
|
|
11
16
|
})
|
|
12
17
|
}
|
|
@@ -31,13 +31,20 @@ export function SignInForm({ auth, redirect }: { auth: AuthClient; redirect: str
|
|
|
31
31
|
const { errors, isSubmitting } = form.formState
|
|
32
32
|
|
|
33
33
|
async function onSubmit(values: SignInValues) {
|
|
34
|
-
const
|
|
34
|
+
const result = await auth.signIn.email(values)
|
|
35
35
|
|
|
36
|
-
if (error !== null) {
|
|
37
|
-
applyServerError(ApiError.fromAuthError(error), ['email', 'password'], form.setError)
|
|
36
|
+
if (result.error !== null) {
|
|
37
|
+
applyServerError(ApiError.fromAuthError(result.error), ['email', 'password'], form.setError)
|
|
38
38
|
return
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// prumo:mcp
|
|
42
|
+
// Signing in during an MCP client's authorization: Better Auth has already sent the browser on.
|
|
43
|
+
if (result.data.redirect) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// prumo:end-mcp
|
|
41
48
|
// Invalidating is not enough: nothing observes the session, and the protected route's ensureQueryData
|
|
42
49
|
// would keep returning the cached null. Refetching replaces it before the route reads it.
|
|
43
50
|
await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { Button } from '@/components/ui/button'
|
|
4
|
+
import type { AuthClient } from '@/features/auth/auth-client'
|
|
5
|
+
|
|
6
|
+
type PublicClient = { client_name?: string }
|
|
7
|
+
|
|
8
|
+
// Better Auth checks the signed query on submit; what is shown here comes from the same URL.
|
|
9
|
+
export function ConsentForm({
|
|
10
|
+
auth,
|
|
11
|
+
clientId,
|
|
12
|
+
scopes,
|
|
13
|
+
}: {
|
|
14
|
+
auth: AuthClient
|
|
15
|
+
clientId: string
|
|
16
|
+
scopes: string[]
|
|
17
|
+
}) {
|
|
18
|
+
const [failed, setFailed] = useState(false)
|
|
19
|
+
const client = useQuery({
|
|
20
|
+
queryKey: ['oauth-client', clientId],
|
|
21
|
+
queryFn: async () => {
|
|
22
|
+
const { data } = await auth.$fetch<PublicClient>('/oauth2/public-client', {
|
|
23
|
+
query: { client_id: clientId },
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return data
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
const name = client.data?.client_name ?? clientId
|
|
30
|
+
|
|
31
|
+
async function answer(accept: boolean) {
|
|
32
|
+
// On success Better Auth sends the browser back to the client, so nothing follows here.
|
|
33
|
+
const { error } = await auth.$fetch('/oauth2/consent', { method: 'POST', body: { accept } })
|
|
34
|
+
|
|
35
|
+
setFailed(error !== null)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex flex-col gap-6">
|
|
40
|
+
<p>
|
|
41
|
+
<strong>{name}</strong> wants to act on your behalf.
|
|
42
|
+
</p>
|
|
43
|
+
{scopes.length > 0 && (
|
|
44
|
+
<ul className="list-disc pl-6 text-sm text-muted-foreground">
|
|
45
|
+
{scopes.map((scope) => (
|
|
46
|
+
<li key={scope}>{scope}</li>
|
|
47
|
+
))}
|
|
48
|
+
</ul>
|
|
49
|
+
)}
|
|
50
|
+
{failed && (
|
|
51
|
+
<p role="alert" className="text-sm text-destructive">
|
|
52
|
+
The authorization could not be completed. Start again from the application.
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
<div className="flex gap-3">
|
|
56
|
+
<Button onClick={() => answer(true)}>Allow</Button>
|
|
57
|
+
<Button variant="outline" onClick={() => answer(false)}>
|
|
58
|
+
Deny
|
|
59
|
+
</Button>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -13,6 +13,7 @@ import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
|
|
13
13
|
import { Route as SignInRouteImport } from './routes/sign-in'
|
|
14
14
|
import { Route as SignUpRouteImport } from './routes/sign-up'
|
|
15
15
|
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
|
16
|
+
import { Route as AuthenticatedConsentRouteImport } from './routes/_authenticated/consent'
|
|
16
17
|
|
|
17
18
|
const AuthenticatedRoute = AuthenticatedRouteImport.update({
|
|
18
19
|
id: '/_authenticated',
|
|
@@ -33,15 +34,22 @@ const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
|
|
|
33
34
|
path: '/',
|
|
34
35
|
getParentRoute: () => AuthenticatedRoute,
|
|
35
36
|
} as any)
|
|
37
|
+
const AuthenticatedConsentRoute = AuthenticatedConsentRouteImport.update({
|
|
38
|
+
id: '/consent',
|
|
39
|
+
path: '/consent',
|
|
40
|
+
getParentRoute: () => AuthenticatedRoute,
|
|
41
|
+
} as any)
|
|
36
42
|
|
|
37
43
|
export interface FileRoutesByFullPath {
|
|
38
44
|
'/': typeof AuthenticatedIndexRoute
|
|
39
45
|
'/sign-in': typeof SignInRoute
|
|
40
46
|
'/sign-up': typeof SignUpRoute
|
|
47
|
+
'/consent': typeof AuthenticatedConsentRoute
|
|
41
48
|
}
|
|
42
49
|
export interface FileRoutesByTo {
|
|
43
50
|
'/sign-in': typeof SignInRoute
|
|
44
51
|
'/sign-up': typeof SignUpRoute
|
|
52
|
+
'/consent': typeof AuthenticatedConsentRoute
|
|
45
53
|
'/': typeof AuthenticatedIndexRoute
|
|
46
54
|
}
|
|
47
55
|
export interface FileRoutesById {
|
|
@@ -49,18 +57,20 @@ export interface FileRoutesById {
|
|
|
49
57
|
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
|
50
58
|
'/sign-in': typeof SignInRoute
|
|
51
59
|
'/sign-up': typeof SignUpRoute
|
|
60
|
+
'/_authenticated/consent': typeof AuthenticatedConsentRoute
|
|
52
61
|
'/_authenticated/': typeof AuthenticatedIndexRoute
|
|
53
62
|
}
|
|
54
63
|
export interface FileRouteTypes {
|
|
55
64
|
fileRoutesByFullPath: FileRoutesByFullPath
|
|
56
|
-
fullPaths: '/' | '/sign-in' | '/sign-up'
|
|
65
|
+
fullPaths: '/' | '/sign-in' | '/sign-up' | '/consent'
|
|
57
66
|
fileRoutesByTo: FileRoutesByTo
|
|
58
|
-
to: '/sign-in' | '/sign-up' | '/'
|
|
67
|
+
to: '/sign-in' | '/sign-up' | '/consent' | '/'
|
|
59
68
|
id:
|
|
60
69
|
| '__root__'
|
|
61
70
|
| '/_authenticated'
|
|
62
71
|
| '/sign-in'
|
|
63
72
|
| '/sign-up'
|
|
73
|
+
| '/_authenticated/consent'
|
|
64
74
|
| '/_authenticated/'
|
|
65
75
|
fileRoutesById: FileRoutesById
|
|
66
76
|
}
|
|
@@ -100,14 +110,23 @@ declare module '@tanstack/react-router' {
|
|
|
100
110
|
preLoaderRoute: typeof AuthenticatedIndexRouteImport
|
|
101
111
|
parentRoute: typeof AuthenticatedRoute
|
|
102
112
|
}
|
|
113
|
+
'/_authenticated/consent': {
|
|
114
|
+
id: '/_authenticated/consent'
|
|
115
|
+
path: '/consent'
|
|
116
|
+
fullPath: '/consent'
|
|
117
|
+
preLoaderRoute: typeof AuthenticatedConsentRouteImport
|
|
118
|
+
parentRoute: typeof AuthenticatedRoute
|
|
119
|
+
}
|
|
103
120
|
}
|
|
104
121
|
}
|
|
105
122
|
|
|
106
123
|
interface AuthenticatedRouteChildren {
|
|
124
|
+
AuthenticatedConsentRoute: typeof AuthenticatedConsentRoute
|
|
107
125
|
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
|
108
126
|
}
|
|
109
127
|
|
|
110
128
|
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
|
129
|
+
AuthenticatedConsentRoute: AuthenticatedConsentRoute,
|
|
111
130
|
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
|
112
131
|
}
|
|
113
132
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { ConsentForm } from '@/features/oauth/consent-form'
|
|
4
|
+
|
|
5
|
+
// Loose, because every parameter Better Auth signed has to stay in the URL for the consent request.
|
|
6
|
+
export const Route = createFileRoute('/_authenticated/consent')({
|
|
7
|
+
validateSearch: z.looseObject({ client_id: z.string(), scope: z.string().default('') }),
|
|
8
|
+
component: ConsentPage,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
function ConsentPage() {
|
|
12
|
+
const { auth } = Route.useRouteContext()
|
|
13
|
+
const { client_id, scope } = Route.useSearch()
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<>
|
|
17
|
+
<h1 className="text-2xl font-semibold">Allow access</h1>
|
|
18
|
+
<ConsentForm auth={auth} clientId={client_id} scopes={scope.split(' ').filter(Boolean)} />
|
|
19
|
+
</>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { fakeTransport, json } from '@test/support/fake-transport'
|
|
2
|
+
import { renderApp } from '@test/support/render-app'
|
|
1
3
|
import { screen } from '@testing-library/react'
|
|
2
4
|
import userEvent from '@testing-library/user-event'
|
|
3
5
|
import { describe, expect, it } from 'vitest'
|
|
4
6
|
import type { Profile } from '@/api-contract'
|
|
5
|
-
import { fakeTransport, json } from '../test/fake-transport'
|
|
6
|
-
import { renderApp } from '../test/render-app'
|
|
7
7
|
|
|
8
8
|
const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
|
|
9
9
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { fakeTransport, json } from '@test/support/fake-transport'
|
|
2
|
+
import { renderApp } from '@test/support/render-app'
|
|
1
3
|
import { screen, within } from '@testing-library/react'
|
|
2
4
|
import userEvent from '@testing-library/user-event'
|
|
3
5
|
import { describe, expect, it } from 'vitest'
|
|
4
|
-
import { fakeTransport, json } from '../../../test/fake-transport'
|
|
5
|
-
import { renderApp } from '../../../test/render-app'
|
|
6
6
|
|
|
7
7
|
describe('SignInForm', () => {
|
|
8
8
|
it('shows a rejected sign-in as a form-level error', async () => {
|
|
@@ -0,0 +1,45 @@
|
|
|
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 { afterEach, describe, expect, it } from 'vitest'
|
|
6
|
+
|
|
7
|
+
const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
|
|
8
|
+
|
|
9
|
+
// The shape Better Auth signs: every signed name listed in ba_param, then the signature.
|
|
10
|
+
const SIGNED =
|
|
11
|
+
'client_id=client-1&scope=openid+profile&ba_param=ba_param&ba_param=client_id&ba_param=scope&sig=signature'
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
window.history.replaceState(null, '', '/')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('ConsentForm', () => {
|
|
18
|
+
it('names the client, lists the scopes and sends the signed query with the answer', async () => {
|
|
19
|
+
const visitor = userEvent.setup()
|
|
20
|
+
let consent: Record<string, unknown> | undefined
|
|
21
|
+
|
|
22
|
+
// The OAuth client plugin reads the browser's own URL, not the router's memory history.
|
|
23
|
+
window.history.replaceState(null, '', `/consent?${SIGNED}&unrelated=1`)
|
|
24
|
+
|
|
25
|
+
renderApp(
|
|
26
|
+
`/consent?${SIGNED}`,
|
|
27
|
+
fakeTransport({
|
|
28
|
+
'GET /api/auth/get-session': () =>
|
|
29
|
+
json(200, { session: { id: 's-1', userId: user.id }, user }),
|
|
30
|
+
'GET /api/auth/oauth2/public-client': () => json(200, { client_name: 'Claude' }),
|
|
31
|
+
'POST /api/auth/oauth2/consent': async (request) => {
|
|
32
|
+
consent = await request.json()
|
|
33
|
+
return json(200, { redirect: false })
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
expect(await screen.findByText('Claude')).toBeInTheDocument()
|
|
39
|
+
expect(screen.getByText('profile')).toBeInTheDocument()
|
|
40
|
+
|
|
41
|
+
await visitor.click(screen.getByRole('button', { name: 'Allow' }))
|
|
42
|
+
|
|
43
|
+
expect(consent).toMatchObject({ accept: true, oauth_query: SIGNED })
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
2
3
|
import { render, screen } from '@testing-library/react'
|
|
3
4
|
import userEvent from '@testing-library/user-event'
|
|
4
5
|
import { describe, expect, it } from 'vitest'
|
|
5
6
|
import { createClient, type Profile } from '@/api-contract'
|
|
6
|
-
import {
|
|
7
|
-
import { ProfileForm } from './profile-form'
|
|
7
|
+
import { ProfileForm } from '@/features/profile/profile-form'
|
|
8
8
|
|
|
9
9
|
const profile: Profile = {
|
|
10
10
|
id: '01a09ffe-635c-77e5-9aeb-d3e9d1a7ee24',
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { QueryClient } from '@tanstack/react-query'
|
|
2
2
|
import { createMemoryHistory } from '@tanstack/react-router'
|
|
3
|
+
import { API_URL } from '@test/support/fake-transport'
|
|
3
4
|
import { render } from '@testing-library/react'
|
|
4
5
|
import { createClient, type Transport } from '@/api-contract'
|
|
5
6
|
import { App } from '@/app'
|
|
6
7
|
import { createAuth } from '@/features/auth/auth-client'
|
|
7
|
-
import { API_URL } from './fake-transport'
|
|
8
8
|
|
|
9
9
|
export function renderApp(path: string, transport: Transport) {
|
|
10
10
|
const context = {
|