# Tessera — Phase 2 Prompt: Implementation Implement tessera per the approved design docs at `~/code/tessera/docs/phase-1-design.md` (or wherever Phase 1 was saved). The tessera repo should be created at `~/code/tessera`, deployed to `tessera.limic.dev`. The other limic repos remain available read-only under `~/code` for reference (`anvil`, `bland`, `ccccocc`, `git-on-cloudflare`, `flamemail`, `limic`) — match their conventions where they exist (project layout, scripts, Drizzle config, `wrangler.jsonc` shape, `.prettierrc`, etc.). Do not modify any of those repos in this PR. ## Constraints - Stack per `~/code/limic/frontend-spec.md` (React 19, Vite 7, Tailwind v4 with warm-zinc theme, Hono on Workers, Drizzle on D1, KV for session and short-lived state, strict TypeScript). Match the directory layout that anvil and bland use; when they diverge, follow anvil for the canonical structure. - Better Auth `^1.5` as the framework. OAuth 2.1 Provider plugin enables OIDC. GitHub and Google social providers enabled. Account linking enabled (multiple credentials → one user, same `sub`). - **Password hashing: Argon2id via `@noble/hashes/argon2.js` (pure JS, no WASM)**, m=19456 t=2 p=1 hashLength=32, wired into Better Auth's `emailAndPassword.password.{hash,verify}`. Return an encoded PHC string: `$argon2id$v=19$m=19456,t=2,p=1$$`. Salt = 16 random bytes from `crypto.getRandomValues`. Verify path parses the PHC string for params, recomputes, and uses constant-time comparison. WASM-based Argon2 libraries (`@awasm/noble`, `hash-wasm`) do NOT work on Cloudflare Workers — workerd disallows runtime WASM compilation from byte arrays. Use only the pure-JS noble path. ~1s per hash at these params is expected and acceptable. - **JWT signing**: RS256, keys managed by Better Auth's OAuth 2.1 Provider plugin defaults. Don't reimplement key generation or rotation. Public keys exposed at `/api/auth/jwks` (with discovery linking from `/.well-known/openid-configuration`). - **OAuth client registration**: static only. No dynamic client registration. Downstream RPs are inserted into D1's `oauth_clients` table by an operator running a documented `wrangler d1 execute` command. The OPERATOR.md must include this command with a worked example. - **Secrets architecture** per the design doc: - Wrangler secrets: `BETTER_AUTH_SECRET`, `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET`, `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`, `D1_ENCRYPTION_KEY` - D1: user/account/session/oauth_clients/invites tables, plus Better Auth's signing-key table (private key bytes encrypted at rest) - Workers Rate Limiting binding: public auth throttling - Sensitive D1 columns use the same AES-GCM encryption-at-rest pattern as anvil. Reference the specific anvil file paths cited in the Phase 1 inventory's encryption-at-rest reference; copy the helper, don't reinvent. - **Turnstile** gates `/sign-in` and the invite-acceptance form (`/invite/`). Mirror the pattern from anvil, bland, and flamemail — reference the specific file paths cited in the Phase 1 Turnstile- pattern reference table. Fail-closed: if Turnstile env vars are missing or `siteverify` fails, reject the request. Use the standard Turnstile test keys for local dev (the same ones in anvil's and bland's `.dev.vars.example`). - **Rate limiting**: `/sign-in` and `/invite/` rate-limited via the native Workers Rate Limiting binding at 10 attempts per minute per IP, returning 429 with `Retry-After` on exceed. - Invite-only registration. No open signup. Implement the invite-token flow per the design doc — single-use tokens, optional email-binding, default 7-day expiry, atomic consume on sign-up. Reference `~/code/anvil/src/worker/db/d1/schema/invites.ts` and `~/code/bland/src/worker/db/d1/schema.ts` for table shape. - No Better Auth UI dependency. No shadcn install. Build pages using primitive components (button, card, input, error-banner, page-header) following `~/code/limic/frontend-spec.md` and using `~/code/anvil/src/client/components/ui/` as the canonical structural reference. - Frontend follows the spec section-by-section: header pattern, footer pattern, app-shell, animation rules, accessibility (skip link, focus-visible, prefers-reduced-motion). ## Implementation order 1. Worker scaffolding: Hono router, Better Auth init with the D1 adapter, OAuth 2.1 Provider plugin mounted, OIDC discovery + JWKS endpoints responding with stub data. **Verify the Argon2id integration works: compute one test hash + verify round-trip during a smoke route, log the elapsed ms, confirm it lands in the ~700–1500ms range. If it throws or takes over 5s, stop and report — something is wrong with the import or the worker bundle.** 2. D1 schema: Better Auth's tables plus `oauth_clients` and `invites`. Wire up the AES-GCM encryption-at-rest helper for the sensitive columns (signing key private bytes, downstream client_secret_hash if not already hashed via Argon2id). Verify by writing one row and reading it back through the helper. 3. Email + password login with Turnstile gating. Verify the OIDC discovery endpoint, `/api/auth/oauth2/authorize`, `/api/auth/oauth2/token`, `/api/auth/oauth2/userinfo` round-trip with a tiny test client (a 30-line Worker in `scripts/test-client/` that simulates an OIDC RP). This step is load-bearing — do not skip it. The claims emitted here must match what the design doc specifies for Access compatibility (ccccocc reads these through the `Cf-Access-Jwt-Assertion` downstream). 4. GitHub social provider. Then Google. Then account-linking endpoints (link/unlink, list linked identities). Verify that linking a social identity to an existing account preserves the `sub` — log in with email+password, link GitHub, log out, log in via GitHub, confirm the ID token's `sub` is unchanged. 5. Frontend: sign-in (with Turnstile widget), sign-up-via-invite (with Turnstile widget), account settings (link/unlink social, change password), invite-accept landing. Each page is one file in `src/client/pages/`, wired to `authClient`. 6. Admin: a tiny page to mint invites. No fancy admin panel — just a form that produces a one-time link. ## Out of scope for v1 - MFA. Defer to a later iteration. - SAML. Defer. - Multi-tenancy / orgs. Tessera is single-tenant for the limic suite. - Email delivery for invites — for v1, mint the invite link, copy it from the admin UI, deliver it manually. Resend integration is a follow-up. - SCIM, audit logs, password recovery flows. Follow-ups. ## Tests - Unit tests for the verify-token middleware and the invite-token flow (including expiry, single-use enforcement, email-binding). - Unit test for the Argon2id hash + verify round-trip (asserts the PHC string matches the expected shape, asserts verify accepts the correct password and rejects a wrong one). - Unit test for the AES-GCM encryption-at-rest helper (round-trip encrypt → decrypt with `D1_ENCRYPTION_KEY`). - Unit test for Turnstile verification (mock the `siteverify` response; assert fail-closed when the env var is missing). - Unit test for the rate limiter (assert 429 with `Retry-After` after the threshold). - One e2e test: invite → sign up → log in → link GitHub → log out → log in via GitHub → confirm same `sub`. - One integration test: register the test client as an OAuth client, perform the full code flow, assert the ID token has the expected claims (matching the design doc's claim schema). ## Deliverables - The tessera Worker at `~/code/tessera`, deployable via `npm run deploy` to `tessera.limic.dev`. - A README following the same shape as anvil's and bland's READMEs. - An `OPERATOR.md` covering: - **Wrangler secrets to set** (full list with placeholder values and `wrangler secret put` commands). - **GitHub OAuth app setup**: where to create the app, what callback URL to register (`https://tessera.limic.dev/api/auth/callback/github` or the Better-Auth-specific path the plugin uses — verify against the plugin's docs), what scopes to request, where to put the resulting client_id / client_secret. - **Google OAuth app setup**: same pattern. - **Turnstile widget setup**: hostname `tessera.limic.dev`, mode "Managed". - **D1 / KV bindings** in `wrangler.jsonc`. - **How to mint the first invite** (bootstrap script — the operator will not be able to sign in until at least one invite has been minted). - **How to register tessera with Cloudflare Access as the Generic OIDC IdP** (this is the path ccccocc will consume once it gets persisted workspaces) — include the discovery URL, the Access UI steps, and the claim mapping. - **How to register a downstream project as an OAuth client** — the `wrangler d1 execute` command with a worked example for "anvil". - A `MIGRATION.md` per downstream project (`anvil`, `bland`, `git-on-cloudflare`, `flamemail`) describing the exact code changes and SQL migrations needed to cut that project over to tessera, _to be applied later in separate PRs against those repos_. Do not modify those repos in this PR. No `MIGRATION.md` for `ccccocc` — it consumes tessera through Cloudflare Access (no app-level changes needed) and its persisted- workspace work is a separate effort that will happen after tessera lands. The OPERATOR.md's Access-IdP section covers what ccccocc needs. Stop after the deliverables above. Do not migrate anvil or bland in this PR — that's a separate task per project, after tessera is live and validated against the test client and at least one Cloudflare Access integration.