@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.
- package/.prumo-templates/api/auth.md +50 -60
- package/.prumo-templates/api/config.md +2 -2
- package/.prumo-templates/api/routes.md +6 -4
- package/.prumo-templates/api/structure.md +4 -1
- package/.prumo-templates/client/data.md +16 -18
- package/.prumo-templates/client/forms.md +2 -2
- package/.prumo-templates/core/testing.md +4 -5
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/mcp/server.md +39 -12
- package/.prumo-templates/mobile/routing.md +1 -1
- package/.prumo-templates/mobile/storage.md +7 -6
- package/.prumo-templates/monorepo/layout.md +4 -4
- package/.prumo-templates/multi-tenancy/client.md +6 -6
- package/.prumo-templates/multi-tenancy/isolation.md +17 -10
- package/README.md +2 -2
- package/dist/clean.js +2 -2
- package/dist/compose.js +1 -9
- package/dist/generate.js +3 -4
- package/dist/mcp.js +15 -8
- package/package.json +1 -1
- package/templates/api/.env.example +3 -5
- package/templates/api/README.md +4 -5
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
- package/templates/api/package.json +6 -8
- package/templates/api/scripts/database.mjs +3 -11
- package/templates/api/scripts/ports.mjs +1 -1
- package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
- package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
- package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
- package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
- package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
- package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
- package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
- package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
- package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
- package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
- package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
- package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
- package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
- package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
- package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
- package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
- package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
- package/templates/api/src/infra/config/env.ts +3 -7
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
- package/templates/api/src/infra/di/auth.di.ts +21 -0
- package/templates/api/src/infra/di/index.ts +4 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -0
- package/templates/api/src/infra/http/app.ts +11 -1
- package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
- package/templates/api/src/infra/http/fastify.d.ts +2 -3
- package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
- package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
- package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
- package/templates/api/src/infra/http/server.ts +1 -4
- package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
- package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
- package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
- package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
- package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
- package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
- package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
- package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
- package/templates/api/test/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
- package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
- package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
- package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/setup.ts +6 -28
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/jest.config.js +0 -17
- package/templates/mobile/package.json +0 -4
- package/templates/mobile/scripts/ports.mjs +1 -1
- package/templates/mobile/src/api-contract/api-error.ts +0 -11
- package/templates/mobile/src/api-contract/auth.ts +161 -0
- package/templates/mobile/src/api-contract/index.ts +10 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- package/templates/mobile/src/app/_layout.tsx +5 -5
- package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
- package/templates/mobile/src/features/auth/token-store.ts +21 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
- package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
- package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
- package/templates/mobile/test/support/memory-token-store.ts +15 -0
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/package.json +0 -3
- package/templates/web/scripts/ports.mjs +1 -1
- package/templates/web/src/api-contract/api-error.ts +0 -11
- package/templates/web/src/api-contract/auth.ts +161 -0
- package/templates/web/src/api-contract/index.ts +10 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
- package/templates/web/src/features/oauth/consent-form.tsx +66 -35
- package/templates/web/src/main.tsx +6 -4
- package/templates/web/src/router.tsx +1 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
- package/templates/web/src/routes/_authenticated.tsx +1 -1
- package/templates/web/test/api-contract/auth.spec.ts +155 -0
- package/templates/web/test/app.spec.tsx +47 -22
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/scripts/ports.mjs +1 -1
- package/templates/api/docker/init/01-auth-schema.sql +0 -1
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
- package/templates/api/src/infra/auth/auth.cli.ts +0 -5
- package/templates/api/src/infra/auth/auth.factory.ts +0 -77
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
- package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
- package/templates/mobile/src/features/auth/auth-client.ts +0 -27
- package/templates/mobile/src/features/auth/session.ts +0 -17
- package/templates/web/src/features/auth/auth-client.ts +0 -17
- package/templates/web/src/features/auth/session.ts +0 -17
|
@@ -0,0 +1,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 {
|
|
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'
|
|
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({
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const transport = fakeTransport({
|
|
17
|
+
'GET /api/auth/session': getSession,
|
|
18
|
+
'POST /api/auth/refresh': () => json(401, {}),
|
|
19
|
+
})
|
|
18
20
|
const auth = createAuthClient({
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
})
|
|
23
|
-
const clients = { auth, api: createClient({ baseUrl: API_URL, fetch:
|
|
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, {
|
|
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(
|
|
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 '
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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 '
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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'], {
|
|
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(() =>
|
|
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: '
|
|
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: '
|
|
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 {
|
|
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({
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
applyServerError(
|
|
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
|
-
//
|
|
15
|
-
//
|
|
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 {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
applyServerError(
|
|
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
|
|