Skip to content
File

Blob: docs/migrations/flamemail.md

Markdown81 lines

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/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:

"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 / 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.