@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
@@ -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<{ name: string; email: string }>
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 id = randomUUID()
11
+ const user = new User(
12
+ overrides.email ?? `${crypto.randomUUID()}@example.com`,
13
+ overrides.passwordHash ?? 'not-a-real-hash',
14
+ )
11
15
 
12
- // auth.user belongs to Better Auth, and the ORM maps only its id, so the row is written in SQL.
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 { getMigrations } from 'better-auth/db/migration'
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
- AUTH_DATABASE_URL: databaseUrl,
29
- BETTER_AUTH_SECRET: 'test-secret-that-is-at-least-32-characters',
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
- const metadata = orm.getMetadata().getAll()
75
- const own = Object.values(metadata)
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.18 or later, pnpm 10.26 or later, and the API running with `MOBILE_APP_SCHEME=app`. A simulator or a device with Expo Go
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,18 +15,14 @@
15
15
  "prepare": "node .githooks/install.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "@better-auth/core": "1.7.7",
19
- "@better-auth/expo": "1.7.7",
20
18
  "@hookform/resolvers": "5.9.1",
21
19
  "@tanstack/query-sync-storage-persister": "5.102.8",
22
20
  "@tanstack/react-query": "5.102.8",
23
21
  "@tanstack/react-query-persist-client": "5.102.8",
24
- "better-auth": "1.7.7",
25
22
  "class-variance-authority": "0.7.1",
26
23
  "expo": "57.0.22",
27
24
  "expo-constants": "57.0.18",
28
25
  "expo-linking": "57.0.10",
29
- "expo-network": "57.0.2",
30
26
  "expo-router": "57.0.21",
31
27
  "expo-secure-store": "57.0.4",
32
28
  "expo-splash-screen": "57.0.9",
@@ -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,
@@ -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 { createAuth, withSessionCookie } from '@/features/auth/auth-client'
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 = createAuth({ baseUrl: config.apiUrl, fetch })
18
- const api = createClient({ baseUrl: config.apiUrl, fetch: withSessionCookie(auth, fetch) })
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 { ApiError } from '@/api-contract'
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({ auth, onSignedIn }: { auth: SessionAuth; onSignedIn: () => void }) {
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
- const { error } = await auth.signIn.email(values)
33
-
34
- if (error !== null) {
35
- applyServerError(ApiError.fromAuthError(error), ['email', 'password'], form.setError)
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 Expo plugin deletes the stored cookie before it
22
- // sends. The server's session outlives it until it expires, and signing out locally is still what was asked.
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 plugin cannot know is the rest of this user's data: the query cache and every user-scoped key, or 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()
@@ -3,23 +3,27 @@ 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 { ApiError } from '@/api-contract'
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
  name: z.string().min(1).max(80),
16
14
  email: z.email(),
17
- password: z.string().min(8),
15
+ password: z.string().min(8).max(128),
18
16
  })
19
17
 
20
18
  type SignUpValues = z.infer<typeof schema>
21
19
 
22
- export function SignUpForm({ auth, onSignedUp }: { auth: SessionAuth; onSignedUp: () => void }) {
20
+ export function SignUpForm({
21
+ auth,
22
+ onSignedUp,
23
+ }: {
24
+ auth: Pick<AuthClient, 'signUp' | 'getSession'>
25
+ onSignedUp: () => void
26
+ }) {
23
27
  const queryClient = useQueryClient()
24
28
  const form = useForm<SignUpValues>({
25
29
  resolver: zodResolver(schema),
@@ -30,10 +34,10 @@ export function SignUpForm({ auth, onSignedUp }: { auth: SessionAuth; onSignedUp
30
34
  const { errors, isSubmitting } = form.formState
31
35
 
32
36
  async function onSubmit(values: SignUpValues) {
33
- const { error } = await auth.signUp.email(values)
34
-
35
- if (error !== null) {
36
- applyServerError(ApiError.fromAuthError(error), ['name', 'email', 'password'], form.setError)
37
+ try {
38
+ await auth.signUp(values)
39
+ } catch (error) {
40
+ applyServerError(error, ['name', 'email', 'password'], form.setError)
37
41
  return
38
42
  }
39
43