11 KiB
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-domainheader
Scenario: AuthClientConfig validates at the boundary
- WHEN
createAuthClientis invoked with a non-URLbaseURL - THEN a
ReactAuthConfigInvalidErroris thrown with codeREACT_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
Dateinstances - THEN the returned
Session[]contains ISO-stringcreatedAt/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
AuthProvidermounts withskipInitialCheck=falseand the client'sgetSession()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 anyAuthProvider - THEN a
ReactAuthContextMissingErroris thrown with codeREACT_AUTH.CONTEXT_MISSING
Scenario: each action settles loading and surfaces errors
- WHEN any of the 11 actions resolves successfully OR rejects
- THEN
isLoadingflips false; on rejecterrorcarries the wrappedErrorand 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
childrenis rendered
Scenario: unauthenticated user gets fallback when requireAuth
- WHEN an unauthenticated user enters an
AuthGuard requireAuth fallback={<X/>} - THEN
<X/>is rendered andchildrenis not
Scenario: role mismatch denied
- WHEN an authenticated user with
role="user"entersAuthGuard 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")andhasPermission(1)are called withuser.roles = 0b1 - THEN both return
true
Scenario: unauthenticated bails out
- WHEN no user is present
- THEN
hasPermission,hasAnyPermission,hasAllPermissionsall returnfalse
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_CODESvalues - 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+InMemoryMetricExporteris 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"