Files
2026-05-27 23:29:53 +01:00

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-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"