Skip to content
File

Blob: docs/phase-1-inventory.md

Markdown325 lines

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 in src/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

  1. Add OIDC client wiring: env vars TESSERA_OIDC_ISSUER (=https://auth.limic.dev), TESSERA_OIDC_CLIENT_ID, wrangler secret TESSERA_OIDC_CLIENT_SECRET. New /auth/callback route exchanges code at https://auth.limic.dev/token and validates the ID token signature against https://auth.limic.dev/.well-known/jwks.json.
  2. Replace handleLogin: sign-in becomes a redirect to tessera's /authorize (PKCE, state, nonce). The password form on /sign-in is deleted.
  3. Drop password_credentials table once tessera is live for all anvil users (one-shot SQL: DROP TABLE password_credentials;). Remove src/worker/auth/passwords.ts.
  4. Add a tessera_sub TEXT UNIQUE column to users with an index. users.id and every FK column referencing it stay untouched. The OIDC callback finds the local user by tessera_sub; on first sign-in for an email already in users, the callback binds by UPDATE users SET tessera_sub = ?. (See Document 2 § Migration strategy for the design rationale; the actual migration code lives in anvil, not tessera.)
  5. Invite flow: anvil's invites table stays for "invite this tessera-identified user into anvil" semantics; handleInviteAccept becomes "associate the OIDC-authenticated user with this anvil invite" — no password fields.

What stays unchanged

  • users.id column 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.
  • requireAuth middleware (still reads bearer, still calls readSession).
  • 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

  1. Add OIDC client wiring: TESSERA_OIDC_ISSUER, TESSERA_OIDC_CLIENT_ID, secret TESSERA_OIDC_CLIENT_SECRET; new /auth/callback route.
  2. Replace POST /auth/login: redirect to tessera's /authorize. Remove password form on /login UI.
  3. Drop the password column once tessera is live for all bland users: ALTER TABLE users DROP COLUMN password_hash;. Drop hashPassword/verifyPassword from src/worker/lib/auth.ts.
  4. Add a tessera_sub TEXT UNIQUE column to users with an index. users.id and every FK column referencing it stay untouched. OIDC callback finds the local user by tessera_sub; on first sign-in for a known email, the callback binds by UPDATE users SET tessera_sub = ?.
  5. 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.id column 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:89 inside authenticateAccess().
  • JWT signature verified against the team's Access JWKS (https://{team}.cloudflareaccess.com/cdn-cgi/access/certs):
    • fetchJWKS at src/worker/auth.ts:134
    • verifySignature at src/worker/auth.ts:158
  • Standard JWT validation in the same function: exp (line 106), aud against CF_ACCESS_AUD (line 111), iss (line 118).
  • payload.sub extracted at src/worker/auth.ts:129 and bound as userId.
  • payload.email is also read into the AuthResult but 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

  1. Stable — same value across logins and across all linked identity providers (email+password, GitHub, Google) for the same human.
  2. Opaque — never email, never email-derived; tessera issues a UUID v4 and binds linked identities to it.
  3. 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 :owner URL param; password is the token; verification is delegated to AuthDurableObject.

Token storage (no D1):

  • AuthDurableObject storage: type AuthUsers = Record<string, string[]> keyed by the literal string "users", mapping owner → array of hashed token strings. Schema at src/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 dashboard
  • POST /:owner/:repo/admin/compact, DELETE /:owner/:repo/admin/compact
  • GET /:owner/admin/registry, POST /:owner/admin/registry/sync
  • GET/PUT /:owner/:repo/admin/refs, GET/PUT /:owner/:repo/admin/head
  • GET /: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 calls verifyAuth(env, owner, request, true).
  • Bearer-admin path: src/routes/auth.ts:30, 50, 84 call getBearerToken(request); verified via stub.adminAuthorizeOrRateLimit() at src/do/auth/authDO.ts:274-323 against env.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 separate AUTH_ADMIN_TOKEN wrangler secret.

What changes for tessera

  1. Replace verifyAuth(..., true) for admin UI/API with a new requireOidcSession middleware that validates a tessera-minted session cookie (or bearer JWT) and asserts the sub claim against OPERATOR_SUB.
  2. Replace AUTH_ADMIN_TOKEN Bearer for /auth/api/users with the same OIDC-session check.
  3. Add new env: TESSERA_OIDC_ISSUER, TESSERA_OIDC_CLIENT_ID, secret TESSERA_OIDC_CLIENT_SECRET, plus OPERATOR_SUB (tessera UUID for the limic operator).
  4. Add /auth/callback route to handle the OIDC redirect.
  5. 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 sub from tessera." There is no historical row to rewrite.

What stays unchanged

  • All git Basic-auth routes (System A) and the AuthDurableObject storage.
  • 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 users D1 table. src/worker/db/schema.ts:17-37 declares only inboxes, emails, attachments, domains.
  • Login compares the submitted password against c.env.ADMIN_PASSWORD at src/worker/api/admin.ts:79 using constantTimeEqualStrings (src/worker/security.ts:73-83, wrapping crypto.subtle.timingSafeEqual via Cloudflare's SubtleCrypto).
  • Successful admin login mints a session token at src/worker/api/admin.ts:88-94 via createSessionToken. Token is tok_<nanoid(32)> (38 chars), stored in KV SESSIONS with 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 inboxes table has no FK to any user table — columns are id, localPart, domain, fullAddress, isPermanent, createdAt, expiresAt (src/worker/db/schema.ts:17-37).
  • Inbox creation (POST /api/public/inboxes at src/worker/api/inboxes.ts:28-84) returns {address, token, ttlHours, expiresAt}. The token is the same tok_<nanoid(32)> shape, stored in KV SESSIONS as { 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 assertTurnstileVerified helper called from each gated handler. Tessera's gated surface is small (/sign-in and /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 jwt plugin wraps jwks.privateKey with BETTER_AUTH_SECRET on write and unwraps on sign (gated by !options.jwks.disablePrivateKeyEncryption, which defaults to encrypted). See node_modules/better-auth/dist/plugins/jwt/{sign,utils}.mjs.
  • OAuth provider access/refresh/id tokens — when account.encryptOAuthTokens: true is set on betterAuth({...}), oauth2/utils.mjs wraps account.access_token / refresh_token / id_token with the same secret. Tessera enables this in src/worker/auth/index.ts. The read path checks isLikelyEncrypted(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.