@stjoseph/prumo 0.1.1 → 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 -48
- 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 +19 -14
- package/dist/clean.js +13 -3
- package/dist/cli.js +38 -9
- package/dist/commands.js +7 -4
- package/dist/compose.js +4 -12
- package/dist/doctor.js +31 -17
- package/dist/generate.js +56 -9
- package/dist/mcp.js +15 -8
- package/dist/names.js +42 -0
- package/dist/questions.js +32 -16
- package/package.json +2 -2
- package/templates/api/.env.example +3 -5
- package/templates/api/README.md +5 -6
- 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 +7 -8
- package/templates/api/scripts/database.mjs +3 -11
- package/templates/api/scripts/ports.mjs +1 -1
- package/templates/api/src/domain/auth/dto/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 +1 -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/README.md +1 -1
- package/templates/site/package.json +1 -1
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/README.md +1 -1
- package/templates/web/package.json +1 -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/README.md +1 -1
- package/templates/workspace/package.json +1 -1
- 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
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { User } from '@/domain/auth/entities/user.entity'
|
|
3
3
|
|
|
4
|
-
type UserOverrides = Partial<{
|
|
4
|
+
type UserOverrides = Partial<{ email: string; passwordHash: string }>
|
|
5
5
|
|
|
6
|
+
// The hash is not a real one: a test that signs in goes through the sign-up route instead.
|
|
6
7
|
export async function createUser(
|
|
7
8
|
em: EntityManager,
|
|
8
9
|
overrides: UserOverrides = {},
|
|
9
10
|
): Promise<string> {
|
|
10
|
-
const
|
|
11
|
+
const user = new User(
|
|
12
|
+
overrides.email ?? `${crypto.randomUUID()}@example.com`,
|
|
13
|
+
overrides.passwordHash ?? 'not-a-real-hash',
|
|
14
|
+
)
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
await em
|
|
14
|
-
.getConnection()
|
|
15
|
-
.execute(
|
|
16
|
-
'insert into auth."user" (id, name, email, "emailVerified", "createdAt", "updatedAt") values (?, ?, ?, false, now(), now())',
|
|
17
|
-
[id, overrides.name ?? 'Test Person', overrides.email ?? `${id}@example.com`],
|
|
18
|
-
)
|
|
16
|
+
await em.persist(user).flush()
|
|
19
17
|
|
|
20
|
-
return id
|
|
18
|
+
return user.id
|
|
21
19
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { FakeAccessTokens } from '@test/support/fakes/fake-access-tokens'
|
|
2
|
+
import { FakeOpaqueTokens } from '@test/support/fakes/fake-opaque-tokens'
|
|
3
|
+
import { FakePasswordHasher } from '@test/support/fakes/fake-password-hasher'
|
|
4
|
+
import { ImmediateTransactionManager } from '@test/support/fakes/immediate-transaction-manager'
|
|
5
|
+
import { InMemoryProfileRepository } from '@test/support/fakes/in-memory-profile.repository'
|
|
6
|
+
import { InMemorySessionRepository } from '@test/support/fakes/in-memory-session.repository'
|
|
7
|
+
import { InMemoryUserRepository } from '@test/support/fakes/in-memory-user.repository'
|
|
8
|
+
import { RefreshSessionUseCase } from '@/domain/auth/use-cases/refresh-session.use-case'
|
|
9
|
+
import { SignInUseCase } from '@/domain/auth/use-cases/sign-in.use-case'
|
|
10
|
+
import { SignOutUseCase } from '@/domain/auth/use-cases/sign-out.use-case'
|
|
11
|
+
import { SignUpUseCase } from '@/domain/auth/use-cases/sign-up.use-case'
|
|
12
|
+
import { StartSessionUseCase } from '@/domain/auth/use-cases/start-session.use-case'
|
|
13
|
+
import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
|
|
14
|
+
|
|
15
|
+
// Every auth use case wired to in-memory ports, sharing one set of them as the container would.
|
|
16
|
+
export function authFakes() {
|
|
17
|
+
const users = new InMemoryUserRepository()
|
|
18
|
+
const sessions = new InMemorySessionRepository()
|
|
19
|
+
const profiles = new InMemoryProfileRepository()
|
|
20
|
+
const hasher = new FakePasswordHasher()
|
|
21
|
+
const tokens = new FakeAccessTokens()
|
|
22
|
+
const opaque = new FakeOpaqueTokens()
|
|
23
|
+
const transactions = new ImmediateTransactionManager()
|
|
24
|
+
const startSession = new StartSessionUseCase(sessions, tokens, opaque)
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
users,
|
|
28
|
+
sessions,
|
|
29
|
+
profiles,
|
|
30
|
+
hasher,
|
|
31
|
+
signUp: new SignUpUseCase(
|
|
32
|
+
users,
|
|
33
|
+
hasher,
|
|
34
|
+
transactions,
|
|
35
|
+
new CreateProfileUseCase(profiles),
|
|
36
|
+
startSession,
|
|
37
|
+
),
|
|
38
|
+
signIn: new SignInUseCase(users, hasher, startSession),
|
|
39
|
+
refresh: new RefreshSessionUseCase(sessions, tokens, opaque, transactions),
|
|
40
|
+
signOut: new SignOutUseCase(sessions),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccessClaims,
|
|
3
|
+
AccessTokens,
|
|
4
|
+
IssuedToken,
|
|
5
|
+
} from '@/domain/auth/ports/access-tokens.port'
|
|
6
|
+
|
|
7
|
+
export class FakeAccessTokens implements AccessTokens {
|
|
8
|
+
async issue({ userId, sessionId }: AccessClaims, expiresAt: Date): Promise<IssuedToken> {
|
|
9
|
+
return { token: `access:${userId}:${sessionId}`, expiresAt }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async verify(token: string): Promise<AccessClaims | null> {
|
|
13
|
+
const [kind, userId, sessionId] = token.split(':')
|
|
14
|
+
|
|
15
|
+
return kind === 'access' && userId !== undefined && sessionId !== undefined
|
|
16
|
+
? { userId, sessionId }
|
|
17
|
+
: null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
|
|
2
|
+
|
|
3
|
+
export class FakeOpaqueTokens implements OpaqueTokens {
|
|
4
|
+
private next = 0
|
|
5
|
+
|
|
6
|
+
create(): string {
|
|
7
|
+
this.next += 1
|
|
8
|
+
return `secret-${this.next}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
digest(token: string): string {
|
|
12
|
+
return `digest:${token}`
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
|
|
2
|
+
|
|
3
|
+
// Reversible on purpose, so a test can read what was stored. Argon2 itself is proved in its adapter's test.
|
|
4
|
+
export class FakePasswordHasher implements PasswordHasher {
|
|
5
|
+
verifications = 0
|
|
6
|
+
|
|
7
|
+
async hash(password: string): Promise<string> {
|
|
8
|
+
return `hashed:${password}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async verify(hash: string | undefined, password: string): Promise<boolean> {
|
|
12
|
+
this.verifications += 1
|
|
13
|
+
return hash === `hashed:${password}`
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TransactionManager } from '@/domain/shared/transactions/transaction-manager'
|
|
2
|
+
|
|
3
|
+
// Runs the work and nothing else: atomicity is the adapter's to prove, against a real database.
|
|
4
|
+
export class ImmediateTransactionManager implements TransactionManager {
|
|
5
|
+
run<T>(work: () => Promise<T>): Promise<T> {
|
|
6
|
+
return work()
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Session } from '@/domain/auth/entities/session.entity'
|
|
2
|
+
import type { SessionRepository } from '@/domain/auth/repositories/session.repository'
|
|
3
|
+
|
|
4
|
+
export class InMemorySessionRepository implements SessionRepository {
|
|
5
|
+
readonly rows = new Map<string, Session>()
|
|
6
|
+
|
|
7
|
+
async findById(id: string): Promise<Session | null> {
|
|
8
|
+
return this.rows.get(id) ?? null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
lockById(id: string): Promise<Session | null> {
|
|
12
|
+
return this.findById(id)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async save(session: Session): Promise<void> {
|
|
16
|
+
session.id ??= crypto.randomUUID()
|
|
17
|
+
this.rows.set(session.id, session)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { User } from '@/domain/auth/entities/user.entity'
|
|
2
|
+
import type { UserRepository } from '@/domain/auth/repositories/user.repository'
|
|
3
|
+
|
|
4
|
+
export class InMemoryUserRepository implements UserRepository {
|
|
5
|
+
readonly rows = new Map<string, User>()
|
|
6
|
+
|
|
7
|
+
async findById(id: string): Promise<User | null> {
|
|
8
|
+
return this.rows.get(id) ?? null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async findByEmail(email: string): Promise<User | null> {
|
|
12
|
+
return [...this.rows.values()].find((user) => user.email === email) ?? null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async save(user: User): Promise<void> {
|
|
16
|
+
// The database assigns the id; here the first save does.
|
|
17
|
+
user.id ??= crypto.randomUUID()
|
|
18
|
+
this.rows.set(user.id, user)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
// tsyringe reads decorator metadata as each class is loaded, so this must come before any other import.
|
|
2
2
|
import 'reflect-metadata'
|
|
3
3
|
import { MikroORM } from '@mikro-orm/postgresql'
|
|
4
|
-
import {
|
|
5
|
-
import { Client, type Pool } from 'pg'
|
|
4
|
+
import { Client } from 'pg'
|
|
6
5
|
import { afterAll, beforeAll, beforeEach, inject } from 'vitest'
|
|
7
|
-
import { createAuth } from '@/infra/auth/auth.factory'
|
|
8
6
|
import type { Env } from '@/infra/config/env'
|
|
9
7
|
import { createOrmConfig } from '@/infra/database/mikroorm/mikro-orm.factory'
|
|
10
8
|
|
|
@@ -25,9 +23,8 @@ export function testEnv(): Env {
|
|
|
25
23
|
NODE_ENV: 'test',
|
|
26
24
|
PORT: 3000,
|
|
27
25
|
DATABASE_URL: databaseUrl,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
BETTER_AUTH_URL: 'http://localhost:3000',
|
|
26
|
+
JWT_SECRET: 'test-secret-that-is-at-least-32-characters',
|
|
27
|
+
API_URL: 'http://localhost:3000',
|
|
31
28
|
WEB_ORIGIN: 'http://localhost:5173',
|
|
32
29
|
LOG_LEVEL: 'error',
|
|
33
30
|
}
|
|
@@ -44,41 +41,22 @@ async function createWorkerDatabase(baseUrl: string, name: string): Promise<stri
|
|
|
44
41
|
const url = new URL(baseUrl)
|
|
45
42
|
url.pathname = `/${name}`
|
|
46
43
|
|
|
47
|
-
const worker = new Client({ connectionString: url.toString() })
|
|
48
|
-
|
|
49
|
-
await worker.connect()
|
|
50
|
-
await worker.query('CREATE SCHEMA IF NOT EXISTS auth')
|
|
51
|
-
await worker.end()
|
|
52
|
-
|
|
53
44
|
return url.toString()
|
|
54
45
|
}
|
|
55
46
|
|
|
56
|
-
async function migrateAuth(): Promise<void> {
|
|
57
|
-
const auth = createAuth(testEnv())
|
|
58
|
-
const { runMigrations } = await getMigrations(auth.options)
|
|
59
|
-
|
|
60
|
-
await runMigrations()
|
|
61
|
-
await (auth.options.database as Pool).end()
|
|
62
|
-
}
|
|
63
|
-
|
|
64
47
|
beforeAll(async () => {
|
|
65
48
|
const name = `test_${process.env.VITEST_WORKER_ID ?? '1'}`
|
|
66
49
|
|
|
67
50
|
databaseUrl = await createWorkerDatabase(inject('postgresUrl'), name)
|
|
68
|
-
await migrateAuth()
|
|
69
51
|
|
|
70
52
|
orm = await MikroORM.init(createOrmConfig(testEnv()))
|
|
71
53
|
|
|
72
54
|
await orm.migrator.up()
|
|
73
55
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
.filter(
|
|
77
|
-
(meta) => meta.tableName !== undefined && meta.pivotTable !== true && meta.schema !== 'auth',
|
|
78
|
-
)
|
|
56
|
+
// getAll() is a Map: Object.values() on it is empty, and nothing would be truncated.
|
|
57
|
+
tables = [...orm.getMetadata().getAll().values()]
|
|
58
|
+
.filter((meta) => meta.tableName !== undefined && meta.pivotTable !== true)
|
|
79
59
|
.map((meta) => `"${meta.tableName}"`)
|
|
80
|
-
|
|
81
|
-
tables = [...own, 'auth."user"', 'auth.session', 'auth.account', 'auth.verification']
|
|
82
60
|
})
|
|
83
61
|
|
|
84
62
|
beforeEach(async () => {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Requirements
|
|
4
4
|
|
|
5
|
-
Node 22.
|
|
5
|
+
Node 22.18 or later, pnpm 10.26 or later, and the API running. A simulator or a device with Expo Go
|
|
6
6
|
to open the app.
|
|
7
7
|
|
|
8
8
|
## Running it
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
const preset = require('jest-expo/jest-preset')
|
|
2
|
-
|
|
3
|
-
const [nodeModules, ...rest] = preset.transformIgnorePatterns
|
|
4
|
-
const babel = preset.transform['\\.[jt]sx?$']
|
|
5
|
-
|
|
6
|
-
if (!nodeModules.includes('(?!(') || babel === undefined) {
|
|
7
|
-
throw new Error(
|
|
8
|
-
'jest-expo changed its transform settings; re-check the Better Auth entries below',
|
|
9
|
-
)
|
|
10
|
-
}
|
|
11
|
-
|
|
12
1
|
module.exports = {
|
|
13
2
|
preset: 'jest-expo',
|
|
14
3
|
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', '^@test/(.*)$': '<rootDir>/test/$1' },
|
|
@@ -16,10 +5,4 @@ module.exports = {
|
|
|
16
5
|
// workspace the contract is a package to transform too, and Jest's 5s default was crossed in CI while the same
|
|
17
6
|
// suite passed alone. The budget is the machine's slowness, not the test's.
|
|
18
7
|
testTimeout: 30_000,
|
|
19
|
-
// Better Auth ships ESM only, partly as .mjs, so it joins the packages jest-expo transforms, with the same Babel.
|
|
20
|
-
transform: { '\\.mjs$': babel },
|
|
21
|
-
transformIgnorePatterns: [
|
|
22
|
-
nodeModules.replace('(?!(', '(?!(better-auth|@better-auth|nanostores|'),
|
|
23
|
-
...rest,
|
|
24
|
-
],
|
|
25
8
|
}
|
|
@@ -15,17 +15,14 @@
|
|
|
15
15
|
"prepare": "node .githooks/install.mjs"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@better-auth/expo": "1.7.6",
|
|
19
18
|
"@hookform/resolvers": "5.9.1",
|
|
20
19
|
"@tanstack/query-sync-storage-persister": "5.102.8",
|
|
21
20
|
"@tanstack/react-query": "5.102.8",
|
|
22
21
|
"@tanstack/react-query-persist-client": "5.102.8",
|
|
23
|
-
"better-auth": "1.7.6",
|
|
24
22
|
"class-variance-authority": "0.7.1",
|
|
25
23
|
"expo": "57.0.22",
|
|
26
24
|
"expo-constants": "57.0.18",
|
|
27
25
|
"expo-linking": "57.0.10",
|
|
28
|
-
"expo-network": "57.0.2",
|
|
29
26
|
"expo-router": "57.0.21",
|
|
30
27
|
"expo-secure-store": "57.0.4",
|
|
31
28
|
"expo-splash-screen": "57.0.9",
|
|
@@ -59,6 +56,6 @@
|
|
|
59
56
|
"typescript": "6.0.3"
|
|
60
57
|
},
|
|
61
58
|
"engines": {
|
|
62
|
-
"node": ">=22.
|
|
59
|
+
"node": ">=22.18.0"
|
|
63
60
|
}
|
|
64
61
|
}
|
|
@@ -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,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useQuery } from '@tanstack/react-query'
|
|
2
2
|
import { Redirect, Stack, usePathname } from 'expo-router'
|
|
3
|
+
import { sessionQuery } from '@/api-contract'
|
|
3
4
|
import { rememberIntendedRoute } from '@/features/auth/intended-route'
|
|
4
|
-
import { sessionQuery } from '@/features/auth/session'
|
|
5
5
|
import { useClients } from '@/features/clients/clients-context'
|
|
6
6
|
|
|
7
7
|
export default function AuthenticatedLayout() {
|
|
@@ -4,18 +4,18 @@ import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client
|
|
|
4
4
|
import { Stack } from 'expo-router'
|
|
5
5
|
import * as SplashScreen from 'expo-splash-screen'
|
|
6
6
|
import { useEffect, useState } from 'react'
|
|
7
|
-
import { createClient } from '@/api-contract'
|
|
7
|
+
import { createAuthClient, createClient, sessionQuery } from '@/api-contract'
|
|
8
8
|
import { config } from '@/config'
|
|
9
|
-
import {
|
|
10
|
-
import { sessionQuery } from '@/features/auth/session'
|
|
9
|
+
import { secureTokenStore } from '@/features/auth/token-store'
|
|
11
10
|
import { ClientsProvider, useClients } from '@/features/clients/clients-context'
|
|
12
11
|
import { QUERY_CACHE_MAX_AGE, queryPersister } from '@/features/storage/query-persister'
|
|
13
12
|
|
|
14
13
|
SplashScreen.preventAutoHideAsync()
|
|
15
14
|
|
|
16
15
|
function createClients() {
|
|
17
|
-
const auth =
|
|
18
|
-
|
|
16
|
+
const auth = createAuthClient({ baseUrl: config.apiUrl, fetch, tokens: secureTokenStore })
|
|
17
|
+
// Through the auth client, so every request carries the bearer and an expired one is refreshed and repeated.
|
|
18
|
+
const api = createClient({ baseUrl: config.apiUrl, fetch: auth.fetch })
|
|
19
19
|
|
|
20
20
|
return { auth, api }
|
|
21
21
|
}
|
|
@@ -3,13 +3,11 @@ import { useQueryClient } from '@tanstack/react-query'
|
|
|
3
3
|
import { Controller, useForm } from 'react-hook-form'
|
|
4
4
|
import { View } from 'react-native'
|
|
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 } from '@/components/ui/field'
|
|
9
9
|
import { FormError } from '@/features/forms/form-error'
|
|
10
10
|
import { applyServerError } from '@/features/forms/server-errors'
|
|
11
|
-
import type { SessionAuth } from './auth-client'
|
|
12
|
-
import { sessionQuery } from './session'
|
|
13
11
|
|
|
14
12
|
const schema = z.object({
|
|
15
13
|
email: z.email(),
|
|
@@ -18,7 +16,13 @@ const schema = z.object({
|
|
|
18
16
|
|
|
19
17
|
type SignInValues = z.infer<typeof schema>
|
|
20
18
|
|
|
21
|
-
export function SignInForm({
|
|
19
|
+
export function SignInForm({
|
|
20
|
+
auth,
|
|
21
|
+
onSignedIn,
|
|
22
|
+
}: {
|
|
23
|
+
auth: Pick<AuthClient, 'signIn' | 'getSession'>
|
|
24
|
+
onSignedIn: () => void
|
|
25
|
+
}) {
|
|
22
26
|
const queryClient = useQueryClient()
|
|
23
27
|
const form = useForm<SignInValues>({
|
|
24
28
|
resolver: zodResolver(schema),
|
|
@@ -29,10 +33,10 @@ export function SignInForm({ auth, onSignedIn }: { auth: SessionAuth; onSignedIn
|
|
|
29
33
|
const { errors, isSubmitting } = form.formState
|
|
30
34
|
|
|
31
35
|
async function onSubmit(values: SignInValues) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
applyServerError(
|
|
36
|
+
try {
|
|
37
|
+
await auth.signIn(values)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
applyServerError(error, ['email', 'password'], form.setError)
|
|
36
40
|
return
|
|
37
41
|
}
|
|
38
42
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import type { AuthClient } from '@/api-contract'
|
|
2
3
|
import { Button } from '@/components/ui/button'
|
|
3
4
|
import { clearUserData } from '@/features/storage/storage'
|
|
4
|
-
import type { AuthClient } from './auth-client'
|
|
5
5
|
|
|
6
6
|
export type SignOutAuth = Pick<AuthClient, 'signOut'>
|
|
7
7
|
|
|
@@ -18,11 +18,11 @@ export function SignOutButton({
|
|
|
18
18
|
try {
|
|
19
19
|
await auth.signOut()
|
|
20
20
|
} catch {
|
|
21
|
-
// A failed request leaves nothing on the device to act on: the
|
|
22
|
-
//
|
|
21
|
+
// A failed request leaves nothing on the device to act on: the auth client forgets the tokens either way. The
|
|
22
|
+
// server's session outlives them until it expires, and signing out locally is still what was asked.
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
// What the
|
|
25
|
+
// What the auth client cannot know is the rest of this user's data: the query cache and every user-scoped key, or the
|
|
26
26
|
// next person on this device reads them.
|
|
27
27
|
queryClient.clear()
|
|
28
28
|
clearUserData()
|