Custom Domain & Email Ops Runbook
Cutover in progress — some steps below are already done
The domain is bought and the zone is live: AUTH_EMAIL_FROM is set to"zudo-text <auth@zudo-text.app>" in workers/[vars], and its comment states the domain is Resend-verified on the apex (step 4 below), which requires live DNS. https:returns HTTP 200. The apex https: itself does not resolve (operator to confirm whether that's expected or a gap).
What is still outstanding: whether sync.zudo-text.app is attached and serving traffic (step 2), whether the iss/aud values have been flipped (step 3, section 7's flip-list), and whether social sign-in is fully wired in production — socialProviders.google and socialProviders.twitter are independently dark-shipped behind their credential-pair resolvers, which require both credentials for each provider; whether either pair is set in production is operator to confirm. Do not assume any step below is unexecuted just because this page reads like a runbook — check each section's own state before acting on it.
Today, production identity and API origins are the *.takazudo.workers.dev subdomains Cloudflare assigns automatically, and the shared Better Auth audience is the placeholder logical string https: (a domain the project does not own). This runbook covers moving both onto owned infrastructure: sync.zudo-text.app fronting zudo-sync-server, Resend email delivery from a verified sending domain, and Google and X OAuth clients scoped to the new origin.
1. Buy the domain and add the zone
Purchase
zudo-text.appthrough any registrar.In the Cloudflare dashboard, add
zudo-text.appas a new zone on the account that already hosts thezudo-sync-server/zudo-publish-serverWorkers and thezudo-text-appPages project.Point the registrar's nameservers at the two Cloudflare nameservers shown during zone setup, and wait for the zone to go active (usually minutes, occasionally longer for propagation).
Nothing below works until the zone is active.
2. Attach custom domains per worker (dashboard only)
Target layout:
| Custom domain | Backing service | Purpose |
|---|---|---|
sync.zudo-text.app | zudo-sync-server Worker | Auth (Better Auth mount) + sync/automation API |
doc.zudo-text.app | zudo-text-doc Pages project | Developer documentation site |
manual.zudo-text.app | zudo-text-manual Pages project | End-user manual (Japanese) |
app.zudo-text.app (optional) | zudo-text-app Pages project | Web editor |
For each Worker: Cloudflare dashboard → Workers & Pages → (worker) → Settings → Domains & Routes → Custom domain → Add. This is a dashboard-managed attachment, not a wrangler config change.
Keep [[routes
Do not add a [[routes]] block to any wrangler.toml/wrangler.jsonc for this. A [[routes]] entry requires the CI deploy token to hold zone-level permissions on zudo-text.app; a dashboard-attached custom domain does not — it persists across ordinary wrangler deploy runs with the existing account-scoped token untouched.
Only flip workers_dev = false in the affected wrangler.toml at the actual cutover moment, once the custom domain is verified reachable — until then, leave workers_dev = true so the *.workers.dev origin keeps working as a fallback / current production.
The two documentation domains attach through the Pages project's Custom domains tab rather than the Worker settings page — see the dedicated runbook, Documentation Site Domains, for their full step-by-step (project creation, attach, verification, and the workers_dev equivalent). They carry no auth or CORS consequences: both sites are static and call no API.
app.zudo-text.app is optional and attaches the same way, but through the Pages project's Custom domains tab (Workers & Pages → the Pages project → Custom domains) rather than the Worker settings page — the Pages UI is a separate tab from the Worker Domains & Routes tab. If it's attached, also add https: and https: to BETTER_AUTH_TRUSTED_ORIGINS in workers/ and point WEB_EDITOR_URL/the web-editor deploy at the new origin — this isn't one of the iss/aud/client-URL vars in the flip-list below, but it's a direct consequence of adopting this optional domain and is easy to miss.
3. Pick final logical iss/aud values — once
BETTER_AUTH_ISSUER and BETTER_AUTH_AUDIENCE are logical strings, not physical hostnames that have to match where Better Auth is actually mounted (see doc/, "Issuer, JWKS, and shared audience"). They are duplicated in [vars] across two independently deployed Workers — zudo-sync-server and zudo-publish-server — each with its own path-filtered deploy workflow, so treat picking these values as a one-way door:
Decide the final logical origin now, e.g.
BETTER_andAUTH_ ISSUER = "https: / / zudo- text. app" BETTER_(or keep them distinct — the only hard requirement is that both workers'AUTH_ AUDIENCE = "https: / / zudo- text. app" [vars]blocks stay byte-for-byte identical for each of the two).Only
BETTER_AUTH_JWKS_URLneeds to track the physical mount (https:once cut over) — that's a real fetch target, not a logical string./ / sync. zudo- text. app/ api/ auth/ jwks Because these are pre-release, invalidating every existing session and minted JWT at cutover is expected and fine (no migration).
Never move
iss/audagain after this. A later move means both workers must redeploy in the same window or verification breaks across services (D9: the shared audience means a token minted at sync-server is accepted at publish-server too).
4. Resend: verify the sending domain
Until this is done, AUTH_EMAIL_FROM is unset and workers/ falls back to Resend's sandboxed zudo-text <onboarding@resend.dev> sender, which delivers only to the Resend account owner's own address — password reset and any other auth email is effectively broken for every other user until this section is complete.
In the Resend dashboard, add the apex
zudo-text.appas a sending domain (region: Tokyo /ap-northeast-1). Register the apex, not asend.subdomain — the from-address must live on the domain you register, andauth@zudo-text.appreads better thanauth@send.zudo-text.app. Isolation is preserved anyway: Resend puts its own MX/SPF pair on asend.subdomain of whatever you register (see below), so the apex MX stays free for inbound mail (e.g. Cloudflare Email Routing).Resend generates the exact record values; add them as Cloudflare DNS records on the
zudo-text.appzone (DNS-only — TXT and MX are never proxied):DKIM — TXT record on
resend._domainkey(value supplied by Resend, unique per domain).SPF — TXT record on
send(Resend-suppliedv=spf1 include:amazonses.com ~all) plus the MX record Resend supplies on the samesendsubdomain (priority 10, pointed at Resend's inbound-feedback host, e.g.feedback-smtp.ap-northeast-1.amazonses.com) — both are required together for SPF alignment. Resend rides Amazon SES, which is why these name SES hosts.DMARC — TXT record on
_dmarc(v=DMARC1; p=none; rua=mailto:<address>to start in monitor-only mode; tighten toquarantine/rejectlater). Required, not optional: add this DNS record before sending any real volume, even though Resend itself does not gate domain verification on it. This is a DNS action the operator performs alongside DKIM and SPF above.
Wait for Resend to show the domain as verified (DNS propagation, usually minutes).
Set
AUTH_EMAIL_FROM = "zudo-text <auth@zudo-text.app>"inworkers/sync- server/ wrangler. toml [vars]. This is a tracked config change, so it ships through the normal push-to-maindeploy — not a dashboard edit.Mint a Resend API key with sending permission for
zudo-text.app(prefer a domain-scoped key over a full-account one) and set it as a secret — never a plain var:wrangler secret put RESEND_API_KEY
5. Google Cloud Console: OAuth client
socialProviders.google is already wired into workers/ (#5089) — this section documents the matching console-side setup, not work the code is still waiting on. The provider is dark-shipped: resolveGoogleCredentials returns null, and Google sign-in stays off, unless both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set. Whether both are set in production today is operator to confirm (see the operator cutover step below for GOOGLE_CLIENT_ID's current state).
Google Cloud Console → APIs & Services → Credentials → Create Credentials → OAuth client ID.
Application type: Web application. One client covers every environment — don't create a second client per origin.
Authorized redirect URIs — add all three so the same client works across the transition instead of needing a second cutover later:
https:(current production origin — keep it;/ / zudo- sync- server. takazudo. workers. dev/ api/ auth/ callback/ google workers_devstays reachable after a custom domain is attached, it isn't replaced)https:(the new custom domain, once attached)/ / sync. zudo- text. app/ api/ auth/ callback/ google http:(local/ / localhost: 8787/ api/ auth/ callback/ google wrangler devorigin, perworkers/)sync- server/ . dev. vars. example
Save. Set the client ID as a plain var (it's not sensitive) and the secret via
wrangler secret put:wrangler secret put GOOGLE_CLIENT_SECRETGOOGLE_CLIENT_IDgoes inworkers/sync- server/ wrangler. toml [vars]as a plain string.Operator cutover: move
GOOGLE_CLIENT_IDfrom secret to[vars]. TodayGOOGLE_CLIENT_IDis deployed as awrangler secretin production (ops drift — the value only ever needed to be a plain[vars]entry, since it ships to the browser in the authorization URL anyway per step 4 above). The[vars]line inworkers/is deliberately commented out until this cutover runs. Do these steps in this exact order:sync- server/ wrangler. toml Uncomment the
GOOGLE_CLIENT_ID = "….apps.googleusercontent.com"line inworkers/sync- server/ wrangler. toml [vars], filling in the real client ID, then commit and push tomain.deploy-sync-server.ymlredeployszudo-sync-serveron that push (itspathsfilter matches anyworkers/sync-server/**change) — no manualwrangler deployneeded. Wait for that deployment to succeed before continuing.This order is deliberate and applies to the sync-server's pinned Wrangler 4.110.0. In that exact tag, Wrangler's same-name check classifies a
[vars]name matching a remote secret as an override and says the deployment will replace the secret with the environment variable; the corresponding deploy check warns and asks to continue rather than hard-rejecting it. Its non-interactive confirmation path continues without prompting unless--strictis set; this repository's deploy workflow runs the plainpnpm run deploy, with no--strict. The 4.110.0 test asserts that replacement message. Cloudflare's general documentation says ordinary deploys preserve secrets, but also says Wrangler config is the source of truth for environment variables; the tagged collision check above is the more specific behavior for this same-name case (docs).Inference from that documented behavior: the currently deployed secret remains the active binding until the replacement deployment succeeds, and the successful deployment makes the plain var active. Deploying
[vars]first therefore avoids the Google-sign-in dark window that deleting the secret first would create.From
workers/sync-server/, confirm the old encrypted binding is gone:wrangler secret listWrangler 4.110.0 says the successful deployment replaces the remote secret, so
GOOGLE_CLIENT_IDshould no longer appear in that list and no separate delete is expected. If it still appears (for example, because the command targeted a different environment), stop and resolve the target mismatch. Only after the[vars]deployment is confirmed live, delete a genuinely remaining old secret withwrangler secret delete GOOGLE_CLIENT_ID; never delete it before the deployment.
This wrangler.toml edit is a production action, and it redeploys TWO things
workers/sits in thesync- server/ wrangler. toml paths:filter of twoGitHub Actions workflows, not one:deploy-sync-server.yml— matches via itsworkers/sync-server/**glob, redeploying thezudo-sync-serverWorker.deploy-web-editor.yml— listsworkers/explicitly, redeploying the web-editor Pages production build.sync- server/ wrangler. toml
A single commit touching this file triggers both production deploys at once. Land step 1 deliberately, on its own, not bundled with unrelated changes.
6. X Developer Portal: OAuth client
socialProviders.twitter is already wired into workers/ (#5363). It is dark-shipped as a pair: with either TWITTER_CLIENT_ID or TWITTER_CLIENT_SECRET missing, the provider is omitted, the handoff page omits the X button, and the deploy behaves exactly as it does today. This section is the matching portal setup; it does not enable X until the operator deliberately supplies both values.
App Store release gate — read before enabling X
The local iOS native sign-in planrecords the App Store requirement: Sign in with Apple must ship in the same iOS release as any third-party social login used for the primary account. No social provider is enabled in the current production configuration, so merging this work does not trigger that gate. Enabling TWITTER_* in production does. Do not run the production wrangler secret put step below until the iOS release plan has Apple shipping in the same release; retrofitting Apple after X-first users exist leaves them with a second, unlinked identity.
In the X Developer Console, create or open the app used for zudo-text and ensure it is attached to a Project. Enable OAuth 2.0 user authentication for the app. X's OAuth 2.0 scope reference documents the scopes; this implementation's Better Auth wire provider id is
twitter, while the app's UI and our handoff copy call it X.Under User authentication settings → App permissions, enable Request email from users. This is the highest-value portal trap: without it, X does not return
confirmed_email, and every sign-in is refused witherror=email_not_foundrather than creating an account. Theusers.emailscope in the authorization request does not replace this portal permission.In the same app's callback/redirect URI allowlist, add these exact URLs. One X app can cover the
*.workers.devorigin, custom domain, and local development; X requires exact matching, supports at most 10 callback URLs, and requires HTTPS in production (current app configuration guidance):https://zudo-sync-server.takazudo.workers.dev/api/auth/callback/twitter https://sync.zudo-text.app/api/auth/callback/twitter http://127.0.0.1:8787/api/auth/callback/twitterUse
http:for local/ / 127. 0. 0. 1: 8787 wrangler dev, nothttp:. Browse the local Worker at the 127.0.0.1 host so Better Auth's request-derived callback matches automatically; no app or/ / localhost: 8787 baseURLchange is needed. Do not add a trailing slash unless that is the exact URI sent by the app.Confirm Project enrollment before the first real smoke. A
/response of2/ users/ me 403 client-not-enrolledis a known portal failure mode for freshly-created apps; X's current error reference documents the broader case as an app that is not enrolled or lacks required access. The exactclient-not-enrolledwording and the need for X support to clear a stuck fresh app are not confirmed by the official docs I could verify. Treat it as an enrollment/portal issue first, allow time for the state to settle, and contact X support if Project enrollment is visibly correct but the 403 persists.Check billing before enabling production. X's current pricing page lists User: Read —
$0.010 per resourceunder its pay-per-use credit model. This flow is expected to make two/reads, so two one-resource responses are nominally2/ users/ me $0.020 per sign-in; verify the usage dashboard and current rate before rollout because prices can change. The specific claim that X's Free tier closed to new signups in February 2026 is unconfirmed in the official pricing page checked for this runbook; do not rely on a free tier.Generate the OAuth client credentials. Put the public client id in
workers/undersync- server/ wrangler. toml [vars]:TWITTER_CLIENT_ID = "<x-oauth-client-id>"Put the client secret in the secret store, never in
[vars]or a tracked file:wrangler secret put TWITTER_CLIENT_SECRETThis is the settled X end-state:
TWITTER_CLIENT_IDis a plain var andTWITTER_CLIENT_SECRETis a secret. It intentionally differs from the current Google mid-cutover drift, whereGOOGLE_CLIENT_IDis still a production secret until section 5's migration is run. Keep the X pair together; either one missing keeps X dark-shipped.
Manual pre-enable X smoke checklist
Stubbed tests cannot verify the X portal permission, Project enrollment, or billing. Before enabling the pair on the production Worker, run a real round trip against the deployment where the pair is configured:
Sign in with a real X account with a confirmed email. Confirm that an account is created, the real address is stored, and a session is established.
Sign in with a real X account without a confirmed email. Confirm that the
email_not_foundlanding renders the guidance to confirm the email on X and retry, and confirm that no account is created.In the X app's usage dashboard, confirm that the expected two
/reads appear for the successful sign-in.2/ users/ me
7. Origin audit — the cutover flip-list
Every hard-coded *.workers.dev / zudo.app origin found by grepping the repository, as of this audit. Nothing here has been changed. Use this as the literal checklist at cutover time — check each box only once that value has actually been flipped and redeployed.
Note
*.workers.dev origins keep working after a custom domain is attached (Cloudflare doesn't disable them). Flipping these is about moving production traffic and canonical references onto owned infrastructure, not fixing something broken.
[vars] blocks in wrangler config (the two-worker iss/aud/JWKS triple from step 3):
workers/—sync- server/ wrangler. toml BETTER_AUTH_ISSUER,BETTER_AUTH_AUDIENCE,BETTER_AUTH_JWKS_URLworkers/—publish- server/ wrangler. toml BETTER_AUTH_ISSUER,BETTER_AUTH_AUDIENCE,BETTER_AUTH_JWKS_URL(must byte-for-byte match sync-server's issuer/audience, per D9)workers/— noagent- server/ wrangler. jsonc [vars]block; verifies auth via theAUTH_SERVERservice binding to sync-server, so nothing to flip here directlyworkers/—sync- server/ wrangler. toml VAPID_SUBJECT = "mailto:push@zudo.app"(cosmetic — it's a contact address embedded in outbound Web Push JWTs, not part of the auth trust chain — but it still references the placeholder domain and should move tomailto:push@zudo-text.appfor consistency)workers/— the commented-outsync- server/ wrangler. toml AUTH_EMAIL_FROM = "zudo-text <auth@zudo.app>"example line (step 4 above; update the domain when uncommenting, don't uncomment as-is)
Local dev parity (not deployed, but audience must still match the pinned logical value or local-dev-minted JWTs verify against a stale audience):
workers/—sync- server/ . dev. vars. example BETTER_AUTH_AUDIENCE
(BETTER_AUTH_ISSUER stays http: in the sync-server example — the issuer is allowed to differ per environment; only the audience is the shared pinned value.)
Desktop/web client build config:
tauri-—app/ . env. example VITE_SYNC_SERVER_URL,VITE_BETTER_AUTH_URL(bothhttps:today; both should move to/ / zudo- sync- server. takazudo. workers. dev https:)/ / sync. zudo- text. app tauri-—app/ . env. example VITE_PUBLISH_SERVER_URL(https:— leave on/ / zudo- publish- server. takazudo. workers. dev *.workers.devunless/untilzudo-publish-serveralso gets a custom domain; not part of this runbook's target layout)
e2e specs (usage examples in JSDoc comments — not read at runtime, so these are documentation freshness, not a functional flip):
e2e/— thesync- smoke. spec. ts WEB_EDITOR_SYNC_SERVER_URL=...example in the header commente2e/— the same exampleweb- genesis- round- trip. spec. ts
The actual functional value used by E2E and CI lives outside the repo as a GitHub Actions secret, not in a tracked file:
Repo secret
WEB_EDITOR_SYNC_SERVER_URL(documented indoc/) — update in GitHub repo settings, not in a filesrc/ content/ docs/ manual/ web- editor. mdx
doc/ references:
doc/— thesrc/ content/ docs/ architecture/ better- auth. mdx BETTER_AUTH_AUDIENCEvalue, theissuer/jwks_url/audienceexample block, and the D9 callout's audience mentiondoc/— thesrc/ content/ docs/ manual/ web- editor. mdx WEB_EDITOR_SYNC_SERVER_URLexample value in the secrets tabledoc/— the base URL and everysrc/ content/ docs/ backend- api/ automation- api. mdx curlexample against itdoc/— the base URL (prose + heading line) and thesrc/ content/ docs/ backend- api/ sync- server- api. mdx wss:WebSocket example/ /
8. The cutover PR
The flip-list above is the cutover PR checklist — don't maintain a separate one. One PR, reviewed and merged with every checkbox above ticked, touching the two wrangler.toml [vars] blocks, tauri-, the sync-server dev-vars example, and the doc/ references together. Land it in the same window as both workers' redeploys (D9's shared-audience requirement means a partial flip is a partial outage, not a safe incremental step).
Do not flip workers_dev = false in the same PR as the iss/aud change — confirm the custom domain serves traffic correctly first, then flip workers_dev in a follow-up once satisfied.
9. Post-cutover operator smoke (no CI coverage, by design)
These four checks have no automated coverage and won't get any: there is no usable staging environment ([env.staging] in each wrangler.toml has a placeholder D1 database ID and no BETTER_AUTH_* vars of its own — see the NOTE (codex review, #4476) comment in workers/), and CI cannot exercise a real inbox or a real Google consent screen. Run these by hand, on the deployed production origin, right after the cutover PR's redeploys land:
Sign-in — sign in with email/password against
https:(desktop handoff or web handoff), confirm a session is established./ / sync. zudo- text. app Service-JWT mint — call
GET /with a live session, confirm an RS256 JWT comes back and decodes with the newapi/ auth/ token iss/aud.Real reset-email delivery — trigger a password reset for a real, non-owner inbox and confirm the email actually arrives (not just that the API call returned 200 — that's the exact failure mode step 4 exists to catch).
Real Google round trip — complete an actual Google OAuth consent screen against
https:and confirm the resulting session/account-linking behaves as expected./ / sync. zudo- text. app/ api/ auth/ callback/ google
10. Cookie rule: host-only, never cross-subdomain
Better Auth's session cookie must stay host-only. Never enable advanced.crossSubDomainCookies on the sync-server Better Auth instance (workers/), even after app.zudo-text.app is attached.
The desktop and web handoff flows (see doc/, "Desktop: per-app deep-link OTT handoff" and "Browser: allowlisted HTTPS handoff") exist specifically so that no surface other than sync.zudo-text.app itself ever needs to read the session cookie directly — the one-time-token handoff is the cross-origin bridge, not a shared cookie. Turning on cross-subdomain cookies would widen the cookie's blast radius to every current and future *.zudo-text.app subdomain for no behavior the app actually needs.
11. SameSite warning: published content must not share the registrable domain
zudo-publish-server serves published user content at / (see workers/). That content must not be hosted on any *.zudo-text.app subdomain, now or later.
SameSite cookie defense (the browser's CSRF protection for the Better Auth session cookie) is scoped to the registrable domain (eTLD+1), not the full hostname. zudo-text.app is the registrable domain for sync.zudo-text.app, app.zudo-text.app, and any other *.zudo-text.app subdomain alike — they're all "same-site" to each other even though they're different hosts. If published user content ever moved to, say, u.zudo-text.app, a malicious page hosted there would be same-site with the auth cookie and could ride it in requests, silently defeating SameSite CSRF protection for every signed-in user.
A future custom domain for published content must be a separate registrable domain entirely (e.g. a distinct purchased domain, not a zudo-text.app subdomain) — this is a hard constraint on any future publish custom-domain decision, not a suggestion.
12. Apex landing page: operator checklist
workers/landing (Worker name zudo-text-landing) is a static-assets Worker serving three pages — /, /, / — built for one purpose: give the Google OAuth consent screen a public home page plus privacy-policy and terms links, so it can leave Testing status (epic #5124, superseding #5099). Local wrangler dev confirms all three pages return 200 and an unknown path returns 404; deploy-landing.yml deploys the Worker on every push that touches workers/landing/**, with workers_dev = true so the *.workers.dev origin is reachable before a custom domain is attached. None of that unblocks the OAuth cap by itself — the five steps below are the parts only the operator can do, and they must run in this order.
Fill the
[CONTACT — operator to fill]placeholders.privacy.htmlhas four (data controller, attachment-purge contact, account-deletion contact, contact section) andterms.htmlhas three (operating entity, governing law, contact section). This is blocking — do not proceed to step 4 (the OAuth consent screen) with any placeholder still in place; Google reads the live page, not the source.Attach
zudo-text.apptozudo-text-landingthe same way every other Worker in this runbook is attached: Cloudflare dashboard → Workers & Pages →zudo-text-landing→ Settings → Domains & Routes → Custom domain → Add. Not a[[routes]]block — see section 2 above for why (a committed route requires the CI deploy token to hold zone-level permissions; a dashboard-attached custom domain does not).Once the custom domain is confirmed serving, flip
workers_devfromtruetofalseinworkers/. It startslanding/ wrangler. toml trueonly so the first deploy has a reachable, verifiable URL before the custom domain exists — leaving ittrueafterward is unnecessary, not incorrect, but the flip is the same one-way pattern section 7 uses for every other Worker in this runbook.In Google Cloud Console → APIs & Services → OAuth consent screen, enter
https:and/ / zudo- text. app/ privacy https:as the privacy-policy and terms-of-service links./ / zudo- text. app/ terms Press Publish app. This moves the consent screen out of Testing: the 100-listed-test-user cap is lifted, and refresh tokens stop expiring after 7 days (today, every signed-in user has to re-consent weekly). zudo-text only requests non-sensitive scopes (
email,profile,openid), so this does not trigger a Google verification review.
This Worker must never become a user-content surface
workers/landing serves three static files and nothing else — no cookies, no scripts, no user data. Section 10 above explains why: SameSite cookie defense is scoped to the registrable domain (eTLD+1), not the full hostname, so any page under *.zudo-text.app is same-site with the Better Auth session cookie on sync.zudo-text.app. A static landing/privacy/terms page is exactly what that permits — but it must stay that way. Do not extend this Worker to host anything a third party could control the content of.