Blob: docs/phase-1-inventory.md
Tessera — Phase 1 Document 1
Per-project migration inventory
This inventory captures how user identity works today in each repo affected by the tessera migration. All findings come from reading code, not READMEs. Paths are repo-relative (e.g. src/worker/auth/passwords.ts:42).
The two cross-cutting reference tables at the end (Turnstile pattern, encryption-at-rest) collect file paths tessera will mirror.
anvil
Identity storage today
Users table — src/worker/db/d1/schema/users.ts (lines 1–14)
id(text, primary key)slug,email,displayName,createdAt,disabledAt(nullable)
Password credentials table (separate from users) — src/worker/db/d1/schema/password-credentials.ts (lines 1–13)
userId(text, primary key, FK →users.id)algorithm,digest,iterations,salt(Uint8Array),passwordHash(Uint8Array),updatedAt
Hash format: PBKDF2 (not Argon2). Implementation src/worker/auth/passwords.ts:13-34 uses crypto.subtle.deriveBits(PBKDF2, digest, iterations, salt) → 256-bit key. 16-byte random salt; algorithm, digest, and iterations are stored on the row so verify can reproduce.
Session mechanism: KV-backed. src/worker/auth/sessions.ts:
- KV key shape:
sess:{sessionId}with TTL. - Record:
{ userId, issuedAt, expiresAt, version }(ISO8601). - Bearer-token auth — sessionId is sent as
Authorization: Bearer <id>(parsed insrc/worker/auth/middleware.ts:10).
Auth library: none. Custom Hono handlers + @cloudflare/util-en-garde codecs (no Better Auth).
Code reading/writing identity
| Operation | Location |
|---|---|
| Sign-in (POST /auth/login) | src/worker/api/public/auth.ts:62-106 (handleLogin) |
| Sign-up via invite (POST /auth/invite/accept) | src/worker/api/public/auth.ts:119-217 (handleInviteAccept) |
| Password hash | src/worker/auth/passwords.ts:37 (hashPassword) |
| Password verify | src/worker/auth/passwords.ts:52 (verifyPassword, timing-safe) |
| Session create | src/worker/auth/sessions.ts:19 (createSession) |
| Session read | src/worker/auth/sessions.ts:41 (readSession) |
| Session refresh | src/worker/auth/sessions.ts:66 (maybeRefreshSession) |
| Session delete | src/worker/auth/sessions.ts:100 (deleteSession) |
requireAuth middleware |
src/worker/auth/middleware.ts:23-39 (sets c.set("user", user)) |
| Logout | src/worker/api/public/auth.ts:108-117 (handleLogout) |
| Invite create (auth-gated) | src/worker/api/private/invites.ts:11-43 |
Foreign keys to user identifier
D1 (all reference users.id as text):
| Table | FK column | Schema file |
|---|---|---|
password_credentials |
userId |
src/worker/db/d1/schema/password-credentials.ts:6 |
project_index |
ownerUserId |
src/worker/db/d1/schema/projects.ts:8 |
run_index |
triggeredByUserId |
src/worker/db/d1/schema/run-index.ts:9 |
invites |
createdByUserId, acceptedByUserId |
src/worker/db/d1/schema/invites.ts:10, 13 |
Durable Object storage:
project_runs.triggeredByUserId(in ProjectDO) —src/worker/db/durable/schema/project-do.ts:34
DO stub naming is not keyed on user id (uses prj_/run_ prefixes via src/worker/services/id-service.ts:65-68).
What changes to consume tessera as an OIDC IdP
- Add OIDC client wiring: env vars
TESSERA_OIDC_ISSUER(=https://auth.limic.dev),TESSERA_OIDC_CLIENT_ID, wrangler secretTESSERA_OIDC_CLIENT_SECRET. New/auth/callbackroute exchangescodeathttps://auth.limic.dev/tokenand validates the ID token signature againsthttps://auth.limic.dev/.well-known/jwks.json. - Replace
handleLogin: sign-in becomes a redirect to tessera's/authorize(PKCE,state,nonce). The password form on/sign-inis deleted. - Drop
password_credentialstable once tessera is live for all anvil users (one-shot SQL:DROP TABLE password_credentials;). Removesrc/worker/auth/passwords.ts. - Add a
tessera_sub TEXT UNIQUEcolumn touserswith an index.users.idand every FK column referencing it stay untouched. The OIDC callback finds the local user bytessera_sub; on first sign-in for an email already inusers, the callback binds byUPDATE users SET tessera_sub = ?. (See Document 2 § Migration strategy for the design rationale; the actual migration code lives in anvil, not tessera.) - Invite flow: anvil's
invitestable stays for "invite this tessera-identified user into anvil" semantics;handleInviteAcceptbecomes "associate the OIDC-authenticated user with this anvil invite" — no password fields.
What stays unchanged
users.idcolumn and every FK column referencing it — completely untouched.- KV session mechanism — anvil still mints its own bearer-token session at the OIDC callback. The OIDC ID token is consumed once at callback, not on every request.
requireAuthmiddleware (still reads bearer, still callsreadSession).- All Turnstile gating on non-auth public surfaces.
- The encryption-at-rest pattern for repo tokens / webhook secrets — orthogonal to this migration.
bland
Identity storage today
Users table — src/worker/db/d1/schema.ts:4-16
id(text, primary key)email(text, unique),password_hash(text),name,avatar_url,created_at,updated_at
Hash format: Argon2id PHC string $argon2id$v=19$m=19456,t=2,p=1$<base64-salt>$<base64-hash> — src/worker/lib/auth.ts:78-84. Implementation uses @noble/hashes/argon2.js (pure JS, workerd-compatible — same library tessera will use).
Session mechanism: JWT (no Better Auth). Implementation uses jose. Access token TTL 15 min; refresh token TTL 7 days, stored in bland_refresh httpOnly cookie. No session table.
- Token mint:
src/worker/lib/auth.ts:119-133(createAccessToken,createRefreshToken). - Token verify:
src/worker/middleware/auth.ts:36. requireAuth/optionalAuth:src/worker/middleware/auth.ts:53-81.
Auth library: none. Custom JWT impl on jose + @noble/hashes/argon2.js.
Code reading/writing identity
| Operation | Location |
|---|---|
| Sign-in (POST /auth/login) | src/worker/routes/auth.ts:30-71 |
| Invite-accept user create | src/worker/routes/invites.ts:183-201 |
| Password hash (called from invite accept) | src/worker/lib/auth.ts (hashPassword) |
| Password verify | src/worker/lib/auth.ts:86-117 (constant-time compare) |
| Token create | src/worker/lib/auth.ts:119-133 |
requireAuth middleware |
src/worker/middleware/auth.ts:53-81 |
| Logout (clear cookie) | src/worker/routes/auth.ts:113-117 |
| GET /auth/me | src/worker/routes/auth.ts:120-123 |
| Client auth state | src/client/stores/auth-store.ts |
Foreign keys to user identifier
D1 — all reference users.id:
| Table | FK column | Schema file |
|---|---|---|
workspaces |
owner_id |
src/worker/db/d1/schema.ts:23-25 |
memberships |
user_id |
src/worker/db/d1/schema.ts:34-36 |
invites |
invited_by, accepted_by |
src/worker/db/d1/schema.ts:56-58, 64 |
pages |
created_by |
src/worker/db/d1/schema.ts:92-94 |
pageShares |
grantee_id (when grantee_type='user'), created_by |
src/worker/db/d1/schema.ts:119, 124-126 |
uploads |
uploaded_by |
src/worker/db/d1/schema.ts:143-145 |
Durable Objects: none keyed on user id. DocSync and WorkspaceIndexer DOs are keyed on workspace/document.
What changes to consume tessera as an OIDC IdP
- Add OIDC client wiring:
TESSERA_OIDC_ISSUER,TESSERA_OIDC_CLIENT_ID, secretTESSERA_OIDC_CLIENT_SECRET; new/auth/callbackroute. - Replace POST
/auth/login: redirect to tessera's/authorize. Remove password form on/loginUI. - Drop the password column once tessera is live for all bland users:
ALTER TABLE users DROP COLUMN password_hash;. DrophashPassword/verifyPasswordfromsrc/worker/lib/auth.ts. - Add a
tessera_sub TEXT UNIQUEcolumn touserswith an index.users.idand every FK column referencing it stay untouched. OIDC callback finds the local user bytessera_sub; on first sign-in for a known email, the callback binds byUPDATE users SET tessera_sub = ?. - Decide JWT-or-OIDC-token retention: bland currently uses jose-issued JWTs for its own session. Recommendation: keep bland's JWT session (callback validates tessera ID token → mints bland JWT → sets refresh cookie). Minimal change to existing middleware.
What stays unchanged
users.idcolumn and every FK column referencing it — completely untouched.- jose-based JWT verify in middleware.
- All workspace / page / share / upload FKs.
- Turnstile setup (still needed on any unauthenticated public surface; the sign-in form gating goes away with the form).
ccccocc
(Skipping the standard inventory per the Phase 1 instructions — workspace mapping is ephemeral and a separate post-tessera task.)
Cloudflare Access claim usage today
ccccocc reads the sub claim from the verified Cf-Access-Jwt-Assertion header.
- Header parsed at
src/worker/auth.ts:89insideauthenticateAccess(). - JWT signature verified against the team's Access JWKS (
https://{team}.cloudflareaccess.com/cdn-cgi/access/certs):fetchJWKSatsrc/worker/auth.ts:134verifySignatureatsrc/worker/auth.ts:158
- Standard JWT validation in the same function:
exp(line 106),audagainstCF_ACCESS_AUD(line 111),iss(line 118). payload.subextracted atsrc/worker/auth.ts:129and bound asuserId.payload.emailis also read into theAuthResultbut not used for sandbox identification.
The sandbox identifier is derived (no hashing, no slug) at src/worker/auth.ts:49 (deriveSandboxId):
${userId}-${workspace || "default"}This string keys the Sandbox Durable Object stub at src/worker/index.ts:72, 84, 106, 164, 177, 198 via getSandbox(env.Sandbox, sandboxId).
There is no persistent storage of the sub value — it is re-extracted from each request's JWT and used to address the DO directly.
What tessera must guarantee about the sub claim
- Stable — same value across logins and across all linked identity providers (email+password, GitHub, Google) for the same human.
- Opaque — never email, never email-derived; tessera issues a UUID v4 and binds linked identities to it.
- String-shaped, namespace-safe — usable directly in DO stub names. UUID v4 satisfies this.
When tessera replaces the existing direct-GitHub/Google Cloudflare Access OIDC config, ccccocc must stop treating payload.sub as the tessera subject. Cloudflare Access uses payload.sub for its own user ID and exposes the forwarded tessera subject as payload.custom.tessera_sub when the tessera_sub claim is listed in the Access OIDC Claims configuration.
git-on-cloudflare
System A — git HTTP Basic-auth (machine credentials, STAYS UNCHANGED)
Routes (registered in src/routes/git.ts:219-245):
| Route | Auth |
|---|---|
GET /:owner/:repo/info/refs?service=git-upload-pack |
none (read advertise) |
POST /:owner/:repo/git-upload-pack |
none (fetch/clone) |
POST /:owner/:repo/git-receive-pack |
Basic-auth required (push) |
Auth pipeline:
- Header parse:
src/auth/verify.ts:5-19(getBasicCredentials). - Verify (called from
src/routes/git.ts:240):src/auth/verify.ts:21-40. Username must match the:ownerURL param; password is the token; verification is delegated toAuthDurableObject.
Token storage (no D1):
AuthDurableObjectstorage:type AuthUsers = Record<string, string[]>keyed by the literal string"users", mapping owner → array of hashed token strings. Schema atsrc/do/auth/authState.ts:7.- Hash: PBKDF2-SHA256, 100k iterations + 16-byte salt, format
salt:iterations:hash.src/do/auth/authDO.ts:43-71(hashTokenWithPBKDF2). - Verify:
src/do/auth/authDO.ts:80-90(timing-safe; supports variable iteration counts).
This entire system stays as-is in the tessera era. git push over HTTPS continues to use Basic-auth tokens; tessera does not touch it.
System B — Web admin UI (humans, MOVES behind tessera OIDC)
Routes that should move behind OIDC (currently behind reused git Basic-auth via verifyAuth(env, owner, request, true)):
GET /:owner/:repo/admin— admin dashboardPOST /:owner/:repo/admin/compact,DELETE /:owner/:repo/admin/compactGET /:owner/admin/registry,POST /:owner/admin/registry/syncGET/PUT /:owner/:repo/admin/refs,GET/PUT /:owner/:repo/admin/headGET /:owner/:repo/admin/debug-*DELETE /:owner/:repo/admin/pack/:packKey,DELETE /:owner/:repo/admin/purge
Routes currently behind a separate AUTH_ADMIN_TOKEN Bearer (also moves behind OIDC):
GET /auth/api/users,POST /auth/api/users,DELETE /auth/api/users— list / add / delete machine tokens
Routes that stay public:
GET /(home)GET /:owner(repos list)GET /:owner/:repo,/tree,/blob,/commits,/commit/:oid(read-only repo browsing)GET /auth(token management UI page; ops behind it require Bearer)
Handler files:
src/routes/ui.ts:10-45— UI route registrations.src/routes/admin.ts:36-344— admin JSON API.src/routes/auth.ts:6-117— auth UI + token mgmt API.src/routes/ui/adminPage.ts:27— admin page callsverifyAuth(env, owner, request, true).- Bearer-admin path:
src/routes/auth.ts:30, 50, 84callgetBearerToken(request); verified viastub.adminAuthorizeOrRateLimit()atsrc/do/auth/authDO.ts:274-323againstenv.AUTH_ADMIN_TOKEN.
Current identity state for the web admin
- No session cookies.
- No password system.
- No web-level user identity at all — admin pages reuse the same Basic-auth-token system as
git push. The Bearer path (/auth/api/users) uses a separateAUTH_ADMIN_TOKENwrangler secret.
What changes for tessera
- Replace
verifyAuth(..., true)for admin UI/API with a newrequireOidcSessionmiddleware that validates a tessera-minted session cookie (or bearer JWT) and asserts thesubclaim againstOPERATOR_SUB. - Replace
AUTH_ADMIN_TOKENBearer for/auth/api/userswith the same OIDC-session check. - Add new env:
TESSERA_OIDC_ISSUER,TESSERA_OIDC_CLIENT_ID, secretTESSERA_OIDC_CLIENT_SECRET, plusOPERATOR_SUB(tessera UUID for the limic operator). - Add
/auth/callbackroute to handle the OIDC redirect. - No D1 migration needed — git-on-cloudflare has no users table. Identity has been "the holder of this Basic-auth token" up to now; after the change, identity is "the OIDC
subfrom tessera." There is no historical row to rewrite.
What stays unchanged
- All git Basic-auth routes (System A) and the
AuthDurableObjectstorage. - All public read routes.
- Repo metadata SQLite schema (
src/do/repo/db/schema.ts:4-31— only pack catalog, no user data).
flamemail
Confirmation
Persistent identity = the ADMIN_PASSWORD wrangler secret, full stop. Confirmed:
- No
usersD1 table.src/worker/db/schema.ts:17-37declares onlyinboxes,emails,attachments,domains. - Login compares the submitted password against
c.env.ADMIN_PASSWORDatsrc/worker/api/admin.ts:79usingconstantTimeEqualStrings(src/worker/security.ts:73-83, wrappingcrypto.subtle.timingSafeEqualvia Cloudflare's SubtleCrypto). - Successful admin login mints a session token at
src/worker/api/admin.ts:88-94viacreateSessionToken. Token istok_<nanoid(32)>(38 chars), stored in KVSESSIONSwith TTL. Session record:{ type: "admin" }. Implementation:src/worker/services/inbox/session-store.ts:33-40. - No OAuth, SAML, magic-link, or per-user accounts.
Public inbox tokens are anonymous capability tokens, not identity. Confirmed:
- The
inboxestable has no FK to any user table — columns areid,localPart,domain,fullAddress,isPermanent,createdAt,expiresAt(src/worker/db/schema.ts:17-37). - Inbox creation (POST
/api/public/inboxesatsrc/worker/api/inboxes.ts:28-84) returns{address, token, ttlHours, expiresAt}. The token is the sametok_<nanoid(32)>shape, stored in KVSESSIONSas{ type: "user", address }. - Tokens are presented as
Authorization: Bearer, not embedded in URLs. There is no path-based capability URL.
What changes for tessera
The admin login form at /admin/login (currently Turnstile + ADMIN_PASSWORD) becomes an OIDC redirect to tessera's /authorize. The ADMIN_PASSWORD wrangler secret is removed once tessera is live. The KV-backed { type: "admin" } session record can stay; it is minted at the OIDC callback instead of after a password compare.
The public anonymous inbox capability flow stays entirely as-is — those tokens are not identity, they are capability URLs.
What stays unchanged
- Public inbox creation flow (anonymous capability tokens).
- Email reception, storage, attachment handling.
- KV session store mechanism (just minted at OIDC callback instead of after a password compare).
- Turnstile gating on inbox creation and admin login (tessera will mirror flamemail's fail-closed style on missing env keys → 503).
Turnstile pattern reference table
Tessera will mirror this. All three projects fail closed: missing token, missing secret, or failed siteverify all reject the request.
| Concern | anvil | bland | flamemail |
|---|---|---|---|
| Site key env read | src/worker/api/public/app-config.ts:10 |
src/worker/lib/spa-shell.ts:30-32 |
src/worker/api/config.ts:7 |
| Secret env read | src/worker/services/turnstile.ts:60 |
src/worker/middleware/turnstile.ts:31 |
src/worker/services/turnstile.ts:53 |
| Client widget component | src/client/components/turnstile-widget.tsx |
src/client/components/auth/turnstile-widget.tsx |
src/client/components/turnstile-widget.tsx (renders 140-163) |
Server siteverify POST |
src/worker/services/turnstile.ts:101 |
src/worker/middleware/turnstile.ts:37 |
src/worker/services/turnstile.ts:94 |
| Fail-closed enforcement | src/worker/api/public/auth.ts:35-60 (assertTurnstileVerified) |
src/worker/middleware/turnstile.ts:26-62 (400/403 returns) |
src/worker/services/turnstile.ts:49-185 (503 on missing keys / 403 on fail) |
| Local-dev bypass | (none — uses test keys) | src/worker/middleware/turnstile.ts:22 (isLocalRequestUrl) |
(none — uses test keys) |
Recommended pattern for tessera:
- Server side: follow anvil's
assertTurnstileVerifiedhelper called from each gated handler. Tessera's gated surface is small (/sign-inand/invite/<token>); middleware-style verification adds indirection without benefit. - Client side: copy bland's widget (
src/client/components/auth/turnstile-widget.tsx) — most recently iterated, closest to the auth-flow shape tessera needs. - Fail-closed return codes: 503 for missing env keys (deploy-config error, per flamemail); 400 for missing token; 403 for failed siteverify or action mismatch.
Encryption-at-rest reference (anvil)
Anvil ports its own AES-GCM helper (src/worker/security/secrets.ts) and writes a three-column shape (*Ciphertext, *KeyVersion, *Nonce) for every encrypted field, keyed off a versioned appEncryptionKeysJson wrangler secret. Tessera deliberately does not mirror this. Better Auth already covers the two columns that matter here:
- JWKS private bytes — the
jwtplugin wrapsjwks.privateKeywithBETTER_AUTH_SECRETon write and unwraps on sign (gated by!options.jwks.disablePrivateKeyEncryption, which defaults to encrypted). Seenode_modules/better-auth/dist/plugins/jwt/{sign,utils}.mjs. - OAuth provider access/refresh/id tokens — when
account.encryptOAuthTokens: trueis set onbetterAuth({...}),oauth2/utils.mjswrapsaccount.access_token/refresh_token/id_tokenwith the same secret. Tessera enables this insrc/worker/auth/index.ts. The read path checksisLikelyEncrypted(token)first, so flipping the flag is backwards-compatible with any plaintext rows already in D1.
Tessera therefore ships one secret (BETTER_AUTH_SECRET) instead of BETTER_AUTH_SECRET plus a separate appEncryptionKeysJson map, and there is no src/worker/security/secrets.ts in the tree. If a future column genuinely needs at-rest encryption beyond what Better Auth covers, port anvil's helper at that point — don't add it speculatively.