Blob: OPERATOR.md
tessera — operator runbook
Production deploy at https://auth.limic.dev. This document covers everything an operator needs from a fresh repo to a live, working IdP.
1. Prerequisites
- Cloudflare account with Workers paid plan. D1 stores tessera's auth data; the native Workers Rate Limiting binding handles abuse throttling.
- A registered domain with Cloudflare DNS —
auth.limic.devis the production hostname. The hostname is load-bearing for the OIDCissclaim, cookie domain, and downstream Cloudflare Access integration. wranglerCLI logged in (wrangler whoami).
2. Wrangler secrets and runtime variables
Set every secret below before deploying. Use wrangler secret put <NAME> and paste the value when prompted.
| Name | What it is | How to generate / where to get it |
|---|---|---|
BETTER_AUTH_SECRET |
Better Auth's master secret. Drives session HMAC, plus symmetric encryption of JWKS private bytes (jwt plugin) and OAuth provider access/refresh/id tokens (account.encryptOAuthTokens). 64 hex chars. |
openssl rand -hex 32 |
GITHUB_OAUTH_CLIENT_ID / GITHUB_OAUTH_CLIENT_SECRET |
tessera as a GitHub OAuth client. | See § 3. |
GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET |
tessera as a Google OAuth client. | See § 4. |
TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY |
Cloudflare Turnstile site + secret keys. | See § 5. |
Set these non-secret variables too. Use wrangler.jsonc vars if you manage deploy config in the repo, or the Cloudflare dashboard's Workers → Settings → Variables if you keep deployment-specific values out of git. For local development, set them in .dev.vars.
npm run deploy includes wrangler deploy --keep-vars to preserve dashboard-managed variables. Include that flag when deploying directly with Wrangler too.
| Name | What it is |
|---|---|
OPERATOR_NAME |
Operator name rendered on /privacy and /terms; /api/config returns 503 if it is empty. |
OPERATOR_CONTACT_EMAIL |
Operator contact email rendered on /privacy and /terms; /api/config returns 503 if it is empty. |
BOOTSTRAP_ADMIN_EMAIL is not secret — set it as a wrangler vars entry (or in .dev.vars) for the bootstrap window only, then remove it once the operator account exists.
wrangler secret put BETTER_AUTH_SECRET
wrangler secret put GITHUB_OAUTH_CLIENT_ID
wrangler secret put GITHUB_OAUTH_CLIENT_SECRET
wrangler secret put GOOGLE_OAUTH_CLIENT_ID
wrangler secret put GOOGLE_OAUTH_CLIENT_SECRET
wrangler secret put TURNSTILE_SITE_KEY
wrangler secret put TURNSTILE_SECRET_KEY3. GitHub OAuth app
- GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
- Application name:
tessera (auth.limic.dev). - Homepage URL:
https://auth.limic.dev. - Authorization callback URL:
https://auth.limic.dev/api/auth/callback/github. (Better Auth's plugin mounts the callback at this exact path.) - Click Register application, then Generate a new client secret.
- Copy the client ID into
GITHUB_OAUTH_CLIENT_IDand the freshly minted secret intoGITHUB_OAUTH_CLIENT_SECRETviawrangler secret put.
GitHub does not require a scopes whitelist — Better Auth requests the minimum (read:user, user:email).
4. Google OAuth app
- Google Cloud Console → Google Auth Platform → Get started if the project is not registered for Google Auth yet.
- App name:
tessera (auth.limic.dev). Set a monitored support email and contact email. - Audience: External. tessera only requests the Google sign-in scopes
openid email profile; no sensitive or restricted Google API scopes are required. - Google Auth Platform → Data Access: keep scopes limited to
openid,email, andprofileif Google asks you to configure them. - Google Auth Platform → Clients → Create client.
- Application type: Web application. Name:
tessera (auth.limic.dev). - Authorized redirect URIs: add
https://auth.limic.dev/api/auth/callback/google. Leave Authorized JavaScript origins empty; tessera uses the server-side OAuth callback, not Google client-side JS. - Click Create. Copy the client ID and newly shown secret into
GOOGLE_OAUTH_CLIENT_IDandGOOGLE_OAUTH_CLIENT_SECRET.
5. Turnstile widget
- Cloudflare dashboard → Turnstile → Add site.
- Hostname:
auth.limic.dev. - Mode: Managed.
- Copy the site key into
TURNSTILE_SITE_KEYand the secret intoTURNSTILE_SECRET_KEY.
tessera always calls Cloudflare siteverify — there is no host-based short-circuit that skips the call. The always-pass test keys shipped in .dev.vars.example (1x00000000000000000000AA / 1x0000000000000000000000000000000AA) still work for local dev because tessera recognizes Cloudflare's test-mode siteverify response shape (action: "test" or metadata.result_with_testing_key: true) and skips the action/hostname comparisons that would otherwise fail against it. Use those for dev; register a real Turnstile site for production.
Fail-closed return codes:
- Missing
TURNSTILE_SECRET_KEY→ 503 (deploy-config error). - Missing token in the request body → 400.
- Failed siteverify → 403.
- Action mismatch / hostname mismatch → 403.
6. D1 + rate-limit bindings
Edit wrangler.jsonc:
"d1_databases": [
{
"binding": "DB",
"database_name": "tessera-prod",
"database_id": "<from `wrangler d1 create tessera-prod`>",
"migrations_dir": "drizzle/d1"
}
],
"ratelimits": [
// Tight tier. Used for sign-in, invite acceptance, OAuth issuance,
// admin routes, and most /api/auth/* paths.
{
"name": "RL_AUTH",
"namespace_id": "110001",
"simple": {
"limit": 10,
"period": 60
}
},
// Loose tier. Used for high-volume reads such as session reads,
// /oauth2/userinfo, and /oauth2/introspect.
{
"name": "RL_API",
"namespace_id": "110002",
"simple": {
"limit": 300,
"period": 60
}
}
]namespace_id is an account-global positive integer string. Reuse one only if you intentionally want multiple Workers to share counters for the same keys. Both RL_AUTH and RL_API must exist before deployment: tessera-owned sign-in, social sign-in, and invite acceptance call RL_AUTH directly; /api/auth/* is tiered by src/worker/middleware/rate-limit.ts; public JWKS and auth utility endpoints are explicitly exempt.
Apply migrations to the remote DB:
npm run db:migrate # equivalent to: wrangler d1 migrations apply tessera-prod --remoteBetter Auth 1.6 → 1.7 cutover
0004_better_auth_1_7.sql adds the 1.7 OAuth tables and columns, renames oauth_clients.type to application_type, and removes public. The production check on September 9, 2026 found seven clients with null types, HTTPS redirects, and no duplicate account identities or foreign-key violations. No client-type or account-identity backfill is needed.
Before upgrading the provider, correct the four registrations whose applications use ClientSecretPost: anvil, bland, flamemail admin, and git-on-cloudflare. The reviewed SQL targets their exact client IDs and changes only token_endpoint_auth_method, preserving secrets and existing tokens. It is compatible with the 1.6 provider and returns four updated rows on the first run, zero on a repeat:
npx wrangler d1 execute tessera-prod --remote --file scripts/migrations/better-auth-1-7-client-auth.sqlDeploy tres's Cloudflare change to explicit ClientSecretBasic before upgrading tessera. Keep the shared tres registration, dab, and Cloudflare Access on Basic. tres's Go implementation starts with Basic; relying on its fallback to Post fails under 1.7 because the first attempt can consume the authorization code. Better Auth's client-update API does not support editing the authentication-method field, so use the narrow SQL correction above.
The schema is incompatible with the 1.6 Worker. The completed rollout deployed tres first, validated a D1 backup, uploaded the tested 1.7.3 Worker version, applied the client corrections and schema migration, then activated the uploaded version at 100% traffic. npm run deploy builds before applying migrations and preserves dashboard-managed variables. Rolling back to 1.6 requires restoring the corresponding database schema as well as the old Worker.
Token requests must use the client's registered authentication method. New tessera registrations default to client_secret_basic; explicitly configure the RP's discovery client to use Basic. Email/profile ID-token claims remain preserved by tessera, subject to their scopes. Sign-out now revokes access tokens bound to that session. Local HTTP callbacks use native redirect policy and must use exactly localhost, 127.0.0.1, or [::1]; .localhost subdomains are no longer accepted as OAuth redirects. After deployment, verify fresh logins in the four Post clients, both tres backends, dab, and Cloudflare Access.
7. Bootstrap the first admin
tessera is invite-only. Until at least one invite has been minted and accepted, no one can sign in.
Set
BOOTSTRAP_ADMIN_EMAILto the operator's email for the bootstrap window. Use the Cloudflare dashboard variable UI or, if this deployment keeps runtime vars inwrangler.jsonc, add it alongside the existing vars:"vars": { "LOG_LEVEL": "info", "BETTER_AUTH_URL": "https://auth.limic.dev", "OIDC_ISSUER": "https://auth.limic.dev", "OPERATOR_NAME": "Example Operator", "OPERATOR_CONTACT_EMAIL": "ops@limic.dev", "BOOTSTRAP_ADMIN_EMAIL": "ops@limic.dev" }The auth
databaseHooks.user.create.beforehook auto-promotes torole: "admin"only when the first user signs up with a matching email.Mint the first invite. The admin UI is unreachable until step 3, so seed the row directly via the helper script:
npm run db:seed-initial-user -- --email ops@limic.dev # or, for a local dev DB: npm run db:seed-initial-user:local -- --email ops@limic.devThe script refuses to run if any user rows already exist, generates a 32-byte token (matching the production
encodeBase64Url/sha256helpers), inserts the invite withcreated_by = 'bootstrap', and prints the invite URL. Pass--expires-in-days N,--issuer URL,--force,--dry-run,--print-sql, or--jsonfor non-default behavior;--helplists every flag.Open the printed URL, accept the invite. Your account is now
role=adminthanks to the bootstrap hook.Remove
BOOTSTRAP_ADMIN_EMAILfrom the deployment variables and redeploy. Subsequent signups won't auto-promote.
After this, mint future invites through /admin/invites. The seed script refuses to run once any user exists, so it's only ever the very first invite.
8. Register downstream projects as OAuth clients
OAuth client registration and launcher tile registration are independent
admin surfaces. Register a client for any app that performs an OIDC flow
against tessera; register a launcher tile for any app that should appear
on the signed-in landing page (/), including apps fronted by Cloudflare
Access whose OAuth client is Cloudflare Access itself.
8a. OAuth client (/admin/clients)
- Sign in to tessera as admin.
- Navigate to Admin → Clients.
- Fill in:
- Client name:
anvil(orbland, etc.). - Redirect URIs (one per line):
https://anvil.limic.dev/auth/callback, plus any preview-deploy URLs. - Homepage URL (optional): the public homepage of the app. Stored as the OIDC
client_uri; rendered on the consent screen and the connected-apps page. - For first-party limic apps, enable Skip consent screen. Leave it off for third-party clients.
- Client name:
- Click Register client. The page reveals the client ID and
client_secretexactly once — copy both. - In the downstream project, set wrangler secrets:
TESSERA_OIDC_CLIENT_ID=<client_id>TESSERA_OIDC_CLIENT_SECRET=<client_secret>TESSERA_OIDC_ISSUER=https://auth.limic.dev
To rotate a secret, click Rotate in the same UI; the new secret is shown once, the old one is invalidated immediately.
To rename a client or toggle Skip consent screen on an existing client, click Edit on that row and save. Existing tokens are not revoked by an edit; rotation remains the kill-switch for a leaked secret.
The same operation is exposed programmatically:
curl -X POST https://auth.limic.dev/api/admin/clients \
-H "content-type: application/json" \
-H "origin: https://auth.limic.dev" \
-H "cookie: better-auth.session_token=<your-admin-session>" \
-d '{
"name": "anvil",
"redirectUris": ["https://anvil.limic.dev/auth/callback"],
"skipConsent": true,
"uri": "https://anvil.limic.dev"
}'The response includes client_id and client_secret (the secret is included on this response only — it is hashed at rest after this point).
8b. Launcher tile (/admin/launcher-apps)
- Sign in to tessera as admin.
- Navigate to Admin → Launcher.
- Fill in:
- Name: e.g.
anvil. - URL: the public landing URL of the app (e.g.
https://anvil.limic.dev). - Optional icon and tint from the curated registry.
- Name: e.g.
- Click Add tile. A tile renders on the launcher for every signed-in user.
Use the Enabled toggle on a row to temporarily hide a tile without deleting its config. Clicking a tile is a hard navigation to the URL; the destination app's own auth flow takes over from there.
9. Register tessera with Cloudflare Access (Generic OIDC)
This configures Cloudflare Access to use tessera as its Generic OIDC identity provider. Access then issues its own Cf-Access-Jwt-Assertion JWT to protected apps.
First, mint a dedicated OAuth client in tessera:
- Find your Zero Trust team name in Cloudflare. The Access callback URI is
https://<your-team>.cloudflareaccess.com/cdn-cgi/access/callback. - Sign in to tessera as admin and open Admin → Clients.
- Register a client:
- Client name:
cloudflare-access. - Redirect URIs:
https://<your-team>.cloudflareaccess.com/cdn-cgi/access/callback.
- Client name:
- Copy the generated
client_idandclient_secret.
In the Cloudflare Zero Trust dashboard:
- Integrations → Identity providers → Add new identity provider → OpenID Connect.
- Fill in:
- Name:
tessera. - App ID: the dedicated
client_idminted above. - Client secret: the corresponding
client_secret. - Auth URL:
https://auth.limic.dev/api/auth/oauth2/authorize. - Token URL:
https://auth.limic.dev/api/auth/oauth2/token. - Certificate URL:
https://auth.limic.dev/api/auth/jwks. - Scopes:
openid email profile. - PKCE: enabled. tessera advertises
S256, and Access performs PKCE on every login attempt when this option is on. - OIDC Claims: add
tessera_subwhenever a protected downstream app needs tessera's stable UUID through Access. tessera emits this claim on every ID token and/userinforesponse; Access exposes the forwarded claim to protected origins underCf-Access-Jwt-Assertion.payload.custom.tessera_sub. Leave blank only if no downstream app keys on the tessera UUID via Access.
- Name:
- Save. Click Test — Access redirects through tessera, you sign in, Access reports success.
- Claim behavior:
Cf-Access-Jwt-Assertion.payload.subis Cloudflare Access's user ID, not tessera's upstream OIDCsub. Do not treat it as a tessera UUID. Downstream apps that need tessera's stablesubthrough Access read it frompayload.custom.tessera_sub— tessera emits this claim on every ID token and/userinforesponse (mirroringsub); pass it through by listingtessera_subin the OIDC Claims field above.
The cloudflare-access OAuth client can be marked Skip consent screen in /admin/clients. Access is operated as first-party limic infrastructure, so the per-user grant prompt is unnecessary; this is a runtime decision for the operator, not a code default.
For an app fronted by Cloudflare Access (e.g. ccccocc) to appear on the launcher, register a launcher tile under /admin/launcher-apps pointing at the app's public URL. The OAuth identity is Cloudflare Access; the launcher tile is just a navigation affordance.
10. Local development
cp .dev.vars.example .dev.vars
# Generate the local secret:
openssl rand -hex 32 # → BETTER_AUTH_SECRET
# Fill OPERATOR_NAME and OPERATOR_CONTACT_EMAIL. /api/config fails closed
# when either is empty.
# Leave BETTER_AUTH_URL and OIDC_ISSUER empty so the worker derives the base URL
# from the request origin. Use the Worker URL printed by npm run dev; pass
# ISSUER=... to the test scripts if it is not http://localhost:5174.
# The always-pass Turnstile test keys in .dev.vars.example are fine for dev —
# tessera recognizes the test-mode siteverify response and skips action/hostname
# comparisons for it. For non-localhost dev, register a real Turnstile site.
npx wrangler d1 migrations apply tessera-prod --local
npm run devTo verify the OIDC flow end-to-end against the running dev server:
TEST_EMAIL=ops@limic.dev TEST_PASSWORD=... npm run test:clientThe script self-bootstraps: signs in with the operator credentials, registers a fresh OAuth client (with skip_consent so the run is non-interactive), walks discovery → authorization endpoint (PKCE) → token endpoint → ID token verify against JWKS → userinfo endpoint, prints the verified claims, then deletes the ephemeral client. Override ISSUER (default http://localhost:5174) or TEST_REDIRECT_URI if you need to.
11. Secret rotation
tessera leans entirely on BETTER_AUTH_SECRET for Better Auth's master crypto. It signs session cookies and encrypts D1-backed JWKS private keys plus GitHub/Google OAuth provider tokens. tessera intentionally does not keep a keyring of old master secrets, so rotating this value is destructive and requires a maintenance window plus D1 cleanup. Do not change BETTER_AUTH_SECRET by itself: existing encrypted JWKS rows will no longer decrypt and OIDC token issuance can fail.
Before touching the secret, export the production D1 database:
wrangler d1 export tessera-prod --remote --output tessera-prod-before-secret-rotation.sqlThen perform the rotation:
Enter a maintenance window and expect all users to sign in again.
wrangler secret put BETTER_AUTH_SECRET(paste a newopenssl rand -hex 32value).Delete secret-bound auth state from D1:
wrangler d1 execute tessera-prod --remote --command " DELETE FROM oauth_access_tokens; DELETE FROM oauth_refresh_tokens; DELETE FROM sessions; DELETE FROM jwkss; DELETE FROM verifications; UPDATE accounts SET access_token = NULL, refresh_token = NULL, id_token = NULL, access_token_expires_at = NULL, refresh_token_expires_at = NULL WHERE access_token IS NOT NULL OR refresh_token IS NOT NULL OR id_token IS NOT NULL; "Fetch
https://auth.limic.dev/api/auth/jwksto force Better Auth to create a fresh signing key under the new secret.Verify password sign-in, GitHub/Google sign-in, and the OIDC flow with
npm run test:clientagainst production credentials.
Effects: sessions are revoked, OAuth access/refresh tokens are revoked, and already-issued ID tokens may fail validation once their old signing key is removed from JWKS. Users, invites, OAuth clients, client secrets, and consent rows are not wiped. GitHub/Google provider tokens are cleared from accounts; users reacquire them on the next social login or account link.
The JWT plugin also manages signing-key rotation independently of the master secret. That normal JWKS rotation is not a replacement for the destructive BETTER_AUTH_SECRET procedure above.
12. Follow-ups deferred from v1
- MFA, SAML, multi-tenancy / orgs.
- Email delivery for invites (Resend integration). Operators mint the URL and deliver it manually.
- SCIM, audit log table, password reset flow.
- Real GitHub/Google OAuth app automation. The unit, integration, and local e2e tests cover the OIDC shape and the password / Turnstile / rate-limit / invite flows; the GitHub-link-and-relogin flow is documented in the test plan but not automated.