Blob: docs/tessera-integration-plan.md
tessera integration plan
Purpose
Replace bland's email+password sign-in with tessera-issued OIDC identity while keeping bland's local JWT sessions and product authorization unchanged. This document is the source of truth for the integration; it pins the boundary, the identity-binding invariant, the callback state matrix, the test cells, the file-by-file changes, and the two-stage delivery.
Identity-binding invariant
One verified tessera sub maps to exactly one bland users.id; one bland user has at most one tessera identity; users.email is a tessera-owned profile projection used only as a one-time bind lookup and never as the long-term identity key.
Boundary decision
- tessera owns: human identity, password storage, verified email, bot protection on the identity flow, account linking across providers.
- bland keeps: HS256 access tokens (via
jose),bland_refreshcookie (HttpOnly, 7-day, non-rotating),bland_has_sessionhint cookie,requireAuthmiddleware, DocSync admission via?token=, workspace memberships and role RBAC, page/share authorization, share-token surfaces, R2 uploads gating, Sites publication checks, D1 bookmark propagation, Dexie local-replica ownership. - OIDC uses
openid-clientonly. tessera ID tokens are validated in the callback and discarded; we never store them or send them to a Durable Object.
Callback host policy
Both bland.tools and docs.limic.dev are bland app custom domains that reach the SPA router (Sites dispatch matches only PUBLISHED_SITE_DOMAIN, currently bland.site). tessera supports multiple redirect URIs per client, so the chosen deploy shape is to register both callbacks:
https://bland.tools/api/v1/oidc/callbackhttps://docs.limic.dev/api/v1/oidc/callback
The OIDC start route derives redirect_uri from new URL(c.req.url)'s origin and validates it against ALLOWED_ORIGINS via isAllowedOrigin. The transaction cookie is __Host-bland_oidc_tx with Secure, HttpOnly, Path=/, SameSite=Lax, maxAge=300s — the same shape in dev and prod. Modern browsers (Chrome, Firefox, Safari) treat localhost and 127.0.0.1 as potentially-trustworthy origins, so Secure cookies (and therefore __Host- prefixed cookies) are accepted over loopback HTTP. No dev/prod cookie-name branching is needed.
OIDC routes
Both routes mount under /api/v1 and use RL_AUTH.
GET /api/v1/oidc/start
- Validates
redirect_uriderivable from request origin againstALLOWED_ORIGINS. - Reads and sanitizes
return_toquery param (rules below). - Generates PKCE verifier+challenge (
oidc.randomPKCECodeVerifier,oidc.calculatePKCECodeChallenge),state(oidc.randomState),nonce(oidc.randomNonce). - Sets signed transaction cookie with HKDF-derived key from
TESSERA_OIDC_CLIENT_SECRET(purpose string"bland-oidc-transaction-v1"); payload{ state, nonce, codeVerifier, redirectUri, returnTo, createdAt }base64url-encoded JSON. - Calls
oidc.discovery(new URL(issuer), clientId, undefined, oidc.ClientSecretPost(clientSecret), discoveryOptions). Discovery is cached at module level by issuer URL string, 5-minute TTL.discoveryOptionsopts intooidc.allowInsecureRequestsonly when the issuer hostname is loopback. - Calls
oidc.buildAuthorizationUrl(config, { redirect_uri, code_challenge, code_challenge_method: "S256", state, nonce, scope: "openid email profile" }). - Returns 302 to the authorization URL.
GET /api/v1/oidc/callback
- Reads transaction cookie via
getSignedCookie. If missing/tampered/expired, clear cookie and redirect to/login?error=oidc_session_expired. - Verifies
statequery param matches cookie payload. - Validates
redirect_urihost matches request host (defense in depth — the__Host-cookie already guarantees this in production). - Calls
oidc.authorizationCodeGrant(config, callbackUrl, { pkceCodeVerifier: payload.codeVerifier, expectedNonce: payload.nonce, expectedState: payload.state }). The returned token set is queried viatokens.claims()(the openid-client v6 helper that returns the validated ID-token claims). - Validates claims: nonempty
sub,email_verified === true, usableemailstring, optionalname. Anything missing → clear tx cookie, redirect/login?error=oidc_<short_code>. No exception for already-boundsub: ifemail_verified !== true, fail closed. - Runs the identity-binding matrix.
- On success: clear tx cookie, mint bland access token + set
bland_refresh+bland_has_sessionvia existingsetRefreshCookie(), then redirect 302 to validatedreturn_towith the one-shot markeroidc=1appended (preserving any existing query string). The D1 bookmark from the callback's writes is propagated to the SPA via the subsequent/auth/refreshresponse, not via the redirect — see the D1 bookmark and identity-swap sections.
Identity-binding state/transition matrix
| # | Pre-state | Outcome | DB writes | Token mint | Redirect |
|---|---|---|---|---|---|
| M1 | tessera_identities.sub exists, email matches existing |
sign in existing user | UPDATE tessera_identities.last_seen_at |
yes | return_to |
| M2 | tessera_identities.sub exists, tessera email changed to a free email |
sign in same user, update email projection | UPDATE users.email; UPDATE tessera_identities.last_seen_at |
yes | return_to |
| M3 | tessera_identities.sub exists, tessera email collides with another bland user's email |
fail closed | none | no | /login?error=tessera_email_conflict |
| M4 | new sub, verified email matches unbound legacy bland user |
bind identity to existing user | INSERT tessera_identities (onConflictDoNothing, with .returning() to confirm row) |
yes | return_to |
| M5 | new sub, verified email matches already-bound user (different sub) |
fail closed | none | no | /login?error=identity_conflict |
| M6 | new sub, new email |
create user + identity + default workspace + owner membership | db.batch (4 inserts; IDs generated upfront with ulid()) |
yes | return_to |
| M7 | racing M6 for same sub |
exactly one batch wins | loser re-reads canonical user via findUserByTesseraSub |
yes (for both) | return_to |
| M8 | email_verified !== true or missing email |
fail closed | none | no | /login?error=oidc_unverified_email |
| M9 | missing/tampered/expired tx cookie OR bad state | fail closed | none | no | /login?error=oidc_session_expired |
| M10 | failed discovery OR failed token exchange | fail closed | none | no | /login?error=oidc_provider_error |
| M11 | callback while existing bland session, same sub | idempotent: update last_seen_at, mint fresh tokens |
per M1/M2 | yes | return_to (with oidc=1 marker) |
| M12 | callback while existing bland session, different sub | sign in the new sub; old session is replaced server-side; SPA must block render until refresh + owner validation complete (see Post-OIDC client bootstrap) | per matching row of M1/M2/M4/M6 | yes (new sub) | return_to (with oidc=1 marker) |
Implementation flow for binding(claims) → outcome:
existing = findUserByTesseraSub(db, sub)
if (existing) {
if (existing.email !== claims.email) {
try { UPDATE users SET email = claims.email WHERE id = existing.id }
catch (UNIQUE) { return M3 }
}
UPDATE tessera_identities SET last_seen_at = now WHERE sub = claims.sub
return M1 or M2
}
legacyByEmail = SELECT users WHERE email = claims.email
if (legacyByEmail) {
inserted = INSERT INTO tessera_identities (sub, user_id)
VALUES (?, ?) ON CONFLICT DO NOTHING RETURNING sub
if (inserted.length === 1) return M4
// lost race: another callback bound this sub or this user
recheck = findUserByTesseraSub(db, sub)
if (recheck && recheck.id === legacyByEmail.id) return M4 (raced ourselves; benign)
return M5
}
// brand new: M6 with race-safe pattern
userId = ulid(); workspaceId = ulid()
try {
db.batch([
INSERT INTO users (id=userId, email=claims.email, name=claims.name||fallback,
password_hash=PASSWORD_DISABLED_SENTINEL),
INSERT INTO tessera_identities (sub, user_id=userId),
INSERT INTO workspaces (id=workspaceId, name, slug=defaultSlugWithIdSuffix(name, workspaceId),
owner_id=userId),
INSERT INTO memberships (user_id=userId, workspace_id=workspaceId, role="owner"),
])
return M6
} catch (UNIQUE on sub OR users.email) {
recheck = findUserByTesseraSub(db, sub)
if (recheck) return M7 (sign in canonical)
// email collided with a user that isn't ours -> identity conflict
return M5
}users.name is NOT NULL in the existing schema. tessera seeds name only on M6 (with a sane fallback derived from the email local part if the name claim is missing). M1/M2/M4 do not touch users.name; users keep any bland-local name they may have set.
Schema
New table in src/worker/db/d1/schema.ts:
export const tesseraIdentities = sqliteTable(
"tessera_identities",
{
sub: text("sub").primaryKey(),
user_id: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
created_at: text("created_at")
.notNull()
.default(sql`(datetime('now'))`),
last_seen_at: text("last_seen_at"),
},
(table) => [uniqueIndex("idx_tessera_identities_user_id").on(table.user_id)],
);Migration: drizzle/d1/0004_tessera_identities.sql (drizzle-kit generated; do not hand-edit unless explicitly required by review).
Test helpers (tests/worker/helpers/*) — add tessera_identities to deletion order before users in resetD1Tables. ON DELETE CASCADE is also declared on the FK as defense in depth.
return_to sanitization
Apply at both /oidc/start query parse and /oidc/callback cookie restore (defense in depth):
export function sanitizeReturnTo(raw: string | undefined): string {
if (!raw) return "/";
const v = raw.trim();
if (!v.startsWith("/") || v.startsWith("//")) return "/";
if (v.includes("://") || v.includes("\\")) return "/";
if (/[\x00-\x1f\x7f]/.test(v)) return "/";
return v;
}Password sentinel
In src/worker/lib/auth.ts:
export const PASSWORD_DISABLED_SENTINEL = "tessera!disabled";verifyPassword(password: string, stored: string) short-circuits false when its second argument equals PASSWORD_DISABLED_SENTINEL — before any Argon2 PHC parse. The sentinel is !-bearing so it is definitively non-PHC.
Stage 2 deletes both the constant and the helper module if no remaining caller exists.
Profile semantics
users.email: tessera-owned. Updated on every callback from verified tessera email (M2, with M3 conflict handling). Profile UI is display-only.users.name: bland-local after first creation. tessera seedsnameonly on M6.PATCH /api/v1/auth/me(UpdateProfileRequest) acceptsnameandavatar_url; no email field exists today, so no email-update rejection path is needed.users.avatar_url: bland-local. Not derived from tessera; existing R2 avatar uploads continue.
Invite changes
GET /api/v1/invite/:token(preview): unchanged, public.POST /api/v1/invite/:token/accept: now strictly auth-required viarequireAuth. Body no longer acceptsemail/password/name/turnstileToken. The existing conditionalUPDATE invites+INSERT-SELECT membershipsatomicity gate (src/worker/routes/invites.ts:267-294, including the.returning({ id: invites.id })claim probe) is preserved and remains the concurrency authority.- Email-pinned compare:
invite.email === c.get("user")!.email.toLowerCase().
Client invite page (src/client/components/auth/invite-page.tsx):
- Drop password/name fields and Turnstile widget.
- Unauthenticated state: single CTA
Sign in with tessera to accept. Thereturn_toquery value is built withURLSearchParamsso the nested?accept=1is properly encoded, e.g.`/api/v1/oidc/start?${new URLSearchParams({ return_to: `/invite/${token}?accept=1` })}`, which yieldsreturn_to=%2Finvite%2F...%3Faccept%3D1. Never concatenate raw paths into the query string. - Authenticated state: single
Accept invitebutton. - When
?accept=1ANDuseAuth.isAuthenticated, auto-submit the accept POST on mount. State machine:awaiting-bootstrap→accepting→accepted | failed. Cover via focused client test.
Session bootstrap (src/client/lib/session-bootstrap.ts): extend getSessionBootstrapStrategy() so that when bland_has_session === "1", /invite/:token is treated as block-on-refresh (currently public). The post-OIDC oidc=1 marker (see Post-OIDC client bootstrap) takes precedence and forces block regardless of stored user. Add a client test for both rules.
Turnstile removal (Stage 1)
Delete:
src/worker/middleware/turnstile.tsand all imports.- Turnstile gates in
src/worker/routes/auth.ts(POST /auth/loginis being deleted entirely). - Turnstile gates in
src/worker/routes/invites.tsaccept endpoint. TURNSTILE_SECRET,TURNSTILE_SITE_KEYentries from.dev.vars.example(these are not present inwrangler.jsoncroot vars today; if production has Turnstile secrets set viawrangler secret put, delete them after deploy).<TurnstileWidget/>fromsrc/client/components/auth/login-page.tsxandinvite-page.tsx.- Any public config field exposing
TURNSTILE_SITE_KEYto the client. - CSP
script-srcTurnstile allowance. - All Turnstile test references.
Routes deleted (Stage 1)
POST /api/v1/auth/login: deleted entirely (not a stub).- Client
api.auth.login()removed fromsrc/client/lib/api.ts. useAuth().login()removed fromsrc/client/hooks/use-auth.ts(keeplogout(); refresh path stays implicit via api layer).
Routes preserved (Stage 1)
POST /api/v1/auth/refresh: unchanged. Continues to consumebland_refresh, mint a fresh access token, and rotate nothing.POST /api/v1/auth/logout: unchanged — clearsbland_refresh+bland_has_session. RP-initiated tessera logout viaend_session_endpointis deferred (see Deferred Work).GET /api/v1/auth/me: unchanged.PATCH /api/v1/auth/me: continues to acceptname/avatar_url.
Client API auto-refresh
The OIDC callback and start routes are top-level navigations, not JSON API calls — sendApiRequest never sees their responses. No change is required to the 401 auto-refresh logic in src/client/lib/api.ts. Document the assumption inline so a future refactor that funnels OIDC responses through sendApiRequest knows to add an exclusion.
DocSync admission
Unchanged. Client passes ?token=<accessToken> (bland HS256 JWT) to the DocSync Durable Object connection upgrade. Token refresh on close stays in decideDocSyncRefresh(). tessera tokens are never sent to a Durable Object.
D1 bookmark propagation
The OIDC callback is a top-level 302 navigation; browser JS cannot persist custom response headers from a redirect, so attaching the bookmark to the redirect response itself accomplishes nothing on the client. Today refreshSession() in src/client/lib/api.ts:52 calls requestSessionRefresh() directly with fetch() and does not invoke persistBookmark(). So even though the Worker's session DB attaches D1_BOOKMARK_HEADER to the refresh response, the SPA currently discards it.
Stage 1 must update refreshSession() to call persistBookmark(res) on the successful refresh path. After the OIDC callback, the SPA's post-OIDC bootstrap calls /auth/refresh (POST, first-primary), the Worker session DB returns the latest bookmark in the response header, and persistBookmark() stores it. Subsequent GETs (workspaces, invite preview, page reads) include the stored bookmark and observe M4/M6 writes.
Tests:
- focused unit:
refreshSession()persists the response bookmark when the header is present, leaves storage untouched on failure - worker → E2E: first-time OIDC callback writes propagate via the refresh-response bookmark to a subsequent
/workspacesGET
Post-OIDC client bootstrap
The OIDC callback redirects to return_to with the one-shot marker oidc=1 appended (built via URLSearchParams so existing query keys are preserved without clobbering). The SPA detects the marker and forces a synchronous refresh + owner validation pass before rendering any cached user data, then strips the marker from the URL.
Concretely, getSessionBootstrapStrategy() in src/client/lib/session-bootstrap.ts grows a new injectable argument (e.g. search?: string or href?: string, mirroring the existing cookieHeader injection point) and gains a new pre-check that runs before the existing hasStoredUser/hint-cookie branches:
if (new URLSearchParams(search).has("oidc")) return "block";The caller in src/client/main.tsx (or the bootstrap entry) passes location.search (or the full location.href) in, preserving the helper's test contract — the function stays pure and injectable for DOM tests.
After the block strategy completes (refresh + owner validation), the SPA strips the oidc query param via history.replaceState() so a subsequent reload does not re-trigger the block.
This rule is what makes M11 and M12 correct across the cross-boundary case: even if the SPA has a stored user from a prior session (Zustand persisted state, Dexie local-replica owner), the marker forces full revalidation against the freshly-minted bland JWT and a fresh owner check for Dexie. Without the marker, M12 could render stale workspace lists and Dexie data for the previous user before refresh completes.
Tests:
- DOM:
getSessionBootstrapStrategy()returns"block"whenoidc=1is present in the URL, even with a stored user - DOM: after the post-OIDC
blockpath completes, the URL no longer containsoidc=1 - E2E: M11 (same sub) callback into existing stored user → SPA renders same user, no flicker
- E2E: M12 (different sub) callback into existing stored user → SPA never renders the prior user's data before refresh completes
tessera issuer URL policy
In src/worker/lib/oidc.ts on first call (lazy init):
- Normalize
TESSERA_OIDC_ISSUER(strip trailing slash). - Require HTTPS unless hostname is loopback (
localhost,127.0.0.1,::1). - Throw fail-closed at first use on invalid config.
- After discovery, re-validate the returned
authorization_endpoint,token_endpoint, andjwks_uriagainst the same policy.
Worker test seam
src/worker/lib/oidc.ts exports an internal __test namespace, reachable only from test code via direct module import (not via HTTP):
export const __test = {
setProviderForTesting(issuer: string, config: oidc.Configuration): void { ... },
setAuthorizationCodeGrantImpl(
fn: ((c: oidc.Configuration, url: URL, opts: oidc.AuthorizationCodeGrantChecks)
=> Promise<{ claims(): IdTokenClaims }>) | null,
): void { ... },
clear(): void { ... },
};Tests preload a hand-constructed oidc.Configuration (mirroring goc's test/util/oidcFake.ts) and stub the token-grant step to return an object whose .claims() returns the desired ID-token claims.
File-by-file touch list (Stage 1)
New files:
src/worker/lib/oidc.ts— discovery cache, transaction cookie helpers, claim validators, issuer URL policy,__testseam.src/worker/routes/oidc.ts—/oidc/start,/oidc/callbackhandlers; identity-binding logic per matrix.drizzle/d1/0004_tessera_identities.sql— drizzle-kit generated migration.tests/worker/routes/oidc.workers.test.ts— Worker runtime tests, one per matrix cell where Worker bindings matter.tests/worker/lib/oidc.test.ts— pure unit tests for issuer policy, tx cookie helpers,sanitizeReturnTo.tests/worker/db/tessera-identities.workers.test.ts— schema/migration tests (uniqueness, sentinel insert, legacy load).
Modified files:
src/worker/db/d1/schema.ts— addtesseraIdentitiestable.src/worker/lib/auth.ts— addPASSWORD_DISABLED_SENTINEL;verifyPasswordshort-circuit guard. Retain JWT/cookie helpers.src/worker/routes/auth.ts— deletePOST /auth/login; keep/refresh,/logout,/me(GET + PATCH).src/worker/routes/invites.ts— drop unauthenticated path and password/Turnstile fields; make accept strictlyrequireAuth; preserve atomicity gate.src/worker/router.ts— mountoidcRouterunder/api/v1.src/worker/middleware/turnstile.ts— delete file.wrangler.jsonc— addTESSERA_OIDC_ISSUERto rootvars(env-specific block not currently in use).TESSERA_OIDC_CLIENT_ID/TESSERA_OIDC_CLIENT_SECRETare production secrets set viawrangler secret put(not inwrangler.jsonc)..dev.vars.example— addTESSERA_OIDC_ISSUER,TESSERA_OIDC_CLIENT_ID,TESSERA_OIDC_CLIENT_SECRET; removeTURNSTILE_*.src/client/components/auth/login-page.tsx— replace password form with single tessera sign-in button linking to/api/v1/oidc/start?return_to=...; render?error=codes inline.src/client/components/auth/invite-page.tsx— drop password form / Turnstile; unauth CTA links to OIDC start with?accept=1; auto-submit on mount when authenticated with?accept=1.src/client/lib/session-bootstrap.ts— extend strategy:/invite/:tokenisblock-on-refresh whenbland_has_session=1.src/client/hooks/use-auth.ts— droplogin(); keeplogout()and identity rehydration.src/client/lib/api.ts— dropapi.auth.login; keeprefresh,logout,me. UpdaterefreshSession()to callpersistBookmark(res)on the successful refresh path so post-OIDC writes propagate to subsequent GETs.src/client/components/auth/turnstile-widget.tsx— delete (and call sites).tests/e2e/global-setup.ts— start@mongodb-js/oidc-mock-providerconfigured so the issued identity is selectable per request (e.g. by query param, header, or a per-spec configuration helper). Write the mock issuer/client into the isolated.dev.varsfor the test server. Seed one baseline tessera identity row (sub = e2e-baseline-sub, emaile2e@bland.test, workspace + owner membership) for fixtures that need an already-authenticated state. First-time-login and invite-new-user specs configure the mock provider to issue a different unseededsub+ email for that test only — they MUST NOT collide with the baseline.tests/e2e/fixtures/bland-test.ts—loginPage(page)navigates/api/v1/oidc/start?return_to=/(mock provider configured to issue the baseline identity), the provider auto-approves, the callback runs end-to-end, and on landing the helper calls/api/v1/auth/refreshto capture the access token for downstream API helper calls. A separateloginAsFreshTesseraUser(page, { sub, email, name })fixture configures the mock provider to issue the supplied identity for the next authorization request — used by the first-time-login spec and invite-new-user spec.tests/worker/routes/invite-accept.workers.test.ts— drop password-path tests; add tessera-bound user fixtures.tests/worker/helpers/*— addtessera_identitiestoresetD1Tables; addseedTesseraIdentity(sub, userId)helper.package.json— addopenid-client(deps),@mongodb-js/oidc-mock-provider(devDeps).
Tests mapped to matrix cells
| Cell | Test layer | File | What it asserts |
|---|---|---|---|
| M1, M11 | Worker | oidc.workers.test.ts |
returning sub with matching email mints tokens, updates last_seen_at |
| M2 | Worker | oidc.workers.test.ts |
returning sub with changed-but-free email updates users.email |
| M3 | Worker | oidc.workers.test.ts |
returning sub with email collision returns 302 to /login?error=tessera_email_conflict, no writes, no mint |
| M4 | Worker | oidc.workers.test.ts |
new sub with email matching unbound legacy user binds identity, no new workspace |
| M5 | Worker | oidc.workers.test.ts |
new sub with email matching already-bound user returns identity_conflict |
| M6 | Worker | oidc.workers.test.ts |
new sub creates user + identity + default workspace + owner membership; verify all four rows |
| M7 | Worker | oidc.workers.test.ts |
two concurrent M6 callbacks for same sub → exactly one user, both responses successful |
| M8 | Worker | oidc.workers.test.ts |
email_verified=false returns oidc_unverified_email, no writes |
| M9 | Worker | oidc.workers.test.ts |
missing/tampered tx cookie → oidc_session_expired; expired tx cookie also fails |
| M10 | Worker | oidc.workers.test.ts |
discovery / token-exchange failure → oidc_provider_error |
| M12 | Worker | oidc.workers.test.ts |
callback with valid existing bland session but different sub mints new tokens |
| sanitize | Node | oidc.test.ts |
sanitizeReturnTo covers all reject cases |
| issuer policy | Node | oidc.test.ts |
validateIssuerUrl requires HTTPS unless loopback; endpoint re-validation rejects mismatch |
| tx cookie | Node | oidc.test.ts |
sign/verify round-trip; tamper detection; expiry; __Host-bland_oidc_tx + Secure shape (same cookie shape in dev and prod since loopback is a secure context) |
| sentinel | Node | auth.test.ts |
verifyPassword returns false on sentinel, never invokes Argon2 |
| refresh bookmark | Node | api.test.ts |
refreshSession() persists the response bookmark when the header is present; storage untouched on failure |
| post-OIDC marker | DOM | session-bootstrap.dom.test.ts |
getSessionBootstrapStrategy() returns block when oidc=1 is in the URL even with a stored user; URL marker is stripped after block completes |
| migration apply | Worker | tessera-identities.workers.test.ts |
drizzle migration applies to existing D1 schema |
| sub uniqueness | Worker | tessera-identities.workers.test.ts |
inserting two rows with the same sub fails the UNIQUE/PK constraint |
| user_id uniqueness | Worker | tessera-identities.workers.test.ts |
inserting two identity rows for the same user_id fails the unique index |
| stage-1 insert | Worker | tessera-identities.workers.test.ts |
inserting a new user with password_hash = PASSWORD_DISABLED_SENTINEL succeeds |
| legacy load | Worker | tessera-identities.workers.test.ts |
existing users without identity rows load normally until first tessera bind |
| invite | Worker | invite-accept.workers.test.ts |
auth-required accept; email-pinned mismatch 403; concurrent accept atomicity preserved |
| client invite | DOM | invite-page.dom.test.tsx |
unauth CTA links to OIDC start; auth+?accept=1 auto-submits; bootstrap flash absent |
| client login | DOM | login-page.dom.test.tsx |
renders single tessera CTA; ?error= codes render inline |
| client bootstrap | DOM | session-bootstrap.dom.test.ts |
/invite/:token becomes block when bland_has_session=1 |
| E2E first-time | Playwright | tests/e2e/oidc-first-login.spec.ts |
new tessera user (fresh sub/email from per-spec mock config) → default workspace → first page creation; subsequent /workspaces GET observes the M6 writes via refreshed bookmark |
| E2E returning | Playwright | tests/e2e/oidc-returning.spec.ts |
second login → same user, no duplicate workspace |
| E2E M11 same-sub re-auth | Playwright | tests/e2e/oidc-reauth-same-sub.spec.ts |
callback while existing stored user with same sub: SPA renders same user, no flicker, marker stripped |
| E2E M12 sub swap | Playwright | tests/e2e/oidc-reauth-different-sub.spec.ts |
callback while existing stored user with different sub: SPA never renders the prior user's data before refresh + owner validation complete |
| E2E invite | Playwright | tests/e2e/oidc-invite.spec.ts |
invite → tessera sign-in → auto-accept lands in invited workspace |
| E2E email-pinned mismatch | Playwright | tests/e2e/oidc-invite-mismatch.spec.ts |
mismatched email-pinned invite fails with structured error |
Sequencing for Stage 1
- Schema + migration: add
tessera_identities, regenerate types. src/worker/lib/oidc.ts+ helpers (issuer policy, tx cookie,sanitizeReturnTo, discovery cache,__testseam).- Add
openid-clientand@mongodb-js/oidc-mock-providertopackage.json; install. src/worker/routes/oidc.ts(start + callback) + matrix logic; mount in router.wrangler.jsonc+.dev.vars.example: add OIDC env vars, drop Turnstile.src/worker/lib/auth.ts: add sentinel +verifyPasswordshort-circuit.- Delete
POST /auth/loginand Turnstile middleware/calls. (Worker tests will fail at this point — fix in step 8.) - Update
invites.tsto strictrequireAuth; drop password/Turnstile branches; update worker tests. - Client login page, invite page, session bootstrap, api layer, hooks.
- E2E global-setup: spin up
@mongodb-js/oidc-mock-provider; rewriteloginPage()fixture to drive the OIDC flow end-to-end. - Worker test seam wiring; cover matrix cells; add schema/migration tests.
- Run validation (typecheck, lint, build, focused test, e2e).
Stage 2 follow-up (separate PR)
- Drop
users.password_hashcolumn via drizzle-kit migration (D1 SQLite supportsALTER TABLE DROP COLUMN; drizzle-kit will emit the appropriate form). - Delete
hashPassword,verifyPassword,PASSWORD_DISABLED_SENTINEL,ARGON2_PARAMSfromsrc/worker/lib/auth.ts. - Remove
@noble/hashesif no remaining callers. - Drop any remaining password-specific request schemas and test fixtures.
- Re-run validation.
Validation
Run before opening PR:
npm run typecheck(includesemoji:generateprerun)npm run lint(does NOT runemoji:generate)npm run build(route wiring changed)npm run test— Node/DOM + Worker projectsnpm run test:e2e— full Playwright run with mock OIDC providernpm run format:checkornpx prettier --check <touched paths>
Deployment
Production:
- Register two redirect URIs at tessera for the bland client:
https://bland.tools/api/v1/oidc/callbackhttps://docs.limic.dev/api/v1/oidc/callback
- Add
TESSERA_OIDC_ISSUERto rootvarsinwrangler.jsonc(the current config has noenv.productionblock; introducing one is out of scope for this PR). wrangler secret put TESSERA_OIDC_CLIENT_IDwrangler secret put TESSERA_OIDC_CLIENT_SECRET- After deploy: delete any production
TURNSTILE_SECRET/TURNSTILE_SITE_KEYsecrets that may have been set.
Local dev: copy .dev.vars.example to .dev.vars; point TESSERA_OIDC_ISSUER to a dev tessera instance (loopback HTTP allowed).
Deferred work
- RP-initiated tessera logout (
end_session_endpoint) —/auth/logoutclears bland cookies only; the user's tessera session remains valid in their browser. - Refresh-token rotation remains deferred (already in CLAUDE.md Deferred Work).
- M5 ("identity conflict on bind") is a hard fail. There is no automated merge path; support intervention required.
- Workspace deletion still does not proactively clear DocSync DOs (unchanged).
- Stage 1 retains
users.password_hash NOT NULLcolumn with sentinel; removed in Stage 2.