@stjoseph/prumo 0.2.0 → 0.3.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 (163) hide show
  1. package/.prumo-templates/api/auth.md +50 -60
  2. package/.prumo-templates/api/config.md +2 -2
  3. package/.prumo-templates/api/routes.md +6 -4
  4. package/.prumo-templates/api/structure.md +4 -1
  5. package/.prumo-templates/client/data.md +16 -18
  6. package/.prumo-templates/client/forms.md +2 -2
  7. package/.prumo-templates/core/testing.md +4 -5
  8. package/.prumo-templates/database/testing.md +6 -8
  9. package/.prumo-templates/mcp/server.md +39 -12
  10. package/.prumo-templates/mobile/routing.md +1 -1
  11. package/.prumo-templates/mobile/storage.md +7 -6
  12. package/.prumo-templates/monorepo/layout.md +4 -4
  13. package/.prumo-templates/multi-tenancy/client.md +6 -6
  14. package/.prumo-templates/multi-tenancy/isolation.md +17 -10
  15. package/README.md +2 -2
  16. package/dist/clean.js +2 -2
  17. package/dist/compose.js +1 -9
  18. package/dist/generate.js +3 -4
  19. package/dist/mcp.js +15 -8
  20. package/package.json +1 -1
  21. package/templates/api/.env.example +3 -5
  22. package/templates/api/README.md +4 -5
  23. package/templates/api/docker-compose.yml +0 -1
  24. package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
  25. package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
  26. package/templates/api/package.json +6 -8
  27. package/templates/api/scripts/database.mjs +3 -11
  28. package/templates/api/scripts/ports.mjs +1 -1
  29. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  30. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  31. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  32. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  33. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  34. package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
  35. package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
  36. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  37. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  38. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  39. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  40. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  41. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  42. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  43. package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
  44. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  45. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  46. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
  47. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
  48. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  49. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
  50. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
  51. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  52. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
  53. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
  54. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  55. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
  56. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  57. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  58. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  59. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  60. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  61. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  62. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
  63. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  64. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
  65. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  66. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  67. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
  68. package/templates/api/src/infra/config/env.ts +3 -7
  69. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
  70. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  71. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
  72. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
  73. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
  74. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  75. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
  76. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  77. package/templates/api/src/infra/di/auth.di.ts +21 -0
  78. package/templates/api/src/infra/di/index.ts +4 -10
  79. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  80. package/templates/api/src/infra/http/app.ts +11 -1
  81. package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
  82. package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
  83. package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
  84. package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
  85. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  86. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  87. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  88. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  89. package/templates/api/src/infra/http/server.ts +1 -4
  90. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  91. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  92. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  93. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  94. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  95. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  96. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
  97. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
  98. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  99. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  100. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  101. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  102. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
  103. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
  104. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
  105. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  106. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  107. package/templates/api/test/support/factories/user.factory.ts +9 -11
  108. package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
  109. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  110. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  111. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  112. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  113. package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
  114. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  115. package/templates/api/test/support/setup.ts +6 -28
  116. package/templates/mobile/README.md +1 -1
  117. package/templates/mobile/jest.config.js +0 -17
  118. package/templates/mobile/package.json +0 -4
  119. package/templates/mobile/scripts/ports.mjs +1 -1
  120. package/templates/mobile/src/api-contract/api-error.ts +0 -11
  121. package/templates/mobile/src/api-contract/auth.ts +161 -0
  122. package/templates/mobile/src/api-contract/index.ts +10 -0
  123. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  124. package/templates/mobile/src/app/_layout.tsx +5 -5
  125. package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
  126. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  127. package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
  128. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  129. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  130. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  131. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  132. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  133. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  134. package/templates/site/scripts/ports.mjs +1 -1
  135. package/templates/web/package.json +0 -3
  136. package/templates/web/scripts/ports.mjs +1 -1
  137. package/templates/web/src/api-contract/api-error.ts +0 -11
  138. package/templates/web/src/api-contract/auth.ts +161 -0
  139. package/templates/web/src/api-contract/index.ts +10 -0
  140. package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
  141. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  142. package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
  143. package/templates/web/src/features/oauth/consent-form.tsx +66 -35
  144. package/templates/web/src/main.tsx +6 -4
  145. package/templates/web/src/router.tsx +1 -2
  146. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  147. package/templates/web/src/routes/_authenticated.tsx +1 -1
  148. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  149. package/templates/web/test/app.spec.tsx +47 -22
  150. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  151. package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
  152. package/templates/web/test/support/render-app.tsx +4 -4
  153. package/templates/workspace/scripts/ports.mjs +1 -1
  154. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  155. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  156. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  157. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  158. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  159. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  160. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  161. package/templates/mobile/src/features/auth/session.ts +0 -17
  162. package/templates/web/src/features/auth/auth-client.ts +0 -17
  163. package/templates/web/src/features/auth/session.ts +0 -17
