Blob: docs/phase-1-design.md
Tessera — Phase 1 Document 2
Design doc
Stack-version override: the user has clarified
frontend-spec.mdis 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 choseauth.limic.dev(same load-bearing properties: cookie domain, OIDCissclaim, 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 | <client_id> |
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:
- Email + password — primary. The only source available at sign-up time.
- GitHub — linked from the user's account page after sign-in.
- 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_subto 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:
ALTER TABLE users ADD COLUMN tessera_sub TEXT UNIQUE;plus an index.- 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).
- Once all known users have a
tessera_sub, drop any password column the project previously stored (e.g. anvil'spassword_credentialstable, bland'susers.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-salt>$<base64-hash>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:
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
kids. 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:
// 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/<token> (the raw token, base64url-encoded).
Consumption flow:
GET /invite/<token>— server hashes the token, looks up the invite row, checksexpiresAt > now()andconsumedAt IS NULL. On invalid/expired/consumed, render a clear error page (410 Gone, not 404 — too ambiguous).- The page renders a sign-up form (email pre-filled and disabled — bound at invite creation) with a Turnstile challenge.
POST /invite/<token>— 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 setconsumedAtor get zero affected rows and re-read to classify (404 / 410 consumed / 410 expired).- Successful consume calls Better Auth's
signUpEmailwith the bound invite address. signUpEmail issues theuser,account(provider='credential'+ Argon2id PHC hash), and session rows, and sets the autoSignIn cookie on the response.
- 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— discoveryGET /.well-known/jwks.json— JWKSGET /authorize— authorization endpoint (redirects to/sign-inif no session)POST /token— token endpointGET /userinfo— userinfo endpoint
Login UI:
GET /sign-in— email+password form, Turnstile-gatedPOST /sign-in— credential check + session createGET /sign-in/github,GET /sign-in/google— kick off social OAuthGET /callback/github,GET /callback/google— link/login callbacksGET /sign-out— destroy session
Account:
GET /account— linked-identities management (post-auth)POST /account/link/github,POST /account/link/google— link a new social identityPOST /account/unlink/github,POST /account/unlink/google— unlink (must keep at least one credential method)
Invite:
GET /invite/<token>— landing + sign-up form (Turnstile-gated)POST /invite/<token>— consume + create userGET /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 toopenid email profile). On submit, the server calls the plugin's create-client API and returns the mintedclient_idandclient_secretonce. 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://<team>.cloudflareaccess.com/cdn-cgi/access/callback - Scopes:
openid email profile - Claim mapping: Access reads
sub,email,name, andtessera_subfrom tessera's ID token. Access then re-signs its own JWT (theCf-Access-Jwt-Assertionheader). In that JWT,payload.subis Cloudflare Access's user ID, while tessera's stable subject is available aspayload.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, plusdrizzle-kitas 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'srolecolumn. 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:import { drizzle } from "drizzle-orm/d1"; import * as schema from "./schema"; export const makeDb = (env: Env) => drizzle(env.DB, { schema });Better Auth init:
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 asbetter-sqlite3and friends.Migrations via drizzle-kit + wrangler:
- In
drizzle.config.ts, setdialect: 'sqlite'andout: './migrations'so drizzle-kit writes SQL into the directory wrangler reads.
npx drizzle-kit generate # write SQL migrations into ./migrations/ npx wrangler d1 migrations apply tessera-prod # apply to D1- In
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": "<base64url-raw-32-bytes>"}, 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,updatedAtaccount—userId,provider(credential|github|google),accountId,password(PHC string forcredentialrows; null for OAuth rows),accessToken?,refreshToken?,idToken?,createdAtsession—id,userId,expiresAt,token,ipAddress,userAgent,createdAtverification—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 withkid, public bytes, private bytes. Implementation must confirm whether the plugin encrypts the private bytes; if not, wrap the column withD1_ENCRYPTION_KEYusing the helper from Document 1's encryption-at-rest reference.
Tessera-specific table:
invites— schema in § Auth primitives above.
Rate limiting
RATE_LIMITSWorkers Rate Limiting binding — per-IP per-route throttling. Keys:rl:sign-in:ip:<ip>,rl:sign-in:email:<email>,rl:invite:<ip>. Config:namespace_id: "110001",limit: 10,period: 60. On exceed, return 429 withRetry-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:
<col>Ciphertext,<col>KeyVersion,<col>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/<token>. 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
assertTurnstileVerifiedhelper (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_KEYorTURNSTILE_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 surfaceszinc-800, recessedzinc-900. - Default transition 75ms (
--default-transition-duration). Nevertransition-all. - Body weight 450 set on
<body>. - Shadows: prefer
shadow-sm; no colored accent shadows; reserveshadow-2xlfor 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-motioncollapses 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/<token>— 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-reactglyph intext-accent-400; signaturegroup-hover:-rotate-6micro-interaction. Suggested glyph:lucide-key-roundorlucide-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 intext-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 —
subis a UUID v4,issmatches the configured issuer,email_verifiedis 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-inwith valid credentials → 302 to/authorizeredirect →/tokenexchange → ID token validates against/.well-known/jwks.json. - Invite-accept flow: GET
/invite/<token>→ POST with credentials → user row + account row exist, invite is consumed. - Account-linking flow: authenticated user →
/account/link/github→ callback → account row appears withprovider='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 hasrole='admin'. - Turnstile fail-closed: missing
TURNSTILE_SITE_KEY→ 503; failedsiteverify→ 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
/authorizeand 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
Accent color— Burnished Gold (#c89738) chosen 2026-04-25.Display font— Spectral chosen 2026-04-25.No auto-create from social— confirmed 2026-04-25.— confirmed retained 2026-04-25.preferred_usernameclaim- Plugin table/column names — generated via
@better-auth/cliintosrc/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. - 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).