zudo-text

検索したい単語を入力

いつでも検索バーを開ける

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 <[email protected]>" in workers/sync-server/wrangler.toml[vars], and its comment states the domain is Resend-verified on the apex (step 4 below), which requires live DNS. https://editor.zudo-text.appreturns HTTP 200. The apex https://zudo-text.app 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://sync.zudo.app (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

  1. Purchase zudo-text.app through any registrar.

  2. In the Cloudflare dashboard, add zudo-text.app as a new zone on the account that already hosts the zudo-sync-server / zudo-publish-server Workers and the zudo-text-app Pages project.

  3. 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 domainBacking servicePurpose
sync.zudo-text.appzudo-sync-server WorkerAuth (Better Auth mount) + sync/automation API
doc.zudo-text.appzudo-text-doc Pages projectDeveloper documentation site
manual.zudo-text.appzudo-text-manual Pages projectEnd-user manual (Japanese)
app.zudo-text.app (optional)zudo-text-app Pages projectWeb 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://app.zudo-text.app and https://app.zudo-text.app/callback to BETTER_AUTH_TRUSTED_ORIGINS in workers/sync-server/src/better-auth.ts 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/src/content/docs/architecture/better-auth.mdx, "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_AUTH_ISSUER = "https://zudo-text.app" and BETTER_AUTH_AUDIENCE = "https://zudo-text.app" (or keep them distinct — the only hard requirement is that both workers' [vars] blocks stay byte-for-byte identical for each of the two).

  • Only BETTER_AUTH_JWKS_URL needs to track the physical mount (https://sync.zudo-text.app/api/auth/jwks once cut over) — that's a real fetch target, not a logical string.

  • Because these are pre-release, invalidating every existing session and minted JWT at cutover is expected and fine (no migration).

  • Never move iss/aud again 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/sync-server/src/email.ts falls back to Resend's sandboxed zudo-text <[email protected]> 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.

  1. In the Resend dashboard, add the apex zudo-text.app as a sending domain (region: Tokyo / ap-northeast-1). Register the apex, not a send. subdomain — the from-address must live on the domain you register, and [email protected] reads better than [email protected]. Isolation is preserved anyway: Resend puts its own MX/SPF pair on a send. subdomain of whatever you register (see below), so the apex MX stays free for inbound mail (e.g. Cloudflare Email Routing).

  2. Resend generates the exact record values; add them as Cloudflare DNS records on the zudo-text.app zone (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-supplied v=spf1 include:amazonses.com ~all) plus the MX record Resend supplies on the same send subdomain (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 to quarantine/reject later). 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.

  3. Wait for Resend to show the domain as verified (DNS propagation, usually minutes).

  4. Set AUTH_EMAIL_FROM = "zudo-text <[email protected]>" in workers/sync-server/wrangler.toml [vars]. This is a tracked config change, so it ships through the normal push-to-main deploy — not a dashboard edit.

  5. 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/sync-server/src/better-auth.ts (#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).

  1. Google Cloud Console → APIs & Services → Credentials → Create Credentials → OAuth client ID.

  2. Application type: Web application. One client covers every environment — don't create a second client per origin.

  3. Authorized redirect URIs — add all three so the same client works across the transition instead of needing a second cutover later:

    • https://zudo-sync-server.takazudo.workers.dev/api/auth/callback/google (current production origin — keep it; workers_dev stays reachable after a custom domain is attached, it isn't replaced)

    • https://sync.zudo-text.app/api/auth/callback/google (the new custom domain, once attached)

    • http://localhost:8787/api/auth/callback/google (local wrangler dev origin, per workers/sync-server/.dev.vars.example)

  4. 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_SECRET

    GOOGLE_CLIENT_ID goes in workers/sync-server/wrangler.toml [vars] as a plain string.

  5. Operator cutover: move GOOGLE_CLIENT_ID from secret to [vars]. Today GOOGLE_CLIENT_ID is deployed as a wrangler secret in 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 in workers/sync-server/wrangler.toml is deliberately commented out until this cutover runs. Do these steps in this exact order:

    1. Uncomment the GOOGLE_CLIENT_ID = "….apps.googleusercontent.com" line in workers/sync-server/wrangler.toml [vars], filling in the real client ID, then commit and push to main. deploy-sync-server.yml redeploys zudo-sync-server on that push (its paths filter matches any workers/sync-server/** change) — no manual wrangler deploy needed. 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 --strict is set; this repository's deploy workflow runs the plain pnpm 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.

    2. From workers/sync-server/, confirm the old encrypted binding is gone:

      wrangler secret list

      Wrangler 4.110.0 says the successful deployment replaces the remote secret, so GOOGLE_CLIENT_ID should 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 with wrangler 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/sync-server/wrangler.toml sits in the paths: filter of twoGitHub Actions workflows, not one:

    • deploy-sync-server.yml — matches via its workers/sync-server/**glob, redeploying the zudo-sync-server Worker.

    • deploy-web-editor.yml — lists workers/sync-server/wrangler.tomlexplicitly, redeploying the web-editor Pages production build.

    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/sync-server/src/better-auth.ts (#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.

  1. 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.

  2. 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 with error=email_not_found rather than creating an account. The users.email scope in the authorization request does not replace this portal permission.

  3. In the same app's callback/redirect URI allowlist, add these exact URLs. One X app can cover the *.workers.dev origin, 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/twitter

    Use http://127.0.0.1:8787 for local wrangler dev, not http://localhost:8787. Browse the local Worker at the 127.0.0.1 host so Better Auth's request-derived callback matches automatically; no app or baseURL change is needed. Do not add a trailing slash unless that is the exact URI sent by the app.

  4. Confirm Project enrollment before the first real smoke. A /2/users/me response of 403 client-not-enrolled is 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 exact client-not-enrolled wording 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.

  5. Check billing before enabling production. X's current pricing page lists User: Read — $0.010 per resource under its pay-per-use credit model. This flow is expected to make two /2/users/me reads, so two one-resource responses are nominally $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.

  6. Generate the OAuth client credentials. Put the public client id in workers/sync-server/wrangler.toml under [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_SECRET

    This is the settled X end-state: TWITTER_CLIENT_ID is a plain var and TWITTER_CLIENT_SECRET is a secret. It intentionally differs from the current Google mid-cutover drift, where GOOGLE_CLIENT_ID is 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_found landing 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 /2/users/me reads appear for the successful sign-in.

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.tomlBETTER_AUTH_ISSUER, BETTER_AUTH_AUDIENCE, BETTER_AUTH_JWKS_URL

  • workers/publish-server/wrangler.tomlBETTER_AUTH_ISSUER, BETTER_AUTH_AUDIENCE, BETTER_AUTH_JWKS_URL (must byte-for-byte match sync-server's issuer/audience, per D9)

  • workers/agent-server/wrangler.jsonc — no [vars] block; verifies auth via the AUTH_SERVER service binding to sync-server, so nothing to flip here directly

  • workers/sync-server/wrangler.tomlVAPID_SUBJECT = "mailto:[email protected]" (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 to mailto:[email protected] for consistency)

  • workers/sync-server/wrangler.toml — the commented-out AUTH_EMAIL_FROM = "zudo-text <[email protected]>" 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.exampleBETTER_AUTH_AUDIENCE

(BETTER_AUTH_ISSUER stays http://localhost:8787 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.exampleVITE_SYNC_SERVER_URL, VITE_BETTER_AUTH_URL (both https://zudo-sync-server.takazudo.workers.dev today; both should move to https://sync.zudo-text.app)

  • tauri-app/.env.exampleVITE_PUBLISH_SERVER_URL (https://zudo-publish-server.takazudo.workers.dev — leave on *.workers.dev unless/until zudo-publish-server also 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/sync-smoke.spec.ts — the WEB_EDITOR_SYNC_SERVER_URL=... example in the header comment

  • e2e/web-genesis-round-trip.spec.ts — the same example

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 in doc/src/content/docs/manual/web-editor.mdx) — update in GitHub repo settings, not in a file

doc/ references:

  • doc/src/content/docs/architecture/better-auth.mdx — the BETTER_AUTH_AUDIENCE value, the issuer/jwks_url/audience example block, and the D9 callout's audience mention

  • doc/src/content/docs/manual/web-editor.mdx — the WEB_EDITOR_SYNC_SERVER_URL example value in the secrets table

  • doc/src/content/docs/backend-api/automation-api.mdx — the base URL and every curl example against it

  • doc/src/content/docs/backend-api/sync-server-api.mdx — the base URL (prose + heading line) and the 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-app/.env.example, 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/sync-server/wrangler.toml), 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://sync.zudo-text.app (desktop handoff or web handoff), confirm a session is established.

  • Service-JWT mint — call GET /api/auth/token with a live session, confirm an RS256 JWT comes back and decodes with the new 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://sync.zudo-text.app/api/auth/callback/google and confirm the resulting session/account-linking behaves as expected.

Better Auth's session cookie must stay host-only. Never enable advanced.crossSubDomainCookies on the sync-server Better Auth instance (workers/sync-server/src/better-auth.ts), even after app.zudo-text.app is attached.

The desktop and web handoff flows (see doc/src/content/docs/architecture/better-auth.mdx, "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 /u (see workers/publish-server/src/index.ts). 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 — /, /privacy, /terms — 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.

  1. Fill the [CONTACT — operator to fill] placeholders. privacy.html has four (data controller, attachment-purge contact, account-deletion contact, contact section) and terms.html has 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.

  2. Attach zudo-text.app to zudo-text-landing the 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).

  3. Once the custom domain is confirmed serving, flip workers_dev from true to false in workers/landing/wrangler.toml. It starts true only so the first deploy has a reachable, verifiable URL before the custom domain exists — leaving it true afterward is unnecessary, not incorrect, but the flip is the same one-way pattern section 7 uses for every other Worker in this runbook.

  4. In Google Cloud Console → APIs & Services → OAuth consent screen, enter https://zudo-text.app/privacy and https://zudo-text.app/terms as the privacy-policy and terms-of-service links.

  5. 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.