Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
# Add @suaveplan/react-auth — core (Wave 1)
**Package**: `@suaveplan/react-auth`
**Status**: PROPOSED
**Type**: new-package
- **Spec delta**: ./specs/react-auth/spec.md
## Why
Genesis needs a React Better-Auth integration so downstream apps can authenticate users, gate routes, and check permissions. The upstream package at `../suaveplan/packages/react/react-auth/` ships a typed `AuthClient` wrapper around `better-auth/client`, an `AuthProvider` context, `AuthGuard` route gate, `useAuthGuard`/`usePermission` hooks, a `betterAuthAdapter` bridge, locale packs for `en-GB`/`cy-GB`/`zh-CN`, plus full page and form surfaces. Wave 1 ships the foundational *core* — adapter, client, context, hooks, guard, translations data, types — so that downstream packages (the parallel `react-auth-whitelabel` and consumer apps) can compile and authenticate today. Forms (Wave 2) and pages (Wave 3) layer on top of this core without changing it.
## What changes
- ADDED: `AuthClient` + `createAuthClient(config)` — typed wrapper around `better-auth/client` with `x-site-domain` header propagation.
- ADDED: `AuthProvider` + `useAuth` — React context wrapping `AuthClient` with `signIn`/`signUp`/`signOut`/`updateProfile`/`changePassword`/`forgotPassword`/`resetPassword`/`verifyEmail`/`resendVerificationEmail`/`deleteAccount`/`refreshSession` actions and `{ user, session, isLoading, isAuthenticated, error }` state.
- ADDED: `AuthGuard` component — declarative `requireAuth` / `requireGuest` / `requireVerified` / `allowedRoles` gate with `loadingComponent` + `fallback` slots.
- ADDED: `useAuthGuard(options)` hook — imperative `react-router-dom` redirect counterpart to `AuthGuard`.
- ADDED: `usePermission()` hook — bitwise permission check (16 flags) against `user.roles`.
- ADDED: `transformSessionResponse` / `transformAuthResponse` / `transformGenericResponse` / `transformUpdateProfileResponse` / `isBetterAuthError` / `hasBetterAuthData` — better-auth response adapter functions.
- ADDED: Locale packs `enGb` / `cyGb` / `zhCn` and aggregate `authTranslations` (Wave 2 forms consume these).
- ADDED: `User` / `Session` / `Account` / `LoginCredentials` / `RegisterData` / `ResetPasswordData` / `ResetPasswordCredentials` / `UpdateProfileData` / `ChangePasswordData` / `AuthResponse` / `AuthError` (`auth.types.ts`) and `Site` / `UserSettings` / `BankAccount` / `Friend` / `Group` / `Post` / `Message` / `Notification` / `Event` / `Role` / `DeviceToken` / `PushNotification` (`crud.types.ts`).
- ADDED: `REACT_AUTH_CODES` registry (10 stable codes) + `ReactAuthError` base class + 10 typed subclasses.
- ADDED: OTel instruments under meter `suaveplan.react-auth` — counters for sign-in/sign-up/sign-out/guard-allow/guard-deny, histogram for session-fetch duration.
## Out of scope
- Form components (`LoginForm`, `RegisterForm`, `ForgotPasswordForm`, `ResetPasswordForm`, `EmailVerifyForm`) — deferred to Wave 2.
- Page components (`Login`, `Register`, `ForgotPassword`, `ResetPassword`, `EmailVerify`, `Profile`) and `PermissionGate` — deferred to Wave 3.
### Excluded from this migration
- **Form components** (`login-form`, `register-form`, `forgot-password-form`, `reset-password-form`, `email-verify-form`) — Deferred to Wave 2 — scope-bounded migration to keep ship velocity at 100/100/100/100 quality.
- **Page components** (`login`, `register`, `forgot-password`, `reset-password`, `email-verify`, `profile`) and `PermissionGate` — Deferred to Wave 3 — scope-bounded migration to keep ship velocity at 100/100/100/100 quality.
## Source mapping
This change derives from `../suaveplan/enterprise/react-auth.md` and `../suaveplan/packages/react/react-auth/src/`.
| Requirement | Source lines | Notes |
|---|---|---|
| `AuthClient` wraps `better-auth/client` | `client/auth-client.ts:1-237` | Reworded: `@ts-expect-error` on `forgetPassword` replaced with conditional dispatch through an optional-method lookup, removing the suppression. |
| `AuthProvider` context + 11 actions | `context/auth-context.tsx:1-435` | Verbatim semantics; replaced loose `(error as Error)` casts with `instanceof Error ? error : new Error(String(error))` to satisfy `useThrowOnlyError`. |
| `AuthGuard` declarative gate | `components/auth-guard/auth-guard.tsx:1-69` | Verbatim. |
| `useAuthGuard` imperative gate | `hooks/use-auth-guard.ts:1-86` | Verbatim. |
| `usePermission` bitwise hook | `hooks/use-permission.ts:1-88` | Verbatim. |
| `betterAuthAdapter` response transforms | `lib/better-auth-adapter.ts:1-215` | Verbatim; `any` payloads narrowed via `unknown` + structural type-guards. |
| Locale packs (`en-GB`, `cy-GB`, `zh-CN`) | `translations/{en-gb,cy-gb,zh-cn}.ts` | Verbatim string tables. |
| Auth + CRUD types | `types/{auth.types,crud.types}.ts` | Verbatim shapes. |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `REACT_AUTH_CODES` error-code registry + `ReactAuthError` subclasses | Genesis-wide invariant (`@suaveplan/error` `defineCodes`); upstream threw plain `Error`. Audit `enterprise/react-auth.md §3` flagged loss of programmatic handling and OTel tagging. |
| OTel instruments under `suaveplan.react-auth` | Genesis-wide invariant (every package emits structured metrics). |
| Zod validation at `createAuthClient` config + `useAuthGuard` options + `AuthGuardProps` boundary | Genesis rule §0a — Zod boundary on every public option bundle. |
| `FetchLike` injection seam at `AuthClient` construction | Required by the testing rule "no mocks; real services or injectable boundaries". |
## Verification approach
- `src/client/auth-client.test.ts` — covers `AuthClient` construction, `siteDomain` header propagation, every wrapper method's call shape, session-list date normalisation.
- `src/lib/better-auth-adapter.test.ts` — covers all 6 transform functions over success/error/empty payloads.
- `src/context/auth-context.test.tsx` — covers `AuthProvider` initial-session, all 11 actions, error state, `skipInitialCheck`, `onAuthStateChange` notification, unmount cleanup, `useAuth` outside-provider throw.
- `src/hooks/use-auth-guard.test.tsx` — covers all 4 redirect paths (auth, guest, verified, role) + `canAccess` matrix + loading bail-out.
- `src/hooks/use-permission.test.tsx` — covers `hasPermission` / `hasAnyPermission` / `hasAllPermissions` / `getPermissionNames` with string & numeric inputs, unauthenticated bail-outs.
- `src/components/auth-guard/auth-guard.test.tsx` — covers loading state, every grant/deny branch, custom `loadingComponent` + `fallback` slots, multi-child rendering.
- `src/components/auth-provider/auth-provider.test.tsx` — covers the re-export surface compiles to the canonical `AuthProvider` and type re-exports.
- `src/types/auth.types.test.ts` + `src/types/crud.types.test.ts` — `expectTypeOf` shape pins for every exported interface.
- `src/translations/translations.test.ts` — locale-pack key-parity across `en-gb` / `cy-gb` / `zh-cn`, default export shape.
- `src/error-codes/error-codes.test.ts` — every code thrown by at least one production path; error name + code field round-trip.
- `src/observability/react-auth-metrics.test.ts` — counters + histogram emit through a real in-memory `MeterProvider`.
## Migration impact
- `../suaveplan/packages/react/react-auth/src/components/auth-provider/auth-provider.types.ts` imports `PartialResources` + `SupportedLanguage` from `@suaveplan/react-translate`. Wave 1 does **not** consume these types at runtime (the legacy `AuthProviderProps.translationConfig` is a Wave-2 form concern); they are re-declared as local `TranslationConfig { defaultLanguage?: string; resources?: Record<string, unknown> }` so this Wave-1 surface compiles without an `@suaveplan/react-i18n` dependency. Wave 2 will tighten the type when it wires `useTranslation`.
## Impact
### Affected dependents
No dependents yet — this is a new package landing on a clean genesis branch. Verified by `bunx turbo ls --filter=...@suaveplan/react-auth` (empty downstream list).

