Skip to content
File

Blob: docs/tessera-phase-2-implementation.md

Markdown200 lines

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$<b64-salt>$<b64-hash>. 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/<token>). 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/<token> 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.