# Tessera — Phase 1 Prompt: Design and Inventory You are working on a new project called `tessera` — an OIDC/OAuth 2.1 identity provider for the limic.dev suite. It will run on Cloudflare Workers + D1, with native Workers Rate Limiting for abuse throttling, built on Better Auth (`^1.5`, which is when the OAuth 2.1 Provider plugin was introduced, replacing the older OIDC Provider plugin). It will eventually be the IdP that Cloudflare Access uses (via Generic OIDC), and it will federate with GitHub and Google for social login + account linking. Hostname: **`tessera.limic.dev`**. OIDC issuer URL: **`https://tessera.limic.dev`**. The hostname determines the cookie domain, OIDC `iss` claim, GitHub/Google OAuth callback URLs, and the Cloudflare Access Generic OIDC endpoint config — it's load-bearing. The relevant repos are checked out locally under `~/code`: - `~/code/anvil` - `~/code/bland` - `~/code/ccccocc` - `~/code/git-on-cloudflare` - `~/code/flamemail` - `~/code/limic` (the landing site; contains `frontend-spec.md`) Before writing any code, produce two documents. ## Document 1: Per-project migration inventory For each repo under `~/code` listed above (except `limic`), write a short Markdown section covering: - Where user identity lives today (table names, column shapes, hash format, session mechanism) - Every place in the code that reads or writes user identity (file paths + function names, with line numbers where useful) - Every other table or DO that has a foreign key to the user identifier - What needs to change to consume tessera as an OIDC IdP - What stays unchanged Write this from code, not from READMEs. Cite file paths relative to the repo root (e.g. `src/worker/db/d1/schema/users.ts`). For `ccccocc` specifically: there is no data migration to do — workspace mapping is currently ephemeral and will be reworked separately after tessera lands. **Skip the standard inventory template for ccccocc; only fill in the section below.** Identify exactly which Cloudflare Access claim is read to derive the per-user sandbox ID today (likely `email` or `sub` from `Cf-Access-Jwt-Assertion`). Tessera must send this same value when it acts as the Generic OIDC IdP behind Access, so when ccccocc is later upgraded to persist workspace state it can do so against a stable identity that tessera issues. Document the claim, the file path, and what tessera needs to guarantee about that claim's stability. For `git-on-cloudflare` specifically: identify the boundary between the git HTTP Basic-auth token system (which stays unchanged — it's machine credentials for `git push`) and the web admin UI (which moves behind tessera). List the specific routes / handlers on each side. For `flamemail` specifically: confirm that the only persistent identity is the admin shared password, and that public inbox tokens are anonymous capability URLs that don't interact with identity at all. Additionally — separate from the per-project sections — produce a **Turnstile-pattern reference table** with the specific file paths for the Turnstile integration in `anvil`, `bland`, and `flamemail`: where the secret is read, where the client-side widget is rendered, where the server-side `siteverify` call happens, and where the fail-closed check lives. Tessera will mirror this pattern, so the agent (and any reviewer) needs the canonical references in one place. Also produce an **encryption-at-rest reference**: anvil encrypts repo tokens and webhook secrets in D1 with AES-GCM using a wrangler-secret- derived key. Cite the exact file paths in anvil that implement this (encryption helper, key derivation, where it's called on insert/select). Tessera will mirror this pattern for sensitive D1 columns. ## Document 2: tessera design doc Cover: ### Identity & claims - The OIDC claim schema tessera will issue. `sub` must be a stable opaque identifier (UUID, never email). `email`, `name`, `email_verified` are standard. Decide whether anything else is needed for downstream projects. Cross-check the claim shape against ccccocc's Access-claim usage from Document 1 — tessera's claims must satisfy what ccccocc reads through Access. - Account-linking model: the limic operator (one human, me) signs in with email+password initially, then can link a GitHub identity and a Google identity. All three resolve to the same `sub`. - Migration strategy: forced password reset. There is exactly one human user (me). Existing anvil and bland password hashes are NOT carried into tessera. Existing per-project user rows in anvil and bland get rewritten to reference the new tessera `sub` (not email) once tessera is live. Document that rewrite as a one-time SQL migration per project. ccccocc has no migration; it consumes tessera through Cloudflare Access once tessera is registered as the Access IdP. ### Auth primitives - Password hashing: **Argon2id via `@noble/hashes/argon2.js` (pure JavaScript, no WASM)**, with parameters m=19456 (19 MiB), t=2, p=1, output length 32 bytes. Wire it into Better Auth via the `emailAndPassword.password.{hash,verify}` config, returning an encoded PHC string of the form `$argon2id$v=19$m=19456,t=2,p=1$$`. **Why pure-JS, not WASM**: workerd refuses to compile WebAssembly modules from byte arrays at runtime (`CompileError: Wasm code generation disallowed by embedder`). This rules out `@awasm/noble`, `hash-wasm`, and every other library that ships its WASM as base64-inlined bytes — both have been verified to fail on workerd. The pure-JS noble-hashes Argon2id has been tested and works, with ~1s per hash at the params above. That performance is acceptable for tessera's single-user scale and well within the 30s Workers CPU limit. Document the exact PHC string format, salt generation (16 random bytes from `crypto.getRandomValues`), and a constant-time comparison helper for the verify path. - JWT signing for ID tokens: **RS256 with keys managed by Better Auth's OAuth 2.1 Provider plugin** (don't reimplement key generation or rotation — use the plugin's defaults). Public keys exposed at `/.well-known/jwks.json`. Document the rotation expectation (the plugin supports overlapping `kid`s, so JWKS shows current + previous to give RPs a grace window). - Invite flow: invite-only registration, no open signup endpoint. Design the schema and flow concretely: - `invites` table: id, token_hash (SHA-256 of a ~32-byte random raw token), email (bound at mint time), created_by, created_at, expires_at (default 7 days), consumed_at. Reference `~/code/anvil/src/worker/db/d1/schema/invites.ts` and `~/code/bland/src/worker/db/d1/schema.ts` for shape. - URL: `https://tessera.limic.dev/invite/`. - Consumption: the link lands on a sign-up page; on submit, tessera runs a single CAS that sets `consumed_at` where the row is still pending and unexpired, then calls Better Auth's `signUpEmail` with the invite-bound address. Failures after the CAS leave the invite consumed — operator mints a new invite if needed. - Expired or consumed tokens return a clear error page, not a 404. ### Endpoints & integration - Endpoints tessera exposes: OIDC discovery at `/.well-known/openid-configuration`, JWKS at `/.well-known/jwks.json`, `/authorize`, `/token`, `/userinfo`, plus the login UI pages. - OAuth client registration: **static configuration only — no dynamic client registration**. Downstream apps (anvil, bland, etc.) register as clients via a row inserted into D1 with their `client_id`, hashed `client_secret`, redirect URIs, and allowed scopes. The OPERATOR.md will document the `wrangler d1 execute` command to mint a client; a small admin UI is acceptable but not required for v1. - How Cloudflare Access integration works: tessera as the Generic OIDC provider for the Access team. Document the callback URL shape (`https://.cloudflareaccess.com/cdn-cgi/access/callback`), the claim mapping, and how ccccocc (the reference Access consumer) will see tessera-issued identity once the IdP swap happens. ### Storage & secrets The mental model: **wrangler secrets are for "things tessera needs to be tessera"** (its own identity, its upstream OAuth credentials, the master encryption key). **D1 is for "things tessera issues to others"** (downstream client credentials, signing keys it rotates). - **Wrangler secrets** (static, rarely rotated): - `BETTER_AUTH_SECRET` — session/state encryption key - `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET` — tessera as a GitHub OAuth client - `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET` — same for Google - `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` - `D1_ENCRYPTION_KEY` — AES-GCM master key for at-rest encryption of sensitive D1 columns; pattern mirrors anvil's repo-token encryption (cite the specific anvil file paths from the encryption-at-rest reference in Document 1) - **D1** (per-entity, dynamic): - Better Auth's user / account / session tables - `oauth_clients` table for downstream RPs (anvil, bland, etc.) — columns: client_id, client_secret_hash, redirect_uris (JSON array), scopes, created_at. The `client_secret_hash` should use the same Argon2id parameters as user passwords (consistent with how GitHub / GitLab hash their PATs). - JWT signing keys — Better Auth's OAuth 2.1 Provider plugin manages these. Document the table name the plugin uses and confirm the private-key bytes are encrypted at rest (either by the plugin or by a column-level wrapper using `D1_ENCRYPTION_KEY`). - `invites` table - **Workers Rate Limiting binding**: - Public auth throttling for `/sign-in` and `/invite/*` (per IP, 10 attempts per minute, 429 with `Retry-After` on exceed) - Better Auth auth/session/OIDC state remains D1-backed through the Drizzle adapter; do not configure KV secondary storage. ### Turnstile Tessera gates `/sign-in` and the invite-acceptance form (the page served at `/invite/`) with Cloudflare Turnstile, mirroring the pattern in anvil, bland, and flamemail. The Turnstile-pattern reference table from Document 1 should be cited here so the implementation step can directly copy the pattern. Fail-closed: if Turnstile env vars are missing or the `siteverify` call fails, reject the request. Local dev uses Cloudflare's Turnstile test keys (the same ones anvil and bland use in their `.dev.vars.example`). ### Frontend - This is a limic.dev project, so it follows `~/code/limic/frontend-spec.md` exactly. React 19 + Vite 7 + Tailwind v4, warm zinc, lifted canvas, 75ms transitions, Hanken Grotesk + JetBrains Mono + one display font of your choice. Verify the chosen display font is not already used by anvil, bland, flamemail, git-on-cloudflare, or ccccocc — collisions violate the "Consistently Distinctive" philosophy. - Pick an accent color that doesn't clash with anvil (blue), bland (amethyst), flamemail (orange), or git-on-cloudflare (indigo). Propose three accent options with hex values and a justification for each. - Do NOT install Better Auth UI or shadcn — use the existing limic UI primitive patterns (button.tsx, card.tsx, input.tsx, etc.) and write the auth pages directly. **Use `~/code/anvil/src/client/components/ui/` as the canonical structural reference** when anvil and bland diverge. Submit both documents for review. Do not write code yet.