# Migration: flamemail → tessera **Apply this in a separate PR against `~/code/flamemail` after tessera is live and validated.** --- ## What changes Flamemail today identifies the operator via a single `ADMIN_PASSWORD` wrangler secret. Login at `/admin/login` Turnstile-checks then string-compares (`constantTimeEqualStrings` against `crypto.subtle.timingSafeEqual`) and mints a KV-backed admin session token (`tok_`). After this migration: - The `/admin/login` form becomes an OIDC redirect to tessera. The Turnstile gate is removed (tessera handles human verification at sign-in). - The KV admin-session mechanism stays — it just gets minted at the OIDC callback instead of after a password compare. - `ADMIN_PASSWORD` is removed. There is **no D1 migration** — flamemail has no users table. The `inboxes` / `emails` / `attachments` / `domains` tables have no FK to identity. --- ## Code changes ### 1. New env / secrets `wrangler.jsonc` `vars`: ```jsonc "TESSERA_OIDC_ISSUER": "https://auth.limic.dev", "TESSERA_OIDC_CLIENT_ID": "", "OPERATOR_SUB": "" ``` `wrangler secret put TESSERA_OIDC_CLIENT_SECRET`. `wrangler secret delete ADMIN_PASSWORD` (after the cutover). ### 2. Replace the admin login handler `src/worker/api/admin.ts:79` — the `constantTimeEqualStrings` compare against `c.env.ADMIN_PASSWORD` is replaced with a redirect to `/auth/start`. The new `/auth/callback` validates tessera's ID token, asserts `payload.sub === env.OPERATOR_SUB`, then mints the existing admin session token via `createSessionToken` (`src/worker/api/admin.ts:88-94`) — completely unchanged. Sketch: ```ts // src/worker/api/admin.ts (rewritten) export const handleAdminCallback = async (c: AppContext): Promise => { const code = c.req.query("code"); const state = c.req.query("state"); // ... PKCE verifier from KV under `oidc:state:`, then: const { payload } = await jwtVerify(idToken, jwks, { issuer: env.TESSERA_OIDC_ISSUER }); if (payload.sub !== env.OPERATOR_SUB) { throw new HttpError(403, "not_operator", ADMIN_ACCESS_UNAVAILABLE_MESSAGE); } const token = await createSessionToken(c.env, { type: "admin" }); return c.redirect("/admin", { headers: { "set-cookie": cookieFor(token) } }); }; ``` ### 3. Remove the password form The login form at `/admin/login` becomes a single "Sign in with tessera" button. The Turnstile widget on that page is removed — tessera handles verification. `getAdminPasswordConfigurationIssue` and the related `MIN_ADMIN_PASSWORD_LENGTH` / `DISALLOWED_ADMIN_PASSWORDS` validation in `src/worker/security.ts` are deleted. ### 4. CSP `APP_CONTENT_SECURITY_POLICY` in `src/worker/security.ts:7-19` currently allows `https://challenges.cloudflare.com` for the Turnstile widget. The Turnstile-on-admin-login goes away, but the `connect-src` and `frame-src` allowances stay if Turnstile is still used elsewhere (e.g. on the public anonymous-inbox creation flow). Verify before tightening. ### 5. What stays unchanged - `inboxes` / `emails` / `attachments` / `domains` D1 tables. - The public anonymous inbox capability flow (`POST /api/public/inboxes` returning `{address, token, ttlHours, expiresAt}`). Those tokens are capability tokens, not identity, and tessera does not change them. - The KV-backed `{ type: "admin" }` session record shape — minted at the OIDC callback instead of after a password compare. - All email reception, storage, and attachment handling. - `withSecurityHeaders`, `issueNoStoreHeaders`, `isAllowedWebSocketOrigin`, `decodeWebSocketTicket`, `decodeSessionRecord` — orthogonal to identity. --- ## No forced password reset Flamemail had a single shared `ADMIN_PASSWORD` rather than per-user credentials. There is no per-user password to migrate, and no user records to flip a `tessera_sub` on.