# Tessera — Phase 1 Document 2 ## Design doc > **Stack-version override:** the user has clarified `frontend-spec.md` is outdated. Tessera uses **Vite 8** and **TypeScript 6** (the spec calls for Vite 7 / TS 5.9). Everything else from the spec — React 19, Tailwind v4, warm zinc, lifted canvas, 75ms transitions, Hanken Grotesk + JetBrains Mono + a project-specific display font, focus-visible rings, prefers-reduced-motion override, body weight 450 — is followed exactly. > **Hostname override:** the Phase 1 prompt specified `tessera.limic.dev`. After review, the operator chose **`auth.limic.dev`** (same load-bearing properties: cookie domain, OIDC `iss` claim, GitHub/Google callback URLs, Cloudflare Access endpoint config). The product name in copy and references stays **tessera**; only the hostname changed. Wrangler binding names, env-var prefixes (`TESSERA_OIDC_*`), the D1 binding (`tessera-prod`), the docs path (`~/code/tessera/`), and the project's identity throughout are unaffected. --- ## Identity & claims ### OIDC claim schema Tessera issues these claims in ID tokens and at `/userinfo`: | Claim | Type | Example | Notes | | -------------------------- | ---------------- | --------------------------- | --------------------------------------------------------------------------------- | | `iss` | URL | `https://auth.limic.dev` | The hostname is load-bearing — see Phase 1 prompt. | | `sub` | string (UUID v4) | `01HW0K7P3W...` | **Opaque**. Never email. Stable across all linked identities. | | `aud` | string | `` | Per-RP. | | `exp`, `iat`, `nbf`, `jti` | standard | | | | `email` | string | `cookies.can.eat@gmail.com` | Verified primary email. | | `email_verified` | boolean | `true` | Always `true` — both the password-reset path and OAuth flows confirm the address. | | `name` | string | `Operator` | Display name. | | `preferred_username` | string | `operator` | Optional; useful as a slug for git-on-cloudflare's `:owner` URL segment. | For Cloudflare Access, tessera also emits `tessera_sub`, mirroring `sub`, so Access-protected origins can recover tessera's stable subject after Access re-signs its own application JWT. **Cross-check with ccccocc** (per Document 1): ccccocc originally read only `payload.sub` from `Cf-Access-Jwt-Assertion`, but Access uses that field for its own user ID. Downstream Access consumers that need tessera's stable subject must read `payload.custom.tessera_sub`. ### Account-linking model Tessera supports multiple users. Each user has one or more linked identity sources, all resolving to the same `sub` via Better Auth's `account` linking table. The available sources are: 1. **Email + password** — primary. The only source available at sign-up time. 2. **GitHub** — linked from the user's account page after sign-in. 3. **Google** — same. `account` rows hold `(provider, accountId)` per linked identity, all pointing at the same `userId` (= `sub`). A user can unlink any provider as long as at least one credential method remains. **A login attempt via GitHub or Google for an email that is not linked to any tessera user returns "this account is not linked — sign in with email+password first to link" and never auto-creates a user.** Tessera registration is invite-only; auto-create would defeat that. ### Roles Each user has a role: `'user'` (default) or `'admin'`. Admin gates: - Creating and revoking invites. - Registering, rotating, and revoking OAuth clients. - Viewing and revoking other users' sessions. The operator (the human running tessera) is the initial admin. **Bootstrap path:** a wrangler env var `BOOTSTRAP_ADMIN_EMAIL` flags one email as auto-promoting to admin on first signup; remove the env var once bootstrap is complete. Subsequent admin grants happen through `/admin/users`. Implementation: use Better Auth's admin plugin (`better-auth/plugins/admin`), which provides the `role` column on `user`, the admin-gated CRUD endpoints, and ban/unban semantics out of the box. Roles stay tessera-internal — the ID token does **not** include a `role` claim, since downstream apps may have their own role concepts that don't map 1:1 to tessera's. ### Migration strategy (downstream projects) **Tessera does not mutate `users.id` in any downstream project.** Each downstream project (anvil, bland) adds a new `tessera_sub` column to its existing `users` table — typed text, indexed, unique. The OIDC callback in each project finds the local user by `WHERE tessera_sub = ?` (or by email on first sign-in to bind), creates/updates the local row as needed, and the project's existing `users.id` and every FK column referencing it stays untouched. Why column-add rather than ID-replace: - Existing FK columns continue to point at the original `users.id`. Zero cascade rewrites; zero risk of partial migrations corrupting referential integrity. - The tessera ↔ project mapping is explicit and reversible — drop `tessera_sub` to roll back. - Each project owns its own migration timing, code, and testing. Tessera is not coupled to per-project migration logic. **Per-project migration code lives outside tessera.** Each downstream project (anvil, bland) is migrated separately, on its own schedule. Tessera's responsibility ends at "issue stable `sub` claims and a working OIDC flow." The migration shape each downstream project will run is: 1. `ALTER TABLE users ADD COLUMN tessera_sub TEXT UNIQUE;` plus an index. 2. The OIDC callback handler does: - `SELECT id FROM users WHERE tessera_sub = ?` → if found, sign in. - Else `SELECT id FROM users WHERE email = ?` → if found, `UPDATE users SET tessera_sub = ?` and sign in (first-time bind). - Else register the user (or reject, depending on project policy). 3. Once all known users have a `tessera_sub`, drop any password column the project previously stored (e.g. anvil's `password_credentials` table, bland's `users.password_hash`). Forced password reset for any password column in downstream projects: tessera does not carry over hashes. **ccccocc**: no migration. Sandbox keys are derived per-request from the JWT `sub`. The next sign-in via tessera-through-Cloudflare-Access seamlessly resolves to a sandbox keyed on the new `sub`. The previous sandbox (keyed on the old GitHub/Google `sub` Access used) becomes orphaned and can be reaped out-of-band. **git-on-cloudflare**: no migration. No D1 user-identity rows existed; identity gains a tessera-issued `sub` claim verified at request time. **flamemail**: no migration. The `ADMIN_PASSWORD` wrangler secret is removed; the admin login form becomes an OIDC redirect. --- ## Auth primitives ### Password hashing — Argon2id via `@noble/hashes/argon2.js` **Choice rationale (do not re-litigate later):** workerd refuses runtime WASM compilation (`CompileError: Wasm code generation disallowed by embedder`). This rules out `@awasm/noble`, `hash-wasm`, and every WASM-shipping Argon2 lib (verified failing). Pure-JS noble-hashes Argon2id works on workerd at single-user scale: ~1s per hash, well within the 30s CPU limit. **Parameters**: `m=19456` (19 MiB), `t=2`, `p=1`, output 32 bytes. Salt: 16 random bytes from `crypto.getRandomValues`. **PHC string format** (exact; matches bland's existing format): ``` $argon2id$v=19$m=19456,t=2,p=1$$ ``` Base64 here is "standard base64 without padding" per the PHC spec (`+`/`/` alphabet, `=` stripped). **Wired into Better Auth via `emailAndPassword.password.{hash, verify}`**. The hash function returns the encoded PHC string; the verify function parses the PHC string, recomputes with the stored params and salt, and constant-time compares. **Constant-time compare**: use Cloudflare's runtime-provided `crypto.subtle.timingSafeEqual` (https://developers.cloudflare.com/workers/examples/protect-against-timing-attacks/). It accepts `ArrayBuffer` or `TypedArray`. Per Cloudflare's example, do not return early on length mismatch — compare the user input against itself and negate the result, to keep timing constant across every path: ```ts const lengthsMatch = userValue.byteLength === secretValue.byteLength; const isEqual = lengthsMatch ? crypto.subtle.timingSafeEqual(userValue, secretValue) : !crypto.subtle.timingSafeEqual(userValue, userValue); ``` flamemail already uses this pattern (`src/worker/security.ts:73-83`). ### JWT signing — RS256, plugin-managed keys ID tokens are signed RS256 via Better Auth's OAuth 2.1 Provider plugin. Do **not** reimplement key generation, rotation, or JWKS publishing. - Public keys at `/.well-known/jwks.json` (plugin-managed endpoint). - Rotation: the plugin supports overlapping `kid`s. JWKS responses include current + previous, giving RPs a grace window before they need to refresh their cached keys. - Private key bytes live in D1 (per the plugin's default schema). Implementation must verify whether the plugin encrypts these at rest. If not, wrap the column with the encryption-at-rest helper (see § Storage & secrets). ### Invite flow Invite-only registration. No public sign-up endpoint. **`invites` table:** ```ts // src/worker/db/schema/invites.ts { id: text primary key, // 'inv_' + base64url(12 bytes) tokenHash: text unique not null, // SHA-256 of the raw token (mirrors anvil's pattern) email: text not null, // bound at mint time; signup uses this address createdBy: text not null, // FK -> user.id createdAt: text not null, // ISO8601 expiresAt: text not null, // ISO8601, default createdAt + 7 days consumedAt: text, } ``` Shape mirrors `~/code/anvil/src/worker/db/d1/schema/invites.ts` (which stores `tokenHash`, not the raw token; tessera does the same). The raw 32-byte token only exists in the URL emitted at creation time. **URL**: `https://auth.limic.dev/invite/` (the raw token, base64url-encoded). **Consumption flow:** 1. `GET /invite/` — server hashes the token, looks up the invite row, checks `expiresAt > now()` and `consumedAt IS NULL`. On invalid/expired/consumed, render a clear error page (410 Gone, not 404 — too ambiguous). 2. The page renders a sign-up form (email pre-filled and disabled — bound at invite creation) with a Turnstile challenge. 3. `POST /invite/` — rate-limit, validate body and password length, Turnstile siteverify (fail-closed), and run a single CAS: - `UPDATE invites SET consumedAt = ? WHERE tokenHash = ? AND consumedAt IS NULL AND expiresAt > ?` — both placeholders are JS ISO timestamps. SQLite serializes writes, so concurrent callers either set `consumedAt` or get zero affected rows and re-read to classify (404 / 410 consumed / 410 expired). - Successful consume calls Better Auth's `signUpEmail` with the bound invite address. signUpEmail issues the `user`, `account` (`provider='credential'` + Argon2id PHC hash), and session rows, and sets the autoSignIn cookie on the response. 4. Return the signUpEmail response. The client refetches the session and redirects to the post-signup landing page. **Failure-closed:** signUpEmail throwing, a duplicate-email collision, or a transient D1 error leaves the invite consumed. Operator remediation is to mint a new invite. The rule trades a rare burned invite for a much smaller surface area than a state-machine that tries to release reservations or compensate orphan rows. **Expired/consumed responses** are dedicated pages (`/invite/expired`, `/invite/consumed`) returning HTTP 410, with copy explaining the state. --- ## Endpoints & integration ### Endpoints tessera exposes OIDC standard (plugin-managed): - `GET /.well-known/openid-configuration` — discovery - `GET /.well-known/jwks.json` — JWKS - `GET /authorize` — authorization endpoint (redirects to `/sign-in` if no session) - `POST /token` — token endpoint - `GET /userinfo` — userinfo endpoint Login UI: - `GET /sign-in` — email+password form, Turnstile-gated - `POST /sign-in` — credential check + session create - `GET /sign-in/github`, `GET /sign-in/google` — kick off social OAuth - `GET /callback/github`, `GET /callback/google` — link/login callbacks - `GET /sign-out` — destroy session Account: - `GET /account` — linked-identities management (post-auth) - `POST /account/link/github`, `POST /account/link/google` — link a new social identity - `POST /account/unlink/github`, `POST /account/unlink/google` — unlink (must keep at least one credential method) Invite: - `GET /invite/` — landing + sign-up form (Turnstile-gated) - `POST /invite/` — consume + create user - `GET /invite/expired`, `GET /invite/consumed` — error pages Admin (post-auth, admin role only): - `GET /admin/clients` — list / register / rotate-secret / revoke OAuth clients (UI). - `POST /admin/clients` — programmatic client registration (admin-gated API). - `GET /admin/users` — list / role-grant / role-revoke / ban users (Better Auth admin plugin). - `GET /admin/invites` — list / create / revoke invites. ### OAuth client registration — dynamic, admin-gated Tessera supports **dynamic** OAuth client registration to reduce operator friction (no `wrangler d1 execute` to mint each new RP). The operator registers a new RP through the admin UI; tessera calls Better Auth's OAuth 2.1 Provider plugin's programmatic client-creation API behind the scenes. Two operator surfaces: - **Admin UI** at `/admin/clients` — list, register, rotate-secret, revoke. Form fields: client name, redirect URIs (newline-separated), allowed scopes (defaults to `openid email profile`). On submit, the server calls the plugin's create-client API and returns the minted `client_id` and `client_secret` once. The secret is shown to the admin and never again — copy it directly into the RP's wrangler secret. - **Programmatic API** at `POST /admin/clients` — same operation, gated by admin authentication (admin session cookie or one-shot admin API token). Lets a CLI script register a new RP without the browser. The plugin manages the `client_secret` hash with Argon2id (same params as user passwords — consistent with how GitHub and GitLab hash PATs). Client-secret rotation: mint new via the admin surface, copy to the RP's wrangler secret, revoke the old hash. OPERATOR.md will document the admin client-registration flow (UI walkthrough + the equivalent `curl` invocation against `/admin/clients`). ### Cloudflare Access integration Tessera registers as a **Generic OIDC** provider for the Access team: - Auth URL: `https://auth.limic.dev/api/auth/oauth2/authorize` - Token URL: `https://auth.limic.dev/api/auth/oauth2/token` - Certs URL: `https://auth.limic.dev/api/auth/jwks` - Cloudflare Access callback URL: `https://.cloudflareaccess.com/cdn-cgi/access/callback` - Scopes: `openid email profile` - Claim mapping: Access reads `sub`, `email`, `name`, and `tessera_sub` from tessera's ID token. Access then re-signs its own JWT (the `Cf-Access-Jwt-Assertion` header). In that JWT, `payload.sub` is Cloudflare Access's user ID, while tessera's stable subject is available as `payload.custom.tessera_sub`. For ccccocc (the reference Access consumer), the swap is not transparent: its Access auth code must derive the sandbox owner from `payload.custom.tessera_sub`, not from `payload.sub`. --- ## Storage & secrets Mental model: - **Wrangler secrets** = "things tessera needs to be tessera" (its own identity, upstream OAuth credentials, master encryption key). Static, rarely rotated. - **D1** = "things tessera issues to others" (downstream client credentials, signing keys it rotates). Dynamic, per-entity. - **Workers Rate Limiting binding** = ephemeral abuse throttling for public auth surfaces. ### Data layer — drizzle-orm + Better Auth Drizzle adapter Tessera uses **drizzle-orm** against D1, with Better Auth wired through Drizzle via the official `@better-auth/drizzle-adapter` package (https://better-auth.com/docs/adapters/drizzle — install separately from `better-auth`). drizzle-orm provides D1 support via `drizzle-orm/d1`, which the adapter accepts directly. This matches the existing suite pattern — anvil and bland already use Drizzle for their D1 schemas. Concretely: - Install: `npm install drizzle-orm @better-auth/drizzle-adapter`, plus `drizzle-kit` as a dev dep. - `src/worker/db/schema/` — Drizzle table definitions: - `auth.ts` — Better Auth core tables (`user`, `account`, `session`, `verification`) plus the OAuth 2.1 Provider plugin tables and the admin plugin's `role` column. Generate via Better Auth's CLI: `npx auth@latest generate` (configure the output path in your Better Auth config to land here). - `invites.ts` — tessera-specific table; managed by hand. - `src/worker/db/index.ts` — Drizzle's D1 driver: ```ts import { drizzle } from "drizzle-orm/d1"; import * as schema from "./schema"; export const makeDb = (env: Env) => drizzle(env.DB, { schema }); ``` - Better Auth init: ```ts import { betterAuth } from "better-auth"; import { drizzleAdapter } from "@better-auth/drizzle-adapter"; export const makeAuth = (env: Env) => betterAuth({ database: drizzleAdapter(makeDb(env), { provider: "sqlite" }), // ... plugins, secrets, etc. }); ``` The `provider: 'sqlite'` value covers D1 — Better Auth's adapter routes SQLite-compatible D1 through the same code path as `better-sqlite3` and friends. - Migrations via drizzle-kit + wrangler: - In `drizzle.config.ts`, set `dialect: 'sqlite'` and `out: './migrations'` so drizzle-kit writes SQL into the directory wrangler reads. ```sh npx drizzle-kit generate # write SQL migrations into ./migrations/ npx wrangler d1 migrations apply tessera-prod # apply to D1 ``` **Schema authority.** Run `npx auth@latest generate` once to seed `auth.ts` with the plugin-provided tables, then treat the file as authoritative — manage subsequent migrations through drizzle-kit. Better Auth treats Drizzle as a black-box adapter and won't help with schema evolution beyond initial generation; regenerate after any Better Auth or plugin upgrade and review the diff before committing. ### Wrangler secrets | Secret | Purpose | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `BETTER_AUTH_SECRET` | Better Auth's session/state HMAC key. 64 hex chars (`openssl rand -hex 32`). | | `GITHUB_OAUTH_CLIENT_ID` / `GITHUB_OAUTH_CLIENT_SECRET` | tessera as a GitHub OAuth client. Callback registered with GitHub: `https://auth.limic.dev/api/auth/callback/github`. | | `GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET` | Same for Google. Callback: `https://auth.limic.dev/api/auth/callback/google`. | | `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile. Local dev uses Cloudflare's official test keys (the same ones anvil/bland use in their `.dev.vars.example`). | | `D1_ENCRYPTION_KEY` | AES-GCM master key for at-rest encryption of sensitive D1 columns. Format: versioned JSON map `{"1": ""}`, mirroring anvil's `appEncryptionKeysJson` (Document 1, encryption-at-rest reference). | ### D1 schema Schema lives in `src/worker/db/schema/` and is managed via Drizzle (see § Data layer above). Better Auth-managed tables are generated via `@better-auth/cli`; tessera-specific tables (`invites`) are hand-written. Columns below are defaults at design time — regenerate after any plugin upgrade. **Better Auth core tables** (generated): - `user` — `id` (= `sub`, UUID v4), `email`, `name`, `emailVerified`, `image?`, `createdAt`, `updatedAt` - `account` — `userId`, `provider` (`credential` | `github` | `google`), `accountId`, `password` (PHC string for `credential` rows; null for OAuth rows), `accessToken?`, `refreshToken?`, `idToken?`, `createdAt` - `session` — `id`, `userId`, `expiresAt`, `token`, `ipAddress`, `userAgent`, `createdAt` - `verification` — `id`, `identifier`, `value`, `expiresAt` (used for email verification, password reset, etc.) **OAuth 2.1 Provider plugin tables** (generated): - `oauth_application` — `clientId`, `clientSecretHash`, `name`, `redirectUris` (JSON), `scopes` (JSON), `createdAt`. Hash uses Argon2id with the same params as user passwords. - `oauth_access_token`, `oauth_authorization_code` — short-lived tokens / codes; plugin-managed. - `jwks` (or similar) — RS256 signing keys with `kid`, public bytes, private bytes. **Implementation must confirm whether the plugin encrypts the private bytes; if not, wrap the column with `D1_ENCRYPTION_KEY` using the helper from Document 1's encryption-at-rest reference.** **Tessera-specific table:** - `invites` — schema in § Auth primitives above. ### Rate limiting - `RATE_LIMITS` Workers Rate Limiting binding — per-IP per-route throttling. Keys: `rl:sign-in:ip:`, `rl:sign-in:email:`, `rl:invite:`. Config: `namespace_id: "110001"`, `limit: 10`, `period: 60`. On exceed, return 429 with `Retry-After: 60`. - Better Auth's auth/session/OIDC state remains D1-backed through the Drizzle adapter; do not configure KV secondary storage. ### A note on encryption-at-rest If Better Auth's plugin does not encrypt private signing-key bytes, follow anvil's pattern exactly: - Helper functions `encryptSecret` / `decryptSecret` — see Document 1's reference table for anvil's location. - Three columns per ciphertext: `Ciphertext`, `KeyVersion`, `Nonce`. - Master key as base64url raw 32-byte AES-256, in a versioned JSON map for rotation. --- ## Turnstile Tessera gates two surfaces: `GET/POST /sign-in` and `GET/POST /invite/`. The OAuth `/authorize` flow itself is **not** Turnstile-gated directly — it redirects to `/sign-in` when no session exists, and `/sign-in` is the gated endpoint. Mirror the canonical patterns from Document 1's Turnstile reference table: - **Server side**: follow anvil's `assertTurnstileVerified` helper (`src/worker/api/public/auth.ts:35-60` + `src/worker/services/turnstile.ts:60, 101`). A dedicated helper called from each gated handler — no middleware indirection for two routes. - **Client side**: copy bland's widget (`src/client/components/auth/turnstile-widget.tsx`). Fail-closed return codes: - Missing `TURNSTILE_SITE_KEY` or `TURNSTILE_SECRET_KEY` → 503 (deploy-config error; matches flamemail's pattern). - Missing token in the request → 400. - Failed siteverify response → 403. - Action mismatch → 403. Local dev uses Cloudflare's official Turnstile test keys (matches anvil/bland `.dev.vars.example`): - Site key: `1x00000000000000000000AA` (always passes). - Secret: `1x0000000000000000000000000000000AA`. --- ## Frontend ### Stack - **React 19**, **Vite 8**, **TypeScript 6**, **Tailwind v4** (CSS-native config, no PostCSS). - Icons: `lucide-react`. - Deploy: Cloudflare Workers via `@cloudflare/vite-plugin`. (Stack-version override note repeated from the doc preamble: `frontend-spec.md` says Vite 7 / TS 5.9. Tessera uses Vite 8 / TS 6.) ### Visual system Per the spec (followed exactly except for stack versions): - Warm zinc palette overridden via `@theme`: `zinc-900 #1b181a`, `zinc-800 #2a2729`, `zinc-700 #423f42`, `zinc-600 #555259`, `zinc-500 #747178`, `zinc-400 #a3a1a8`. Canvas `#221f21`. - Lifted canvas: chrome `zinc-900`, elevated surfaces `zinc-800`, recessed `zinc-900`. - Default transition 75ms (`--default-transition-duration`). Never `transition-all`. - Body weight 450 set on ``. - Shadows: prefer `shadow-sm`; no colored accent shadows; reserve `shadow-2xl` for modals. - Animations: `fade-in 0.4s`, `slide-up 0.35s`, `scale-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1)`, `shimmer 1.5s`. Stagger reveals at 60ms base delay, cap 8 items. - `prefers-reduced-motion` collapses all timing to 0.01ms. - Focus-visible ring: `ring-2 ring-accent-500/50 ring-offset-2 ring-offset-canvas`. ### Display font Existing display fonts in the suite (verified from each repo's `app.css`): | Project | Display font | | ----------------- | ----------------------------------------------------------- | | anvil | Bricolage Grotesque | | bland | Bricolage Grotesque + Outfit | | flamemail | Space Grotesk | | git-on-cloudflare | IBM Plex Serif | | ccccocc | (no distinctive display font set; uses Hanken Grotesk only) | A "tessera" is a small ceramic tile bearing an identification mark — Roman authentication tokens. The aesthetic should suggest **authority, heritage, precision**. A serif with character is the right register; only one project in the suite uses a serif (git-on-cloudflare with IBM Plex Serif), and the proposed face is sufficiently different in tone. **Chosen display font: Spectral** (Google Fonts, by Production Type) — confirmed 2026-04-25. - Contemporary editorial serif. Calm, intelligent, distinctive — reads like a thoughtful long-form magazine. - Sets a tone of considered authority for an IdP without leaning into Roman-heritage costume. - Multiple weights (200–800) and italic variants give range across H1–H3 plus any UI affordance that wants a serif touch. - No conflict with any existing display font in the suite. Other options considered (recorded for decision lineage): - **Marcellus**, **Cinzel** — Roman-heritage angle (thematic tie to "tessera"); rejected as too on-the-nose. - **Cormorant Garamond**, **Bodoni Moda** — high-contrast luxury serifs that pair well with the gold accent. - **Big Shoulders Display** — brutalist sans for cold/warm contrast. - **Instrument Serif**, **Fraunces**, **DM Serif Display** — initial proposals. ### Accent color Existing accents in the suite (verified): | Project | Accent (500) | | ----------------- | ----------------------- | | anvil | `#3b82f6` blue | | bland | `#9d6ee8` warm amethyst | | flamemail | `#f97316` orange | | git-on-cloudflare | `#6366f1` indigo | | ccccocc | `#a85a65` dusty rose | Constraints: avoid blue, amethyst/violet, orange, indigo, dusty rose. Spec also forbids stock Tailwind palette values (especially `violet-500 / #8b5cf6`). The hue should feel trustworthy and premium — tessera is the security perimeter. **Three proposed accents:** | Name | Hex (500) | Justification | | --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Burnished Gold** (chosen) | `#c89738` | Roman tessera were sometimes gilded — direct thematic tie. Only flamemail is warm in the suite, and orange is far enough from gold on the wheel to read as clearly distinct. | | Verdigris | `#3aa17a` | Aged-bronze green — patina on Roman bronze. Reads as "secured and authentic," not "go button green." | | Slate Teal | `#0e8a8a` | Cooler, archival, vault-like. Feels "infrastructure," fits an IdP. More reserved. | **Chosen: Burnished Gold (`#c89738`)** — confirmed by the operator on 2026-04-25. ### UI primitives Use the existing limic UI primitive patterns (`button.tsx`, `card.tsx`, `input.tsx`, etc.). **`~/code/anvil/src/client/components/ui/` is the canonical structural reference** when anvil and bland diverge (per Phase 1 prompt). Do **not** install Better Auth UI or shadcn; write the auth pages directly. Pages to build: - `/sign-in` — single card centered on the canvas. Email + password fields, primary button "Sign in", secondary buttons "Sign in with GitHub" / "Sign in with Google", Turnstile widget below the password field. - `/invite/` — same shell. Email field (pre-filled and disabled if invite specifies one) + password + Turnstile. - `/invite/expired`, `/invite/consumed` — minimal error pages: heading, sentence, back-to-sign-in link. - `/account` — linked-identities management. List of (provider, email, linked-at) rows with Unlink buttons; Link buttons for unlinked providers. - `/sign-out` — landing. - The OAuth consent screen — plugin handles it; styling alignment to be confirmed during implementation. ### Header and footer Per spec: - Sticky header `top-0 z-50 bg-zinc-900/95 backdrop-blur-sm border-b border-zinc-800/60`. - Brand: stroked `lucide-react` glyph in `text-accent-400`; signature `group-hover:-rotate-6` micro-interaction. Suggested glyph: `lucide-key-round` or `lucide-shield-check` (final pick during implementation). - Footer: "Made with ❤️ on Cloudflare" link to `https://limic.dev`, source code link to the tessera repo, `text-xs text-zinc-500`, heart in `text-accent-500`. --- ## Testing strategy Three layers, with hard CI gates on the first two. ### Unit tests — Vitest (in-Worker) Run inside workerd via `@cloudflare/vitest-pool-workers` (https://developers.cloudflare.com/workers/testing/vitest-integration/). Tests run inside a Miniflare-backed Worker, so D1, KV, and Durable Object bindings are real — not mocked. This is the only viable way to test code that depends on Workers-specific runtime behavior (subtle differences in `crypto.subtle`, Cloudflare-only headers, DO storage semantics). Coverage targets: - Argon2id hashing — round-trip hash → verify; reject wrong password; reject malformed PHC strings. - Invite token generation — uniqueness, length, base64url alphabet. - Invite consumption — single-use atomicity (concurrent consume returns one success + one consumed-error). - OIDC discovery / JWKS endpoint shape (assertions against the JSON response, not against Better Auth plugin internals). - Claim issuance — `sub` is a UUID v4, `iss` matches the configured issuer, `email_verified` is true for credential users post-signup. - Rate-limiter behavior — 11 requests in 60s from one IP returns 429 with `Retry-After`. Layout: `*.test.ts` adjacent to source. ### Integration tests — Vitest (in-Worker, broader scope) Same runner, broader scope. Walk the request boundary end-to-end inside the test Worker. Coverage targets: - Full sign-in flow: POST `/sign-in` with valid credentials → 302 to `/authorize` redirect → `/token` exchange → ID token validates against `/.well-known/jwks.json`. - Invite-accept flow: GET `/invite/` → POST with credentials → user row + account row exist, invite is consumed. - Account-linking flow: authenticated user → `/account/link/github` → callback → account row appears with `provider='github'`. - Admin client registration: admin session → POST `/admin/clients` → row appears, secret is returned once; non-admin returns 403. - Bootstrap admin: signup with email matching `BOOTSTRAP_ADMIN_EMAIL` → user row has `role='admin'`. - Turnstile fail-closed: missing `TURNSTILE_SITE_KEY` → 503; failed `siteverify` → 403. ### E2E tests — Playwright (browser) For flows that traverse browser state (cookies, redirects, OAuth round-trips, JS-rendered widgets): Coverage targets: - Full email+password sign-in from a fresh browser session. - Invite acceptance: click invite link → fill form → land on post-auth page. - GitHub OAuth login + linking (requires a test GitHub OAuth app with predictable test credentials). - Google OAuth login + linking. - OIDC redirect-and-callback as seen by an RP: spin up a minimal mock RP that calls `/authorize` and verifies the round-trip ends with a valid ID token. Run Playwright against a local `wrangler dev` for fast iteration; against a preview deployment in CI so OIDC redirects work end-to-end with real URLs. ### CI Two jobs gating merges: - `vitest` (fast, ~30s) — unit + integration. - `playwright` (slow, ~5min) — E2E against a preview deployment. ### Out of scope for tessera tests - Better Auth's own internal correctness — treated as a black-box dependency. - Cloudflare's Turnstile `siteverify` — only the call shape and fail-closed handling are tested. - Browser-engine quirks across Firefox/Safari/etc. — Playwright's chromium pool is sufficient for v1. - Cloudflare Access end-to-end — depends on a deployed Access team. Covered indirectly by integration tests of the JWT shape tessera issues. --- ## Open questions for review 1. ~~Accent color~~ — **Burnished Gold (`#c89738`)** chosen 2026-04-25. 2. ~~Display font~~ — **Spectral** chosen 2026-04-25. 3. ~~No auto-create from social~~ — confirmed 2026-04-25. 4. ~~`preferred_username` claim~~ — confirmed retained 2026-04-25. 5. **Plugin table/column names** — generated via `@better-auth/cli` into `src/worker/db/schema/auth.ts`. Implementation must regenerate after any Better Auth or OAuth 2.1 Provider plugin upgrade and review the diff before applying migrations. 6. **Plugin private-key encryption** — implementation must verify whether the OAuth 2.1 Provider plugin encrypts RS256 private key bytes at rest. If not, wrap the column using anvil's encryption-at-rest pattern (Document 1).