Blob: docs/tessera-phase-1-design.md
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; containsfrontend-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.
submust be a stable opaque identifier (UUID, never email).email,name,email_verifiedare 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 theemailAndPassword.password.{hash,verify}config, returning an encoded PHC string of the form$argon2id$v=19$m=19456,t=2,p=1$<b64-salt>$<b64-hash>.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 overlappingkids, 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:
invitestable: 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.tsand~/code/bland/src/worker/db/d1/schema.tsfor shape.- URL:
https://tessera.limic.dev/invite/<token>. - Consumption: the link lands on a sign-up page; on submit, tessera
runs a single CAS that sets
consumed_atwhere the row is still pending and unexpired, then calls Better Auth'ssignUpEmailwith 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, hashedclient_secret, redirect URIs, and allowed scopes. The OPERATOR.md will document thewrangler d1 executecommand 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://<team>.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 keyGITHUB_OAUTH_CLIENT_ID,GITHUB_OAUTH_CLIENT_SECRET— tessera as a GitHub OAuth clientGOOGLE_OAUTH_CLIENT_ID,GOOGLE_OAUTH_CLIENT_SECRET— same for GoogleTURNSTILE_SITE_KEY,TURNSTILE_SECRET_KEYD1_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_clientstable for downstream RPs (anvil, bland, etc.) — columns: client_id, client_secret_hash, redirect_uris (JSON array), scopes, created_at. Theclient_secret_hashshould 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). invitestable
Workers Rate Limiting binding:
- Public auth throttling for
/sign-inand/invite/*(per IP, 10 attempts per minute, 429 withRetry-Afteron exceed) - Better Auth auth/session/OIDC state remains D1-backed through the Drizzle adapter; do not configure KV secondary storage.
- Public auth throttling for
Turnstile
Tessera gates /sign-in and the invite-acceptance form (the page served
at /invite/<token>) 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.mdexactly. 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.