@@ -0,0 +1,21 @@
1
+ import * as SecureStore from 'expo-secure-store'
2
+ import type { TokenStore, Tokens } from '@/api-contract'
3
+
4
+ const KEY = 'auth.tokens'
5
+
6
+ // The Keychain on iOS and the Keystore on Android: the tokens never sit in plain storage, MMKV included.
7
+ export const secureTokenStore: TokenStore = {
8
+ async read() {
9
+ const value = await SecureStore.getItemAsync(KEY)
10
+
11
+ return value === null ? null : (JSON.parse(value) as Tokens)
12
+ },
13
+
14
+ async write(tokens) {
15
+ if (tokens === null) {
16
+ await SecureStore.deleteItemAsync(KEY)
17
+ } else {
18
+ await SecureStore.setItemAsync(KEY, JSON.stringify(tokens))
19
+ }
20
+ },
21
+ }
@@ -1,6 +1,5 @@
1
1
  import { createContext, type ReactNode, useContext } from 'react'
2
- import type { ApiClient } from '@/api-contract'
3
- import type { AuthClient } from '@/features/auth/auth-client'
2
+ import type { ApiClient, AuthClient } from '@/api-contract'
4
3
 
5
4
  type Clients = { api: ApiClient; auth: AuthClient }
6
5
 
@@ -1,26 +1,28 @@
1
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
2
  import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
- import { createAuthClient } from 'better-auth/react'
3
+ import { memoryTokenStore } from '@test/support/memory-token-store'
4
4
  import { renderRouter, screen } from 'expo-router/testing-library'
5
5
  import type { ReactNode } from 'react'
6
6
  import { Text } from 'react-native'
7
- import { createClient } from '@/api-contract'
7
+ import { createAuthClient, createClient } from '@/api-contract'
8
8
  import AuthenticatedLayout from '@/app/(app)/_layout'
9
- import type { AuthClient } from '@/features/auth/auth-client'
10
9
  import { ClientsProvider } from '@/features/clients/clients-context'
11
10
 
12
- const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
11
+ const user = { id: 'user-1', email: 'ana@example.com' }
12
+
13
+ const TOKENS = { accessToken: 'access-1', refreshToken: 'refresh-1', expiresIn: 900 }
13
14
 
14
15
  async function renderAt(getSession: () => Response | Promise<Response>) {
15
- const transport = fakeTransport({ 'GET /api/auth/get-session': getSession })
16
- // jest-expo leaves the app manifest empty, and the Expo plugin needs it to build an origin; the layout depends only on
17
- // what getSession returns, which the plugin does not change.
16
+ const transport = fakeTransport({
17
+ 'GET /api/auth/session': getSession,
18
+ 'POST /api/auth/refresh': () => json(401, {}),
19
+ })
18
20
  const auth = createAuthClient({
19
- baseURL: API_URL,
20
- basePath: '/api/auth',
21
- fetchOptions: { customFetchImpl: transport },
22
- }) as unknown as AuthClient
23
- const clients = { auth, api: createClient({ baseUrl: API_URL, fetch: transport }) }
21
+ baseUrl: API_URL,
22
+ fetch: transport,
23
+ tokens: memoryTokenStore(TOKENS),
24
+ })
25
+ const clients = { auth, api: createClient({ baseUrl: API_URL, fetch: auth.fetch }) }
24
26
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
25
27
 
