# Covara > Your Drizzle schema is already a backend This file contains all documentation content in a single document following the llmstxt.org standard. ## Account security flows These flows are opt-in via [`useAuth`](./sessions.md) options. They build on the same `adapter` and add routes under the auth router's mount path (e.g. `/api/auth`). ## CSRF protection ```typescript useAuth({ adapter, login, csrf: true }); // or: csrf: { headerName: "X-CSRF-Token", cookieName: "csrf_token", skip: (c) => false } ``` Uses the **double-submit-cookie** pattern: a non-`httpOnly` `csrf_token` cookie is issued on safe requests and refreshed on login, and unsafe methods (`POST`/`PUT`/`PATCH`/`DELETE`) must echo it back in the `X-CSRF-Token` header. Requests carrying an `Authorization` header (bearer/API-key clients) are exempt. A mismatch returns `403`. Also available standalone as `createCsrfMiddleware`. ## Login throttling ```typescript useAuth({ adapter, login, throttle: true }); // or: throttle: { maxAttempts: 5, windowMs: 15 * 60 * 1000, store: myRateLimitStore } ``` Failed logins are counted **per email and per IP**; once `maxAttempts` (default 5) is exceeded within the window (default 15 min), `/login` returns `429` with `Retry-After`. A successful login resets the counters. Provide a distributed `RateLimitStore` (e.g. Redis-backed) for multi-instance deployments; the default is in-memory. ## Email verification ```typescript import { InMemoryVerificationTokenStore } from "covara"; useAuth({ adapter, login, verification: { store: new InMemoryVerificationTokenStore(), sendToken: async ({ identifier, token, expiresAt }) => sendEmail(identifier, token), markVerified: async (email) => db.update(users).set({ emailVerified: true }).where(eq(users.email, email)), ttlMs: 24 * 60 * 60 * 1000, // default 24h hashTokens: true, // store SHA-256 of the token }, }); ``` Adds `POST /verify/request` (issues a one-time token and calls `sendToken`) and `POST /verify/confirm` (`{ email, token }` → calls `markVerified`). ## Password reset ```typescript useAuth({ adapter, login, passwordReset: { store: new InMemoryVerificationTokenStore(), sendToken: async ({ identifier, token }) => sendEmail(identifier, token), resetPassword: async (email, passwordHash) => db.update(users).set({ passwordHash }).where(eq(users.email, email)), findUserByEmail: async (email) => db.query.users.findFirst({ where: eq(users.email, email) }), ttlMs: 60 * 60 * 1000, // default 1h logoutEverywhere: true, // revoke all sessions after reset }, }); ``` Adds: - `POST /password/forgot` — `{ email }` → issues a token via `sendToken`; **always** returns `{ success: true }` (no email enumeration). - `POST /password/reset` — `{ email, token, password }` → hashes the new password with the built-in [scrypt hasher](./passwords.md) and calls `resetPassword`. When `logoutEverywhere` is set and the adapter implements `invalidateUserSessions`, all of the user's sessions are revoked. ## Token stores `verification`, `passwordReset`, and [`magicLink`](./magic-links.md) all use the `VerificationTokenStore` interface (`create`/`consume`/`deleteByIdentifier`). `InMemoryVerificationTokenStore` ships for development; back it with your own table for production. Set `hashTokens: true` to store only the SHA-256 of each token. ## Related - [Sessions](./sessions.md) · [Passwords](./passwords.md) · [Magic links](./magic-links.md) · [Email](../platform/email.md) - [Security headers](./security-headers.md) · [Rate limiting](../tooling/middleware.md#rate-limiting) --- ## API keys Covara provides standalone helpers for issuing and verifying API keys. They are **not** wired into [`useAuth`](./sessions.md) routes — use them in your own endpoints (e.g. a settings page that creates keys, and an adapter's `validateApiKey` that verifies them). A key is formatted `[prefix_].`. Only its **hash** is stored; the raw key is returned once at creation and never again. ```typescript import { createApiKey, verifyApiKey, listApiKeys, revokeApiKey, rotateApiKey, InMemoryApiKeyStore, } from "covara"; const store = new InMemoryApiKeyStore(); // or your own ApiKeyStore backed by a table // Create — raw key shown once const { key, metadata } = await createApiKey({ store, userId: "user_123", label: "CI token", scopes: ["read"], prefix: "myapp", // optional; prefixes the raw key expiresAt: null, // or a Date / ttlMs }); // Verify — touches lastUsedAt by default const result = await verifyApiKey(key, { store }); if (result.valid) { // result.metadata: { id, userId, scopes, expiresAt, lastUsedAt, ... } } else { // result.reason: "not_found" | "expired" | "mismatch" } await listApiKeys({ store, userId: "user_123" }); await rotateApiKey({ store, id: metadata.id }); // revoke + reissue, inheriting label/scopes/expiry await revokeApiKey(metadata.id, { store }); ``` ## The store interface ```typescript interface ApiKeyStore { create(...): Promise<...>; list(...): Promise<...>; findById(id: string): Promise<...>; delete(id: string): Promise; touch(id: string): Promise; } ``` `InMemoryApiKeyStore` is a reference implementation for development; back it with your own table for production. ## Wiring into auth To authenticate requests carrying a key, validate it in your adapter and return the user/scopes: ```typescript const adapter = createPassportAdapter({ getUserById: async (id) => { /* ... */ }, validateApiKey: async (rawKey) => { const result = await verifyApiKey(rawKey, { store }); return result.valid ? { userId: result.metadata.userId, scopes: result.metadata.scopes } : null; }, }); ``` The client can send keys via the `X-API-Key` header (`useAuth({ strategy: "apiKey", apiKey })`). See [Client auth](../client/auth.md). ## Related - [Sessions](./sessions.md) · [JWT](./jwt.md) · [Client auth](../client/auth.md) --- ## Federated login The [OIDC provider](./oidc-provider.md) can delegate authentication to upstream identity providers (social login) via `backends.federated`. Users authenticate with Google/Microsoft/etc. and your provider issues its own tokens. ```typescript import { createOIDCProvider, oidcProviders } from "covara"; const { router, middleware } = createOIDCProvider({ issuer: "https://auth.myapp.com", keys: { algorithm: "RS256" }, clients: [/* ... */], backends: { emailPassword: { enabled: true, validateUser: async () => { /* ... */ }, findUserById: async () => { /* ... */ } }, federated: [ oidcProviders.google({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, }), oidcProviders.microsoft({ clientId: env.MS_CLIENT_ID, clientSecret: env.MS_CLIENT_SECRET, tenantId: "common", // or a specific tenant }), oidcProviders.generic({ name: "custom", clientId: "...", clientSecret: "...", issuer: "https://custom-idp.example.com", scopes: ["openid", "email", "profile"], }), ], }, }); ``` ## Provider helpers | Helper | Provider | |--------|----------| | `oidcProviders.google(...)` | Google | | `oidcProviders.microsoft(...)` | Microsoft / Entra ID (`tenantId`) | | `oidcProviders.okta(...)` | Okta | | `oidcProviders.auth0(...)` | Auth0 | | `oidcProviders.keycloak(...)` | Keycloak | | `oidcProviders.generic(...)` | Any OIDC provider (`issuer`, `scopes`) | ## How id_token verification works Federated `id_token`s are **signature-verified** against the provider's JWKS (fetched from its discovery document and cached), with issuer and audience checks. After verification: 1. the `nonce` is compared to the stored interaction nonce, and 2. the `id_token`'s `sub` is cross-checked against the `userinfo` `sub`. Any mismatch aborts the login. This closes token-substitution and replay vectors. ## Non-OIDC providers (Passport.js) `backends.federated` only works with **OIDC-compliant** identity providers (they expose a discovery document and an `id_token`). For OAuth-2-only providers — GitHub, Discord, Spotify, Twitch, … — use `backends.passport`, which drives any [Passport.js](https://www.passportjs.org/) OAuth2 strategy and resumes the same authorization interaction. Like everything in Covara, it runs on Node **and** Cloudflare Workers (see [Social login](./social.md#how-it-works-on-workers) for the mechanism). ```bash npm install passport-github2 ``` ```typescript import { Strategy as GitHubStrategy } from "passport-github2"; import { createOIDCProvider, fromPassport } from "covara"; createOIDCProvider({ issuer: "https://auth.myapp.com", keys: { algorithm: "RS256" }, clients: [/* ... */], backends: { emailPassword: { enabled: true, /* ... */ }, passport: { providers: [ fromPassport( new GitHubStrategy( { clientID: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, // All passport providers share ONE callback under the provider: callbackURL: "https://auth.myapp.com/auth/passport/callback", }, (_a, _r, profile, done) => done(null, profile) ) ), ], findUserByAccount: async (provider, providerAccountId) => /* existing user or null */, findUserById: async (id) => /* user by id — used by consent/token/userinfo */, createUser: async (account) => /* create user from account.profile */, }, }, }); ``` Each provider appears as a button on the login page, mounted under `/auth/passport/:provider` (start) and `/auth/passport/callback` (shared callback — the provider is recovered from signed state). On success the provider establishes its session and continues to consent / the authorization code exactly like an email/password login, so your relying parties receive normal OIDC tokens. `findUserById` is required here so consent, `/token`, and `/userinfo` can resolve the user when no email/password backend is configured. The config mirrors [`useAuth({ social })`](./social.md) — same `fromPassport` wrapper and `SocialAccount` — the difference is the result: `backends.passport` issues **your provider's** OIDC tokens, while `useAuth` mints a local session. :::note Scope Covers OAuth 2.0 strategies (the bulk of the catalog). OAuth 1.0a strategies are Node-only and not supported on Workers — see [Social login](./social.md#how-it-works-on-workers). ::: ## Related - [OIDC provider](./oidc-provider.md) · [Social login](./social.md) · [Client auth](../client/auth.md) · [Auth contract](../contracts/auth.md) --- ## Auth quickstart This is the shortest path to working authentication: **email/password with email confirmation** and **GitHub social login**, on one session. By the end, users can sign up, confirm their email, log in, or click "Continue with GitHub". ## The one table you must create Covara never owns your **users** table — you provide it and reach it through callbacks. Everything else auth needs (sessions, verification tokens) can live in the [KV store](../platform/kv.md) or memory, so for the fastest start **`users` is the only database table you create**. See [Internal & system tables](./internal-tables.md) for the full picture — including the optional framework-owned SQL tables (`auth_sessions`, `auth_verification_tokens`, …) you'd switch to in production and exactly which columns they need. ```typescript // src/schema.ts import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; export const users = sqliteTable("users", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), email: text("email").notNull().unique(), // null for social-only users who never set a password passwordHash: text("password_hash"), name: text("name"), image: text("image"), // null until the user confirms their email; gates email/password login emailVerified: integer("email_verified", { mode: "timestamp" }), }); ``` The shape Covara expects back from your lookups is just `{ id, email?, name?, image?, emailVerified? }` — documented under [App-supplied tables → Users](./internal-tables.md#users). ## Type-safe env Define your config once with [`createEnv`](../deployment/environment-variables.md) — it's Zod-validated, fails fast on a missing var, and is **Workers-safe** (it reads through the runtime-safe primitive under the hood, never `process.env` directly). Reference `env.X` everywhere instead of reaching for `process.env`. ```typescript // src/env.ts import { createEnv } from "covara"; import { z } from "zod"; export const env = createEnv({ APP_URL: z.string().default("http://localhost:3000"), PORT: z.string().default("3000").transform(Number), RESEND_API_KEY: z.string(), GITHUB_CLIENT_ID: z.string(), GITHUB_CLIENT_SECRET: z.string(), }); ``` ## Configure auth `useAuth` wires the adapter, the login/signup/logout routes, **email confirmation** (`verification`), and **social login** (`social`) in one call. Read config from the typed `env` above and send mail with the [email helpers](../platform/email.md). ```typescript // src/auth.ts import { useAuth, cookieSession, hashPassword, verifyPassword, fromPassport, InMemoryVerificationTokenStore, } from "covara"; import { setGlobalEmail, createResendAdapter, sendEmail } from "covara/email"; import { Strategy as GitHubStrategy } from "passport-github2"; import { eq } from "drizzle-orm"; import { db } from "./db"; import { users } from "./schema"; import { env } from "./env"; // Configure email once (for local dev you can skip this and console.log the link). setGlobalEmail(createResendAdapter({ apiKey: env.RESEND_API_KEY })); export const auth = useAuth({ // Server-side sessions. Swap for jwtSession({ secret: env.JWT_SECRET }) to issue // JWTs instead — every provider below works unchanged. See Sessions › strategies. session: cookieSession({ getUserById: (id) => db.query.users.findFirst({ where: eq(users.id, id) }), }), // --- Email / password --- login: { validateCredentials: async (email, password) => { const user = await db.query.users.findFirst({ where: eq(users.email, email) }); if (!user?.passwordHash) return null; // social-only or no such user if (!(await verifyPassword(password, user.passwordHash))) return null; if (!user.emailVerified) return null; // block until confirmed return { id: user.id, email: user.email, name: user.name }; }, }, signup: { createUser: async ({ email, password, name }) => { const [u] = await db .insert(users) .values({ email, name, passwordHash: await hashPassword(password) }) .returning(); return { id: u.id, email: u.email, name: u.name }; }, }, // --- Email confirmation --- verification: { store: new InMemoryVerificationTokenStore(), // prod: createKVVerificationTokenStore(kv) sendToken: async ({ identifier, token }) => { const link = `${env.APP_URL}/verify?email=${encodeURIComponent(identifier)}&token=${token}`; await sendEmail({ from: "Acme ", to: identifier, subject: "Confirm your email", html: `Confirm your email: Verify`, text: `Confirm your email: ${link}`, }); }, markVerified: async (email) => { await db.update(users).set({ emailVerified: new Date() }).where(eq(users.email, email)); }, }, // --- Social login (any Passport.js OAuth2 strategy) --- social: { providers: [ fromPassport( new GitHubStrategy( { clientID: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, callbackURL: `${env.APP_URL}/api/auth/social/github/callback`, }, (_accessToken, _refreshToken, profile, done) => done(null, profile) ) ), ], findOrCreateUser: async ({ profile }) => { if (profile.email) { const existing = await db.query.users.findFirst({ where: eq(users.email, profile.email) }); if (existing) return existing; } const [u] = await db .insert(users) .values({ email: profile.email ?? `${profile.username}@github.local`, name: profile.name, image: profile.image, emailVerified: new Date(), // the provider already verified the address }) .returning(); return u; }, successRedirect: "/", }, }); ``` ```bash npm install passport-github2 # whichever Passport strategies you use ``` ## Mount it ```typescript // src/index.ts import { createCovara } from "covara"; import { startServer } from "covara/node"; import { auth } from "./auth"; import { env } from "./env"; const app = createCovara({ auth }); // mounts the auth routes under /api/auth await startServer(app, { port: env.PORT }); ``` This gives you, under `/api/auth`: | Route | Purpose | |-------|---------| | `POST /signup` · `POST /login` · `POST /logout` | email/password | | `POST /verify/request` · `POST /verify/confirm` | email confirmation | | `GET /social/github` · `GET /social/github/callback` | GitHub social login | | `GET /me` | the current user | ## The email-confirmation flow 1. **Sign up** → creates the user with `emailVerified = null`. 2. **Request a token** → `POST /api/auth/verify/request` issues a token and calls your `sendToken` (the email above). 3. **User clicks the link** → your `/verify` page reads `email` + `token` from the URL and calls `POST /api/auth/verify/confirm`. 4. **`markVerified`** stamps `emailVerified` → the `login` check now passes. ```typescript import { getOrCreateClient } from "covara/client"; const client = getOrCreateClient({ baseUrl: location.origin, credentials: "include" }); // after signup, ask the server to email a confirmation link await client.session.signup({ email, password, name }); await client.session.requestEmailVerification(email); // on your /verify page (link target from the email) const params = new URLSearchParams(location.search); await client.session.confirmEmail(params.get("email")!, params.get("token")!); ``` ## From the client Every auth flow is a first-class client method — no hand-written `fetch`. ```typescript import { getOrCreateClient } from "covara/client"; const client = getOrCreateClient({ baseUrl: location.origin, credentials: "include" }); await client.session.login(email, password); // email/password client.loginWithSocial("github"); // redirects to GitHub, returns with a session const user = await client.session.me(); // current user, or null await client.session.logout(); ``` In React the `useAuth` hook exposes the same flows and tracks `user`/`status` for you: ```tsx import { useAuth } from "covara/client/react"; function SignIn() { const { user, isAuthenticated, login, signup, signInWith } = useAuth(); if (isAuthenticated) return Hi {user?.name}; return ( <> ); } ``` ## Going to production The dev setup above keeps sessions and verification tokens in memory. For real deployments, swap the in-memory pieces for shared stores — no code change beyond the store you pass: - **Sessions:** pass a `sessionStore` to `createPassportAdapter` — [`createKVSessionStore({ kv })`](./sessions.md) (Redis / Durable Object, no SQL table) or [`createDrizzleSessionStore({ db, resolver })`](./internal-tables.md) (which needs the `auth_sessions` table). - **Verification tokens:** `createKVVerificationTokenStore(kv)` instead of `InMemoryVerificationTokenStore`. - **Harden login:** layer on [account security](./account-security.md) (CSRF, login throttling, password policy) and [authorization scopes](./scopes.md) to lock down your resources. ## Related - [Internal & system tables](./internal-tables.md) · [Sessions](./sessions.md) · [Social login](./social.md) · [Account security](./account-security.md) · [Email](../platform/email.md) --- ## Internal & system tables Covara's auth and system features touch a handful of tables. This page lists **every one of them**, what columns each requires, who reads/writes it, and how to point Covara at your own schema — including renaming tables and **remapping column names**. There are three categories: - **Framework-owned SQL tables** — `auth_sessions`, `auth_accounts`, `auth_api_keys`, `auth_verification_tokens`. Covara ships their schema and (optionally) migrates them. You can rename them and remap their columns. - **App-supplied tables** — your **users** table and (if you use file uploads) a **files** table. Covara never owns these; it reaches them through callbacks/resources you provide. You only need to satisfy a required shape. - **Not database tables** — the **changelog** and **rate limits** are backed by the [KV store](../platform/kv.md) (or memory), *not* DB tables. The example app defines `changelog`/`rate_limits` tables for illustration, but the framework does not read them. ## Framework-owned SQL tables Built-in definitions live in `internal-schema.ts` (exported as `authSessions`, `authAccounts`, `authApiKeys`, `authVerificationTokens`, and the dialect-specific `…Sqlite`/`…Pg` variants). Only **`auth_sessions`** is read column-by-column at runtime today (by the Drizzle session store); the others are reached through stores/adapters or only created by migration. ### `auth_sessions` Session storage. Read/written by the [Drizzle session store](./sessions.md) — the one table queried by column directly. | Logical key | Required | Purpose | |---|---|---| | `id` | ✅ | session id (primary key) | | `userId` | ✅ | owning user | | `createdAt` | ✅ | creation time | | `expiresAt` | ✅ | expiry (indexed) | | `data` | optional | JSON blob (e.g. `lastActiveAt`) | ### `auth_accounts` OAuth/OIDC linked accounts. Persisted by your OIDC/Auth.js **adapter callbacks** — Covara has no direct store for it; the schema exists for migration + reference. Compound primary key on `(provider, providerAccountId)`. Required: `userId`, `type`, `provider`, `providerAccountId`. Optional token columns: `refresh_token`, `access_token`, `expires_at`, `token_type`, `scope`, `id_token`, `session_state`. ### `auth_api_keys` API keys. Reached through the [`ApiKeyStore`](./api-keys.md) interface. Required: `id`, `userId`, `name`, `keyHash`, `keyPrefix`, `createdAt`. Optional: `scopes` (JSON), `expiresAt`, `lastUsedAt`, `revokedAt`. ### `auth_verification_tokens` Email-verification / password-reset / magic-link tokens. Reached through the `VerificationTokenStore` interface. Compound primary key on `(identifier, token)`. Required: `identifier`, `token`, `expires`. ## Bringing your own tables (and remapping columns) Use `defineInternalSchema(...)` to point Covara at your own Drizzle tables. Each table accepts a `fieldMap` that maps Covara's **logical keys** to your columns — so a schema with Auth.js-style or snake_case columns works unchanged. Each value can be either the **Drizzle column object** (type-safe and refactor-safe — recommended) or the **property name** as a string. ```typescript import { defineInternalSchema, createDrizzleSessionStore, createPassportAdapter } from "covara"; import { mySessions } from "./schema"; const internal = defineInternalSchema({ dialect: "sqlite", sessions: { table: mySessions, // any table name fieldMap: { // logical key -> your column (object or property name) userId: mySessions.ownerId, // pass the Drizzle column directly createdAt: mySessions.created, // (or a string: createdAt: "created") expiresAt: mySessions.expires, data: mySessions.blob, }, }, }); // Build the session store with the resolved table, then pass the bundle to // createCovara for migration/introspection. const adapter = createPassportAdapter({ getUserById, sessionStore: createDrizzleSessionStore({ db, resolver: internal.sessions }), }); const app = createCovara({ internalSchema: internal, auth: useAuth({ adapter }) }); ``` - `defineInternalSchema` **validates required keys at startup** and throws a precise error (e.g. `internalSchema.sessions.expiresAt: required column not found…`) if a required column is missing — fail-fast, never at query time. Passing a column object that belongs to a different table is also rejected at startup. - Omitting a table override falls back to the built-in table, so today's behavior is unchanged when you pass nothing. - `createCovara({ internalSchema })` records the bundle for migration + introspection. It does **not** rewire already-constructed stores — build `createDrizzleSessionStore({ resolver })` yourself (as above). - The `SessionData` shape your app sees stays in logical keys (`id`/`userId`/`createdAt`/`expiresAt`/`data`); remapping happens only at the SQL boundary. ### The KV session store works with any backend `createKVSessionStore({ kv })` is backed by the [KV abstraction](../platform/kv.md), so sessions can live in Redis, the Cloudflare Durable Object store, or in-memory for tests — its internal hash fields are a private serialization and are **not** subject to `fieldMap`. (`createRedisSessionStore` remains as a deprecated alias.) ## Migrations `migrateInternal(db, { schema?, dialect? })` has three modes: 1. **No `schema`** → creates the built-in tables with the canonical DDL (indexes + compound PKs). This is the default and is byte-for-byte unchanged. 2. **`schema.managedExternally = true`** → **no-op**. Recommended whenever you customize tables: create them with your own tool (e.g. `drizzle-kit`), which handles indexes and compound primary keys correctly. 3. **`schema` without `managedExternally`** → generates `CREATE TABLE IF NOT EXISTS` for overridden tables from the Drizzle table objects. Single-primary-key tables only — it **throws** for compound-PK tables (`auth_accounts`, `auth_verification_tokens`), directing you to mode 2. Indexes are not regenerated in this mode. ## App-supplied tables ### Users Covara **never owns a users table**. Auth adapters reach the user through a `getUserById(id)` callback you implement, so you can store users however you like (a Drizzle table, an external service, anything). The object you return must include: | Field | Required | Notes | |---|---|---| | `id` | ✅ | string | | `email` | optional | | | `name` | optional | | | `image` | optional | | | `emailVerified` | optional | `Date \| null` | | `metadata` | optional | arbitrary record | ### Files If you use [file uploads](../platform/storage.md) via `fileResource`, you supply the files table. Required columns: `id`, `filename`, `mimeType`, `size`, `storagePath`, `status` (`"pending" | "completed"`), `createdAt`. Optional: `userId` (for access control), `url`. ## Changelog & rate limits are not DB tables The [changelog](../realtime/changelog.md) is stored in the [KV store](../platform/kv.md) (sorted set) with an in-memory fallback. [Rate limiting](../tooling/middleware.md) uses the KV store or an in-memory map. **Neither reads a database table.** # Related - [Sessions](./sessions.md) · [API keys](./api-keys.md) · [KV store](../platform/kv.md) - [Auth contract](../contracts/auth.md) --- ## JWT authentication JWT auth uses signed bearer tokens instead of cookie sessions — a good fit for mobile apps, third-party API clients, and stateless services. Covara provides a server-side JWT adapter and a client `JWTClient` + `useJWTAuth` hook with pluggable token storage. ## Server Use the `jwtSession` [session strategy](./sessions.md#session-strategies) so the middleware validates `Authorization: Bearer ` and populates the [request user](./overview.md#the-request-user). It's decoupled from how the user logs in — so the **same credential providers** (`login`, `signup`, [`social`](./social.md), …) issue JWTs instead of cookies just by swapping the strategy. ```typescript import { jwtSession, useAuth } from "covara"; const { router, middleware } = useAuth({ session: jwtSession({ secret: env.JWT_SECRET, // HMAC, or a key pair for RS256/ES256 getUserById: async (id) => db.query.users.findFirst({ where: eq(users.id, id) }), refreshStore: kvStore, // optional: revocable refresh tokens }), login: { validateCredentials: async (email, password) => { /* ... */ } }, signup: { createUser: async (data) => { /* ... */ } }, }); ``` `/login` and `/signup` return `{ accessToken, expiresIn, tokenType: "Bearer" }` and set an `httpOnly` refresh cookie (instead of a session cookie); `POST /api/auth/refresh` mints a fresh access token. Protected routes read the bearer token. > **Migrating from `createJWTAdapter`:** the standalone `createJWTAdapter` (mounted via its own `getRoutes()` + `middleware`) still works but is deprecated in favor of `jwtSession`, which integrates with `useAuth` (and its social/MFA/magic-link providers). Its options map 1:1. ## Client With `jwtSession`, the standard [`useAuth()` hook](../client/auth.md) handles JWTs uniformly — `login`/`signup` capture the returned access token and send it as a bearer on subsequent requests (and `client.session.login` does the same outside React): ```tsx import { useAuth } from "covara/client/react"; function SignIn() { const { login, signup, signInWith } = useAuth(); // login(email, password) -> stores the JWT and authenticates; same code as a cookie session } ``` For finer control (manual token storage, refresh scheduling, React Native), use the dedicated `useJWTAuth` hook / `JWTClient`: ### `useJWTAuth` ```tsx import { initJWTClient, useJWTAuth } from "covara/client/react"; initJWTClient({ baseUrl: location.origin, authPath: "/api/auth" }); function App() { const { user, accessToken, isAuthenticated, login, signup, logout, refresh } = useJWTAuth(); const onLogin = () => login("user@example.com", "password"); const onSignup = () => signup("user@example.com", "password", "Jane Doe"); if (!isAuthenticated) return ; return ; } ``` Alternatively configure JWT on the main client and let the generic [`useAuth`](../client/auth.md) hook auto-detect the strategy: ```typescript import { getOrCreateClient } from "covara/client"; const client = getOrCreateClient({ baseUrl: location.origin, jwt: { authPath: "/api/auth" } }); ``` ```tsx const { user, isAuthenticated, accessToken } = useAuth(); // auto-detects JWT ``` The client refreshes the access token automatically before expiry and retries a request once after a `401` by refreshing. See [Client auth](../client/auth.md). ## Token storage The client stores tokens through a pluggable `TokenStorage`, so the same code runs in the browser and React Native: | Storage | Behavior | |---------|----------| | `MemoryStorage` | Most secure; tokens lost on refresh. | | `LocalStorageAdapter("prefix_")` | Persists across tabs/sessions. | | `SessionStorageAdapter("prefix_")` | Persists until the tab closes. | | AsyncStorage-compatible | Provide your own for React Native. | See [React Native](../client/react-native.md) for native token storage. ## Setting a token manually ```typescript client.setAuthToken("your-jwt-token"); client.clearAuthToken(); ``` ## Related - [Sessions](./sessions.md) · [OIDC provider](./oidc-provider.md) · [API keys](./api-keys.md) - [Client auth](../client/auth.md) · [React Native](../client/react-native.md) --- ## Magic links Passwordless login via emailed single-use tokens. Enable it by passing a `magicLink` config to [`useAuth`](./sessions.md). ```typescript import { InMemoryVerificationTokenStore } from "covara"; useAuth({ adapter, magicLink: { store: new InMemoryVerificationTokenStore(), sendLink: async ({ identifier, token }) => sendEmail(identifier, `https://app.com/magic?token=${token}`), findUserByEmail: async (email) => db.query.users.findFirst({ where: eq(users.email, email) }), ttlMs: 15 * 60 * 1000, // default 15m hashTokens: true, // store SHA-256 instead of the raw token }, }); ``` ## Routes | Route | Method | Description | |-------|--------|-------------| | `/magic-link/request` | POST | `{ email }` — issues a single-use token and calls `sendLink` **only if the user exists**, but always returns `{ success: true }` (no email enumeration). | | `/magic-link/verify` | POST | `{ email, token }` — consumes the token and creates the session, returning `{ user, sessionId }`; invalid/expired returns `401`. | ## Low-level helpers ```typescript import { issueMagicLinkToken, consumeMagicLinkToken } from "covara"; ``` Use these for custom flows (e.g. issuing a link from a different channel). ## Token store `magicLink.store` implements the `VerificationTokenStore` interface (`create`/`consume`/`deleteByIdentifier`), shared with [email verification and password reset](./account-security.md). `InMemoryVerificationTokenStore` ships for development; back it with your own table for production. ## Related - [Account security](./account-security.md) · [Sessions](./sessions.md) · [Email](../platform/email.md) --- ## Multi-factor auth (TOTP) Enable TOTP-based MFA (authenticator apps) with backup codes by passing an `mfa` config to [`useAuth`](./sessions.md). ```typescript useAuth({ adapter, login, mfa: { issuer: "My App", requireOnLogin: true, getUserByEmail: async (email) => db.query.users.findFirst({ where: eq(users.email, email) }), getEnrollment: async (userId) => db.query.mfa.findFirst({ where: eq(mfa.userId, userId) }), saveEnrollment: async (userId, enrollment) => db.insert(mfa).values({ userId, ...enrollment }).onConflictDoUpdate({ target: mfa.userId, set: enrollment }), consumeBackupCode: async (userId, index) => { /* mark backup code `index` used */ }, }, }); ``` Required callbacks: `getUserByEmail`, `getEnrollment`, `saveEnrollment`. The stored `MfaEnrollment` is `{ secret: string; enabled: boolean; backupCodeHashes?: string[] }`. ## Routes | Route | Method | Description | |-------|--------|-------------| | `/mfa/enroll` | POST | Generate a TOTP secret + backup codes for the current user (saved as `enabled: false`). Returns `{ secret, otpauthUri, backupCodes }` — codes shown once. | | `/mfa/enroll/confirm` | POST | `{ code }` — verify the first TOTP and flip the enrollment to `enabled: true`. | | `/mfa/verify` | POST | `{ email, code }` — second-factor login step; on success creates the session. Accepts a TOTP **or** a backup code. | ## Login flow When `requireOnLogin` is true and the user has an enabled enrollment, `POST /login` returns `{ mfaRequired: true }` with `401` if no `mfaCode` is supplied. The client then either resubmits `/login` with `mfaCode`, or calls `/mfa/verify`. A matched backup code triggers `consumeBackupCode`. ```mermaid sequenceDiagram participant C as Client participant S as Server C->>S: POST /login (email, password) S-->>C: 401 { mfaRequired: true } C->>S: POST /mfa/verify (email, code) S-->>C: 200 { user, sessionId } ``` ## Config ```typescript mfa?: { issuer?: string; totp?: { step?: number; digits?: number; window?: number }; backupCodeCount?: number; // default 10 requireOnLogin?: boolean; getUserByEmail(email): Promise<(AuthUser & { mfa?: MfaEnrollment | null }) | null>; getEnrollment(userId): Promise; saveEnrollment(userId, enrollment): void | Promise; saveBackupCodeHashes?(userId, hashes): void | Promise; consumeBackupCode?(userId, index): void | Promise; } ``` ## Low-level primitives For custom flows: ```typescript import { generateTotpSecret, generateTotp, verifyTotp, getTotpUri, generateBackupCodes, verifyBackupCode, } from "covara"; ``` ## Related - [Sessions](./sessions.md) · [Passwords](./passwords.md) · [Account security](./account-security.md) --- ## OIDC provider `createOIDCProvider` turns Covara into a standards-based **OpenID Connect identity server**: authorization-code flow with PKCE, JWT access/ID tokens, refresh-token rotation, federated login, token revocation (RFC 7009) and introspection (RFC 7662), and a login/consent UI — with hardening on by default. ```typescript import { Hono } from "hono"; import { createOIDCProvider } from "covara"; import { eq } from "drizzle-orm"; const app = new Hono(); const { router, middleware, stores, tokenService } = createOIDCProvider({ issuer: "https://auth.myapp.com", keys: { algorithm: "RS256" }, tokens: { accessToken: { ttlSeconds: 3600 }, refreshToken: { ttlSeconds: 30 * 24 * 3600, rotateOnUse: true }, }, clients: [ { id: "web-app", name: "My Web App", redirectUris: ["https://myapp.com/callback"], postLogoutRedirectUris: ["https://myapp.com"], grantTypes: ["authorization_code", "refresh_token"], responseTypes: ["code"], tokenEndpointAuthMethod: "none", // public client — PKCE required scopes: ["openid", "profile", "email", "offline_access"], }, ], backends: { emailPassword: { enabled: true, validateUser: async (email, password) => { const u = await db.query.users.findFirst({ where: eq(users.email, email) }); return u && (await verifyPassword(password, u.passwordHash)) ? { id: u.id, email: u.email, name: u.name } : null; }, findUserById: async (id) => { const u = await db.query.users.findFirst({ where: eq(users.id, id) }); return u ? { id: u.id, email: u.email, name: u.name } : null; }, }, }, }); app.route("/oidc", router); // OIDC endpoints app.use("/api/*", middleware); // validates bearer tokens → c.get("user") ``` The return value: `router` (a `Hono` instance), `middleware` (validates bearer tokens and populates the [request user](./overview.md#the-request-user)), `stores`, and `tokenService`. ## Endpoints | Endpoint | Method | Description | |----------|--------|-------------| | `/.well-known/openid-configuration` | GET | Discovery document | | `/authorize` | GET | Authorization code flow with PKCE | | `/token` | POST | Token exchange & refresh | | `/userinfo` | GET/POST | User claims | | `/jwks` | GET | Public keys for verification | | `/logout` | GET | End session with redirect | | `/revoke` | POST | Token revocation (RFC 7009) | | `/introspect` | POST | Token introspection (RFC 7662) | | `/login` | GET/POST | Login UI (customizable) | | `/consent` | GET/POST | Consent UI | | `/consent/revoke` | POST/DELETE | Revoke a user's consent (one client or all) | | `/register` | POST | Dynamic client registration (opt-in) | `/revoke` and `/introspect` require client authentication and are advertised in discovery as `revocation_endpoint`/`introspection_endpoint`. Revoking a refresh token invalidates it; introspection returns `{ active, scope, sub, client_id, exp, ... }` for access and refresh tokens. **Confidential client secrets** may be stored hashed: a secret beginning with `scrypt$` is verified with [`verifyPassword`](./passwords.md); a plaintext secret is compared in constant time. Generate one with `await hashPassword(secret)`. ## Configuration ```typescript interface OIDCProviderConfig { issuer: string; // HTTPS in production keys: { algorithm?: "RS256" | "ES256"; privateKey?: string | Buffer; rotationIntervalMs?: number }; tokens?: { accessToken?: { ttlSeconds?: number }; // default 3600 idToken?: { ttlSeconds?: number }; // default 3600 refreshToken?: { enabled?: boolean; ttlSeconds?: number; rotateOnUse?: boolean }; // 30d, rotate }; clients: OIDCClient[]; backends: { emailPassword?: EmailPasswordBackendConfig; federated?: FederatedProvider[] }; stores?: { type?: "memory" | "redis" | "drizzle"; // KV-backed by default when a global KV exists kv?: KVAdapter; sessionStore?: SessionStore; prefix?: string; db?: unknown; }; ui?: { loginPath?: string; consentPath?: string; templates?: { login?; consent?; error? } }; security?: { pkce?: { required?: boolean; methods?: ("S256")[] }; consent?: { ttlSeconds?: number }; // default 1 year rateLimiting?: { token?; jwks?; introspect? }; // { windowMs, max } }; registration?: { enabled?: boolean; defaultScopes?: string[]; initialAccessToken?: string }; hooks?: { onUserAuthenticated?(user, method): Promise; onTokenIssued?(userId, clientId, scopes): Promise; onConsentGranted?(userId, clientId, scopes): Promise; getAccessTokenClaims?(user, client, scopes): Promise>; }; } ``` ## Hardening (on by default) - **Redirect URI validation** — matched component-by-component (protocol, host, port, normalized path, registered query/fragment), not by prefix. An unregistered URI is rejected with `400` **before** any redirect, so an attacker never receives a redirect. - **PKCE** — `code_challenge_method=plain` is always rejected (only `S256` is supported/advertised). PKCE is **required for public clients** (`tokenEndpointAuthMethod: "none"`). Set `security.pkce.required: true` to require it for all clients. - **`at_hash`** — computed correctly (left-half of the hash matching the signing algorithm) whenever an access token is issued. - **Nonce** — `validateIdTokenNonce(idToken, expectedNonce)` is exported for relying parties. - **Rate limiting** — `/token`, `/jwks`, `/introspect` can be limited per client or IP via `security.rateLimiting` (uses the global [KV](../platform/kv.md) when present, else an in-memory bucket; emits `X-RateLimit-*` and `429` + `Retry-After`). No limit unless configured. - **Persistent stores by default** — with a global KV registered, clients, codes, refresh tokens, consents, interactions, and state are KV-backed with expiry-derived TTLs. Pass `stores.type: "memory"` to force in-memory. - **`login_hint` escaping** — all dynamic values are HTML-escaped in the default login template. ## Dynamic client registration Enable RFC 7591-style registration: ```typescript registration: { enabled: true, defaultScopes: ["openid", "profile", "email"], initialAccessToken: env.OIDC_REGISTRATION_TOKEN, // optional gate } ``` `POST /register` accepts a JSON/form body with at least `redirect_uris` (each validated as a URL), defaults `token_endpoint_auth_method` to `client_secret_basic` (use `none` for public clients), `grant_types` to `["authorization_code"]`, `response_types` to `["code"]`, and returns `201` with a generated `client_id` (+ `client_secret` for confidential clients). Returns `404` when disabled; requires `Authorization: Bearer ` when configured. The `registration_endpoint` is added to discovery only when enabled. ## Consent revocation `POST /consent/revoke` (or `DELETE`) revokes a logged-in user's consent — with `client_id` in the body for one client, without it for all. Requires a valid `oidc_session` cookie (else `401`). Stored consents also expire after `security.consent.ttlSeconds` (default 1 year), after which the user re-consents. ## Federated login Add Google, Microsoft, Okta, Auth0, Keycloak, or a generic OIDC provider via `backends.federated`. See [Federated login](./federated.md). ## Client side The Covara client handles the OIDC PKCE flow, token refresh, and 401 retry. See [Client auth](../client/auth.md). ## Related - [Federated login](./federated.md) · [JWT](./jwt.md) · [Client auth](../client/auth.md) - [KV store](../platform/kv.md) · [Auth contract](../contracts/auth.md) --- ## Authentication overview Covara ships a complete authentication and authorization stack. There are two ways to authenticate users, and one consistent way to authorize them. :::tip Just want it working? Follow the [**Auth quickstart**](./getting-started.md) — the fastest path to email/password (with email confirmation) plus GitHub social login, and the one table you actually need to create. ::: ## Two authentication approaches | Approach | Use when | Page | |----------|----------|------| | **OIDC provider** | You want a standards-based identity server (OAuth2/OIDC, PKCE, federated login, JWT access tokens) for one or many apps. | [OIDC provider](./oidc-provider.md) | | **Session-based `useAuth`** | You want classic email/password sessions with cookies, the fastest path to login/signup/logout. | [Sessions](./sessions.md) | Both populate the same request context, so [authorization scopes](./scopes.md), [subscriptions](../realtime/subscriptions.md), and the [client `useAuth` hook](#client-side-useauth) work identically regardless of which you choose. On top of either approach you can layer: [social login](./social.md) (sign in with GitHub/Discord/Google/… via any Passport.js strategy), [JWT tokens](./jwt.md), [federated login](./federated.md), [API keys](./api-keys.md), [MFA/TOTP](./mfa.md), [magic links](./magic-links.md), a [password policy](./passwords.md), and [account-security flows](./account-security.md) (CSRF, login throttling, email verification, password reset). ## The request user After auth middleware runs, the authenticated user is available in any Hono handler: ```typescript import { getUser, requireUser, getSession } from "covara"; app.get("/api/profile", (c) => { const user = requireUser(c); // throws 401 if absent return c.json(user); }); ``` `getUser(c)` returns the user or `null`; `requireUser(c)` throws an [`UnauthorizedError`](../tooling/error-handling.md); `getSession(c)` returns the session. These read from Hono's typed `ContextVariableMap` (`user`, `session`, `requestId`, `apiVersion`). ## Quick session setup `useAuth` decouples **how** the identity is persisted (a [session strategy](./sessions.md#session-strategies) — `cookieSession` or `jwtSession`) from **who** the user is (credential providers — `login`, `signup`, [`social`](./social.md), …), so any provider composes with any session type. ```typescript import { createCovara, cookieSession, useAuth, hashPassword, verifyPassword } from "covara"; import { eq } from "drizzle-orm"; const auth = useAuth({ // swap for jwtSession({ secret, getUserById }) to issue JWTs instead session: cookieSession({ getUserById: async (id) => db.query.users.findFirst({ where: eq(users.id, id) }), }), login: { validateCredentials: async (email, password) => { const user = await db.query.users.findFirst({ where: eq(users.email, email) }); return user && (await verifyPassword(password, user.passwordHash)) ? { id: user.id, email: user.email, name: user.name } : null; }, }, signup: { createUser: async ({ email, password, name }) => { const [u] = await db.insert(users) .values({ id: crypto.randomUUID(), email, name, passwordHash: await hashPassword(password) }) .returning(); return { id: u.id, email: u.email, name: u.name }; }, }, }); const app = createCovara({ auth }); // mounts /api/auth/* and the middleware ``` This creates four routes: | Route | Method | Description | |-------|--------|-------------| | `/api/auth/me` | GET | Current user, or `{ user: null }` | | `/api/auth/login` | POST | Email/password login | | `/api/auth/signup` | POST | Create account | | `/api/auth/logout` | POST | Clear session | Full options and adapters: **[Sessions](./sessions.md)**. ## Route guards ```typescript import { requireAuth, requireRole, requirePermission, getUser } from "covara"; app.get("/profile", requireAuth(), (c) => c.json(getUser(c))); app.get("/admin", requireRole("admin"), (c) => c.json({ ok: true })); app.post("/posts", requirePermission("posts:create"), async (c) => { /* ... */ }); ``` ## Authorization Authentication answers *who is this*; **authorization** answers *what can they touch*. Covara enforces row-level access with [RSQL scopes](./scopes.md) on every read, write, subscription, and search — combined with the request filter and impossible to bypass from the client. See [Authorization scopes](./scopes.md) and [Secure queries](./secure-queries.md). ## Client-side `useAuth` The React [`useAuth`](../client/auth.md) hook exposes the auth state and supports several strategies — cookie sessions, JWT, bearer token, API key, or auto-detect: ```tsx import { useAuth } from "covara/client/react"; function App() { const { user, isAuthenticated, isLoading, logout } = useAuth(); if (isLoading) return Loading…; if (!isAuthenticated) return ; return ; } ``` See [Client auth](../client/auth.md) for every strategy and the OIDC PKCE flow. ## Related - [Quickstart](./getting-started.md) · [Sessions](./sessions.md) · [Social login](./social.md) · [JWT](./jwt.md) · [OIDC provider](./oidc-provider.md) · [Federated login](./federated.md) - [Scopes](./scopes.md) · [Secure queries](./secure-queries.md) · [Passwords](./passwords.md) - [Auth contract](../contracts/auth.md) — the threat model and guarantees --- ## Passwords Covara ships a **Workers-safe scrypt** password hasher, so you don't need `bcrypt` or any native dependency. Hashes are self-describing strings (`scrypt$N=...,r=...,p=...$salt$hash`), so parameters can evolve without a schema change. ## Hashing ```typescript import { hashPassword, verifyPassword, needsRehash } from "covara"; // On signup const passwordHash = await hashPassword(plaintext); // On login if (!(await verifyPassword(plaintext, user.passwordHash))) { throw new Error("Invalid credentials"); } // Upgrade old hashes to stronger params after a successful login if (needsRehash(user.passwordHash)) { await db.update(users).set({ passwordHash: await hashPassword(plaintext) }).where(eq(users.id, user.id)); } ``` - `hashPassword(password, options?)` accepts scrypt cost parameters (`N`, `r`, `p`, `keylen`, `saltlen`). - `needsRehash(stored, options?)` returns `true` when the stored hash is weaker than the target parameters (or unparseable). - `verifyPassword` is constant-time. These power the [session](./sessions.md), [OIDC](./oidc-provider.md) (confidential client secrets), and [password reset](./account-security.md) paths. ## Password policy Enforce strength rules on signup and password reset by passing `passwordPolicy` to [`useAuth`](./sessions.md): ```typescript useAuth({ adapter, login, signup, passwordPolicy: { minLength: 12, maxLength: undefined, requireUppercase: true, requireLowercase: false, requireNumber: true, requireSymbol: true, denylist: ["mycompany"], useBuiltInDenylist: true, // default: blocks ~20 common passwords }, }); ``` When set, the policy is enforced (before your own `signup.validatePassword`) on `POST /signup` and again on `POST /password/reset`. A weak password fails with a `422` listing the violations. The built-in denylist (on by default) blocks ~20 of the most common passwords (`password`, `123456`, `qwerty`, …); disable with `useBuiltInDenylist: false` or extend via `denylist`. ### Standalone helpers ```typescript import { validatePasswordStrength, enforcePasswordStrength, builtInPasswordDenylist } from "covara"; const { valid, errors } = validatePasswordStrength(password, { minLength: 12 }); enforcePasswordStrength(password, { minLength: 12 }); // throws ValidationError if invalid ``` ## Related - [Sessions](./sessions.md) · [Account security](./account-security.md) · [OIDC provider](./oidc-provider.md) --- ## Authorization scopes A **scope** is an [RSQL filter](../core/filtering.md), derived from the current user, that Covara `AND`-combines with every query for a resource. Scopes are enforced on reads, writes, subscriptions, count, aggregate, and search — server-side, so a client can never widen its own access. ```typescript import { useResource, rsql } from "covara"; useResource(postsTable, { id: postsTable.id, db, auth: { public: { read: true }, // anonymous read update: async (user) => rsql`authorId==${user.id}`, // own posts only delete: async (user) => user.metadata?.role === "admin" ? rsql`*` : rsql`authorId==${user.id}`, subscribe: async (user) => rsql`authorId==${user.id}`, // live stream scope }, }); ``` | Return | Meaning | |--------|---------| | `` rsql`*` `` | Allow all rows for this operation. | | `` rsql`` `` | Allow only rows matching the expression. | | ``` rsql`` ``` (empty) | Deny — no rows. | Operations: `read`, `create`, `update`, `delete`, `subscribe`. Omit one to deny it (unless `public` grants it). **Anonymous access — `public`.** `public: true` makes **read and subscribe** public (writes still require auth — a safe default). The object form opts each operation in explicitly and can open **writes** too: `public: { read: true, subscribe: true, create: true, update: true, delete: true }` for a fully-public resource (e.g. a prototype or a genuinely open collection). With the object form, `subscribe` is **not** implied by `read` — grant it explicitly or anonymous SSE subscriptions return `401`. An operation not granted by `public` and reached without a user returns `401`. ```mermaid flowchart LR Req[Client request\n?filter=...] --> Combine Scope[auth scope\nrsql for user] --> Combine Combine[AND-combine] --> SQL[Drizzle WHERE] SQL --> DB[(Database)] ``` ## Scope patterns Common cases are presets: ```typescript import { scopePatterns } from "covara"; auth: scopePatterns.ownerOnly("userId"), auth: scopePatterns.publicReadOwnerWrite("userId"), auth: scopePatterns.ownerOrAdmin("userId", (user) => user.metadata?.role === "admin"), auth: scopePatterns.orgBased("organizationId"), auth: scopePatterns.authenticatedFullAccess(), // any signed-in user, full access auth: scopePatterns.fullyPublic(), // every op public, incl. anonymous writes — demos only ``` > `fullyPublic()` opts **every** operation in (read, subscribe, create, update, delete) for unauthenticated callers — it's the `covara create` starter default so the app works end-to-end; lock it down before production. For public reads with authenticated writes, use `publicReadOwnerWrite`. ## Building scopes programmatically The `rsql` template helper interpolates values safely. Or compose with builders. See [**RSQL**](../core/rsql.md#building-rsql-in-typescript) for the full builder reference (escaping rules, every helper, sub-scope composition, and the special `allScope()`/`emptyScope()`). ```typescript import { rsql, eq, ne, gt, gte, lt, lte, inList, notIn, like, notLike, isNull, isNotNull, and, or } from "covara"; const a = eq("userId", user.id); const b = and(eq("status", "active"), eq("organizationId", user.orgId)); const c = or(eq("userId", user.id), eq("public", true)); const d = like("email", "%@example.com"); // emits %= const e = notLike("email", "%@spam.com"); // emits !%= // equivalent template form: const scope = rsql`userId==${user.id};status=="active"`; ``` :::note No NOT combinator The filter grammar has no `NOT` combinator, so there is no `not()` helper. Use the negated operators instead: `ne` (`!=`), `notIn` (`=out=`), `notLike` (`!%=`), `isNotNull` (`=isnull=false`). ::: ## RSQL template helper ```typescript import { rsql } from "covara"; auth: { update: async (user) => rsql`userId==${user.id}`, delete: async (user) => rsql`userId==${user.id}`, } ``` Interpolated values are escaped, so user IDs and other dynamic values are injected safely. ## How enforcement works Every resource endpoint routes through the [secure query builder](./secure-queries.md), which resolves the scope for the operation and combines it with the request filter before touching the database. Subscriptions resolve the `subscribe` scope at connect and match it in-memory against the changelog, so the live stream never leaks rows outside scope. See the [auth contract](../contracts/auth.md) for the guarantee. ## Related - [RSQL](../core/rsql.md) · [Secure queries](./secure-queries.md) · [Filtering](../core/filtering.md) · [Fields & masking](../core/fields.md) - [Subscriptions](../realtime/subscriptions.md) · [Auth contract](../contracts/auth.md) --- ## Secure query builder The secure query builder is the layer `useResource` uses to enforce [authorization scopes](./scopes.md) on every read, count, aggregate, and mutation. You can also use it directly in custom routes when you need scope-safe database access. `createScopeResolver` and `createResourceFilter` are exported from `covara`; the builder factory lives in `src/resource/secure-query.ts`. ```typescript import { createScopeResolver, createResourceFilter, getUser } from "covara"; import { createSecureQueryBuilder } from "@/resource/secure-query"; const scopeResolver = createScopeResolver(config.auth); const filterer = createResourceFilter(postsTable); app.get("/api/my-posts", async (c) => { const builder = createSecureQueryBuilder(postsTable, db, scopeResolver, filterer, { user: getUser(c) }); const posts = await builder.executeSelect("published==true", { limit: 10 }); return c.json(posts); }); ``` ## Methods | Method | Description | |--------|-------------| | `executeSelect(filter?, opts?)` | Scoped select. `opts`: `limit`, `offset`, `orderBy`, `cursorCondition`. | | `executeCount(filter?)` | Scoped count. | | `executeAggregate(params, filter?)` | Scoped [aggregation](../core/aggregations.md). | | `select(filter?)` | Build the scoped Drizzle `SQL` WHERE without executing. | | `selectWithScope(op, filter?)` | Resolve the scope for a specific operation (`"read"`/`"update"`/`"delete"`). | ```typescript const results = await builder.executeSelect('status=="active";createdAt>"2024-01-01"', { limit: 20 }); const count = await builder.executeCount('status=="published"'); const stats = await builder.executeAggregate({ groupBy: ["category"], count: true, avg: ["views"] }); ``` The user's scope is always applied, so a query can only narrow within it: ```typescript // auth.read scope: rsql`userId==${user.id}` await builder.executeSelect('status=="draft"'); // → SELECT * FROM posts WHERE status = 'draft' AND userId = 'user123' ``` ## Scoped mutations ```typescript import { createSecureMutationBuilder } from "@/resource/secure-query"; const mutations = createSecureMutationBuilder(postsTable, db, scopeResolver, filterer, { user: getUser(c) }); const updateFilter = await builder.selectWithScope("update", 'category=="draft"'); await mutations.update(updateFilter, { status: "published" }); const deleteFilter = await builder.selectWithScope("delete", 'createdAt<"2023-01-01"'); await mutations.delete(deleteFilter); ``` ## Admin bypass with audit logging ```typescript const adminBuilder = builder.asAdmin("Admin data export"); const everything = await adminBuilder.executeSelect(); // logs: { level: "warn", type: "admin_scope_bypass", reason: "Admin data export", ... } import { getAdminAuditLog, clearAdminAuditLog } from "@/resource/secure-query"; const entries = getAdminAuditLog(); // [{ reason, timestamp, userId }] ``` ## Field-level write enforcement Separately from scope filters, [`fields.writable`](../core/fields.md) is an enforced allowlist of columns a client may set, stripping protected columns (e.g. `ownerId`, `role`) from inbound bodies before hooks or the database see them — mass-assignment protection. See [Fields](../core/fields.md#write-enforcement-fieldswritable-mass-assignment-protection). ## Type safety ```typescript const posts = await builder.executeSelect(); posts[0].title; // string ``` ## Related - [Authorization scopes](./scopes.md) · [Fields & masking](../core/fields.md) · [Filtering](../core/filtering.md) - [Auth contract](../contracts/auth.md) --- ## Security headers `createCovara` auto-mounts a security-headers middleware that sets sensible defaults on every response. You can tune it, and opt into a Content-Security-Policy (off by default so it never silently blocks your frontend). ## Defaults | Header | Default value | |--------|---------------| | `X-Content-Type-Options` | `nosniff` | | `X-Frame-Options` | `DENY` | | `Referrer-Policy` | `strict-origin-when-cross-origin` | | `X-DNS-Prefetch-Control` | `off` | | `Cross-Origin-Opener-Policy` | `same-origin` | | `Strict-Transport-Security` | `max-age=15552000; includeSubDomains` (on HTTPS or in production) | | `Content-Security-Policy` | **not set** (opt-in) | Each header is set only if absent, so your own routes can override any of them. HSTS is emitted only for HTTPS requests or when `isProduction()` is true. ## Configuring Pass `securityHeaders` options (the same shape as the standalone middleware): ```typescript import { createSecurityHeaders, STRICT_API_CSP } from "covara/middleware/securityHeaders"; const headers = createSecurityHeaders({ contentSecurityPolicy: STRICT_API_CSP, // or your own policy string, or false contentTypeOptions: true, frameOptions: "DENY", // "DENY" | "SAMEORIGIN" | false referrerPolicy: "strict-origin-when-cross-origin", // or false dnsPrefetchControl: "off", // or false crossOriginOpenerPolicy: "same-origin",// or false hsts: { maxAge: 15552000, includeSubDomains: true, preload: false }, // or false }); app.use("*", headers); ``` ## Content-Security-Policy CSP is **off by default** because it is application-specific — a strict policy right for a JSON-only API will break an app that serves its own frontend. Enable it by passing a policy string. For a pure JSON API, `STRICT_API_CSP` is a good starting point: ```typescript import { STRICT_API_CSP } from "covara/middleware/securityHeaders"; // STRICT_API_CSP === "default-src 'none'; frame-ancestors 'none'" createSecurityHeaders({ contentSecurityPolicy: STRICT_API_CSP }); ``` For an app that serves a frontend, write a policy that allows your scripts/styles/connect sources. ## Disabling a header Set any option to `false` to omit that header (e.g. `frameOptions: false` if you embed the app in an iframe, `hsts: false` to never send HSTS). ## Related - [Middleware](../tooling/middleware.md) · [Account security](./account-security.md) · [CORS](../core/resources-and-app.md#createcovara) --- ## Session-based auth `useAuth` wires up authentication: `/login`, `/signup`, `/logout`, and `/me` routes plus the middleware that populates the request [user](./overview.md#the-request-user). Two concerns are **decoupled**: - **A session strategy** (`session:`) — *how* the authenticated identity is persisted, validated per request, and issued at login. Pick [`cookieSession`](#session-strategies) (server-side sessions) or [`jwtSession`](#session-strategies) (stateless JWTs). - **Credential providers** — *who* the user is at login: `login`, `signup`, [`social`](./social.md), [`verification`](./account-security.md), [`mfa`](./mfa.md), [`magicLink`](./magic-links.md). They compose freely: **any provider works with any session strategy** (e.g. [Passport social login that issues JWTs](#session-strategies)). ```typescript import { cookieSession, useAuth, hashPassword, verifyPassword } from "covara"; import { eq } from "drizzle-orm"; const { router, middleware } = useAuth({ session: cookieSession({ getUserById: async (id) => db.query.users.findFirst({ where: eq(users.id, id) }), }), login: { validateCredentials: async (email, password) => { const user = await db.query.users.findFirst({ where: eq(users.email, email) }); return user && (await verifyPassword(password, user.passwordHash)) ? { id: user.id, email: user.email, name: user.name } : null; }, }, signup: { createUser: async ({ email, password, name }) => { const [u] = await db.insert(users) .values({ id: crypto.randomUUID(), email, name, passwordHash: await hashPassword(password) }) .returning(); return { id: u.id, email: u.email, name: u.name }; }, }, }); app.route("/api/auth", router); app.use("*", middleware); ``` With the factory, pass the result directly: `createCovara({ auth: { router, middleware } })` mounts the router at `/auth` and applies the middleware (override with `auth: { router, middleware, path: "/auth" }`). :::tip Session rotation On a successful `/login`, the prior session cookie (if any) is invalidated before a new session is created — sessions are rotated on every login, mitigating session fixation. ::: ## `useAuth` options ```typescript interface UseAuthOptions { session: SessionStrategy; // cookieSession(...) | jwtSession(...) adapter?: AuthAdapter; // deprecated: legacy adapter (mapped to a session internally) cookieName?: string; // default "session" cookieOptions?: { httpOnly?: boolean; // default true secure?: boolean; // default true in production sameSite?: "strict" | "lax" | "none"; // default "lax" maxAge?: number; // default 7 days }; login?: { validateCredentials: (email, password) => Promise }; signup?: { createUser: (data: { email; password; name? }) => Promise; validateEmail?: (email) => boolean | Promise; validatePassword?: (password) => boolean | Promise; }; serializeUser?: (user) => Record; onLogin?: (user, c) => void | Promise; onLogout?: (user, c) => void | Promise; onSignup?: (user, c) => void | Promise; // Opt-in flows — see linked pages csrf?: boolean | CsrfOptions; // → Account security throttle?: boolean | LoginThrottleOptions; // → Account security verification?: VerificationConfig; // → Account security passwordReset?: PasswordResetConfig; // → Account security passwordPolicy?: PasswordPolicy; // → Passwords mfa?: MfaConfig; // → MFA magicLink?: MagicLinkConfig; // → Magic links } ``` ## Routes & shapes ```jsonc // POST /api/auth/login { "email": "...", "password": "..." } // → { "user": { "id", "email", "name" }, "sessionId": "sess_..." } // POST /api/auth/signup { "email": "...", "password": "...", "name": "..." } // → { "user": { "id", "email", "name" } } // GET /api/auth/me → { "user": {...}, "expiresAt": "..." } or { "user": null } // POST /api/auth/logout → { "success": true } ``` ## Session strategies The `session` strategy decides how the identity is persisted and validated — independent of how the user logged in. Both take `getUserById` (to hydrate the user from a session/token). ### `cookieSession` — server-side sessions An opaque id in an `httpOnly` cookie, backed by a [session store](#session-stores). Revocable; rotates on login. ```typescript import { cookieSession } from "covara"; cookieSession({ getUserById: async (id) => db.query.users.findFirst({ where: eq(users.id, id) }), store: myStore, // default: in-memory; use KV/Drizzle in prod cookieName: "session", // default ttlMs: 24 * 60 * 60 * 1000, // default }); ``` ### `jwtSession` — stateless JWTs Issues a short-lived access token (returned from `/login` as `{ accessToken }`) plus a refresh token (in an `httpOnly` cookie); validates the `Authorization: Bearer` header. Mounts `/refresh`. ```typescript import { jwtSession } from "covara"; jwtSession({ getUserById, secret: env.JWT_SECRET, accessTokenTtl: 15 * 60, // seconds refreshTokenTtl: 7 * 24 * 60 * 60, refreshStore: kvStore, // optional: makes refresh tokens revocable }); ``` ### Any provider × any session Because the strategy is decoupled from the credential providers, you can, for example, log in with **a Passport.js provider and issue JWTs** — previously impossible: ```typescript import { useAuth, jwtSession, fromPassport } from "covara"; import { Strategy as GitHubStrategy } from "passport-github2"; useAuth({ session: jwtSession({ getUserById, secret: env.JWT_SECRET, refreshStore }), social: { providers: [fromPassport(new GitHubStrategy({ /* ... */ }, (_a, _r, p, done) => done(null, p)))], findOrCreateUser: async ({ profile }) => upsertUser(profile), }, }); // GitHub login → refresh cookie set → POST /api/auth/refresh → bearer access token ``` ### Legacy adapters (deprecated) `createPassportAdapter` / `createAuthJsAdapter` / `createJWTAdapter` still work — pass one as `adapter` and `useAuth` maps it to a session strategy internally. Prefer `session` for new code. ```typescript import { createPassportAdapter } from "covara"; useAuth({ adapter: createPassportAdapter({ getUserById }), login: { /* ... */ } }); ``` ## Session stores `cookieSession` (and the legacy adapters) take a session store. Implement the interface or use a built-in. ```typescript interface SessionStore { get(sessionId: string): Promise; set(sessionId: string, data: SessionData, ttlMs: number): Promise; delete(sessionId: string): Promise; touch(sessionId: string, ttlMs: number): Promise; getAll?(): Promise; // optional, used by the admin UI } ``` | Store | Import | Use | |-------|--------|-----| | In-memory | `InMemorySessionStore` | Development; lost on restart | | KV (Redis / Durable Object / memory) | `createKVSessionStore` (from `covara/auth` stores) | Production, multi-instance | | Drizzle | Drizzle session store (`covara/auth` stores) | DB-backed sessions | ```typescript import { cookieSession, InMemorySessionStore } from "covara"; cookieSession({ getUserById, store: new InMemorySessionStore() }); ``` `createKVSessionStore({ kv })` is backed by the [KV abstraction](../platform/kv.md), so it works with **any** KV adapter — Redis, the Cloudflare Durable Object store, or the in-memory store for tests — not only Redis: ```typescript import { cookieSession } from "covara"; import { createKVSessionStore } from "covara/auth"; cookieSession({ getUserById, store: createKVSessionStore({ kv }) }); ``` > `createRedisSessionStore` / `RedisSessionStore` remain as deprecated aliases of `createKVSessionStore` / `KVSessionStore`. For Redis and Drizzle stores see [`src/auth/stores`]; provide a distributed store so sessions and [login throttling](./account-security.md) work across instances. ## Custom routes A strategy's `issue(c, userId)` mints + transmits a session/token (sets the cookie for `cookieSession`, returns tokens for `jwtSession`) — reuse it in your own routes: ```typescript import { cookieSession, readJsonBody } from "covara"; const session = cookieSession({ getUserById }); app.post("/custom-login", async (c) => { const { email, password } = await readJsonBody(c); const user = await validate(email, password); if (!user) return c.json({ error: "invalid" }, 401); const issued = await session.issue(c, user.id); // sets the session cookie return c.json({ user: issued.user }); }); ``` ## Related - [Passwords](./passwords.md) · [Account security](./account-security.md) · [MFA](./mfa.md) · [Magic links](./magic-links.md) - [JWT](./jwt.md) · [Scopes](./scopes.md) · [Client auth](../client/auth.md) --- ## Social login (Passport.js) Covara can drive **any [Passport.js](https://www.passportjs.org/) OAuth2 strategy** as a social login — GitHub, Discord, Google, Facebook, Spotify, Twitch, GitLab, Slack, and the hundreds of others in the Passport catalog. You construct the strategy exactly as its docs show, wrap it with `fromPassport`, and hand it to [`useAuth`](./sessions.md). A successful login mints the **same session cookie** as a password login, so the rest of your app — `getUser(c)`, scopes, the client — works unchanged. It runs on **Node and Cloudflare Workers**. See [How it works on Workers](#how-it-works-on-workers). ## Quick start ```bash npm install passport-github2 # or any passport-* OAuth2 strategy ``` ```typescript import { Strategy as GitHubStrategy } from "passport-github2"; import { useAuth, fromPassport, cookieSession } from "covara"; const { router, middleware } = useAuth({ // Social login works with ANY session strategy — swap for // jwtSession({ secret, getUserById }) to have GitHub login issue JWTs. session: cookieSession({ getUserById: async (id) => db.query.users.findFirst({ where: eq(users.id, id) }), }), social: { providers: [ fromPassport( new GitHubStrategy( { clientID: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, // Point this at the mounted callback route (see below). callbackURL: "https://myapp.com/api/auth/social/github/callback", }, // The value you pass to done() becomes the SocialAccount; the bridge // normalizes it. Pass the profile straight through. (_accessToken, _refreshToken, profile, done) => done(null, profile) ) ), ], findOrCreateUser: async ({ provider, providerAccountId, profile }) => { // Look up or create your app user from the provider account. const existing = await db.query.accounts.findFirst({ where: and(eq(accounts.provider, provider), eq(accounts.providerAccountId, providerAccountId)), }); if (existing) return db.query.users.findFirst({ where: eq(users.id, existing.userId) }); const user = await createUser({ email: profile.email, name: profile.name, image: profile.image }); await linkAccount(user.id, provider, providerAccountId); return user; }, successRedirect: "/", }, }); app.route("/api/auth", router); app.use("*", middleware); ``` That mounts two routes under the auth router: | Route | Purpose | |-------|---------| | `GET /api/auth/social/:provider` | Start login — redirects the browser to the provider | | `GET /api/auth/social/:provider/callback` | Provider callback — exchanges the code, resolves the user, sets the session cookie | `:provider` is the strategy's name (`github`, `discord`, …). Point each strategy's `callbackURL` at its `/callback` route. ## From the client Both client libraries start a login with a single call. After the server completes the OAuth flow it sets the session cookie and redirects back to `successRedirect`. **TypeScript:** ```typescript import { createClient } from "covara/client"; const client = createClient({ baseUrl: location.origin, credentials: "include" }); client.loginWithSocial("github"); // navigates the browser to the provider // or build the URL yourself (e.g. for an or React Native): const url = client.socialLoginUrl("github"); ``` **React:** ```tsx import { useAuth } from "covara/client/react"; function SignIn() { const { signInWith, user, isAuthenticated } = useAuth(); if (isAuthenticated) return Hi {user?.name}; return ( <> ); } ``` If the client is configured with a custom social mount, set it once: ```typescript createClient({ baseUrl, social: { basePath: "/auth/social" } }); // React, without a client: useAuth({ socialBasePath: "/auth/social" }) ``` ## `findOrCreateUser` Called after the provider verifies the user. It receives a `SocialAccount` and must return your app user (`{ id, email?, name?, image? }`): ```typescript interface SocialAccount { provider: string; // "github" providerAccountId: string; // normalized profile.id — the account link key profile: NormalizedProfile; // { id, email, name, image, username, raw } raw: unknown; // exactly what your strategy's done() returned } ``` `profile` is normalized from the standard Passport `Profile` shape (`displayName`, `emails`, `photos`, …). For non-standard providers, pass `mapProfile` to `fromPassport` or read `account.raw` directly. If you need the OAuth `accessToken`/`refreshToken`, have your strategy's verify return them (e.g. `done(null, { profile, accessToken })`) with a matching `mapProfile`, then read them off `account.raw`. ## CSRF & state The bridge persists the strategy's OAuth `state`/PKCE handle between the redirect and the callback using a short-lived, `httpOnly` cookie (`covara_oauth_state`, default 10 min) plus a state store. A callback with a missing, expired, or mismatched state is rejected **before** any code exchange — this is the standard OAuth CSRF protection, enforced by the strategy itself. - **Single Node process:** the default in-memory state store is fine. - **Multiple instances / Cloudflare Workers:** the authorize and callback requests can hit different isolates, so use a shared store. If a [global KV](../platform/kv.md) is configured (`setGlobalKV`), the KV-backed store is selected **automatically**. To set it explicitly: ```typescript import { createKvSocialStateStore } from "covara"; social: { providers: [/* ... */], findOrCreateUser: async () => { /* ... */ }, stateStore: createKvSocialStateStore(), // uses the global KV } ``` ## Configuration ```typescript social: { providers: SocialProvider[]; // from fromPassport(...) findOrCreateUser: (account, c) => Promise; basePath?: string; // default "/social" (under the auth router) successRedirect?: string; // default "/" failureRedirect?: string; // default: 401 JSON problem stateStore?: SocialStateStore; // default: KV if configured, else in-memory stateCookieName?: string; // default "covara_oauth_state" stateTtlMs?: number; // default 600000 } ``` `fromPassport(strategy, options?)`: ```typescript fromPassport(strategy, { name?: string; // defaults to strategy.name; required if it's the generic "oauth2" scope?: string | string[]; // override the requested scopes mapProfile?: (raw) => NormalizedProfile, }); ``` ## How it works on Workers Passport strategies don't actually need Express. `Strategy.authenticate(req)` only reads `req.query` / `req.headers` / `req.session` and signals its result through `this.success / fail / redirect / error` — methods Passport core injects, not Express. Covara injects them and synthesizes `req` from the Web request. The one runtime-specific piece is that OAuth2 strategies do their HTTP through the legacy `node-oauth` package (`node:https`). All of its requests funnel through a single method, which the bridge swaps for `fetch`. After that, token exchange and profile fetch run over `fetch` — so the whole `passport-oauth2` family works on Workers with no `node:http`. :::note Scope The bridge covers **OAuth 2.0** strategies (the bulk of the catalog). OAuth 1.0a strategies (e.g. the legacy `passport-twitter`) sign requests with `node-oauth`'s OAuth1 client and are not supported on Workers. Strategies that pull in heavy Node-only crypto (some SAML/enterprise strategies) are Node-only. ::: ## Social login vs the OIDC provider These solve different problems — see also [Federated login](./federated.md): | | **Social login (`social` + Passport)** | **[Federated login](./federated.md)** (OIDC provider) | |---|---|---| | Use case | *Your* app lets users sign in with GitHub/Discord/Google/… | You run an [OIDC provider](./oidc-provider.md) that delegates to upstream IdPs | | Provider requirement | Any OAuth 2.0 provider (Passport catalog) | OIDC-compliant IdP (discovery + `id_token`) | | Covers GitHub/Discord/Spotify/… | ✅ | ❌ (no OIDC discovery document) | | Result | A Covara **session** for your app | Your provider issues **its own OIDC tokens** | Reach for **social login** when you just want "sign in with GitHub." Reach for the **OIDC provider** when you're building an identity provider for other apps — and note you can use the *same* Passport strategies there too, via [`backends.passport`](./federated.md#non-oidc-providers-passportjs), which lets your OIDC provider offer GitHub/Discord/… as upstreams and issue its own tokens. --- ## Client authentication The client supports several auth strategies and a full OIDC PKCE flow. The React [`useAuth`](#useauth) hook exposes the current state; the underlying [transport](./overview.md#resilient-transport) refreshes tokens and retries `401`s automatically. ## `useAuth` ```tsx import { useAuth } from "covara/client/react"; function App() { const { user, status, isAuthenticated, isLoading, logout, refetch, accessToken } = useAuth(); if (isLoading) return Loading…; if (!isAuthenticated) return ; return ; } ``` ### Strategies | Strategy | Description | |----------|-------------| | `cookie` | Session cookies (default). | | `jwt` | JWT bearer (auto-used when the client has `jwt` configured). | | `bearer` | Manual bearer token (`token`). | | `apiKey` | API key via `X-API-Key` (`apiKey`). | | `auto` | Auto-detect from client configuration (default). | ```tsx useAuth(); // cookie (default) useAuth({ strategy: "jwt" }); useAuth({ strategy: "bearer", token }); useAuth({ strategy: "apiKey", apiKey: "..." }); useAuth({ checkUrl: "/api/auth/session" }); // Passport/NextAuth ``` ### Options & result ```typescript interface UseAuthOptions { checkUrl?: string; // default /api/auth/me logoutUrl?: string; // default /api/auth/logout strategy?: "cookie" | "jwt" | "bearer" | "apiKey" | "auto"; token?: string; // bearer apiKey?: string; // apiKey baseUrl?: string; authBasePath?: string; // default /api/auth (login/signup/verify) socialBasePath?: string; // default /api/auth/social } interface UseAuthResult { user: TUser | null; status: "loading" | "authenticated" | "unauthenticated"; isAuthenticated: boolean; isLoading: boolean; logout: () => Promise; refetch: () => Promise; accessToken: string | null; // Email/password (refresh `user` on success) login: (email: string, password: string) => Promise; signup: (input: { email: string; password: string; name?: string }) => Promise; // Email confirmation requestEmailVerification: (email: string) => Promise; confirmEmail: (email: string, token: string) => Promise; // Social (Passport) — redirects to the provider signInWith: (provider: string) => void; } ``` Set a global 401 handler to redirect on auth loss: ```tsx useEffect(() => { client.setAuthErrorHandler(logout); }, [logout]); ``` ### Email/password & social flows The hook drives the [`useAuth` server routes](../auth/getting-started.md) directly — no hand-written `fetch`: ```tsx const { login, signup, logout, signInWith, requestEmailVerification, confirmEmail } = useAuth(); await login("a@b.com", "secret"); // refreshes `user` await signup({ email, password, name }); // refreshes `user` signInWith("github"); // redirect to a social provider ``` This is the same code whether the server uses a [cookie or JWT session](../auth/sessions.md#session-strategies): with `jwtSession`, `login`/`signup` capture the returned access token and send it as a bearer automatically (cleared on `logout`). Outside React, the same flows are first-class methods on the client: ```typescript await client.session.signup({ email, password, name }); await client.session.requestEmailVerification(email); await client.session.confirmEmail(email, token); await client.session.login(email, password); const user = await client.session.me(); await client.session.logout(); client.loginWithSocial("github"); ``` Both default to the `/api/auth` mount; override with `useAuth({ authBasePath })` or `createClient({ session: { basePath } })`. For JWT login/signup/refresh control, use [`useJWTAuth`](../auth/jwt.md). ## OIDC PKCE flow Configure `auth` on the client to talk to an [OIDC provider](../auth/oidc-provider.md). The client handles PKCE, token refresh, and 401 retry. ```typescript import { createClient } from "covara/client"; const client = createClient({ baseUrl: "https://api.myapp.com", auth: { issuer: "https://auth.myapp.com/oidc", clientId: "web-app", redirectUri: window.location.origin + "/callback", }, }); await client.auth.login(); // redirect to the provider await client.auth.handleCallback(); // on /callback client.auth.isAuthenticated(); client.auth.getUser(); await client.auth.logout(); const unsubscribe = client.auth.subscribe((state) => console.log(state.status, state.user)); ``` ### Config ```typescript interface OIDCClientConfig { issuer: string; clientId: string; redirectUri: string; postLogoutRedirectUri?: string; scopes?: string[]; // default ["openid","profile","email"] autoRefresh?: boolean; // default true refreshBufferSeconds?: number; // default 60 storage?: TokenStorage; // default MemoryStorage flowType?: "redirect" | "popup"; // default "redirect" } ``` ### Callback page ```tsx function CallbackPage() { const [error, setError] = useState(null); useEffect(() => { client.auth.handleCallback().then(() => location.assign("/")).catch((e) => setError(e.message)); }, []); return error ? Error: {error} : Completing sign in…; } ``` ### Token storage ```typescript import { MemoryStorage, LocalStorageAdapter, SessionStorageAdapter } from "covara/client"; auth: { storage: new MemoryStorage() } // default, most secure auth: { storage: new LocalStorageAdapter("myapp_") } // persists across tabs auth: { storage: new SessionStorageAdapter("myapp_") } // until tab close ``` For React Native, provide an AsyncStorage-compatible `TokenStorage` — see [React Native](./react-native.md). ## Related - [Auth overview](../auth/overview.md) · [OIDC provider](../auth/oidc-provider.md) · [JWT](../auth/jwt.md) - [React Native](./react-native.md) --- ## Billing (client) Configure `billing` on the client to talk to a mounted [billing router](../platform/billing.md), then use `client.billing.*` or the React hooks. ```typescript import { getOrCreateClient } from "covara/client"; const client = getOrCreateClient({ baseUrl: location.origin, billing: { basePath: "/api/billing" }, }); await client.billing.redirectToCheckout({ plan: "pro_monthly", successUrl: location.origin + "/welcome", }); ``` ## React hooks ```tsx import { useCredits, useSubscription, useCheckout } from "covara/client/react"; function Billing() { const { balance, refresh } = useCredits(); const { activeSubscription } = useSubscription(); const { redirectToCheckout, loading } = useCheckout(); return ( Credits: {balance} Plan: {activeSubscription?.status ?? "none"} ); } ``` - `useCredits` — `{ balance, refresh }` from the credits ledger. - `useSubscription` — `{ activeSubscription }` (normalized across providers). - `useCheckout` — `{ redirectToCheckout, loading }`. These map to the [billing router](../platform/billing.md#server-router) endpoints (`/credits`, `/subscription`, `/checkout`, `/portal`). The router resolves the current user as the credits account, so the hooks operate on the signed-in user. ## Related - [Billing (server)](../platform/billing.md) · [Client auth](./auth.md) --- ## File uploads (client) The client talks to a [file resource](../platform/storage.md) for uploads, downloads, listing, and deletion — with upload progress and optional presigned direct-to-bucket transfers. ## Imperative client ```typescript import { createFileClient } from "covara/client"; const files = createFileClient({ transport: client.transport, resourcePath: "/api/files" }); const result = await files.upload(file, { onProgress: ({ percent }) => console.log(`${percent}%`) }); const result2 = await files.uploadWithPresignedUrl(file, { onProgress: ({ percent }) => {} }); // S3/R2 const { data } = await files.list({ limit: 20 }); await files.delete(fileId); ``` `uploadWithPresignedUrl` requests a presigned URL, uploads the bytes directly to the bucket, then confirms — offloading the transfer from your server (S3/R2 only). ## React hooks ```tsx import { useFileUpload, useFile, useFiles } from "covara/client/react"; function UploadButton() { const { upload, isUploading, progress, error } = useFileUpload({ resourcePath: "/api/files", onSuccess: (file) => console.log("uploaded", file.id), }); return ( e.target.files?.[0] && upload(e.target.files[0])} /> ); } function FileList() { const { files, isLoading, deleteFile, getDownloadUrl } = useFiles({ resourcePath: "/api/files" }); return ( {files.map((f) => ( {f.filename} ))} ); } ``` - `useFileUpload` — `{ upload, isUploading, progress, error }`, with `onSuccess`. - `useFile` — load a single file's metadata/URL. - `useFiles` — `{ files, isLoading, deleteFile, getDownloadUrl }`. `getDownloadUrl(id)` returns a URL suitable for ``/``; on React Native use it to fetch bytes — see [React Native](./react-native.md). ## Relating files Upload returns a file record whose `id` you can store on another resource (e.g. `todo.imageId`) and load via [`?include=`](../core/relations.md). See [Storage → relating files](../platform/storage.md#relating-files-to-resources). ## Related - [Storage](../platform/storage.md) · [Relations](../core/relations.md) · [React Native](./react-native.md) --- ## Live queries A **LiveQuery** is the reactive store that powers [`useLiveList`](./react-hooks.md). It does the hybrid [fetch + subscribe](../realtime/subscriptions.md#hybrid-fetch--subscribe) dance, applies optimistic mutations, tracks connection status, and exposes a stable snapshot for `useSyncExternalStore`. Use it directly in non-React apps or for custom integrations. ```typescript import { createLiveQuery, statusLabel } from "covara/client"; const todos = client.resource("/api/todos"); const liveQuery = createLiveQuery(todos, { filter: 'userId=="123"', orderBy: "position", limit: 100, }, { onAuthError: () => redirectToLogin(), getPendingCount: () => client.getPendingCount(), onIdRemapped: (optimisticId, serverId) => console.log(`${optimisticId} -> ${serverId}`), }); const state = liveQuery.getSnapshot(); // stable reference // { items, status, error, pendingCount, lastSeq } const unsubscribe = liveQuery.subscribe(() => render(liveQuery.getSnapshot())); liveQuery.mutate.create({ title: "New" }); liveQuery.mutate.update("123", { completed: true }); liveQuery.mutate.delete("123"); await liveQuery.refresh(); statusLabel(state.status, state.pendingCount); // "Live", "Loading…", "Offline (3 pending)" liveQuery.destroy(); ``` ## State ```typescript interface LiveQueryState { items: T[]; status: "loading" | "live" | "reconnecting" | "offline" | "error"; error: Error | null; pendingCount: number; lastSeq: number; } ``` ## How it stays live 1. Fetch the initial page via `GET`. 2. Subscribe with `skipExisting=true`, passing the known IDs. 3. Apply `added`/`changed`/`removed` events to the in-memory list, honoring the [subscription mode](../realtime/subscriptions.md#subscription-modes-paginated-views). 4. Apply optimistic mutations immediately; reconcile against server responses (and remap temporary IDs). 5. On `invalidate` (sequence gap, auth change, backpressure), refetch. The store is cached per `(path, options)` by the client, so multiple components reading the same query share one subscription. `client.invalidate(...)` and `client.prefetch(...)` operate on these caches — see [Overview](./overview.md#client-methods). ## Low-level subscriptions For full control without the store, subscribe directly — see [Subscriptions → low-level API](../realtime/subscriptions.md#low-level-client-api). ## Related - [React hooks](./react-hooks.md) · [Subscriptions](../realtime/subscriptions.md) · [Offline](./offline.md) --- ## Offline support The client supports offline-first apps: mutations apply optimistically, queue locally while offline, and sync automatically when the connection returns. [`useLiveList`](./live-queries.md) wires all of this up for you. ## Enable it ```typescript import { getOrCreateClient } from "covara/client"; const client = getOrCreateClient({ baseUrl: location.origin, credentials: "include", offline: true, // LocalStorage with sensible defaults }); ``` ```tsx import { useLiveList } from "covara/client/react"; function TodoApp() { const { items, statusLabel, mutate, pendingCount } = useLiveList("/api/todos", { orderBy: "position" }); return ( <> {items.map((t) => {t.title})}
{statusLabel}{pendingCount > 0 && ` • ${pendingCount} pending`}
); } ``` Mutations update the UI instantly, queue when offline, sync on reconnect, and remap temporary IDs to server IDs — automatically. ## Advanced configuration ```typescript import { createClient, LocalStorageOfflineStorage } from "covara/client"; const client = createClient({ baseUrl: "/api", offline: { enabled: true, storage: new LocalStorageOfflineStorage("my-app-offline"), maxRetries: 5, retryDelay: 2000, onIdRemapped: (optimisticId, serverId) => console.log(`${optimisticId} -> ${serverId}`), }, onError: (error) => console.error("Sync error:", error), onSyncComplete: () => console.log("All changes synced"), }); ``` ### Storage backends | Backend | Notes | |---------|-------| | `LocalStorageOfflineStorage("prefix")` | Default with `offline: true`. | | `IndexedDB` | Higher capacity; provide via the `OfflineStorage` interface or the built-in IndexedDB storage. | | `InMemoryOfflineStorage` | Tests. | Implement your own by satisfying `OfflineStorage` (`getMutations`/`addMutation`/`updateMutation`/`removeMutation`/`clear`). ## Optimistic mutations (imperative) ```typescript const users = client.resource("/users"); const created = await users.create({ name: "Alice" }, { optimistic: true }); // temp id like "optimistic_..." await users.update("123", { name: "Alice Smith" }, { optimistic: true }); await users.delete("123", { optimistic: true }); ``` ## Mutation queue ```typescript await client.offline?.getPendingMutations(); // [{ id, type, resource, data, status, retryCount }] await client.offline?.syncPendingMutations(); // trigger sync await client.offline?.clearMutations(); // clear (use with care) client.offline?.getIsOnline(); // current status ``` The client listens to browser `online`/`offline` events and syncs when reconnecting. ### Mutation states | State | Meaning | |-------|---------| | `pending` | Waiting to sync | | `processing` | Syncing now | | `failed` | Sync failed; will retry | ## Conflict resolution When syncing, use the `OfflineManager` to resolve conflicts (server-wins / client-wins / merge): ```typescript import { createOfflineManager, InMemoryOfflineStorage } from "covara/client"; const offlineManager = createOfflineManager({ config: { enabled: true, maxRetries: 5, storage: new InMemoryOfflineStorage() }, onMutationSync: async (mutation) => { if (mutation.type === "update") { const current = await resource.get(mutation.objectId!); if (current.updatedAt > mutation.timestamp) { return; // server wins — or merge / client wins } await resource.update(mutation.objectId!, mutation.data); } }, onMutationFailed: (mutation, error) => console.error(mutation, error), onSyncComplete: () => console.log("done"), }); ``` [Optimistic locking (ETags)](../core/optimistic-locking.md) surfaces server-side conflicts as `412`s you can reconcile. ## Without React ```typescript import { createLiveQuery } from "covara/client"; const liveQuery = createLiveQuery(client.resource("/todos"), { orderBy: "createdAt:desc" }); liveQuery.subscribe(() => render(liveQuery.getSnapshot())); liveQuery.mutate.create({ text: "x", completed: false }); liveQuery.destroy(); ``` ## Limitations - Read operations require the network (cache separately if needed). - Batch operations are not queued (single-item mutations only). - Subscription events are lost while offline (a full sync runs on reconnect). - Optimistic IDs are temporary and change after sync (handle via `onIdRemapped`). ## Related - [Live queries](./live-queries.md) · [Optimistic locking](../core/optimistic-locking.md) · [React hooks](./react-hooks.md) - [Offline-sync contract](../contracts/offline-sync.md) --- ## Client library overview The Covara client is a type-safe, real-time client for your API: typed CRUD, a fluent query builder, live subscriptions, optimistic updates, an offline queue, and React hooks. It's included with the main package. ```typescript import { createClient, getOrCreateClient } from "covara/client"; import { useLiveList, useAuth } from "covara/client/react"; ``` ## Setup Use `getOrCreateClient` for HMR-safe initialization (returns the existing instance if one exists): ```typescript import { getOrCreateClient } from "covara/client"; export const client = getOrCreateClient({ baseUrl: location.origin, credentials: "include", offline: true, }); ``` `createClient` always makes a fresh instance. ### Configuration | Option | Type | Description | |--------|------|-------------| | `baseUrl` | `string` | API server base URL. | | `credentials` | `RequestCredentials` | `"include"` / `"same-origin"` / `"omit"`. | | `headers` | `Record` | Default headers. | | `timeout` | `number` | Default request timeout (ms); aborts when exceeded. | | `offline` | `boolean \| OfflineConfig` | [Offline support](./offline.md). | | `auth` | `OIDCClientConfig` | [OIDC](./auth.md) flow. | | `jwt` | `{ authPath }` | [JWT](./auth.md) auth. | | `billing` | `{ basePath }` | [Billing](./billing.md) client. | | `onError` | `(error) => void` | A mutation failed to sync. | | `onSyncComplete` | `() => void` | Offline sync finished. | | `authCheckUrl` | `string` | Auth status URL (default `/api/auth/me`). | | `parseDates` | `boolean \| DateFieldRegistry` | Convert ISO date strings to `Date` ([dates](#working-with-dates)). | ## Resilient transport Built for unreliable networks: - **Timeouts & cancellation** — a default `timeout` aborts each request via an internal `AbortController`; the low-level transport also accepts a per-request `signal` and `timeoutMs`, combined so either cancels. - **Automatic 401 refresh-and-retry** — with [`auth`](./auth.md) (OIDC) or `jwt` configured, a `401` triggers one token refresh and a transparent retry; if refresh fails, the original `401` surfaces. - **SSE reconnect with jitter** — [subscriptions](../realtime/subscriptions.md) reconnect with exponential backoff + randomized jitter (no stampede on server restart) and resume from the last sequence. ## Client methods ```typescript const todos = client.resource("/api/todos"); // typed resource client client.setAuthToken("jwt"); // bearer auth client.clearAuthToken(); client.setAuthErrorHandler(() => location.assign("/login")); // global 401 handler await client.getPendingCount(); // queued offline mutations const { user } = await client.checkAuth(); await client.session.login(email, password); // email/password session auth client.loginWithSocial("github"); // social (Passport) login // also: session.signup / logout / requestEmailVerification / confirmEmail / me — see Client auth client.invalidate("/api/todos"); // mark cached LiveQueries stale → refetch client.invalidate((path, opts) => opts.filter === "completed==true"); await client.prefetch("/api/todos", { orderBy: "createdAt:desc", limit: 20 }); // warm the cache ``` `invalidate` accepts a path/prefix or a predicate and returns how many cached queries refreshed (propagates across tabs when [`offline.tabSync`](./offline.md) is on). `prefetch` warms the [LiveQuery](./live-queries.md) cache so a later `useLiveList` reads instantly. ## Working with dates Responses carry dates as ISO 8601 **strings** by default (JSON-safe). Get `Date` objects two ways: ```typescript import { toDate, toDateOrNull } from "covara/client"; const created = toDate(todo.createdAt); // Date const due = toDateOrNull(todo.dueAt); // Date | null // or convert on the transport: const client = createClient({ baseUrl: "/api", parseDates: true }); // scoped: parseDates: { "/api/todos": ["createdAt", "dueAt"] } ``` [Generated types](./typegen.md) mark date columns as the branded `ISODateString` (a `string` subtype) so the compiler steers you toward `toDate(...)`. ## Error handling ```typescript import { TransportError } from "covara/client"; try { await todos.get("nope"); } catch (error) { if (error instanceof TransportError) { if (error.isNotFound()) {/* 404 */} else if (error.isUnauthorized()) {/* 401 */} else if (error.isForbidden()) {/* 403 */} else if (error.isRateLimited()) console.log("retry after", error.retryAfter); else if (error.isValidationError()) console.log(error.details); } } ``` ## Where to go next - **[Queries & repository](./queries.md)** — CRUD, the fluent query builder, filter helpers. - **[React hooks](./react-hooks.md)** — `useLiveList`, `useMutation`, `useInfiniteList`, and more. - **[Live queries](./live-queries.md)** — the reactive store under the hooks. - **[Offline](./offline.md)** · **[Auth](./auth.md)** · **[File uploads](./files.md)** · **[Type generation](./typegen.md)** · **[React Native](./react-native.md)** --- ## Queries & repository `client.resource(path)` returns a typed repository for one endpoint. It exposes direct CRUD methods plus a fluent, type-narrowing `query()` builder. ```typescript interface Todo { id: string; title: string; completed: boolean } const todos = client.resource("/api/todos"); ``` ## CRUD ```typescript const { items, hasMore, nextCursor } = await todos.list({ filter: "completed==false", orderBy: "createdAt:desc", limit: 20, cursor: nextCursor, select: ["id", "title"], include: "category,tags", totalCount: true, }); const todo = await todos.get("todo-123", { select: ["id", "title"] }); const created = await todos.create({ title: "Buy groceries", completed: false }); const updated = await todos.update("todo-123", { completed: true }); await todos.delete("todo-123"); ``` For offline/optimistic mutations pass `{ optimistic: true }` or `{ optimisticId }` — see [Offline](./offline.md). ## Batch & RPC ```typescript await todos.batchCreate([{ title: "A" }, { title: "B" }]); const { count } = await todos.batchUpdate("completed==false", { completed: true }); await todos.batchDelete("completed==true"); const result = await todos.rpc<{ ids: string[] }, { archived: number }>("archive", { ids: ["1", "2"] }); ``` See [Batch operations](../core/batch.md) and [Procedures](../core/procedures.md). ## Aggregations ```typescript const stats = await todos.aggregate({ groupBy: ["completed"], count: true }); // { groups: [{ key: { completed: true }, count: 5 }, ...] } ``` ## The query builder `query()` is an **immutable**, chainable builder with full type inference. `select` narrows the return type to exactly the chosen fields. ```typescript const { items } = await users.query().select("id", "name").list(); items[0].name; // ✓ items[0].email; // ✗ type error — not selected ``` ```typescript const activeUsers = await users .query() .select("id", "name", "email") .filter("age>=18") .filter('role=="user"') // filters AND together .orderBy("name:asc") .limit(10) .list(); const user = await users.query().select("id", "name").get("user-123"); const newest = await users.query().orderBy("createdAt:desc").first(); // T | null const adultCount = await users.query().filter("age>=18").count(); // number ``` Each method returns a **new** builder, so a base query can branch: ```typescript const base = users.query().filter("age>=18"); const admins = base.filter('role=="admin"'); const regular = base.filter('role=="user"'); // base unchanged ``` ### Builder methods | Method | Description | |--------|-------------| | `select(...fields)` | Narrow returned fields (and type). | | `filter(f)` / `where(f)` | Add a filter (AND). | | `orderBy(s)` · `limit(n)` · `cursor(c)` · `include(s)` | List options. | | `withTotalCount()` | Request the total count. | | `groupBy(...)` · `withCount()` · `sum/avg/min/max(...)` | Aggregation. | | `list()` · `get(id)` · `first()` · `count()` · `aggregate()` | Execute. | ### Type-safe aggregations ```typescript const stats = await users .query() .groupBy("role") .withCount() .avg("age") // numeric fields only .sum("score") .min("name") // comparable fields .max("createdAt") .aggregate(); // typed: { groups: [{ key: { role }, count, avg: { age }, sum: { score }, min: { name }, max: { createdAt } }] } ``` ## Filter helpers Instead of hand-writing [RSQL](../core/filtering.md), build it with `q` (values escaped automatically): ```typescript import { q } from "covara/client"; const filter = q.and( q.gte("age", 18), q.or(q.eq("role", "user"), q.eq("role", "admin")), q.contains("name", "jo"), ); const adults = await users.query().filter(filter).list(); ``` Builders: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `notLike`, `ilike`, `in`, `out`, `isNull`, `isNotNull`, `startsWith`, `endsWith`, `contains`, `icontains`, `between`, `and`, `or`, `raw`. There is no `q.not` — use the negated operators. ### Typed filter builder `f()` For compile-time field/value checking: ```typescript import { f } from "covara/client"; const filter = f().and( f().eq("completed", false), // must be a boolean field of Todo f().gte("createdAt", since), ); // f().eq("complted", false) // ✗ type error: not a key of Todo ``` It emits the same RSQL as `q`, so it drops straight into `.filter(...)`. ## Generated field-metadata types [Type generation](./typegen.md) emits helpers for type-safe field references: ```typescript export type UserFields = "id" | "name" | "email" | "age" | "role"; export type UserNumericFields = "age" | "score"; export type UserComparableFields = "id" | "name" | "email" | "age" | "createdAt"; ``` ## Related - [React hooks](./react-hooks.md) · [Type generation](./typegen.md) · [Filtering](../core/filtering.md) - [Pagination](../core/pagination.md) · [Aggregations](../core/aggregations.md) --- ## React hooks Import hooks from `covara/client/react`. They build on the [LiveQuery store](./live-queries.md) and the typed [repository](./queries.md). ## `useLiveList` The primary hook for real-time lists with optimistic mutations. ```tsx import { useLiveList } from "covara/client/react"; function TodoList() { const { items, status, statusLabel, error, pendingCount, isLoading, isLive, isOffline, isReconnecting, hasMore, totalCount, isLoadingMore, mutate, refresh, loadMore, } = useLiveList("/api/todos", { filter: 'userId=="123"', orderBy: "position", limit: 100, include: "category,tags", subscriptionMode: "strict", enabled: true, select: ["id", "title", "completed"], }); return ( {items.map((t) => ( mutate.update(t.id, { completed: !t.completed })} /> {t.title} ))} ); } ``` `status`: `"loading" | "live" | "reconnecting" | "offline" | "error"`. `mutate.create/update/delete` apply [optimistically](./offline.md). Pass a [typed `ResourceClient`](./typegen.md) instead of a path to infer `T`: ```tsx const { items } = useLiveList(client.resources.todos, { orderBy: "position" }); ``` ### Pagination With a `limit`, the hook exposes `hasMore`, `totalCount`, `isLoadingMore`, and `loadMore()`: ```tsx const { items, hasMore, loadMore, isLoadingMore } = useLiveList("/api/todos", { limit: 20 }); {hasMore && } ``` ### Subscription modes Control how other clients' changes affect a paginated view — `strict` (default with `limit`), `sorted`, `append`, `prepend`, or `live` (default without `limit`). See [Subscriptions → modes](../realtime/subscriptions.md#subscription-modes-paginated-views). ### Type-safe projections ```tsx const { items } = useLiveList("/api/users", { select: ["id", "name", "avatar"] }); // items: { id; name; avatar }[] ``` ### Relations & optimistic updates With `include`, changing a foreign key clears the stale relation immediately and refills it from the server response. For instant UX, look it up from local cache (`todo.category ?? categories.find(c => c.id === todo.categoryId)`). See [Subscriptions → relations](../realtime/subscriptions.md#relations-in-events). ## `useInfiniteList` Cursor-paginated live list; pages accumulate into `items` and stay realtime-aware. ```tsx const { items, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteList("/api/todos", { limit: 20, orderBy: "createdAt:desc" }); ``` ## `useLiveAggregate` Live [aggregation](./live-queries.md) that recomputes on every change. See [Aggregate subscriptions](../realtime/aggregate-subscriptions.md). ```tsx const { groups, isLive } = useLiveAggregate("/api/todos", { groupBy: ["completed"], count: true }); ``` ## `useMutation` Standalone mutation hook usable outside a list; integrates with optimistic updates, the offline queue, and invalidation. ```tsx const { mutate, mutateAsync, status, error, reset } = useMutation("/api/todos", { invalidates: ["/api/todos"], onSuccess: (todo) => toast(`Created ${todo.id}`), }); mutate({ kind: "create", data: { title: "New" } }); mutate({ kind: "update", id: "1", data: { completed: true } }); mutate({ kind: "delete", id: "2" }); // custom function form const remove = useMutation(async ({ id }: { id: string }, ctx) => { await ctx.resource.delete(id); ctx.invalidate("/api/todos"); }, { resource: "/api/todos" }); ``` ## `useSearch` Debounced [full-text search](../core/search.md): ```tsx const { items, isSearching, search, clear } = useSearch(client.resources.todos, { enabled: true }); search("important"); ``` ## Invalidation & prefetch ```tsx import { useInvalidate } from "covara/client/react"; const invalidate = useInvalidate(); await save(); invalidate("/api/todos"); // same semantics as client.invalidate ``` `client.prefetch(path, options)` warms the cache so a later `useLiveList` skips the loading flash. See [Overview](./overview.md#client-methods). ## Other hooks | Hook | Purpose | Page | |------|---------|------| | `useAuth` | Auth state (cookie/JWT/bearer/apiKey/auto) | [Client auth](./auth.md) | | `useJWTAuth` | JWT login/signup/refresh | [JWT](../auth/jwt.md) | | `usePublicEnv` | Public env vars | [Environment variables](../deployment/environment-variables.md) | | `useFileUpload` / `useFile` / `useFiles` | File uploads | [File uploads](./files.md) | | `useCredits` / `useSubscription` / `useCheckout` | Billing | [Billing hooks](./billing.md) | ## Related - [Live queries](./live-queries.md) · [Queries & repository](./queries.md) · [Offline](./offline.md) - [Subscriptions](../realtime/subscriptions.md) · [Type generation](./typegen.md) --- ## React Native The Covara client makes no DOM assumptions, so the same client, [repository](./queries.md), [hooks](./react-hooks.md), and [subscriptions](../realtime/subscriptions.md) run in React Native. A few integration points differ from the browser. ## Token storage OIDC/JWT tokens are stored through a pluggable `TokenStorage`. In the browser the default is `MemoryStorage` (or `LocalStorageAdapter`); in React Native, provide an [AsyncStorage](https://react-native-async-storage.github.io/async-storage/)-compatible adapter: ```typescript import AsyncStorage from "@react-native-async-storage/async-storage"; import { createClient } from "covara/client"; const asyncStorageAdapter = { getItem: (key: string) => AsyncStorage.getItem(key), setItem: (key: string, value: string) => AsyncStorage.setItem(key, value), removeItem: (key: string) => AsyncStorage.removeItem(key), }; const client = createClient({ baseUrl: "https://api.myapp.com", auth: { issuer: "https://auth.myapp.com/oidc", clientId: "mobile-app", redirectUri: "myapp://callback", storage: asyncStorageAdapter, // satisfies TokenStorage }, }); ``` The same applies to [`useJWTAuth`](../auth/jwt.md) — pass an AsyncStorage-compatible storage. ## Transport & offline backends The [transport](./overview.md#resilient-transport) and [offline](./offline.md) layers are environment-aware: - `fetch` and SSE work over React Native's networking. - For the offline queue, use `InMemoryOfflineStorage` or supply an AsyncStorage/SQLite-backed `OfflineStorage` implementation (the browser-only `LocalStorageOfflineStorage`/`IndexedDBOfflineStorage` aren't available natively). - [`offline.tabSync`](./offline.md) (BroadcastChannel) is a no-op outside the browser — leave it off. ```typescript import { createClient, InMemoryOfflineStorage } from "covara/client"; const client = createClient({ baseUrl: "https://api.myapp.com", offline: { enabled: true, storage: new InMemoryOfflineStorage() }, // or your AsyncStorage-backed store }); ``` ## Files There's no ``/`` — use `getDownloadUrl(id)` from [`useFiles`](./files.md) (or the file client) to obtain a URL, then fetch the bytes with React Native's `fetch`/`Image` source: ```tsx const { getDownloadUrl } = useFiles({ resourcePath: "/api/files" }); ; ``` ## Hooks [`useLiveList`](./react-hooks.md), `useMutation`, `useAuth`, `useJWTAuth`, `useFileUpload`, and the billing hooks all work unchanged — they depend only on React, not the DOM. ## Related - [Client overview](./overview.md) · [Auth](./auth.md) · [Offline](./offline.md) · [File uploads](./files.md) --- ## Type generation Covara generates TypeScript types from your running API, giving you end-to-end type safety: resource interfaces, input/update types, field-metadata helpers for the [query builder](./queries.md), [public env](../deployment/environment-variables.md) types, and a typed client factory. ## CLI ```bash npx covara types --url http://localhost:3000 --out src/generated/api-types.ts ``` Or a script entry point: ```typescript // scripts/typegen.ts import { createTypegenCLI } from "covara/client"; await createTypegenCLI(process.argv.slice(2)); ``` ```bash tsx scripts/typegen.ts http://localhost:3000 typescript > src/generated/api-types.ts ``` ## Programmatic ```typescript import { generateTypes } from "covara/client"; import { writeFileSync } from "fs"; const result = await generateTypes({ serverUrl: "http://localhost:3000", output: "typescript", includeClient: true, includeEnv: true, // default true envPath: "/api/env", }); writeFileSync("./src/generated/api-types.ts", result.code); ``` ## What's generated ```typescript // Resource interfaces export interface Todo { id: string; title: string; completed: boolean; /* ... */ } // Input / update types (auto-increment excluded; PKs, nullable, and default fields optional) export type TodoInput = { title: string; note?: string | null }; export type TodoUpdate = Partial; // Field metadata (for type-safe queries) export type TodoFields = "id" | "title" | "completed"; export type TodoNumericFields = "position"; export type TodoComparableFields = "id" | "title" | "createdAt"; // Path constants export const ResourcePaths = { todo: "/api/todos", user: "/api/users" } as const; // Public env type export type PublicEnv = { PUBLIC_API_URL: string; PUBLIC_VERSION: string }; // Typed client factory export function createTypedClient(baseClient): TypedCovaraClient; ``` Date columns are emitted as the branded `ISODateString` so the compiler nudges you toward [`toDate(...)`](./overview.md#working-with-dates). ## Typed client factory ```typescript import { getOrCreateClient } from "covara/client"; import { createTypedClient } from "./generated/api-types"; const client = createTypedClient(getOrCreateClient({ baseUrl: location.origin, credentials: "include" })); const todos = await client.resources.todos.list(); // Todo[] — no type parameter needed const result = await client.resources.todos.query().select("id", "title").filter("completed==false").list(); const stats = await client.resources.users.query().groupBy("role").withCount().avg("age").aggregate(); ``` With React hooks, types are inferred from the typed resource: ```tsx import { useLiveList } from "covara/client/react"; const { items, mutate } = useLiveList(client.resources.todos, { orderBy: "position" }); // items: Todo[] ``` ## Related - [Queries & repository](./queries.md) · [Client overview](./overview.md) · [Environment variables](../deployment/environment-variables.md) - [CLI](../tooling/cli.md) · [OpenAPI](../tooling/openapi.md) --- ## Authentication Contracts ## Guarantees ### Session strategy decoupling - **Orthogonal to credentials**: `useAuth`'s `session` strategy (how the identity is persisted/validated/issued) is independent of the credential providers (`login`/`signup`/`social`/`verification`/`mfa`/`magicLink`). Any provider composes with any strategy — e.g. a Passport social provider issuing JWTs. - **Single issuance path**: every successful login (password, social, magic link, signup) goes through `strategy.issue(c, userId)`; the auth middleware authenticates every request through `strategy.authenticate(c)`. `cookieSession` mints a server-side session + cookie; `jwtSession` mints a bearer access token + refresh cookie and exposes `/refresh`. - **Back-compat**: a legacy `adapter` passed to `useAuth` is mapped to a session strategy internally, preserving its prior behavior. ### Token Validation - **Required claims checked**: `iss`, `aud`, `exp` are always validated - **Algorithm enforcement**: Only configured algorithms accepted; `none` always rejected - **Clock skew tolerance**: Configurable tolerance for `exp`, `nbf`, `iat` checks - **Signature verification**: All tokens are cryptographically verified ### OIDC Compliance - **Discovery support**: `/.well-known/openid-configuration` endpoint provided - **PKCE required**: Public clients must use PKCE (code_challenge); `security.pkce.required` extends this to all clients - **PKCE plain rejected**: `code_challenge_method=plain` is always rejected; only `S256` is supported and advertised - **State validation**: `state` parameter validated on callback - **Revocation (RFC 7009)**: `POST /revoke` invalidates a refresh token after client authentication - **Introspection (RFC 7662)**: `POST /introspect` reports `{ active }` and claims for access/refresh tokens after client authentication - **Client secret hashing**: Stored secrets prefixed `scrypt$` are verified with scrypt; plaintext secrets are compared in constant time ### OIDC Hardening - **Redirect URI component validation**: `redirect_uri` is matched component-by-component (protocol, host, port, normalized path, and query/fragment when registered), never by prefix; an unregistered URI is rejected with `400` before any redirect is issued - **Federated id_token verification**: external `id_token`s are signature-verified against the provider's JWKS (issuer + audience checked), the nonce is compared to the stored interaction nonce, and the `id_token` `sub` is cross-checked against the `userinfo` `sub` - **External login resumes the interaction**: a successful external callback (federated OIDC or `backends.passport`) establishes the provider session and continues the pending `/authorize` interaction to consent or the authorization-code redirect — the same completion path as email/password login; it never returns the user as a bare JSON body - **Passport backend state binding**: `backends.passport` carries the OAuth `state`/PKCE handle across the redirect→callback in a signed, `httpOnly`, short-lived cookie + state store; a callback with missing/expired/mismatched state is rejected before any token exchange - **Correct at_hash**: the `id_token` `at_hash` claim is the left-half of the hash matching the signing algorithm, computed whenever an access token is issued - **Endpoint rate limiting**: `/token`, `/jwks`, `/introspect` rate-limited per client/IP when `security.rateLimiting` is configured (KV-backed when a global KV exists) - **Persistent stores by default**: clients, codes, refresh tokens, consents, interactions, and state are KV-backed (with expiry-derived TTLs) whenever a global KV is registered; `stores.type: "memory"` forces in-memory - **Dynamic registration is opt-in**: `POST /register` returns `404` unless `registration.enabled` is true; an `initialAccessToken`, when set, is required as a Bearer token - **Consent revocation + TTL**: `POST /consent/revoke` clears a user's consent (one client or all) for an authenticated session; stored consents expire after `security.consent.ttlSeconds` (default 1 year) - **login_hint escaping**: dynamic values in the default login template are HTML-escaped ### Account Security Flows (opt-in via `useAuth`) - **Password policy**: when `passwordPolicy` is set, weak passwords are rejected with `422` on `POST /signup` and `POST /password/reset`; a built-in denylist of common passwords is enabled by default - **MFA/TOTP**: `mfa` adds `/mfa/enroll`, `/mfa/enroll/confirm`, and `/mfa/verify`; enrollment is two-step (created disabled, then confirmed). With `requireOnLogin`, `/login` returns `{ mfaRequired: true }` (`401`) for enrolled users until a valid TOTP or single-use backup code is supplied; a used backup code triggers `consumeBackupCode` - **Magic links**: `magicLink` adds `/magic-link/request` (always returns `{ success: true }`, anti-enumeration) and `/magic-link/verify` (single-use token, creates the session) - **API keys**: `createApiKey`/`verifyApiKey`/`rotateApiKey`/`revokeApiKey` store only a hash of the key; the raw key (`[prefix_].`) is returned once; verification returns a typed `reason` (`not_found`/`expired`/`mismatch`) on failure ### Field-Level Write Enforcement (mass-assignment protection) - **Enforced allowlist**: when `fields.writable` is configured, any table column not in the list is stripped from create/update bodies (single, batch, and `POST /batch/upsert`) before hooks and the database see it - **Exemptions**: the primary key and `generatedFields` are never stripped; non-column keys (relation payloads) pass through - **Hook precedence**: stripping happens before lifecycle hooks, so a server-side hook can still set protected fields - **strictInput**: with `strictInput: true`, unknown fields are rejected with `422` instead of being silently ignored ### Password Storage - **Scrypt hashing**: `hashPassword` derives a self-describing `scrypt$N=..,r=..,p=..$salt$hash` string with a per-hash random salt - **Constant-time verification**: `verifyPassword` compares using `timingSafeEqual` and returns `false` for any unparseable input - **Rehash signalling**: `needsRehash` returns `true` when stored parameters are weaker than the target (or the hash is unparseable) ### Session Security - **Session rotation**: Session ID rotated on login (prior session invalidated, prevents fixation) - **Logout invalidation**: All session tokens invalidated on logout - **Bulk invalidation**: Adapters may implement `invalidateUserSessions(userId, exceptSessionId?)`; password reset uses it when `logoutEverywhere` is set - **Indexed bulk invalidation**: When the session store implements the per-user index (`getByUser`/`deleteByUser` — the Redis, Drizzle, and in-memory stores all do), bulk invalidation touches only that user's sessions; the scan-all `getAll()` path is a fallback for stores without the index - **Session activity metadata**: Login/signup store the client IP and user agent in `session.data` (`ipAddress`, `userAgent`); session use stamps `session.data.lastActiveAt`, throttled to at most one persisted write per minute per session and fire-and-forget (activity tracking never blocks or fails auth) - **Cookie security**: HttpOnly, Secure (in production), SameSite attributes set ### Multi-Tenant - **Issuer isolation**: Same `sub` from different `iss` = different users - **Issuer whitelist**: Only configured issuers accepted ### Field-Level Read Masking - **Allowlist semantics**: When `fields.readable` is configured on a resource, only the listed table columns may appear in any response - **Universal application**: Masking applies to list, get, create, update, batch, and search responses, plus every subscription event (`existing`, `added`, `changed`) and the initial subscription snapshot - **No client bypass**: A hidden column cannot be recovered via `?select=` or by subscribing — masking is enforced server-side after projection - **Column-only**: Only table columns are stripped; relation keys, computed values, and internal markers (`_etag`, `_optimisticId`) pass through ### Relation Scope Enforcement - **Included relations respect target scope**: On the read path (`GET /` and `GET /:id` with `?include=`), each included relation AND-s the target resource's `read` scope (resolved for the effective/impersonated user) into its query — a relation cannot return rows the user could not read directly - **Deny semantics**: A user denied read on the target resource yields `null` (`belongsTo`/`hasOne`) or an empty array (`hasMany`/`manyToMany`) for that relation; out-of-scope rows are filtered, not just hidden - **Applies to discovered relations**: Auto-discovered (`autoRelations`) relations use the same enforced loader as explicit ones - **Unregistered targets**: Relations to tables not registered as resources have no scope to enforce (no resolver) — these are an explicit author choice - **Applies to subscriptions**: Relations embedded in subscription events (`existing`, `added`, `changed`) are scope-filtered **per subscriber** — the subscriber's user is captured at subscribe time, and on every push the target resource's `read` scope is resolved for that user and AND-ed into the included relation, with the same deny semantics as the read path. A subscriber can never receive related rows it could not read directly. Relations are loaded per subscriber rather than shared across them (cost scales with subscriber count; loads are deduplicated per subscriber within a single push) ### Internal Schema & Stores - **Logical contract is stable**: `SessionData` (`id`/`userId`/`createdAt`/`expiresAt`/`data`) is always exposed in logical keys regardless of the underlying column names; remapping (`defineInternalSchema` `fieldMap`) applies only at the SQL persistence boundary — see [Internal tables](../auth/internal-tables.md) - **Fail-fast validation**: `defineInternalSchema` validates that every required logical key resolves to a column on the supplied table at construction time, never at query time - **Defaults are unchanged**: with no `internalSchema`, the built-in tables and the byte-for-byte original migration DDL are used; `createCovara({ internalSchema })` is for migration/introspection and never silently rewires a pre-built store - **Migration safety**: generated DDL (`migrateInternal` mode c) covers single-primary-key tables only and throws for compound-PK tables (`auth_accounts`, `auth_verification_tokens`) rather than emitting an incorrect schema; `managedExternally: true` makes migration a no-op - **KV session store is backend-agnostic**: `createKVSessionStore` works over any KV adapter; its hash field names are a private serialization, never user-facing, and are not remappable - **Users/files are app-owned**: the framework never owns a users table (reached via `getUserById`) or a files table (supplied to `fileResource`); the changelog and rate limits are KV/memory-backed, not database tables ### Observability Storage - **Defaults preserve behavior**: the audit log, request/error logs, and metrics are append-only logs backed by an in-memory ring buffer when no KV is configured — identical to before; with a global KV they persist automatically - **`append` never throws**: a failing storage backend can never break the audited/served action; the in-memory mirror always records the entry even if a KV write fails - **`setAdminAuditSink` is write-only**: it runs alongside the adapter and is for forwarding only; durable read/query/export requires an `ObservabilityLogAdapter` - **Cross-process reads**: in KV mode synchronous reads reflect only the local process mirror; authoritative cross-instance reads use the async `query()`/`export()` path ### Admin Scope Bypass (Admin UI) - **Identity re-verification, no secret**: The resource layer skips per-resource auth scopes only when the request carries the `x-covara-admin-bypass` marker **and** its authenticated user (or forwarded admin `apiKey`) passes the registered admin predicate. The marker is not a secret and grants nothing on its own - **Leaked marker is inert**: A non-admin presenting the marker is served under normal scope enforcement (fail-closed); the marker value is constant and confers no authority - **Disabled by default**: Bypass is inert unless an admin predicate is registered, which happens only when the [admin UI](../tooling/admin-ui.md) is mounted via `createCovara`; standalone `useResource` never bypasses - **Gated on admin auth**: The admin predicate is derived from the UI's `security` config — locking down the UI's auth locks down bypass - **Audited**: Every bypassed API-explorer request is recorded in the admin audit log (`api_explorer_execute`) ### Admin Impersonation (Admin UI) - **Runs as the target, under their scope**: A request carrying the `x-covara-impersonate: ` marker resolves the impersonated user's per-operation scope and attributes writes to them — it does NOT grant `allScope()` - **Identity re-verification, no secret**: The marker is honored only when the forwarded request's real authenticated user passes the admin predicate (same test as bypass) and a `userManager`-backed resolver yields the target user; a forged/leaked marker from a non-admin is inert - **Replaces bypass, never stacks**: When impersonation is active the scope layer uses the impersonated user's scope and skips the bypass branch entirely — impersonating an admin yields only that admin's scope, never full bypass (no escalation) - **Single effective-user swap**: A middleware swaps `c.get("user")` to the impersonated user once, so scope, write attribution, and hooks all observe the same identity; admin-UI routes (`/__covara/*`) are exempt so admin authorization is unaffected - **Audited with both ids**: Every impersonated action records the real admin id and the impersonated user id (`impersonate_execute`, `data_explorer_list`) ## Non-Guarantees ### Token Lifetime (What We Don't Promise) - ❌ **Minimum lifetime**: Tokens may be revoked at any time - ❌ **Refresh success**: Refresh tokens may be revoked or expired - ❌ **Grace period**: No grace period after token expiration ### Availability (What We Don't Promise) - ❌ **JWKS availability**: JWKS endpoint may be temporarily unavailable - ❌ **Session persistence**: Sessions may be cleared (e.g., server restart with memory store) ## Threat Model ### In Scope (Protected Against) - Token forgery - Token replay (with nonce) - Session fixation - CSRF (with state parameter) - Algorithm confusion attacks - JWT injection via `none` algorithm - Mass assignment (via enforced `fields.writable`) - Open redirect via `redirect_uri` (component-wise validation) - PKCE downgrade (plain rejected, required for public clients) - Federated id_token forgery (signature + nonce + sub cross-check) - Stored-XSS via login_hint (HTML-escaped) ### Out of Scope (Not Protected Against) - Compromised signing keys (operational security) - Client-side token theft (XSS) - Phishing attacks - Brute force (rate limiting helps but doesn't eliminate) - Side-channel attacks ## Rate Limiting ### Auth Endpoints - Login: 5 attempts per 15 minutes per IP - Token refresh: 10 per minute per user - Password reset: 3 per hour per email ### Rate Limit Bypass Protection - Header normalization (case-insensitive) - IP normalization (IPv4/IPv6) - X-Forwarded-For only from trusted proxies ## Failure Modes ### Invalid Token - Returns 401 Unauthorized - Clear error message (without leaking info) - No retry (client must re-authenticate) ### JWKS Unavailable - Use cached keys if available - Return 503 if no cached keys - Automatic retry with backoff ### Session Expired - Returns 401 Unauthorized - Client should redirect to login - Refresh token may still be valid ## Test Coverage - `tests/invariants/auth-hardening.test.ts` - Security edge cases - `tests/auth.test.ts` - Basic authentication - `tests/auth-routes.test.ts` - Auth endpoints - `tests/oidc/provider.test.ts` - OIDC provider --- ## Billing Contracts Scope: `src/billing/` — the `BillingAdapter` interface, the Stripe / Lemon Squeezy / Paddle / Polar adapters, the `createBilling` facade, the credits ledger, the webhook handling, and the server router. ## Guarantees - **Unified interface.** All four providers implement the same `BillingAdapter` and return normalized models (`BillingCustomer`, `BillingSubscription` with a canonical `SubscriptionStatus`, `CheckoutSession`, `BillingEvent`). - **Webhook signature verification.** When a `webhookSecret` is configured, `handleWebhook` rejects any payload whose signature does not verify, using the provider's scheme (Stripe `t.payload` HMAC, Lemon Squeezy body HMAC, Paddle `ts:payload` HMAC, Polar Standard-Webhooks), with constant-time comparison and timestamp-skew rejection where the scheme includes a timestamp. - **Idempotent webhook processing.** Deliveries are de-duplicated by event id via the KV (24h window); a retried delivery is parsed and returned but its side effects (credit grant, `onEvent`) run at most once. - **At-least-once credit granting tied to payment.** On `payment.succeeded`, the matched plan's `credits` are granted to the resolved account exactly once per event id (subject to the dedupe window). Granting requires a configured KV. - **Credits-ledger atomicity.** `grant`/`consume` mutate the balance with the KV's atomic `incrBy`, so concurrent operations across instances stay consistent. `consume` refuses to overdraw unless `allowNegative` is set. - **404 → null.** `getCustomer`/`getSubscription` return `null` for not-found rather than throwing; other non-OK responses throw `BillingError` with status. ## Non-guarantees - **No provider-API drift protection.** Adapters target the providers' current REST APIs; a breaking provider change requires an adapter update. - **Capability gaps are explicit, not emulated.** Where a provider lacks a portable capability (e.g. Paddle `reportUsage` / hosted portal), the adapter throws a clear `BillingError` or omits the optional method rather than faking it. - **Dedupe/credit-grant require a global KV.** Without a KV, webhooks are still verified and parsed, but dedupe and auto-grant are skipped. - **No reconciliation/polling.** State is event-driven; if a webhook is never delivered, Covara does not poll the provider to reconcile (handle critical flows in `onEvent` and consider a periodic reconcile task). - **Account resolution is best-effort.** Auto-grant resolves the account from `metadata.accountId`/`userId` (set this at checkout) or the customer id; if neither is present, no credits are granted. --- ## Email Contracts Scope: `src/email/` — the `EmailAdapter` interface, the Resend and Cloudflare Email Service adapters, and the template builder. ## Guarantees - **Adapter uniformity.** Every adapter implements the same `EmailAdapter` (`send`, optional `sendBatch`) and accepts the same `EmailMessage`. Address inputs (`string`, `{ email, name }`, or arrays) normalize identically via the shared helpers. - **Builder output is escaped.** All caller-supplied content passed to the template builder is HTML-escaped before rendering; the only unescaped path is the explicit `.raw(html, text)` block, which is the caller's responsibility. - **Builder produces both representations.** `.build()` always returns both `html` and a plaintext `text` fallback. - **Workers-safe.** Resend uses `fetch`; the Cloudflare adapter uses the Email Service binding via structural types (no `@cloudflare/workers-types` import). - **Errors are explicit.** A non-2xx provider response (Resend) or a rejected binding send (Cloudflare) throws an error naming the provider and status; it is never silently swallowed. ## Non-guarantees - **No delivery guarantee / no built-in retry or queue.** `send` resolves when the provider accepts the request; downstream delivery is the provider's concern. For at-least-once delivery, send from a background task (retries + DLQ) rather than inline. - **No idempotency.** Calling `send` twice sends twice. De-duplicate upstream if needed (e.g. an idempotency key on the triggering mutation). - **No template storage/versioning.** The builder renders at call time; it does not persist or version templates. - **Provider feature parity is not normalized** beyond the common `EmailMessage` fields (e.g. scheduling, provider-specific analytics are not abstracted). --- ## Environment Variables Contracts ## Guarantees ### Parsing - **Validation at startup**: All environment variables are validated when `createEnv` is called - **Fail-fast behavior**: Invalid or missing required variables throw immediately with clear error messages - **Zod integration**: Full Zod schema support including transforms, defaults, enums, and coercion - **Key path joining**: Nested schema keys are joined with underscores (e.g., `SERVER.PORT` reads `SERVER_PORT`) ### Public/Private Separation - **Private by default**: Variables without `PUBLIC_` prefix or `{ public: true }` are never exposed - **PUBLIC_ prefix detection**: Top-level variables starting with `PUBLIC_` are automatically public - **Explicit override**: `envVariable(val, schema, { public: false })` prevents exposure even with `PUBLIC_` prefix - **No server secrets in client**: `getPublicEnvironmentVariables()` and HTTP endpoints never return private variables ### HTTP Endpoint - **Consistent responses**: `GET /` returns the same JSON for a given server deployment - **ETag support**: Response includes ETag header; `If-None-Match` returns 304 when unchanged - **Cache-Control header**: Default `public, max-age=3600` (configurable) - **Schema endpoint**: `GET /schema` returns field paths and inferred types ### ETag Behavior - **Computed once**: ETag is computed at router creation from hash of public env values - **304 response**: Matching `If-None-Match` returns 304 Not Modified with empty body - **Cache invalidation**: ETag changes when server restarts with different values - **Deterministic**: Same values always produce the same ETag ## Non-Guarantees ### Parsing (What We Don't Promise) - ❌ **Import-time validation**: Variables are only validated when `createEnv` is called - ❌ **Partial success**: If any variable fails, the entire `createEnv` call fails - ❌ **Live reload**: Changes to `process.env` after `createEnv` are not reflected ### Schema Endpoint (What We Don't Promise) - ❌ **Zod schema introspection**: Types are inferred from runtime values, not Zod schemas - ❌ **Transform accuracy**: Complex transforms may not reflect the final type accurately - ❌ **Union types**: Union types are represented as the runtime type ### Client (What We Don't Promise) - ❌ **Automatic refresh**: Client must explicitly refetch or use polling - ❌ **Offline support**: Fetching requires network connectivity - ❌ **Type validation**: Client trusts the JSON matches the expected type ## Failure Modes ### Missing Required Variable - Application fails to start with error: `Environment variable validation error for KEY: Required` - Clear error message identifies the missing variable ### Invalid Variable Value - Application fails to start with Zod validation error - Error message indicates the validation failure and expected type ### Schema Endpoint Disabled - `GET /schema` returns 404 - Typegen skips env types (no error, just omitted) ### Client Fetch Error - Network errors propagate as exceptions - React hook sets `error` state; `env` remains `null` ## Test Coverage - `tests/env/env.test.ts` - Server-side parsing, public/private separation, ETag - `tests/env/client-env.test.ts` - Client fetching, schema endpoint, type generation --- ## ETag / Optimistic Concurrency Contracts These guarantees apply to resources configured with `etag` in `useResource(table, { etag: {...} })`. Without the config, no ETag headers are emitted and conditional headers are ignored. ## Guarantees ### ETag Emission - **Mutating and single-item responses carry ETags**: `POST /`, `GET /:id`, `PATCH /:id`, and `PUT /:id` responses include an `ETag` header reflecting the returned representation - **Tag derivation precedence**: `versionField` (if set and present on the item) → `updatedAtField` + `idField` (timestamp-id pair) → MD5 hash of the serialized item - **Weak by default**: Tags are weak (`W/"..."`) unless `algorithm: "strong"` is configured - **Deterministic**: The same item state always produces the same ETag ### Conditional Writes (If-Match) - **Enforcement on mutation**: `If-Match` is checked against the *current* stored item on `PATCH /:id`, `PUT /:id`, and `DELETE /:id` before the mutation executes - **412 on mismatch**: A non-matching `If-Match` fails with `412 Precondition Failed` (`PRECONDITION_FAILED`, RFC 7807 body including `currentETag` in details) and the mutation is not applied - **Compare-and-swap**: When `If-Match` is present, the `PATCH`/`PUT`/`DELETE` statement carries a CAS predicate on the version/updated-at field, so the validated version must still match at write time. If a concurrent writer changed the row between the read and the write, zero rows match and the request fails with `412` instead of silently losing the update — exactly one of N concurrent `If-Match` writers wins, the rest get `412` - **Wildcard**: `If-Match: *` matches any current state (the write proceeds if the item exists) - **Multiple tags**: `If-Match` may contain a comma-separated list; the check passes if any tag matches - **Strong comparison (RFC 7232 §3.1)**: `If-Match` uses the strong comparison function - **No header, no check**: Requests without `If-Match` are unconditional (last write wins, no CAS predicate) ### Conditional Reads (If-None-Match) - **304 on match**: `GET /:id` with a matching `If-None-Match` returns `304 Not Modified` with an empty body and the current `ETag` header - **Fresh data otherwise**: A non-matching tag returns `200` with the full representation and current `ETag` ### Version Auto-Increment (Optimistic Locking) - **Increment on every update**: When `versionField` is configured, the field is incremented by 1 on every `PATCH /:id` and `PUT /:id`, starting from the current stored value (missing/non-numeric values are left untouched) - **Client override**: If the request body explicitly sets the version field, that value is used instead of auto-increment - **Lost-update protection**: Two clients that read version N and both write with `If-Match` — the CAS predicate guarantees exactly one write lands; the other matches zero rows and receives 412 and must refetch ## Non-Guarantees - ❌ **List ETags**: `GET /` (list) responses do not carry per-item ETags - ❌ **Batch conditional writes**: `If-Match` is not enforced on `/batch` operations, and batch updates do not auto-increment the version field - ❌ **CAS without a comparison field**: The compare-and-swap predicate requires a `versionField` or `updatedAtField`. With neither configured (hash-only ETags), the If-Match check is still enforced before the write but is not atomic with it, so an interleaving writer could theoretically slip between check and write - ❌ **Hash stability across versions**: The fallback (hash-based) tag format may change between minor versions; treat ETags as opaque - ❌ **Strong validator semantics**: Weak tags (default) do not guarantee byte-for-byte identity of representations ## Failure Modes ### If-Match Mismatch - Returns `412` with problem details: `code: "PRECONDITION_FAILED"`, `details.currentETag` set to the item's current tag, and a suggestion to refetch - The stored item is unchanged ### Malformed ETag in Header - Tags that are not `"value"` or `W/"value"` never match → conditional writes fail with 412, conditional reads return 200 ### Item Not Found - Conditional requests against a missing id return `404` (the conditional check is not reached) ## Test Coverage - `tests/concurrency/etag-race.test.ts` - ETag emission, If-Match enforcement, `*` wildcard, concurrent optimistic locking, delete --- ## Server-Rendered htmx Pages Conctracts > **Beta.** The htmx page layer is newer than the rest of Covara; these invariants may evolve while it stabilizes. Invariants for `app.page(...)` and the `covara/htmx` layer. See [Overview](../htmx/overview.md) and [Building pages](../htmx/pages.md) for usage. ## Region identity - A page registered at `path` with regions in source order assigns each region a deterministic id: `regionId = slug(path) + "-" + index`, where `slug` lowercases and replaces non-alphanumerics with `-` (`/` → `root`). - `regionId` is stable across requests and deploys **as long as region order is unchanged**. Reordering regions changes ids; an in-flight client reconnect then refetches rather than mis-targets. ## DOM ids - Container: `cv--list`. Row: `cv--`. Template: `cv--tmpl`. - `domSafeId` preserves `[A-Za-z0-9_-]` and hex-escapes other characters, collision-free. ## Generated endpoints (under `/__covara/live`) - `GET /__covara/live/` — list/pagination fragment (rows only, no shell). Honors `?cursor=`. - `POST /__covara/live/` — create; on success returns the rendered row (HTTP 201). - `PATCH /__covara/live//` — update; returns the rendered row, **or an empty body** when the updated row no longer matches the region's display filter (so an `outerHTML` swap removes it). - `DELETE /__covara/live//` — delete; returns an empty body. - `GET /__covara/live//subscribe` — SSE stream. - `GET /__covara/live/_runtime.js` — the client bundle (htmx core + Covara runtime). - Aggregate regions reject create/update/delete (404) and their list endpoint returns the aggregate fragment. All generated endpoints run through the resource engine, inheriting its scope/validation/etag/masking. Auth is forwarded from the inbound request (cookie/authorization) on the in-process dispatch. ## SSE wire format The live stream reuses the resource's own `/subscribe` endpoint with a one-shot HTML renderer injected via the internal `__cvRenderer` token; the resource's scope/resume/heartbeat/backpressure logic is unchanged. Named events: | Event | `data` | Client action | | --- | --- | --- | | `added`, `changed` | rendered row HTML | upsert by row id | | `removed` | the row's DOM id | remove that element | | `aggregate`-region change | `invalidate` (no payload) | refetch the aggregate fragment | | `invalidate` | optional reason | refetch the list | The JSON wire format of `/subscribe` is unchanged when no `__cvRenderer` token is present (the per-handler renderer defaults to JSON). ## Optimistic / offline - **Create is inserted once, by the region's live SSE `added` event.** The create form posts with `hx-swap="none"` (its HTML response is not swapped in), so the new row is never inserted twice — it appears via the same SSE path in every connected client, including the acting one. No optimistic placeholder is used for create (an earlier placeholder caused a duplicate/phantom row). - **Delete** is optimistic: the row is hidden immediately and restored if the request fails; on success it is removed by the response swap / SSE `removed` event. - Per-request optimistic state is correlated across the `htmx:beforeRequest`/`htmx:afterRequest` events via the shared `xhr` (their `event.detail` objects are NOT shared). - The SSE `added`/`changed` handlers upsert by row id, so a row already present is replaced rather than duplicated. - Each page also ships a `