# 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 ` (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$$` — `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` 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_` (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_` 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/`); 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.