@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.
Files changed (139) hide show
  1. package/.prumo-templates/api/auth.md +51 -95
  2. package/.prumo-templates/api/config.md +30 -56
  3. package/.prumo-templates/api/errors.md +64 -44
  4. package/.prumo-templates/api/logging.md +27 -44
  5. package/.prumo-templates/api/observability.md +36 -62
  6. package/.prumo-templates/api/pagination.md +23 -38
  7. package/.prumo-templates/api/routes.md +67 -48
  8. package/.prumo-templates/api/structure.md +130 -0
  9. package/.prumo-templates/client/data.md +4 -3
  10. package/.prumo-templates/core/conventions.md +10 -7
  11. package/.prumo-templates/core/testing.md +43 -26
  12. package/.prumo-templates/core/tooling.md +33 -30
  13. package/.prumo-templates/database/entities.md +53 -61
  14. package/.prumo-templates/database/queries.md +34 -47
  15. package/.prumo-templates/database/transactions.md +28 -38
  16. package/.prumo-templates/mcp/server.md +54 -0
  17. package/.prumo-templates/monorepo/dependencies.md +3 -3
  18. package/.prumo-templates/monorepo/layout.md +1 -1
  19. package/README.md +6 -6
  20. package/dist/asker.js +4 -0
  21. package/dist/cli.js +4 -0
  22. package/dist/commands.js +2 -0
  23. package/dist/compose.js +6 -2
  24. package/dist/context.js +2 -0
  25. package/dist/generate.js +8 -0
  26. package/dist/mcp.js +100 -0
  27. package/dist/questions.js +21 -1
  28. package/package.json +4 -3
  29. package/templates/api/.swcrc +13 -0
  30. package/templates/api/biome.jsonc +5 -3
  31. package/templates/api/package.json +21 -27
  32. package/templates/api/pnpm-workspace.yaml +0 -2
  33. package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
  34. package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
  35. package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
  36. package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
  37. package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
  38. package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
  39. package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
  40. package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
  41. package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
  42. package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
  43. package/templates/api/src/infra/auth/auth.cli.ts +5 -0
  44. package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
  45. package/templates/api/src/infra/config/env.ts +47 -0
  46. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
  47. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
  48. package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
  49. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
  50. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
  51. package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
  52. package/templates/api/src/infra/di/index.ts +32 -0
  53. package/templates/api/src/infra/di/users.di.ts +7 -0
  54. package/templates/api/src/infra/http/app.ts +99 -0
  55. package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
  56. package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
  57. package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
  58. package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
  59. package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
  60. package/templates/api/src/infra/http/errors/http-error.ts +8 -0
  61. package/templates/api/src/infra/http/fastify.d.ts +17 -0
  62. package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
  63. package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
  64. package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
  65. package/templates/api/src/infra/http/server.ts +34 -0
  66. package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
  67. package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
  68. package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
  69. package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
  70. package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
  71. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
  72. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
  73. package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
  74. package/templates/api/test/infra/http/app.spec.ts +21 -0
  75. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
  76. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
  77. package/templates/api/test/support/factories/profile.factory.ts +25 -0
  78. package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
  79. package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
  80. package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
  81. package/templates/api/tsconfig.json +3 -6
  82. package/templates/api/vitest.config.mts +4 -4
  83. package/templates/mobile/jest.config.js +1 -1
  84. package/templates/mobile/package.json +2 -2
  85. package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
  86. package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  87. package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
  88. package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  89. package/templates/mobile/tsconfig.json +2 -1
  90. package/templates/site/{src → test}/app/page.spec.ts +1 -1
  91. package/templates/site/tsconfig.json +2 -1
  92. package/templates/site/vitest.config.mts +4 -2
  93. package/templates/web/package.json +4 -2
  94. package/templates/web/src/features/auth/auth-client.ts +5 -0
  95. package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
  96. package/templates/web/src/features/oauth/consent-form.tsx +63 -0
  97. package/templates/web/src/routeTree.gen.ts +21 -2
  98. package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
  99. package/templates/web/{src → test}/app.spec.tsx +2 -2
  100. package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  101. package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
  102. package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  103. package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
  104. package/templates/web/tsconfig.json +2 -1
  105. package/templates/web/vite.config.ts +3 -3
  106. package/.prumo-templates/api/modules.md +0 -117
  107. package/templates/api/nest-cli.json +0 -10
  108. package/templates/api/src/app.module.ts +0 -46
  109. package/templates/api/src/auth/auth.constants.ts +0 -2
  110. package/templates/api/src/auth/auth.controller.ts +0 -19
  111. package/templates/api/src/auth/auth.guard.ts +0 -43
  112. package/templates/api/src/auth/auth.module.ts +0 -30
  113. package/templates/api/src/auth/auth.ts +0 -4
  114. package/templates/api/src/auth/current-user.decorator.ts +0 -9
  115. package/templates/api/src/auth/express.d.ts +0 -10
  116. package/templates/api/src/auth/public.decorator.ts +0 -4
  117. package/templates/api/src/config/env.module.ts +0 -11
  118. package/templates/api/src/config/env.ts +0 -81
  119. package/templates/api/src/errors/problem-details.filter.ts +0 -86
  120. package/templates/api/src/errors/validation-exception.factory.ts +0 -30
  121. package/templates/api/src/health/health.controller.ts +0 -32
  122. package/templates/api/src/health/health.module.ts +0 -9
  123. package/templates/api/src/main.ts +0 -43
  124. package/templates/api/src/mikro-orm.factory.ts +0 -14
  125. package/templates/api/src/request-context/request-context.module.ts +0 -9
  126. package/templates/api/src/request-context/request-context.service.ts +0 -17
  127. package/templates/api/src/request-context/request-id.middleware.ts +0 -19
  128. package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
  129. package/templates/api/src/users/entities/profile.entity.ts +0 -27
  130. package/templates/api/src/users/users.controller.ts +0 -23
  131. package/templates/api/src/users/users.module.ts +0 -10
  132. package/templates/api/src/users/users.service.spec.ts +0 -44
  133. package/templates/api/src/users/users.service.ts +0 -36
  134. package/templates/api/test/factories/profile.factory.ts +0 -26
  135. package/templates/api/tsconfig.build.json +0 -8
  136. /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
  137. /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  138. /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  139. /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 has no entity here, so the QueryBuilder cannot reach it.
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 '../src/auth/auth.factory'
6
- import { createOrmConfig } from '../src/mikro-orm.factory'
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(databaseUrl: string): Promise<void> {
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 = clientUrl
58
- await migrateAuth(clientUrl)
67
+ databaseUrl = await createWorkerDatabase(inject('postgresUrl'), name)
68
+ await migrateAuth()
59
69
 
60
- orm = await MikroORM.init(createOrmConfig({ DATABASE_URL: clientUrl, NODE_ENV: 'test' }))
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((meta) => meta.tableName !== undefined && meta.pivotTable !== true)
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
- "sourceMap": true,
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: ['src/**/*.spec.ts'],
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.4",
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.4",
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 { API_URL, fakeTransport, json } from '../../../test/fake-transport'
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 { API_URL, fakeTransport, json } from '../../../test/fake-transport'
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',
@@ -6,7 +6,8 @@
6
6
  "noFallthroughCasesInSwitch": true,
7
7
  "forceConsistentCasingInFileNames": true,
8
8
  "paths": {
9
- "@/*": ["./src/*"]
9
+ "@/*": ["./src/*"],
10
+ "@test/*": ["./test/*"]
10
11
  },
11
12
  "types": ["jest", "node"]
12
13
  },
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { metadata } from './page'
2
+ import { metadata } from '@/app/page'
3
3
 