View File

@@ -0,0 +1,198 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: react-auth (add-react-auth-core)
**Package**: `@suaveplan/react-auth`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: AuthClient wraps better-auth with site-domain header propagation
The package SHALL expose an `AuthClient` class and a `createAuthClient(config)` factory. The class SHALL wrap `better-auth/client` and SHALL forward every request through the underlying client. When `AuthClientConfig.siteDomain` is set, every request SHALL carry an `x-site-domain` header.
#### Scenario: site-domain header attached when configured
- **WHEN** `createAuthClient({ baseURL, siteDomain: "tenant-a" }).signIn({ email, password })` is called and the underlying client method is observed
- **THEN** the call options include `fetchOptions.headers["x-site-domain"] === "tenant-a"`
#### Scenario: header omitted when not configured
- **WHEN** `createAuthClient({ baseURL }).getSession()` is called
- **THEN** the call options omit the `x-site-domain` header
#### Scenario: AuthClientConfig validates at the boundary
- **WHEN** `createAuthClient` is invoked with a non-URL `baseURL`
- **THEN** a `ReactAuthConfigInvalidError` is thrown with code `REACT_AUTH.CONFIG_INVALID`
**Verified by:** `src/client/auth-client.test.ts::"propagates x-site-domain header on every method when siteDomain is configured"` AND `src/client/auth-client.test.ts::"omits x-site-domain header when siteDomain is not configured"` AND `src/client/auth-client.test.ts::"throws ReactAuthConfigInvalidError when baseURL is not a valid URL"`
---
### Requirement: AuthClient list-sessions normalises Date fields to ISO strings
The `AuthClient.listSessions()` method SHALL return `Session[]` and SHALL convert each session's `createdAt` / `updatedAt` / `expiresAt` fields from `Date` to ISO string (or leave them as the underlying type when already a string).
#### Scenario: dates converted
- **WHEN** the underlying client returns sessions with `Date` instances
- **THEN** the returned `Session[]` contains ISO-string `createdAt` / `updatedAt`
**Verified by:** `src/client/auth-client.test.ts::"converts Date fields on listSessions to ISO strings"`
---
### Requirement: AuthProvider exposes auth state and 11 actions through context
The package SHALL expose an `AuthProvider` component and a `useAuth()` hook. The context value SHALL include `{ user, session, isLoading, isAuthenticated, error, client }` and the 11 actions `signIn` / `signUp` / `signOut` / `updateProfile` / `changePassword` / `forgotPassword` / `resetPassword` / `verifyEmail` / `resendVerificationEmail` / `deleteAccount` / `refreshSession`. `useAuth()` outside an `AuthProvider` SHALL throw `ReactAuthContextMissingError` with code `REACT_AUTH.CONTEXT_MISSING`.
#### Scenario: initial session loaded on mount
- **WHEN** `AuthProvider` mounts with `skipInitialCheck=false` and the client's `getSession()` returns `{ data: { user, session } }`
- **THEN** the context settles with `isAuthenticated=true`, `user`, `session`, `isLoading=false`
#### Scenario: useAuth outside provider throws
- **WHEN** `useAuth()` is called outside any `AuthProvider`
- **THEN** a `ReactAuthContextMissingError` is thrown with code `REACT_AUTH.CONTEXT_MISSING`
#### Scenario: each action settles loading and surfaces errors
- **WHEN** any of the 11 actions resolves successfully OR rejects
- **THEN** `isLoading` flips false; on reject `error` carries the wrapped `Error` and the action re-throws
**Verified by:** `src/context/auth-context.test.tsx::"loads the initial session and settles isAuthenticated=true"` AND `src/context/auth-context.test.tsx::"useAuth outside an AuthProvider throws ReactAuthContextMissingError"` AND `src/context/auth-context.test.tsx::"every action settles isLoading and propagates errors"`
---
### Requirement: AuthGuard gates children by auth / guest / verified / roles
The `AuthGuard` component SHALL render `loadingComponent` (or a default spinner) while `isLoading=true`. When loading completes it SHALL render `children` if and only if every configured predicate (`requireAuth` / `requireGuest` / `requireVerified` / `allowedRoles`) is satisfied; otherwise it SHALL render `fallback` (or `null`).
#### Scenario: authenticated user passes default requireAuth
- **WHEN** an authenticated, verified user enters an `AuthGuard requireAuth`
- **THEN** `children` is rendered
#### Scenario: unauthenticated user gets fallback when requireAuth
- **WHEN** an unauthenticated user enters an `AuthGuard requireAuth fallback={<X/>}`
- **THEN** `<X/>` is rendered and `children` is not
#### Scenario: role mismatch denied
- **WHEN** an authenticated user with `role="user"` enters `AuthGuard allowedRoles={["admin"]}`
- **THEN** the guard does not render `children`
**Verified by:** `src/components/auth-guard/auth-guard.test.tsx::"renders children when authenticated and no extra predicates are set"` AND `src/components/auth-guard/auth-guard.test.tsx::"renders fallback when unauthenticated and requireAuth is set"` AND `src/components/auth-guard/auth-guard.test.tsx::"denies when allowedRoles does not include the user role"`
---
### Requirement: useAuthGuard imperatively redirects via react-router-dom
The `useAuthGuard(options)` hook SHALL call `react-router-dom`'s `useNavigate()` and SHALL navigate to `redirectTo` (default `/auth/login`) when `requireAuth` fails, to `/` when `requireGuest` fails, to `/auth/verify-email` when `requireVerified` fails, and to `/unauthorized` when `allowedRoles` fails. It SHALL invoke `onUnauthorized` before the auth / role redirects. The hook SHALL also return `{ isLoading, isAuthenticated, user, canAccess }` for inline checks.
#### Scenario: redirects to login when not authenticated
- **WHEN** an unauthenticated user mounts a component calling `useAuthGuard({ requireAuth: true })`
- **THEN** `navigate("/auth/login")` is called once
#### Scenario: canAccess reflects every predicate
- **WHEN** any predicate fails
- **THEN** `canAccess === false`; when all predicates pass, `canAccess === true`
**Verified by:** `src/hooks/use-auth-guard.test.tsx::"redirects to /auth/login when requireAuth fails"` AND `src/hooks/use-auth-guard.test.tsx::"canAccess matrix reflects every predicate combination"`
---
### Requirement: usePermission exposes bitwise permission checks
The `usePermission()` hook SHALL expose `hasPermission` / `hasAnyPermission` / `hasAllPermissions` / `getPermissionNames` / `userRoles`. Each predicate SHALL accept either a `Permission` string key or a numeric bitmask and SHALL test `(user.roles & bit) === bit`. With no authenticated user, every predicate SHALL return `false` and `getPermissionNames()` SHALL return `[]`.
#### Scenario: string and numeric permission accepted
- **WHEN** `hasPermission("USER_READ")` and `hasPermission(1)` are called with `user.roles = 0b1`
- **THEN** both return `true`
#### Scenario: unauthenticated bails out
- **WHEN** no user is present
- **THEN** `hasPermission`, `hasAnyPermission`, `hasAllPermissions` all return `false`
**Verified by:** `src/hooks/use-permission.test.tsx::"accepts string and numeric Permission inputs"` AND `src/hooks/use-permission.test.tsx::"returns false for every predicate when unauthenticated"`
---
### Requirement: better-auth response adapter narrows union to internal shapes
The package SHALL expose `isBetterAuthError` / `hasBetterAuthData` / `transformSessionResponse` / `transformAuthResponse` / `transformGenericResponse` / `transformUpdateProfileResponse`. Each transform SHALL accept `unknown` (no `any`), SHALL detect the `error`-tagged and `data`-tagged variants, and SHALL emit the package's internal `User` / `Session` / `AuthResponse` shapes with ISO-string timestamps.
#### Scenario: session response with Date timestamps normalised
- **WHEN** `transformSessionResponse({ data: { user: { ..., createdAt: Date }, session: { ..., expiresAt: Date } } })` is called
- **THEN** the returned `{ user, session }` carries ISO strings
#### Scenario: error variant surfaces message
- **WHEN** the response carries `{ error, message }`
- **THEN** the transform returns `{ session: null, user: null, error: <message> }`
**Verified by:** `src/lib/better-auth-adapter.test.ts::"transformSessionResponse normalises Date timestamps to ISO strings"` AND `src/lib/better-auth-adapter.test.ts::"transforms surface the better-auth error message"`
---
### Requirement: REACT_AUTH error code registry covers every thrown error
The package SHALL export `REACT_AUTH_CODES = defineCodes("REACT_AUTH", [...])` with at least the 10 codes `CONFIG_INVALID`, `CONTEXT_MISSING`, `GUARD_PROPS_INVALID`, `GUARD_OPTIONS_INVALID`, `CLIENT_NOT_INITIALISED`, `BETTER_AUTH_RESPONSE_INVALID`, `SIGN_IN_FAILED`, `SIGN_UP_FAILED`, `SESSION_FETCH_FAILED`, `PERMISSION_DENIED`. Every code SHALL be thrown by at least one production path and SHALL surface through a typed `ReactAuthError` subclass.
#### Scenario: every code has a thrown call site
- **WHEN** the test suite enumerates `REACT_AUTH_CODES` values
- **THEN** every code is reachable via a typed subclass that ships in `src/error-codes/error-codes.ts`
**Verified by:** `src/error-codes/error-codes.test.ts::"every REACT_AUTH code is thrown by at least one production path"`
---
### Requirement: OTel instruments emit through suaveplan.react-auth meter
The package SHALL build OTel instruments lazily under `metrics.getMeter("suaveplan.react-auth")` and SHALL emit at minimum: `react_auth.sign_in.total`, `react_auth.sign_up.total`, `react_auth.sign_out.total`, `react_auth.guard.allow.total`, `react_auth.guard.deny.total` counters, and `react_auth.session_fetch.duration_ms` histogram.
#### Scenario: in-memory MeterProvider captures every emit
- **WHEN** a real `MeterProvider` + `InMemoryMetricExporter` is wired and every production path that emits is executed
- **THEN** the corresponding instrument records the expected attributes
**Verified by:** `src/observability/react-auth-metrics.test.ts::"emits sign-in / sign-up / sign-out / guard / session-fetch instruments through suaveplan.react-auth"`
---
### Requirement: Locale packs ship for en-GB / cy-GB / zh-CN with key parity
The package SHALL export `enGb`, `cyGb`, `zhCn` (default exports) and an aggregate `authTranslations = { "en-gb": enGb, "cy-gb": cyGb, "zh-cn": zhCn }`. Every locale SHALL carry the same set of `auth.<group>.<key>` paths so Wave-2 forms can resolve without locale-specific guards.
#### Scenario: key parity across locales
- **WHEN** the locale-pack test flattens every dotted key in each locale
- **THEN** the three sets are equal
**Verified by:** `src/translations/translations.test.ts::"every locale pack exposes the same dotted key set"`
---
### Requirement: Root barrel uses explicit named re-exports
The root `src/index.ts` and every feature barrel SHALL use explicit `export { … }` re-exports — never `export *`. The barrel SHALL expose the public surface enumerated in `proposal.md` `## What changes` and nothing else.
#### Scenario: package roots are explicit
- **WHEN** repo-wide `grep -rn '^export \\*' packages/react/react-auth/src/` is run
- **THEN** the result is empty
**Verified by:** `src/index.test.ts::"the root barrel exports exactly the documented public surface"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,59 @@
# Tasks — add-react-auth-core (Wave 1)
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/react-auth.md`
- [x] 1.2 Read genesis `openspec/AGENTS.md` §§ 2a, 6a, 7, 12, 13, 14
- [x] 1.3 Draft `proposal.md` with `## Source mapping` table + `### Excluded from this migration`
- [x] 1.4 Draft `specs/react-auth/spec.md` (`## ADDED Requirements`) — every requirement carries `**Verified by:**`
## 2. Implementation
- [x] 2.1 `src/error-codes/error-codes.ts` — `defineCodes("REACT_AUTH", [...])` + 10 typed `ReactAuthError` subclasses
- [x] 2.2 `src/types/auth.types.ts` + `src/types/crud.types.ts` + `src/types/index.ts` barrel
- [x] 2.3 `src/translations/{en-gb,cy-gb,zh-cn}.ts` + `src/translations/index.ts` barrel
- [x] 2.4 `src/lib/better-auth-adapter.ts` — 6 transforms + 2 guards over `unknown` payloads
- [x] 2.5 `src/client/auth-client.ts` — `AuthClient` class + `createAuthClient(config)` factory, no `@ts-expect-error`, Zod boundary on config
- [x] 2.6 `src/context/auth-context.tsx` — `AuthProvider` + `useAuth` + 11 actions
- [x] 2.7 `src/components/auth-guard/auth-guard.tsx` + `.types.ts` + `.stories.tsx`
- [x] 2.8 `src/components/auth-provider/auth-provider.tsx` — re-export surface for back-compat
- [x] 2.9 `src/hooks/use-auth-guard.ts` + `src/hooks/use-permission.ts`
- [x] 2.10 `src/observability/react-auth-metrics.ts` — OTel counters + histogram
- [x] 2.11 `src/index.ts` — explicit named root barrel
## 3. Tests
- [x] 3.1 Unit tests per feature module — every `it()` has `{ timeout: 5000 }`
- [x] 3.2 Test imports use `@suaveplan/testing/runner` ONLY (zero direct `vitest` / `bun:test` imports)
- [x] 3.3 No `vi.mock`; better-auth client method overrides on real instances; injectable `fetch` via `AuthClientConfig.fetch`
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts`/`.tsx` (≥200 words, basic + advanced examples)
- [x] 4.2 `README.md` linking every co-located `.md` under `## Modules`
## 5. Pre-archive verification (5 gates)
- [x] 5.1 `bun run check` — clean
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/react-auth` — green
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/react-auth` — 100/100/100/100
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/react-auth` — 0 errors
- [x] 5.5 `bunx turbo build --filter=@suaveplan/react-auth` — green
- [x] 5.6 `bun scripts/lint/check-test-runner-imports.ts` — clean
## 6. Pre-archive 9-row checklist
- [x] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
- [x] 6.2 Zod schemas wired at every declared boundary (`AuthClientConfig`, `UseAuthGuardOptions`, `AuthGuardProps`)
- [x] 6.3 Every code in `REACT_AUTH_CODES` thrown by at least one production path
- [x] 6.4 Strict-mode behaviors tested on and off (StrictMode double-mount cleanup)
- [x] 6.5 OTel instruments emitted from production paths and asserted with real in-memory `MeterProvider`
- [x] 6.6 No retroactive tick
- [x] 6.7 No `export *` in any feature barrel
- [x] 6.8 No `// biome-ignore` in src/
- [x] 6.9 Sibling `.md` for every implementation `.ts`/`.tsx`
## 7. Archive
- [x] 7.1 Fold `specs/react-auth/spec.md` → `openspec/specs/react-auth/spec.md`
- [x] 7.2 Move `openspec/changes/add-react-auth-core/` → `openspec/archive/2026-05-19-add-react-auth-core/`