# Migration: bland → tessera **Apply this in a separate PR against `~/code/bland` after tessera is live and validated.** Do not modify bland from the tessera repo. --- ## What changes Bland today stores `users.password_hash` (Argon2id PHC, same shape as tessera's) and mints its own jose-signed JWTs (15-min access + 7-day refresh in `bland_refresh` httpOnly cookie). After this migration: - Sign-in becomes a redirect to tessera's `/api/auth/oauth2/authorize`. - Bland's `users.id` and every FK that references it stay **completely untouched**. - A new `tessera_sub TEXT UNIQUE` column on `users` is the join key. OIDC callback finds the local user by `tessera_sub`; first-time sign-in binds by email. - `users.password_hash` is dropped after every active user has signed in via tessera at least once. `hashPassword` / `verifyPassword` removed from `src/worker/lib/auth.ts`. - **Bland's jose JWT session stays.** The OIDC callback validates the tessera ID token, then mints bland's own access+refresh JWTs exactly as today. This minimizes downstream-middleware churn — `requireAuth` / `optionalAuth` in `src/worker/middleware/auth.ts` are unchanged. --- ## Code changes ### 1. New env / secrets `wrangler.jsonc` `vars`: ```jsonc "TESSERA_OIDC_ISSUER": "https://auth.limic.dev", "TESSERA_OIDC_CLIENT_ID": "" ``` `wrangler secret put TESSERA_OIDC_CLIENT_SECRET`. ### 2. New OIDC callback route Add `src/worker/routes/oidc.ts` with `handleOidcStart` + `handleOidcCallback` (same shape as the anvil migration; see [`anvil.md`](./anvil.md) § 2 — bland's tweak is that the callback mints jose tokens via `createAccessToken` / `createRefreshToken` from `src/worker/lib/auth.ts` and sets the `bland_refresh` cookie via `setRefreshCookie` exactly as `routes/auth.ts:30-71` does today). Mount: ```ts app.get("/auth/start", handleOidcStart); app.get("/auth/callback", handleOidcCallback); ``` ### 3. Replace POST `/auth/login` `src/worker/routes/auth.ts:30-71` — delete the password-form handler. The bland-side `/login` UI becomes a "Sign in with tessera" button that redirects to `/auth/start`. ### 4. Drop `password_hash` ```sql -- New migration: ALTER TABLE users ADD COLUMN tessera_sub TEXT; CREATE UNIQUE INDEX idx_users_tessera_sub ON users(tessera_sub); -- Follow-up, after every active user has tessera_sub populated: ALTER TABLE users DROP COLUMN password_hash; ``` Remove `hashPassword`, `verifyPassword`, and the Argon2 imports from `src/worker/lib/auth.ts`. ### 5. What stays unchanged - `users.id` and every FK column referencing it (`workspaces.owner_id`, `memberships.user_id`, `invites.invited_by/accepted_by`, `pages.created_by`, `pageShares.grantee_id/created_by`, `uploads.uploaded_by`). - jose-issued bland JWT session — `createAccessToken`, `createRefreshToken`, `setRefreshCookie`, `clearRefreshCookie`, `verifyAccessToken`, `requireAuth`, `optionalAuth`. - All workspace / page / share / upload paths. - Turnstile gating on remaining public surfaces (the sign-in form gating goes away with the form itself). --- ## Order of operations Same as anvil: 1. Land tessera, validate against the test client. 2. Register `bland` as an OAuth client in tessera. Save the secret. 3. Deploy bland with the new OIDC routes plus the existing password path still in place. 4. Have every active user sign in via tessera once — `tessera_sub` populates. 5. Ship the follow-up PR that drops `password_hash` and removes the password code path. Forced password reset applies — tessera does not carry over the bland-side PHC hashes.