Blob: docs/migrations/flamemail.md
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_<nanoid(32)>).
After this migration:
- The
/admin/loginform 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_PASSWORDis 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:
"TESSERA_OIDC_ISSUER": "https://auth.limic.dev",
"TESSERA_OIDC_CLIENT_ID": "<minted in tessera /admin/clients>",
"OPERATOR_SUB": "<the tessera UUID for the limic operator>"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:
// src/worker/api/admin.ts (rewritten)
export const handleAdminCallback = async (c: AppContext): Promise<Response> => {
const code = c.req.query("code");
const state = c.req.query("state");
// ... PKCE verifier from KV under `oidc:state:<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/domainsD1 tables.- The public anonymous inbox capability flow (
POST /api/public/inboxesreturning{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.