26
28
  function Providers({ children }: { children: ReactNode }) {
@@ -46,7 +48,7 @@ describe('AuthenticatedLayout', () => {
46
48
  it('waits for a session still on its way instead of sending a signed-in visitor to sign in', async () => {
47
49
  await renderAt(async () => {
48
50
  await new Promise((resolve) => setTimeout(resolve, 50))
49
- return json(200, { session: { id: 'session-1', userId: user.id }, user })
51
+ return json(200, { user })
50
52
  })
51
53
 
52
54
  expect(await screen.findByText('Your profile')).toBeOnTheScreen()
@@ -54,7 +56,7 @@ describe('AuthenticatedLayout', () => {
54
56
  })
55
57
 
56
58
  it('sends a visitor without a session to sign in', async () => {
57
- await renderAt(() => json(200, null))
59
+ await renderAt(() => json(401, {}))
58
60
 
59
61
  expect(await screen.findByText('Sign in')).toBeOnTheScreen()
60
62
  })
@@ -1,23 +1,24 @@
1
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
2
  import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
+ import { memoryTokenStore } from '@test/support/memory-token-store'
3
4
  import { render, screen, userEvent } from '@testing-library/react-native'
4
- import { createAuthClient } from 'better-auth/react'
5
+ import { createAuthClient } from '@/api-contract'
5
6
  import { SignInForm } from '@/features/auth/sign-in-form'
6
7
 
7
8
  describe('SignInForm', () => {
8
9
  it('shows a rejected sign-in as a form-level error', async () => {
9
10
  const onSignedIn = jest.fn()
10
- // jest-expo leaves the app manifest empty, and the Expo plugin needs it to build an origin; the form under test
11
- // depends only on the error Better Auth returns, which the plugin does not change.
12
11
  const auth = createAuthClient({
13
- baseURL: API_URL,
14
- basePath: '/api/auth',
15
- fetchOptions: {
16
- customFetchImpl: fakeTransport({
17
- 'POST /api/auth/sign-in/email': () =>
18
- json(401, { message: 'Invalid email or password', code: 'INVALID_EMAIL_OR_PASSWORD' }),
19
- }),
20
- },
12
+ baseUrl: API_URL,
13
+ tokens: memoryTokenStore(),
14
+ fetch: fakeTransport({
15
+ 'POST /api/auth/sign-in': () =>
16
+ json(
17
+ 401,
18
+ { title: 'Unauthorized', status: 401, detail: 'Invalid email or password' },
19
+ 'application/problem+json',
20
+ ),
21
+ }),
21
22
  })
22
23
  const user = userEvent.setup()
23
24
 
@@ -1,7 +1,8 @@
1
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
- import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
2
+ import { API_URL, fakeTransport } from '@test/support/fake-transport'
3
+ import { memoryTokenStore } from '@test/support/memory-token-store'
3
4
  import { render, screen, userEvent, waitFor } from '@testing-library/react-native'
4
- import { createAuthClient } from 'better-auth/react'
5
+ import { createAuthClient } from '@/api-contract'
5
6
  import { SignOutButton } from '@/features/auth/sign-out-button'
6
7
  import { clearUserData } from '@/features/storage/storage'
7
8
 
@@ -11,15 +12,13 @@ jest.mock('@/features/storage/storage', () => ({ clearUserData: jest.fn() }))
11
12
  async function signOutWith(signOut: () => Response | Promise<Response>) {
12
13
  const queryClient = new QueryClient()
13
14
  const onSignedOut = jest.fn()
14
- // jest-expo leaves the app manifest empty, and the Expo plugin needs it to build an origin; the button depends only on
15
- // signOut resolving or failing, which the plugin does not change.
16
15
  const auth = createAuthClient({
17
- baseURL: API_URL,
18
- basePath: '/api/auth',
19
- fetchOptions: { customFetchImpl: fakeTransport({ 'POST /api/auth/sign-out': signOut }) },
16
+ baseUrl: API_URL,
17
+ tokens: memoryTokenStore({ accessToken: 'a', refreshToken: 'r', expiresIn: 900 }),
18
+ fetch: fakeTransport({ 'POST /api/auth/sign-out': signOut }),
20
19
  })
21
20
 
22
- queryClient.setQueryData(['session'], { session: { id: 'session-1' }, user: { id: 'user-1' } })
21
+ queryClient.setQueryData(['session'], { user: { id: 'user-1', email: 'ana@example.com' } })
23
22
  queryClient.setQueryData(['profile', 'me'], { id: 'profile-1', displayName: 'Ana' })
24
23
 
25
24
  await render(
@@ -38,7 +37,7 @@ describe('SignOutButton', () => {
38
37
  beforeEach(() => jest.mocked(clearUserData).mockClear())
39
38
 
40
39
  it('leaves nothing of the user behind for the next person on the device', async () => {
41
- const queryClient = await signOutWith(() => json(200, { success: true }))
40
+ const queryClient = await signOutWith(() => new Response(null, { status: 204 }))
42
41
 
43
42
  expect(queryClient.getQueryCache().getAll()).toHaveLength(0)
44
43
  expect(clearUserData).toHaveBeenCalledTimes(1)
@@ -0,0 +1,15 @@
1
+ import type { TokenStore, Tokens } from '@/api-contract'
2
+
3
+ // expo-secure-store needs the native Keychain or Keystore, which Jest does not have.
4
+ export function memoryTokenStore(initial: Tokens | null = null): TokenStore {
5
+ let current = initial
6
+
7
+ return {
8
+ async read() {
9
+ return current
10
+ },
11
+ async write(tokens) {
12
+ current = tokens
13
+ },
14
+ }
15
+ }
@@ -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: 'BETTER_AUTH_URL' },
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
  ],
@@ -16,13 +16,10 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@base-ui/react": "1.8.0",
19
- "@better-auth/core": "1.7.7",
20
- "@better-auth/oauth-provider": "1.7.7",
21
19
  "@fontsource-variable/geist": "5.3.0",
22
20
  "@hookform/resolvers": "5.9.1",
23
21
  "@tanstack/react-query": "5.102.8",
24
22
  "@tanstack/react-router": "1.170.36",
25
- "better-auth": "1.7.7",
26
23
  "class-variance-authority": "0.7.1",
27
24
  "cn": "0.3.0",
28
25
  "lucide-react": "1.45.0",
@@ -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: 'BETTER_AUTH_URL' },
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
  ],
@@ -8,13 +8,6 @@ type ProblemDocument = {
8
8
  errors?: FieldErrors
9
9
  }
10
10
 
11
- type AuthClientError = {
12
- status: number
13
- statusText: string
14
- message?: string
15
- code?: string
16
- }
17
-
18
11
  export class ApiError extends Error {
19
12
  constructor(
20
13
  readonly status: number,
@@ -44,8 +37,4 @@ export class ApiError extends Error {
44
37
  problem.errors ?? {},
45
38
  )
46
39
  }
47
-
48
- static fromAuthError(error: AuthClientError): ApiError {
49
- return new ApiError(error.status, error.code ?? error.statusText, error.message, undefined, {})
50
- }
51
40
  }
@@ -0,0 +1,161 @@
1
+ import { queryOptions } from '@tanstack/react-query'
2
+ import { ApiError } from './api-error'
3
+ import type { Transport } from './client'
4
+
5
+ export type Session = { user: { id: string; email: string } }
6
+
7
+ export type Tokens = { accessToken: string; refreshToken: string; expiresIn: number }
8
+
9
+ export type SignUpRequest = { name: string; email: string; password: string }
10
+
11
+ export type SignInRequest = { email: string; password: string }
12
+
13
+ // Where a native client keeps its tokens. The web passes none: its tokens live in cookies no script can read.
14
+ export type TokenStore = {
15
+ read(): Promise<Tokens | null>
16
+ write(tokens: Tokens | null): Promise<void>
17
+ }
18
+
19
+ export type AuthClient = ReturnType<typeof createAuthClient>
20
+
21
+ export function createAuthClient({
22
+ baseUrl,
23
+ fetch,
24
+ tokens,
25
+ }: {
26
+ baseUrl: string
27
+ fetch: Transport
28
+ tokens?: TokenStore
29
+ }) {
30
+ const url = (path: string) => `${baseUrl}/api/auth${path}`
31
+
32
+ function send(path: string, body?: unknown): Promise<Response> {
33
+ const headers = new Headers()
34
+
35
+ // Asking for the tokens in the body is what makes the API leave cookies out.
36
+ if (tokens !== undefined) {
37
+ headers.set('x-auth-transport', 'bearer')
38
+ }
39
+
40
+ if (body !== undefined) {
41
+ headers.set('content-type', 'application/json')
42
+ }
43
+
44
+ return fetch(url(path), {
45
+ method: 'POST',
46
+ headers,
47
+ body: body === undefined ? undefined : JSON.stringify(body),
48
+ })
49
+ }
50
+
51
+ async function begin(path: string, body: unknown): Promise<void> {
52
+ const response = await send(path, body)
53
+
54
+ if (!response.ok) {
55
+ throw await ApiError.fromResponse(response)
56
+ }
57
+
58
+ await tokens?.write((await response.json()) as Tokens)
59
+ }
60
+
61
+ let refreshing: Promise<boolean> | undefined
62
+
63
+ // Every request that meets a 401 at once waits on the same refresh: a second refresh would present a token the
64
+ // first has already rotated.
65
+ function refresh(): Promise<boolean> {
66
+ refreshing ??= (async () => {
67
+ try {
68
+ // The web has no store and sends its cookie; a native client with nothing stored has nothing to send.
69
+ const stored = tokens === undefined ? undefined : await tokens.read()
70
+
71
+ if (stored === null) {
72
+ return false
73
+ }
74
+
75
+ const response = await send(
76
+ '/refresh',
77
+ stored === undefined ? undefined : { refreshToken: stored.refreshToken },
78
+ )
79
+
80
+ // Another tab rotated the token a moment ago, and the browser already holds the new one.
81
+ if (response.status === 409) {
82
+ return true
83
+ }
84
+
85
+ if (!response.ok) {
86
+ await tokens?.write(null)
87
+ return false
88
+ }
89
+
90
+ await tokens?.write((await response.json()) as Tokens)
91
+ return true
92
+ } finally {
93
+ refreshing = undefined
94
+ }
95
+ })()
96
+
97
+ return refreshing
98
+ }
99
+
100
+ // Every request to the API goes through here: with a bearer when there is one, and refreshed once on a 401.
101
+ const authorized: Transport = async (input, init) => {
102
+ const attempt = async () => {
103
+ const headers = new Headers(init?.headers)
104
+ const stored = await tokens?.read()
105
+
106
+ if (stored !== null && stored !== undefined) {
107
+ headers.set('authorization', `Bearer ${stored.accessToken}`)
108
+ }
109
+
110
+ return fetch(input, { ...init, headers })
111
+ }
112
+ const response = await attempt()
113
+
114
+ if (response.status !== 401 || !(await refresh())) {
115
+ return response
116
+ }
117
+
118
+ return attempt()
119
+ }
120
+
121
+ return {
122
+ baseUrl,
123
+ fetch: authorized,
124
+ refresh,
125
+
126
+ signUp: (request: SignUpRequest) => begin('/sign-up', request),
127
+
128
+ signIn: (request: SignInRequest) => begin('/sign-in', request),
129
+
130
+ // The device forgets the tokens whether or not the request arrives: signing out locally is what was asked.
131
+ async signOut(): Promise<void> {
132
+ try {
133
+ const response = await authorized(url('/sign-out'), { method: 'POST' })
134
+
135
+ if (!response.ok && response.status !== 401) {
136
+ throw await ApiError.fromResponse(response)
137
+ }
138
+ } finally {
139
+ await tokens?.write(null)
140
+ }
141
+ },
142
+
143
+ // A visitor without a session is not an error: the answer is null.
144
+ async getSession(): Promise<Session | null> {
145
+ const response = await authorized(url('/session'))
146
+
147
+ if (response.status === 401) {
148
+ return null
149
+ }
150
+
151
+ if (!response.ok) {
152
+ throw await ApiError.fromResponse(response)
153
+ }
154
+
155
+ return (await response.json()) as Session
156
+ },
157
+ }
158
+ }
159
+
160
+ export const sessionQuery = (auth: Pick<AuthClient, 'getSession'>) =>
161
+ queryOptions({ queryKey: ['session'], queryFn: () => auth.getSession() })
@@ -1,4 +1,14 @@
1
1
  export { ApiError, type FieldErrors } from './api-error'
2
+ export {
3
+ type AuthClient,
4
+ createAuthClient,
5
+ type Session,
6
+ type SignInRequest,
7
+ type SignUpRequest,
8
+ sessionQuery,
9
+ type TokenStore,
10
+ type Tokens,
11
+ } from './auth'
2
12
  export { type ApiClient, createClient, type Transport } from './client'
3
13
  export {
4
14
  LOCALES,
@@ -3,14 +3,12 @@ import { useQueryClient } from '@tanstack/react-query'
3
3
  import { Link, useNavigate } from '@tanstack/react-router'
4
4
  import { useForm } from 'react-hook-form'
5
5
  import { z } from 'zod'
6
- import { ApiError } from '@/api-contract'
6
+ import { type AuthClient, sessionQuery } from '@/api-contract'
7
7
  import { Button } from '@/components/ui/button'
8
8
  import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
9
9
  import { Input } from '@/components/ui/input'
10
10
  import { FormError } from '@/features/forms/form-error'
11
11
  import { applyServerError } from '@/features/forms/server-errors'
12
- import type { AuthClient } from './auth-client'
13
- import { sessionQuery } from './session'
14
12
 
15
13
  const schema = z.object({
16
14
  email: z.email(),
@@ -19,7 +17,13 @@ const schema = z.object({
19
17
 
20
18
  type SignInValues = z.infer<typeof schema>
21
19
 
22
- export function SignInForm({ auth, redirect }: { auth: AuthClient; redirect: string | undefined }) {
20
+ export function SignInForm({
21
+ auth,
22
+ redirect,
23
+ }: {
24
+ auth: Pick<AuthClient, 'signIn' | 'getSession'>
25
+ redirect: string | undefined
26
+ }) {
23
27
  const queryClient = useQueryClient()
24
28
  const navigate = useNavigate()
25
29
  const form = useForm<SignInValues>({
@@ -31,20 +35,13 @@ export function SignInForm({ auth, redirect }: { auth: AuthClient; redirect: str
31
35
  const { errors, isSubmitting } = form.formState
32
36
 
33
37
  async function onSubmit(values: SignInValues) {
34
- const result = await auth.signIn.email(values)
35
-
36
- if (result.error !== null) {
37
- applyServerError(ApiError.fromAuthError(result.error), ['email', 'password'], form.setError)
38
- return
39
- }
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) {
38
+ try {
39
+ await auth.signIn(values)
40
+ } catch (error) {
41
+ applyServerError(error, ['email', 'password'], form.setError)
44
42
  return
45
43
  }
46
44
 
47
- // prumo:end-mcp
48
45
  // Invalidating is not enough: nothing observes the session, and the protected route's ensureQueryData
49
46
  // would keep returning the cached null. Refetching replaces it before the route reads it.
50
47
  await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
@@ -1,9 +1,9 @@
1
1
  import { useQueryClient } from '@tanstack/react-query'
2
2
  import { useNavigate } from '@tanstack/react-router'
3
+ import type { AuthClient } from '@/api-contract'
3
4
  import { Button } from '@/components/ui/button'
4
- import type { AuthClient } from './auth-client'
5
5
 
6
- export function SignOutButton({ auth }: { auth: AuthClient }) {
6
+ export function SignOutButton({ auth }: { auth: Pick<AuthClient, 'signOut'> }) {
7
7
  const queryClient = useQueryClient()
8
8
  const navigate = useNavigate()
9
9
 
@@ -11,8 +11,8 @@ export function SignOutButton({ auth }: { auth: AuthClient }) {
11
11
  try {
12
12
  await auth.signOut()
13
13
  } catch {
14
- // Better Auth throws when the request never arrives. The cache is cleared regardless, because data left behind
15
- // for the next person on this tab is the worse error; the cookie survives until the server's session expires.
14
+ // The request may never arrive. The cache is cleared regardless, because data left behind for the next person
15
+ // on this tab is the worse error; the cookies then survive until the access token expires and the refresh fails.
16
16
  }
17
17
 
18
18
  // The whole cache, not only the session: a query about "me" belongs to whoever was signed in.
@@ -3,24 +3,22 @@ import { useQueryClient } from '@tanstack/react-query'
3
3
  import { Link, useNavigate } from '@tanstack/react-router'
4
4
  import { useForm } from 'react-hook-form'
5
5
  import { z } from 'zod'
6
- import { ApiError } from '@/api-contract'
6
+ import { type AuthClient, sessionQuery } from '@/api-contract'
7
7
  import { Button } from '@/components/ui/button'
8
8
  import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
9
9
  import { Input } from '@/components/ui/input'
10
10
  import { FormError } from '@/features/forms/form-error'
11
11
  import { applyServerError } from '@/features/forms/server-errors'
12
- import type { AuthClient } from './auth-client'
13
- import { sessionQuery } from './session'
14
12
 
15
13
  const schema = z.object({
16
14
  name: z.string().min(1).max(80),
17
15
  email: z.email(),
18
- password: z.string().min(8),
16
+ password: z.string().min(8).max(128),
19
17
  })
20
18
 
21
19
  type SignUpValues = z.infer<typeof schema>
22
20
 
23
- export function SignUpForm({ auth }: { auth: AuthClient }) {
21
+ export function SignUpForm({ auth }: { auth: Pick<AuthClient, 'signUp' | 'getSession'> }) {
24
22
  const queryClient = useQueryClient()
25
23
  const navigate = useNavigate()
26
24
  const form = useForm<SignUpValues>({
@@ -32,10 +30,10 @@ export function SignUpForm({ auth }: { auth: AuthClient }) {
32
30
  const { errors, isSubmitting } = form.formState
33
31
 
34
32
  async function onSubmit(values: SignUpValues) {
35
- const { error } = await auth.signUp.email(values)
36
-
37
- if (error !== null) {
38
- applyServerError(ApiError.fromAuthError(error), ['name', 'email', 'password'], form.setError)
33
+ try {
34
+ await auth.signUp(values)
35
+ } catch (error) {
36
+ applyServerError(error, ['name', 'email', 'password'], form.setError)
39
37
  return
40
38
  }
41
39