Skip to content
File

Blob: docs/migrations/bland.md

Markdown80 lines

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:

"TESSERA_OIDC_ISSUER": "https://auth.limic.dev",
"TESSERA_OIDC_CLIENT_ID": "<minted in tessera /admin/clients>"

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

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

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