4
4
  describe('home page metadata', () => {
5
5
  it('gives search results a title and a description', () => {
@@ -18,7 +18,8 @@
18
18
  "incremental": true,
19
19
  "plugins": [{ "name": "next" }],
20
20
  "paths": {
21
- "@/*": ["./src/*"]
21
+ "@/*": ["./src/*"],
22
+ "@test/*": ["./test/*"]
22
23
  },
23
24
  "types": ["vitest/globals"]
24
25
  },
@@ -3,6 +3,8 @@ import { defineConfig } from 'vitest/config'
3
3
 
4
4
  export default defineConfig({
5
5
  plugins: [react()],
6
- resolve: { alias: { '@': `${import.meta.dirname}/src` } },
7
- test: { globals: true, include: ['src/**/*.spec.{ts,tsx}'] },
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.4",
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 { error } = await auth.signIn.email(values)
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 { API_URL, fakeTransport, json } from '../../../test/fake-transport'
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 = {
@@ -13,7 +13,8 @@
13
13
  "isolatedModules": true,
14
14
  "noEmit": true,
15
15
  "paths": {
16
- "@/*": ["./src/*"]
16
+ "@/*": ["./src/*"],
17
+ "@test/*": ["./test/*"]
17
18
  },
18
19
  "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom/vitest"]
19
20
  },