Skip to content
File

Blob: docs/phase-1-design.md

Markdown516 lines

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

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

  1. GET /invite/<token> — 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/<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 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/<token> — landing + sign-up form (Turnstile-gated)
  • POST /invite/<token> — 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://<team>.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:

    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 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.
    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": "<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, 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:<ip>, rl:sign-in:email:<email>, rl:invite:<ip>. 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: <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 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 <body>.
  • 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/<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-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/<token> → 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).