dztlandscaping.com
Connected · proven by a build 2026-09-25Repo → branch LimitlessFlo/024 → main
Build No install; cloudflare/build.mjs turns netlify.toml + functions into the Worker
Cloudflare record
← Office · a document, not a console
This page records the move to Cloudflare: why we're doing it, how it works, what's been decided, and every step taken so far. It's the reference to read before doing any more of the work.
TKATI is the control plane (tkati-infra, TKATI's own Worker).
Cloudflare is the client's infrastructure plane. The client owns the infrastructure plane;
TKATI only receives revocable authority to manage it.
36%
done
86%
built
8 of 22 milestones done, counted from the status words in §01
19 of 22 built, counting what's done
What moves them: 11 built milestones (Project A, Phases 2–12) become done only when their live tests run on real Cloudflare accounts, which start with your M3, M23 and M5–M8. The 3 not built wait on dates (Netlify retired from 2026-10-08, tkati.com transferred from 2026-10-21) and on the pilot (Phase 13, 10 owner steps). Today's work (Project A's database in production, M25, the Office and account features) finished none of them.
Project B · TKATI onto Cloudflare
6 of 8 · 75%
built 6 of 8 · 75%
Project A · clients own their infrastructure
2 of 14 · 14%
built 13 of 14 · 93%
Done means checked against the real system: for Project A, a live test with real Cloudflare accounts. Built means built, reviewed and green against local stand-ins, waiting for that live test (most wait on the owner steps in Needs you). tools/infrastructure-progress-check.mjs recounts both from the status words and fails if a figure drifts.
Each status is a word, and Done means someone checked it against the real system. An API returning 200 isn't enough. Built means built, reviewed and green locally, waiting for its live test; it counts toward the second figure at the top, never the first.
Everything tkati.com depends on today, written down.
TKATI's own account: 9a2e66414e0e4235a7551e0342f58c2e (the owner's Google login), pinned as account_id in wrangler.jsonc. Wrangler logged in via browser OAuth on 2026-09-24. Checked 2026-09-25: two-factor is off on the only login, and that login is the account's only member (one Super Administrator). Both fixes are owner steps (§10).
Static site + the 8 /api/* functions + the daily cron run in one Worker, locally, with verify.mjs green. Done 2026-09-24 under wrangler dev; see the record.
Live at tkati.heavensbrewer.workers.dev. Pages, CSP, privacy and routes checked in a real browser. 12 secrets copied from Netlify production; every /api/* route answers as configured; /api/ask returns an answer identical to Netlify's, inside the free-plan CPU limit. Not yet exercised end to end: Stripe checkout, Resend send/inbound (their webhooks still point at tkati.com, which is correct until cutover).
Zone created in Cloudflare; every record diffed against the live snapshot; mail records proven identical. Zone added via the Cloudflare plugin with all 7 records (web + mail), DNS-only. dns-diff.mjs against Cloudflare's nameservers passes, and tkati.com loads with a valid certificate through Cloudflare's configuration.
Step 1, DNS: switch tkati.com's nameservers at Squarespace. The site stays on Netlify, and only DNS moves. Claude was blocked on this one, so it's the owner's click. Step 2, hosting: point apex/www at the Worker (Custom Domain), then prove the site, mail, Stripe and the Resend webhooks.
Move the registration from Squarespace to Cloudflare Registrar, as the owner asked on 2026-09-24. Blocked by ICANN until 2026-10-21: the domain was registered 2026-08-22, and no transfer is allowed in the first 60 days. It also needs Cloudflare nameservers first (B5), the Squarespace lock removed, the auth code from Squarespace, and a payment method on the Cloudflare account. Transferring bills one year's renewal, so the owner confirms the price before it's submitted (§38–41). All 87, checked 2026-09-25: 70 are past the 60-day lock (3 already unlocked: limitlessflo.com, onlyspit.com, whiteghostmeme.com), 16 unlock between 2026-09-27 and 2026-11-02, and arròw.com can't move to Cloudflare at all, because Cloudflare Registrar doesn't take IDNs. The arròw.com and hevnz.com transfers started on 2026-09-24 never reached the registry. Table: docs/cloudflare/transfer-readiness-2026-09-25.md.
Checked 2026-09-29: every moved domain (tkati.com and www, tatailoring, musicted, djthub, biltq, dztlandscaping, and the 404-only otviews, arrxw, siptheglobe and hevnz) answers from Cloudflare, and none carries a Netlify header, so nothing still leans on the sites to be deleted. After two quiet weeks with Netlify kept as the rollback. The soak started with the final tkati.com cutover on 2026-09-24, so the earliest delete date is 2026-10-08. Deleting the Netlify site also stops its duplicate 07:10 cron.
Current Cloudflare docs read; facts recorded in §9 with dates, refreshed 2026-09-25. Refresh tokens are now documented; their lifetime and the temporary-account rate limit still aren't. The two §180 pages on member policies and API tokens are read at Phase 2 entry.
Built and checked locally, and in production since 2026-09-29 (M2): supabase/migrations/0039_a_client_owns_its_cloudflare_account.sql (the draft pa1) (17 tables, RLS forced on all, 11 door functions only cf_integration may call), 218 SQL tests and the PostgREST check green, 11 of 11 mutations caught, verify.mjs --local passing with none of the four new checks skipped. Promoted with pa2–pa12 as 0040–0050 and applied by supabase db push; the production catalog probe then read back every object.
TKATI's OAuth client, server-side code flow, one-time state, all failure paths tested. Built 2026-09-25 and green locally: the tkati-infra Worker (infra/), browser-bound OAuth with PKCE, credential sealing and single-use refresh with a lease, revocation, the account chooser as an aal2 confirmation, and the portal pieces, all hidden in production. Tested against local stand-ins for Cloudflare (108 checks), never Cloudflare itself. Not Done until the 34 live tests in docs/cloudflare/phase-2-deferred.md run against real test accounts.
Hello-world Worker in a temporary account from our backend. Built 2026-09-26 and green locally: one queue for everything (TKATI's ad_jobs generalised, one leased job per lane, today's ads jobs unchanged), the proof-of-work solver in a queued job, the temporary account created only with the client's own terms tick, its token and claim link sealed, the build from a commit, the Static Assets upload, the Worker deploy and its preview URL. 575 local checks, never Cloudflare. Not Done until two real temporary deploys run.
Claimed by a test account (never a real client first); expiry handled. Built 2026-09-27 and merged into main 2026-09-28: a claim link is opened once, by its own person, through a 30-second one-time ticket stored only as its SHA-256; a sweep settles expired claims; Disconnect stops a preview in one flow (lead decision 9). Draft pa8; real-account tests in docs/cloudflare/phase-4-deferred.md.
"Connect TKATI to manage this website?" after claiming. Built 2026-09-27 and merged 2026-09-28: the grant must reach exactly the claimed account (never another client's or TKATI's); promotion reads the account's members read-only, and an unreadable list is "unknown", never "no TKATI member" (lead decision 14); one site profile read the same way by promotion and publish. Draft pa9.
Worker + Static Assets deployed into the client account and recorded. Built 2026-09-27 and merged 2026-09-28: publish, settings (secrets) and a health check of the workers.dev site; a Worker TKATI did not make is never overwritten; bindings come only from the site's profile. A redeploy with changed assets waits for a live test, because Cloudflare's schema and docs disagree on whether a version upload carries assets (facts, 2026-09-27). Draft pa10.
Only what the project needs; lookup before create. Built 2026-09-26 and merged into main: a staff-set site profile says what a site needs, the database refuses any other create, and a double click makes one database (docs/cloudflare/phase-7-deferred.md).
DNS audit first, mail records protected, Custom Domain, real HTTPS check. Built 2026-09-26 and merged into main: TKATI creates no zone, and creates a DNS record only to put back a website record it deleted when the attach fails (pa6: once, the same type, name and content); the site moves only on the client's own yes to a plan that keeps the mail (docs/cloudflare/phase-8-deferred.md).
One Infrastructure section in the client account: owned by / managed by. Built 2026-09-26, red-teamed twice, merged 2026-09-28: the page draws from the database's model (who owns it, who may touch it, what TKATI manages), with no control or link for staff and nothing shown until infraOrigin is set, which production does not set. Draft pa12.
Idempotency, retries, rollback: reusing what exists, not a second system. Built 2026-09-27 and merged 2026-09-28: one queue lane per account, a 429 with Retry-After honoured across the user's lanes, rollback only to a version TKATI recorded and never forced past a changed secret, alerts offered only where Cloudflare allows webhooks (a Pro zone) and still held until M6. Draft pa11.
Exit-readiness checklist and the break-glass document. Built 2026-09-26 and merged into main: the ownership level per project computed by the database (never a badge a person sets), §153's eleven-item checklist with its evidence, the break-glass document with no secret in it, the new-developer steps in the client's own dashboard, and a disconnect flow that says "Your site stays online. Nothing is deleted." before it revokes. The chaos test and a real invite wait for real accounts (docs/cloudflare/phase-11-deferred.md).
Cross-tenant, secret-leak, revocation, replay. No launch until green. Built so far (merged 2026-09-28): one cross-tenant command over every /infra route (a route without a case fails it), a grep of everything published for secret shapes, a leak drill, and a read-only catalog probe that compares production's schema with the drafts, run only by the owner; the open-step alarm and the ownership record's tripwire (2026-09-28). Two red teams (2026-09-26, 2026-09-27) and every open item decided (docs/cloudflare/redteam-2026-09-26.md), and the lead's own pass over the merged seams (2026-09-28). Built locally 2026-09-29: every suite is a gate in verify.mjs --local, which passed on main with 0 failures (77 checks; the two Office screen checks it runs only in part passed in full against the stub: 133 and 87); all 218 SQL mutations are caught. Promoted 2026-09-29 (M2): pa1–pa12 are migrations 0039–0050, applied to production after a rolled-back rehearsal on production itself, a CLI rehearsal on a copy, and a backup; the read-only catalog probe then matched production to the files (218 objects). What remains is production's live pieces: the production keys (M9b) and tkati-infra in its own account (M24, M22), the secret rotation drill (M18), then the catalog probe and two real tenants on two real accounts.
One low-risk real client. New clients first, never a mass migration. Prepared 2026-09-29: an operation, not a build, so nothing here is "built"; what it runs is Phases 2 to 12. docs/cloudflare/pilot-runbook.md walks the production dry run by test identity 1 (connect, confirm, hello-world deploy, Disconnect, and the site still up afterwards: §276), then the pilot, with a friction log and stop rules (the kill switch, then Disconnect). node tools/pilot-check.mjs lists its twelve gates (M25, M24, M2, M9b, M22, M18, M20, M26, M28, M19, M27, M21) with what you have recorded done, and verify.mjs --local holds the runbook to the plan.
"Move to Cloudflare" covers two different jobs. They don't conflict, but mixing them up would.
The product. Every client gets their own Cloudflare account, and their site, database, storage, domain and HTTPS live there. TKATI gets revocable access to manage it. If TKATI disappears, the client keeps everything.
Never: one TKATI account holding every client.
tkati.com is TKATI's own site, so it belongs in TKATI's own Cloudflare account. That fits the ownership model: here the owner and the client are the same company.
Doing B first means we learn Workers, DNS migration and cutover on our own site before touching a client's.
Not in scope here: "TKATI hosted" (Workers for Platforms, many users on one platform). The brief (§218–219) calls that a separate, future product mode. Name things so the two can coexist later, but don't build it now.
Every site below is a Cloudflare Worker. Its repo holds a wrangler.jsonc
that builds and deploys it, so a push goes live through Cloudflare Workers Builds
through its build trigger (all seven exist, one per Worker). GitHub Actions can't do it: every run on the account is
refused over a GitHub billing problem. A push to a watched branch is a production deploy, from any person or agent.
The free plan runs one build at a time, so pushes to several repos queue.
Repo → branch LimitlessFlo/024 → main
Build No install; cloudflare/build.mjs turns netlify.toml + functions into the Worker
Repo → branch LimitlessFlo/tkati → main
Build tools/build-public.mjs (allow-list into dist/)
Repo → branch LimitlessFlo/TA → main
Build npm run build:client (Vite) inside wrangler's build; Express for /api; careers form
Repo → branch LimitlessFlo/MusicFlo → main
Build Vite via cloudflare/build.mjs; needs 4 public VITE_* build values on the trigger
Repo → branch LimitlessFlo/003 → worktree-public-launch-audit
Build Vite + Netlify's generated _redirects → routes; needs 3 public VITE_* build values
Repo → branch LimitlessFlo/FiBot → main
Build npm run build:client (Vite), no functions
Repo → branch LimitlessFlo/david-mounting-assembly → main
Build Same as dzt
Repo → branch —
Build 404-only Workers (otviews, not-found); nothing to deploy
npx wrangler deploy there (Netlify hadn't deployed it since 2026-08-29 either)Repo → branch a snapshot in cloudflare-sites/, not a repo
Build The live deploy's pages + the app's API behind a Workers shim; moved 2026-09-25
Repo → branch still Netlify
Build Dead site (owner: leave it) · readyos, money movement, a separate project
The inventory was taken from the repo and live DNS on 2026-09-24. Variable names are listed; their values live only in the host's settings and never here.
Today Netlify, from LimitlessFlo/tkati main, publish = ".", no build step
Moves to Worker + Static Assets in TKATI's Cloudflare account
Today netlify.toml: CSP with the no-JS style hash, /admin/* noindex + no-store, immutable /assets/*
Moves to dist/_headers, generated from netlify.toml by tools/build-public.mjs, so there's one source while both hosts run. Narrower rules detach before they override (see the record)
/api/* functionsToday 9 Netlify v2 handlers: ask, enquiry, inbound-mail, send-mail, stripe-checkout, stripe-portal, stripe-webhook, ebay-sync over HTTP, plus ads-report-run on a schedule. (account-claim was deleted on purpose on 2026-09-22.)
Moves to worker/index.mjs imports the same modules; there's no copy. They're already Request → Response, the only import is node:crypto, and ask's context.waitUntil maps directly. /.netlify/functions/* is kept as an alias
Today ads-report-run, config.schedule = '10 7 * * *'
Moves to Workers Cron Trigger (wrangler.jsonc). Not reachable by URL, since Netlify never exposed scheduled functions either
Today SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_ANON_KEY/PUBLISHABLE_KEY, RESEND_API_KEY, RESEND_WEBHOOK_SECRET, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_* (3), STRIPE_API_VERSION, GROQ_API_KEY, GROQ_MODEL, EBAY_* (4), ASK_* tuning (11)
Moves to Worker secrets and vars. The owner enters the secret values, never pasted into a Claude chat
Today Netlify DNS (NS1, dns1–4.p03.nsone.net). Apex A → Netlify
Moves to Cloudflare zone in TKATI's account
Today Squarespace Domains, expires 2027-08-22
Moves to Stays. Only the nameservers change. A registrar transfer is optional and separate (§42)
Today MX tkati.com → inbound-smtp.us-east-1.amazonaws.com (Resend inbound) · send.tkati.com MX + SPF · DKIM resend._domainkey · DMARC p=none
Moves to Copied exactly, then diffed against a live lookup before any nameserver change
Today Supabase utxiyqfkcylizuasfiym: Postgres 17, 36 migrations, RLS as the security boundary; the browser talks to it directly (rest/v1, auth/v1)
Moves to Stays on Supabase. See decision D3
Today Supabase Storage (photo-io.mjs, office.mjs)
Moves to Stays for now. R2 is a later, separate choice
Today Stripe, Resend, Groq, eBay, GitHub
Moves to Unchanged. Webhook URLs stay the same as long as /api/* paths are kept
Found during the inventory: the whole repo is public.
publish = "." means Netlify serves every file in the repository.
On 2026-09-24, https://tkati.com/docs/SYSTEM-OVERVIEW.md,
/tools/verify.mjs and /supabase/migrations/0001_roles.sql all
returned 200. There are no secrets in them (the anon key is public by
design), but it shows the full system. The move fixes this properly: the Worker
serves only an explicit list of public files.
npm run cf:check # the gate: routes, cron, allow-list, headers
npm run cf:dev # local Worker on :8788. Reads .env.local = LIVE keys
# → only GET-probe, or use dummy --var values
npx wrangler login # OWNER, once: browser OAuth, no key pasted anywhere
npx wrangler secret put SUPABASE_SERVICE_ROLE_KEY # OWNER, one per name in the table above
npx wrangler deploy # B3 preview on *.workers.dev (no routes = not tkati.com)
wrangler dev: the static site, all /api/* routes and the cron. verify.mjs stays green, with its netlify.toml checks pointed at the new config.dig of today's DNS. Mail records must match exactly..html URLsEffect /admin/office.html answers 307 → /admin/office (the auto-trailing-slash mode). Netlify served the .html URL directly.
Handling Harmless: browsers keep #hash and ?query across the hop. Accepted, and noted so nobody reads it as a bug.
Effect Cloudflare joins every matching rule, while Netlify let the narrower one win.
Handling Fixed in the build with ! Name detaches; worker-check.mjs fails if it regresses.
Effect _headers doesn't reach Worker responses.
Handling The Worker adds nosniff, Referrer-Policy and X-Frame-Options when they're missing. The CSP is left to each function, as on Netlify.
Effect The free plan allows 10 ms of CPU per request.
Handling Measure /api/ask at B3. If it's over, use Workers Paid rather than a rewrite.
wrangler dev reads .env.localEffect Local runs pick up the live keys automatically.
Handling Test writes with CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false and dummy --vars, as was done here.
The picture to keep in your head. The client is at the top because they own everything below them. TKATI connects from underneath, through a door the client can close.
CLIENT
│ owns login, recovery, billing
▼
┌──── CLIENT'S CLOUDFLARE ACCOUNT ────┐
│ │
│ Worker + Static Assets (site) │
│ D1 (database, if needed) │
│ R2 (files, if needed) │
│ Zone (domain · DNS · HTTPS) │
│ │
└──────────────────▲──────────────────┘
│ OAuth — scoped, revocable
│ (closing it does NOT take the site down)
tkati-infra (TKATI's control plane: builds, deploys, watches)
▲
TKATI (people who manage it)
Source code: client-owned repo, or a complete handoff
Other providers: Stripe, Resend, Google… in the client's name where practicalMeans exactly Whose Cloudflare account, whose card and whose login. Always the client in Project A.
Means exactly Who holds revocable access to change things. TKATI, through tkati-infra.
Means exactly TKATI has a working OAuth grant. A connection does not mean ownership, and the database says infrastructure_owner = client explicitly.
Means exactly A client taking over a temporary account we created, which makes it theirs. After a claim, TKATI has no access until the client connects us through OAuth.
Means exactly tkati-infra, TKATI's own Worker: what builds and deploys. It can go down without taking a client's site with it.
Provisioned Worker + Static Assets, custom domain, HTTPS
Provisioned …plus D1 for leads and settings
Provisioned …plus R2 (private bucket, served through the Worker)
Provisioned …plus KV / Queues / Durable Objects only where there's a real reason
Provisioned Hybrid is allowed: Cloudflare for hosting, the client's own Supabase for data. D1 isn't Postgres (§28–29).
If any of these is false for a client, their architecture isn't finished, whatever else works. Brief §252–261.
The test that matters most isn't "can TKATI deploy?" It's "can the customer keep everything after TKATI loses access?" The proof is the chaos test: switch off tkati-infra, revoke TKATI's OAuth, then confirm the site, database, uploads, domain, HTTPS and the client's own Cloudflare login all still work (§154).
One question decides the path: does the client already have Cloudflare?
Disconnecting never destroys anything. Deleting infrastructure is a separate danger-zone action, usually sent to the client's own Cloudflare dashboard.
Real state machines, not a connected boolean and three flags.
The data model below is conceptual. Adapt it to the existing schema rather than
duplicating tables that already exist (§182).
NOT_CONNECTED TEMPORARY_PREVIEW AWAITING_CLAIM CLAIMED_NOT_CONNECTED CONNECTED LIMITED_PERMISSIONS REAUTH_REQUIRED REVOKED ERROR
DRAFT → BUILDING → PREVIEW_PROVISIONING → PREVIEW_DEPLOYING → PREVIEW_READY → CLAIM_REQUIRED ↘ CLAIM_EXPIRED (start over, fresh) → CLAIMED → CLOUDFLARE_AUTH_REQUIRED → AUTHORIZING → CONNECTED → PERMANENT_PROVISIONING → DOMAIN_SETUP → SSL_PROVISIONING → VERIFYING → LIVE any → NEEDS_ATTENTION | REVOKED | FAILED
Holds client, account id + name, status, capabilities, authorized by/at, last verified, revoked at, credential_ref
Never holds Token plaintext
Holds Encrypted OAuth material. A service-only table; RLS gives no browser any read access
Never holds Anything a client role can SELECT
Holds type, id, name, environment, owner, created-by-us vs adopted, last verified
Never holds Secrets
Holds artifact, worker, initiated by, status, error class, previous and rollback target
Never holds Secrets
Holds both expiries (the temporary account's and the claim's), status, who may see it, and the evidence for "claimed" (the temporary token refused, or the OAuth account matching)
Never holds The claim URL or the temporary token. Superseded 2026-09-25: they are sealed in private.cloudflare_credentials and deleted on claim or expiry, never kept in memory only
Holds Every event in §101: claim link generated, connected, revoked, created, deployed, DNS changed, purchased (amount + reference)
Never holds The link itself or any credential
These rules are enforced in code on the server. A prompt can't be the only thing standing in the way (§216).
apiTokens and claim URLs
never reach a browser, HTML, logs, analytics, error reporting or a readable database row.Authorization, Bearer, apiToken,
access_token, refresh_token, client_secret, claim URLs and claimToken, cfat_ tokens, upload JWTs, claim tickets and R2 secret keys from anything logged (added 2026-09-25).account_id sent from the browser is never treated as authority (§70).Safeguard Live price shown → client confirms the exact domain, price and account → server records the confirmation → one call. Never retried automatically; reconcile first.
Safeguard Never automatic. Cloudflare DNS works without moving the registrar. Transfers aren't available through Cloudflare's API, so a transfer is always a step in the client's own dashboard.
Safeguard A confirmed operation with the old record snapshotted first. Revoking the client's owner or changing their billing has no operation at all, because no scope for either is ever requested.
Safeguard Old DNS snapshot → new plan → diff → MX, SPF, DKIM and DMARC shown as preserved → confirm.
Safeguard Separate danger zone. Never part of a disconnect.
Safeguard Explains that nothing is deleted, then revokes and marks Revoked.
Safeguard New → verify → switch → revoke old. No downtime.
Safeguard Staff only, to a recorded known-good version.
Safeguard The client is told, and the client turns it on in their own dashboard (R2, for one, needs a subscription added at checkout). We never accept charges for them.
Safe to retry: reads, deploying the same artifact to the same Worker.
Look up first, then retry: creating a D1, R2 bucket, Worker or zone.
Never auto-retry: domain purchase, transfer, deletes.
A revoked grant is detected, marked REAUTH_REQUIRED, and not hammered.
These can't be automated, and shouldn't be. Each one involves a login, money, a legal agreement or a credential. A 90-second human step beats risky automation (§223).
Create or confirm TKATI's Cloudflare account; turn on two-factor (Profile → Authentication); add a second Super Administrator you trust (Manage Account → Members). Checked 2026-09-25: two-factor off, one member
Registrar transfers: unlock each domain at Squarespace, get its auth code, add a payment method in Cloudflare, confirm the price. About $1,150 a year for the 86 that can move, at Cloudflare's listed renewal prices; the exact price shows when each transfer is set up. Your decision (2026-09-29): each domain moves only when needed, in the month before it expires, so its transfer's paid year stands in for a Squarespace renewal rather than being paid early. Checked 2026-09-29 against the registries: every one is on Cloudflare's nameservers; 68 can move now, 15 more by 2026-11-02 (tkati.com from 10-21). 26 expire in November 2026: move those in October; 2 expire in December, the rest between January and September 2027. hevnz.com (expires Nov 27): start again, because the first attempt never reached the registry. arròw.com: the owner doesn't need it (2026-09-25), so it isn't transferred or renewed. heavensbrewer.co is registered through Key-Systems, not Squarespace, so confirm where its code comes from
Turn Squarespace 2FA back on (it was turned off to speed up the nameserver switches)
Prove the webhook secrets with one real event each. Stripe (tkati.com, musicted.com): each Worker's secret is proven equal to the owner's local copy (a signed no-op event answers 200), so only Stripe's own copy is unproven. Every event type the two endpoints listen to needs a subscription or invoice to change, so it can't be caused without touching real data. Watch the first real event in the Worker's logs, or use Stripe's test-event button if the endpoint offers one. An email to any @tkati.com and @dztlandscaping.com address (proven 2026-09-25 by real signed Resend deliveries). A text to the dzt number: none has arrived since the cutover
Fix GitHub billing ("recent account payments have failed or your spending limit needs to be increased"). Until then no GitHub Action runs, including dzt's policy/migration checks. Re-checked 2026-09-25: still refused on 024, MusicFlo, FiBot and 003
One real test each: a text to the dzt number; one application at tatailoring.com/careers/ (sent and delivered 2026-09-25; the owner can delete the TEST email in hello@)
From 2026-10-08: delete the retired Netlify sites (tkati, dztlandscaping, tatailoring, musicted, djthub, aibuilds, otviews, arrxw-coffee-website, and a builder site that was never deployed), which also stops tkati's duplicate cron. The dashboard app's Netlify site from 2026-10-09, two weeks after its move, and qanava (a spare job tick of the dashboard app; its real worker runs on Railway). Not david-mounting-assembly yet: its netlify.app address is the business's only public URL until it has a domain
mainset and qanava, checked 2026-09-25. mainset is not a risk: it uses its own Supabase project (paichfkovnffzipzndul, not readyos's ptvfivgxezfmohphevqo), has 9 variables, no wallet, RPC or signing keys, and sets VITE_SITE_SHUTDOWN. Its schedules can't move money; delete it whenever you like. qanava is a spare, not the worker: it runs the dashboard app's job tick (every minute), but the app's real worker is a container on Railway (project Limitless, since commit 853a3b87). It was checked live on 2026-09-25: the deployment is SUCCESS, it sweeps schedules every minute, and the queue's jobs are succeeding, with none waiting. The two share the queue on purpose (the queue's claim function arbitrates). So qanava can go with the other Netlify sites after the soak, and the app keeps running on Railway. It was not moved to a Cloudflare cron: that would be a third copy, and a tick may run up to 25 s where the free plan allows 10 ms of CPU. If a second, spare worker is still wanted after Netlify, it belongs on Workers Paid
Project A, Phase 2 entry (next): M3 two-factor + a second Super Admin (the B1 row above; a hard gate). M4: the consent-screen name and publisher domain are decided ("TKATI Site Management", tkati.com; 2026-09-29, at your direction); tkati-infra's hostname waits for M24, since it needs a new domain in that account (tkati.com's zone stays in yours). M23 create the tkati-test account (you as Super Admin with 2FA). M5 create the test OAuth client there (authorization_code + refresh_token, client_secret_basic, PKCE), first checking whether it accepts http://localhost:8787/oauth/callback; its secret goes straight into the Worker's secrets. M6 run the read-only scope listing and approve the scope list. M7, M8 create test identities 1 and 2 (e-mails outside TKATI's and the owner's other domains, their own Cloudflare accounts, 2FA), invited into tkati-test with Minimal Account Access. M22 approve deploying tkati-infra-test into tkati-test. M18 the client-secret rotation drill. M29 only if localhost is refused
Two steps the Phase 2 build surfaced: confirm TOTP two-factor is switched on in the production Supabase project (it is on in the local stack since 2026-09-25), and list each TKATI person's Cloudflare user id in private.cf_tkati_cf_users, so a TKATI login can never be the one consenting for a client.
Project A, later phases: M10 Workers Paid if the proof-of-work or database driver doesn't fit the free plan (Phase 3). M11, M12 tick Cloudflare's terms and complete each test claim within 60 minutes (Phases 3–4). M13 turn on R2 in test identity 1's account (Phase 7). M14 a payment method on a test account, M15 a test domain with Workspace-style mail, and Q11 how domains get bought (Phase 8). M17 an account-owned token, only if kept (Phase 10). M19 confirm the client agreement (Phase 11). M24 create the tkati-infra account (two Super Admins with 2FA, its hostname, deploy path, R2 bucket, Hyperdrive with caching off), M9b production keys, M20 verify the publisher domain and make the production client public (Phase 12). M21 choose the pilot client, M26 move Supabase to asymmetric JWT keys (Phase 13). M27 grant infrastructure authority per client login. (M28, the build host, is decided: the operator's machine, as Phase 10 built it.)
M2: approve the Project A drafts and push them. Done by Claude at the owner's direction ("u do all migrations/apply etc via cli or api or chrome"): pa1–pa12 became migrations 0039–0050 (renames without the banner; docs/cloudflare/promoted.json), every check moved with them, verify.mjs --local green; then a backup, all twelve rehearsed on production inside one transaction that was rolled back, a CLI rehearsal on a local copy, a dry run that listed exactly 0039–0050, supabase db push, and the read-only catalog probe against production: every line ok. Nothing reached Cloudflare; the tables are empty until tkati-infra is deployed (M24, M9b, M22).
M25: the production Supabase admin credentials out of . Done by Claude at the owner's direction, after the pushes that needed them: the seven values that were set (service and secret keys, JWT secret, database password, the database URL and both pooler URLs; the management token was empty) are in the macOS login Keychain, service .env.localtkati-production-supabase, each stored with no trusted application, so every read asks you; each was read back identical (by hash, never shown) before its line was removed. No copy is in a worktree, a shell profile or the shell history (checked). Still yours: rotate the database password and the secret key in the Supabase dashboard (M26), since sessions could read them before today.
Apply migrations 0037 and 0038 to production (a project's next move, request due days, a follow-up day for enquiries, when Stripe stopped collecting, Stripe's own status, and the approval fix). Done by Claude at the owner's direction: a backup of the touched tables first, a dry run that listed exactly those two, supabase db push, then every new column, trigger and constraint read back from production. Project A's drafts (pa1–pa12) are not applied; they still wait for M2.
Log Wrangler into that account on this machine (npx wrangler login, a browser OAuth flow; no key is pasted anywhere)
Enter each secret value with wrangler secret put NAME (typed into the prompt, not the chat)
Add tkati.com to Cloudflare (dashboard → Add a domain → Free). Stop at the nameserver screen. Or run /reload-plugins in Claude Code so the Cloudflare plugin can do it
Switch nameservers at Squarespace to the two Cloudflare gives you, only after node tools/dns-diff.mjs <cloudflare-ns> passes
Apply migrations 0033–0036 to production (supabase db push); Claude's push was refused by the permission system, so the owner ran it
Connect GitHub to Cloudflare (Cloudflare's GitHub app on LimitlessFlo, all repositories)
dzt Worker → Settings → Builds → edit: clear the Build command. Done through the API; its failing branch previews were also turned off
Create the build triggers for tkati, tatailoring, musicted, djthub, biltq and david-mounting, plus the public . Done once auto mode was offVITE_* build values
The dashboard app: set its signing secret; decide its hosting token. Not needed from the owner after all: both values were on this machine (see the record)
Answer Project A's Q1 and Q2. Answered 2026-09-25: TKATI, and the locked-down login (D8, D9). D3 confirmed too
Never paste into a Claude conversation: the Global API Key, any API
token, OAuth client secret, claim URL or password. If a step seems to need one,
the step is wrong. Use wrangler login, wrangler secret put or
the dashboard instead.
Each decision comes with its reason. To reverse one, add a new decision that supersedes it, and leave the old one here.
Decision Move TKATI itself (Project B) before any client work.
Why Learn Workers, DNS migration and cutover on a site whose owner is us, where a mistake costs us and not a client.
Decision tkati.com goes into TKATI's own Cloudflare account.
Why It's TKATI's property. The "one client, one account" rule is about clients, and this is the same rule applied to ourselves.
Decision Supabase stays (Postgres, Auth, Storage) for now. Only hosting, functions and DNS move.
Why RLS is TKATI's security boundary: 36 migrations, with the browser talking to Postgres directly under RLS. D1 is SQLite without RLS, so moving means rewriting the whole security model behind a server API. The brief itself says not to force D1 onto a system that needs Postgres or RLS (§28–29) and allows a hybrid stack. Revisit only as its own project, with its own plan.
Decision The registrar stays at Squarespace; only the nameservers move.
Why Cloudflare DNS doesn't need Cloudflare Registrar. A transfer is a separate, optional, deliberate action (§42).
Decision The Worker serves an explicit allow-list of public files, not the repo root.
Why Today /docs, /tools and /supabase are all publicly readable, which is a side effect of publish = ".".
Decision This page is a static document with no sign-in gate. It must never hold a secret.
Why There's no server in front of /admin, so a JS gate over static text would only look like protection. The honest version: nothing on this page is secret.
Decision The brief is kept verbatim in docs/cloudflare/directive.md and never edited.
Why It's the owner's intent. Corrections to its facts go in §9 here, so both the original and the correction stay visible.
Decision Project A is built in TKATI (repo and Supabase project), with the owner's engine code copied in unedited and pinned to 746b3ccc. Nothing runs outside TKATI. Answers the plan's Q1.
Why TKATI already holds the clients, grants, jobs and the client account Project A hangs off. The brief named a separate control plane (§46, §250); TKATI is it, and the vendored engine code runs inside TKATI.
Decision The Cloudflare integration reaches the database as its own Postgres login (cf_integration), only through Hyperdrive (caching off) in a separate, locked-down tkati-infra account. The role is never granted to PostgREST's authenticator. Answers the plan's Q2.
Why That role can read sealed client credentials. A JWT role would be mintable by anyone holding the project's JWT secret, which sits in .env.local today. The plan's Q12 (a second factor for every confirmation except the terms) and Q13 (owner-granted infrastructure authority) are built as recommended; they can still be dropped before the draft is ever applied (Phase 12).
Decision The integration is its own Worker, tkati-infra, in its own account (two Super Admins with 2FA, nobody else, no Wrangler login on any machine Claude or an autopilot uses). Tests use a separate tkati-test account. Deploys go through a path the owner controls, after reviewing the infra/ diff.
Why That Worker holds the keys that open every client's Cloudflare credentials. Keeping it out of TKATI's main account keeps it out of reach of the tools that deploy tkati.com.
Decision Every person's action starts as a request row written by their own live session (15-minute life), and runs, steps and deployments copy their actor from it. High-risk confirmations need a second factor (aal2). A client login may act for its client only after the owner grants it infrastructure authority. Background work goes through one queue (TKATI's ad_jobs, generalised, one lane per Cloudflare account), not a second system.
Why The actor and tenant come from the database, never from what a browser sends (§70); team members can create client logins today, so acting for a client needs the owner's grant; and the brief forbids parallel duplicate systems (§277).
The brief was written by another AI, and it says itself that its API details are claims (§181). Only what's in this table has been checked against Cloudflare's own docs, and each row gives the date. If the docs change, they win: stop, record the change here, then update the code.
What the docs say POST /client/v4/provisioning/previews/challenge gets a challenge, then POST /client/v4/provisioning/previews creates the account. After that, the normal account APIs (e.g. PUT /accounts/{id}/workers/scripts/{name}).
What the docs say The challenge returns seed (32 bytes, base64url), k and g. checkpoint[0] = SHA-256(seed); each of the k segments is g sequential SHA-256s from the previous checkpoint. The k+1 checkpoints are concatenated and base64-encoded, then sent as solution.checkpoints with challengeToken. Refuse if the seed isn't 32 bytes or k·g > 64,000,000. Solved on the server.
What the docs say acceptTermsOfService: "yes" is a string, not a boolean. It's sent with termsOfService and privacyPolicy URLs, and only after the client ticks the box.
What the docs say account.id, .name, .apiToken, .tokenId, .expiresAt; claim.token, claim.url, claim.expiresAt. That's two separate expiries, one for the account and one for the claim, and both need storing. The docs say to treat claim.url "like a bearer credential".
What the docs say Claim within 60 minutes, or Cloudflare deletes the account and its resources.
What the docs say A claim "does not grant the platform permanent access". Connect afterwards through OAuth.
What the docs say Account creation is rate-limited, but no number is published. Persist state and never create one on page load.
What the docs say Workers on workers.dev only (no custom domains or routes) · Static Assets up to 1,000 files at 5 MiB each · KV · D1: one database, 100 MB · Durable Objects · Hyperdrive: 2 configs, 10 connections · Queues: up to 10 · Certificates: mTLS/CA only. "Temporary credentials do not grant every operation."
What the docs say R2 isn't in the supported table at all. It's not explicitly excluded, just absent. Treat it as unsupported and create it after OAuth.
wrangler deploy --temporaryWhat the docs say Exists (June 2026). Only works unauthenticated: any existing login makes it error. After claiming, use wrangler login. Fine for prototyping; the product uses the REST flow.
What the docs say Free, Pro, Business and Enterprise.
What the docs say Super Administrator, Administrator, or the OAuth Client Write role.
What the docs say Clients are private by default, and only members of our own account can authorize them. Making a client public is permanent. A public client needs its publisher domain verified with a DNS TXT record.
What the docs say Authorization Code, plus refresh tokens (corrected 2026-09-25). The guide still says "Authorization Code only", but the Create OAuth Client API takes grant_types: ["authorization_code","refresh_token"], and offline_access is then added automatically. No client credentials and no device flow, so there's no machine-to-machine OAuth. A server client uses a secret (client_secret_basic/_post); a public client uses PKCE S256.
What the docs say dash.cloudflare.com/oauth2/auth, /oauth2/token, /oauth2/revoke, /oauth2/userinfo; discovery at /.well-known/openid-configuration.
What the docs say At least one scope. All are required by default; we can mark some optional, and the user can turn those off at consent.
What the docs say The user chooses which account(s) to grant on the consent screen.
What the docs say Two secrets can be live at once, so rotation needs no downtime.
What the docs say The user revokes from their profile (Manage OAuth authorizations); the app can call /oauth2/revoke. Blocking at the account level stops only new grants.
What the docs say Refresh tokens exist (the create API and the discovery document list them). Lifetimes are not published: the token response carries expires_in, and the refresh token's life isn't stated anywhere. Cloudflare's own Wrangler code treats refresh tokens as single-use (a stale one gets a 401). Measure both in Phase 2.
What the docs say Platform, product or single resource. Roles: Metadata Read-Only, Content Read-Only, Editor (can't create or delete), Admin. API tokens support product- and resource-level scopes but not platform-level.
What the docs say Supported for Workers, D1, R2, DNS, SSL/TLS, KV, Durable Objects, Hyperdrive and Zone. Registrar is incompatible. Creating one needs Super Administrator.
What the docs say POST /accounts/{id}/registrar/registrations. Billable to the account's default payment method, non-refundable once it succeeds, and needs a billing profile (set in the dashboard). Returns 201, or 202 + polling; states run from pending to succeeded or failed. auto_renew defaults to false. It can't use account-owned tokens.
What the docs say Cloudflare creates the DNS record and certificate. Needs an active zone in the same account. Won't attach over an existing CNAME. No wildcards.
What the docs say The Worker and its assets deploy as one unit.
What Cloudflare returned One real challenge fetched (it creates nothing): k = 1000, g = 2000, so 2,000,000 sequential SHA-256s, well inside the 64,000,000 cap; a 32-byte seed. The response also carries s and expiresAt, which the docs don't list. Benchmarked locally 2026-09-25, one full solve: Node's crypto.hash ~0.9 s of CPU, createHash ~1.3 s (3.4 s under workerd), plain JavaScript ~2.3 s, WebCrypto ~16 s (one await per digest). So a solve needs about 1–3 s of CPU: a hundred to a thousand times the free plan's 10 ms, and inside Workers Paid (M10).
What the docs say OAuth scopes "correspond to API token permission names", and the live list comes from GET /client/v4/oauth/scopes. IDs use dots (workers-scripts.write); colon IDs like Wrangler's workers:write are refused. openid, offline and offline_access can't be optional. No public list of IDs exists, so the scope list is pinned from that call (owner step M6).
What the docs say An Editor can't create or delete Workers; creating a new Worker needs product-level Admin. Whether a .write OAuth scope can create one isn't stated: Phase 6 answers it.
What the docs say The API reference asks for Workers Scripts Write; the permissions page adds Workers Routes Write on every affected zone. Deleting a Custom Domain doesn't delete its advanced certificate.
What the docs say R2 needs an R2 subscription, added through dashboard checkout (it has a free tier). Without it, bucket creation fails with 10042 NotEntitled (403). That's the client's step; OAuth can't do it. A duplicate bucket name is 10073 (409).
What the docs say No idempotency key for Workers, D1 or R2 creation. Look up, create, and on a conflict look up again.
What the docs say There's no rollback endpoint: a rollback is a new deployment of an earlier version at 100%. Only the last 100 versions; blocked if a Durable Object class changed or a bound R2 bucket, KV namespace or queue is gone. Data and bindings aren't rolled back.
What the docs say POST /oauth2/revoke (RFC 7009 form). The docs publish no OAuth error codes. Observed: an unknown bearer token gets 403 with code 9109 "Invalid access token"; an expired token has been reported as 10000, which also means "missing permission". So: on 9109 or 10000 refresh once; invalid_grant on refresh means REAUTH_REQUIRED; a 403 after a good refresh means a missing permission. Whether revoking the refresh token kills live access tokens isn't documented.
What the docs say Minimal Account Access: "Can view account, and nothing else." Private OAuth clients can only be authorized by members of their parent account.
node:dns and TCP in a WorkerWhat the docs say node:dns is DNS-over-HTTPS to 1.1.1.1 only; lookup and resolve throw, so a Worker can't ask a chosen nameserver. connect() refuses Cloudflare IPs, localhost and private ranges; port 53 isn't mentioned.
What the docs say The bound Worker must be in the same account, so a Worker in another account can't sit behind tkati.com/infra/*.
What the docs say On by default for read-only queries; --caching-disabled turns it off. The integration's Hyperdrive config must disable it (D9).
What the docs say On by default on every plan once a zone is active; covers the apex and first-level subdomains. No issuance time is given.
What the docs say GET /zones/{z}/dns_records with DNS Read or DNS Write.
What the docs say Only a Super Administrator creates one; it isn't tied to a user; Registrar is incompatible.
What the docs say Manifest per file: a 32-hex hash and size. The upload and completion JWTs each last one hour.
What the docs say GET /accounts/{a}/workers/scripts returns every script (id is the name, plus tag, etag, has_assets, modified_on and others). It takes only a tags filter and isn't paginated. Workers Scripts Read or Write.
What the docs say GET /accounts/{a}/members needs Account Settings Read (or Write, or SCIM Provisioning), which also reads every account setting. Paginated, at most 50 a page. There's no Super Administrator flag: roles[].name has to be matched, and its exact text isn't documented. The schema lists only the Global API key for this call, so whether an OAuth token works isn't documented, and the scope's ID isn't published.
Why it matters Lead decision 14: promotion reads members read-only, and an unreadable list reads as unknown, never as "no TKATI member". Until a real grant shows it works, the owner checks the Members page by hand before each promotion.
What the docs say PUT …/workers/scripts/{s} (multipart, metadata.main_module) creates a version and deploys it to 100% at once. The answer has id, etag and more, but no version or deployment id, so those are read afterwards. Creating a new Worker needs product-level Admin; an Editor can't.
What the docs say POST …/versions uploads without deploying; a Worker's first upload can't be a version. keep_bindings keeps binding types from the last upload. workers/message is at most 1,000 bytes and workers/tag at most 100. GET …/versions?deployable=true lists what can be deployed, newest first, and only the last 100 versions can be deployed.
Why it matters The versions schema has no assets field, while the prose docs say version uploads take assets. The two disagree, so a live test settles it.
What the docs say POST …/deployments with strategy: "percentage" and versions totalling 100. A deployment holds one or two versions, not more. ?force=true deploys even when it would otherwise be blocked, e.g. rolling back after a secret changed. GET …/deployments is paginated (10 a page by default), and the first entry is the one serving.
What the docs say A version read lists resources.bindings; a secret's text is write-only and never returned. PUT …/secrets "creates a new version with that secret" and answers {name, type} without the value (the builders assumed it echoed). Whether the API call also deploys isn't documented; only Wrangler's command says so. It can answer 429 while the script is busy. PATCH …/secrets-bulk changes many at once.
What the docs say GET/POST/DELETE …/scripts/{s}/subdomain with {enabled, previews_enabled}. assets.config takes _headers and _redirects (each the file's contents), run_worker_first, html_handling, not_found_handling and base_path. _headers doesn't apply to responses the Worker's own code makes.
What the docs say 1,200 requests per 5 minutes per user, counted across the dashboard, keys and tokens together, plus 200 a second per IP. Going over blocks every call for five minutes with 429, and retry-after (seconds) comes only then. The 429's body isn't documented.
Why it matters The budget is the client's own and is shared with anything else they run, so TKATI backs off on retry-after and never retries in a tight loop.
What the docs say A webhook destination (…/alerting/v3/destinations/webhooks) sends its secret in cf-webhook-auth and never returns it; a policy names an alert_type and the webhook. Notifications Write (or Account Settings Write). Webhooks need a zone on Pro or above, so free-plan clients can't have them. There's no documented Workers alert type for a policy.
Why it matters Phase 10's alert set-up stays held (notifications.write is not asked for) until a real account shows which alert type fits, or the feature is dropped.
What the docs say 100,000 requests a day (resets at midnight UTC; over that, error 1027), 10 ms CPU per request, 50 subrequests, 20,000 asset files, 25 MiB per file.
Why it matters ask.mjs does text matching in-process, so measure its CPU. If it goes over 10 ms, the paid plan ($5/mo) is the fix, not a rewrite.
_headers / _redirectsWhat the docs say Both are supported by static assets: up to 100 header rules and 2,000 static + 100 dynamic redirects. They don't apply to responses from Worker code.
Why it matters The Worker sets headers itself, so one code path covers pages and /api/* alike.
What the docs say An official guide exists; it doesn't cover functions, _headers or netlify.toml.
Why it matters The port is ours to design. There's no automated converter.
What the docs say Supported, using Supabase's direct connection string with pg/postgres.js.
Why it matters Only relevant if a Worker ever needs raw SQL. Today every function goes through Supabase REST, which works from a Worker unchanged.
What the docs say No row-level security or SQL roles are documented. Access control lives in Worker code.
Why it matters This is the core reason for decision D3.
Every real step, newest first. Each entry says what was done, how it was verified, and what was not done. Git history is the audit trail for this list.
docs/cloudflare/promoted.json), and every check moved with them (tools/drafts.mjs finds each file wherever it lives). Before the push: verify.mjs --local on the promoted tree (every Project A suite green), all twelve rehearsed on production inside one transaction that was rolled back, the CLI's own push rehearsed on a local copy built through 0038 (the probe then matched it: 218 objects), 13 SQL mutations cut from the moved files all caught, a backup of production's schemas, data, history and roles, and a dry run that listed exactly 0039–0050. Pushed 19:45–19:47 UTC; the history reads 50 rows, local and remote agreeing.tools/prod-catalog-allow.json: the Realtime role is a member of the three API roles (it runs the other way from a hole), and Supabase's "enable RLS on new tables" event trigger function, which cannot be called (checked).authenticated). Fixed in 0050 before the push.client-files (50 MB a file), each file under its client's id; a client puts and reads only its own, staff read all (proved on production in a rolled-back transaction: its own folder allowed, another client's refused, anon reads nothing); a client's file moves its own request to "Sent to us", and only its own (a trigger, proved locally). The client's account now sends files for real, with measured progress and a real Stop. Applied 19:50 UTC; the history reads 51 rows.portal.html forwards there. An account with no role, or a switched-off one, sees only its message: no crew screen, no "No connection" line.sb_secret_…, sent as Supabase asks, on apikey alone) and fall back to the legacy one; the tkati Worker has the new key. Revoking the legacy JWT secret (M26) no longer stops them.tkati-production-supabase), each stored with no trusted application so every read asks you, each read back identical by hash before its line left .env.local; no worktree, shell profile, shell history or CLI cache held a copy. Still yours at M26: rotate the database password and the secret key, since sessions could read them until today.tkati-infra re-checks every byte; revisit when a second builder or a client-authored repository appears); and tkati.com's "Client login" is public (the top bar from 561px up, the menu everywhere), so returning clients have a door..env.local (all 404); the sixteen merged worktrees removed (their branches kept).tkati-infra is not deployed, so its production tables are empty (M24, M9b, M22, M18 next). The dashboard-only steps (P1 TOTP, M26's key rotation) and the pilot remain yours.node tools/verify.mjs --local passed on main with 0 failures (77 checks, every Project A suite among them: the SQL and HTTP suites, the cross-tenant command over every route, the secret scan, the end-to-end walk, the catalog probe's self-test, the live runner and the owner's scripts). The two Office screen checks it runs only in part passed in full against the local stub (133 and 87 checks). All 218 SQL mutations are caught on the merged tree.docs/cloudflare/pilot-runbook.md (the production dry run by test identity 1, the pilot, the friction log, the stop rules) and tools/pilot-check.mjs (its twelve gates with what the owner has recorded done), which verify.mjs --local now runs.SUPABASE_DB_POOLER_URL and SUPABASE_DB_SESSION_POOLER_URL both carry the production database password (checked by parsing them, printing only whether a password is present). The runbook, the promotion runbook and the plan now name eight values, and the check after it greps for all eight.tkati.com can never be the one consenting for a client (the Worker and pa1's constraint); the owner's other domain no longer counts as TKATI's. The Cloudflare user ids in private.cf_tkati_cf_users remain the check that does not depend on an e-mail.tkati-infra, its own Worker), and the engine code vendored from the owner's other project runs inside TKATI. Nothing in Project A connects to anything outside TKATI and the client's own Cloudflare account.docs/cloudflare/other-sites-record.md (the repository is private and docs/ is not served).pa12 §6): each kind of Cloudflare step has a window (75 minutes for a temporary account, two days for a nameserver change, one day for a registration, 30 minutes otherwise). A step still waiting past it is listed for staff and counted on the staff overview ("A Cloudflare step never answered"). A preview whose account creation was never answered ends once Cloudflare's 60-minute claim window has certainly passed, so the client can start again; the step itself stays open, because what Cloudflare did is still unknown and is never guessed.pa12 §5): a system's name, address or note shaped like a password, key or token is refused by the database, for new and changed rows. The Office's new editor for these rows refuses the same shapes before sending anything.docs/cloudflare/lead-answers-2026-09-28.md, one place, and each phase's notes point to it.infraOrigin, so none of it shows there yet.pa1–pa12 apply in order. Phases 4, 5, 6, 9 and 10 now read "Built locally"; Phase 12 is in progress.config.js sets no infraOrigin, so no client sees any of it. The live tests wait on the owner steps in the runbook.verify.mjs --local green (the one screenshot that timed out when Chrome hung under load passed on its own).project-a/wave2-a), Phase 6 and 10 (project-a/wave2-b, Phase 10 still in progress), Phase 9, the client screen (project-a/phase-9, 1,223 checks green), Phase 12 prep (project-a/phase-12-prep: the cross-tenant gate over every route, the secret scan, and a production catalog probe that has never been run against production), the owner runbook (docs/owner-runbook), and the live-test runner (project-a/live-harness: 111 real-Cloudflare tests placed, 25 fully automated).project-a/redteam-fixes with a test per fix; the list is in docs/cloudflare/redteam-2026-09-26.md there.verify.mjs --local reads the same local cluster as its sub-tools.node tools/verify.mjs --local passed (67 checks; only the production tier skipped, as --local means); the SQL suite's 320 tests with pa1–pa7, and 76 of 76 SQL mutations caught; the PostgREST check's 20; the queue check's 19, and its 6 mutations; 166 Worker unit tests; the end-to-end run against local stand-ins (88 scenarios, 23 stand-in self-tests); the handoff check's 18, and its 24 mutations; the static check's 31. Nothing called Cloudflare.pa5), Phase 8 (pa6) and Phase 11 (pa7), each conflict resolved keeping both sides. Every check now builds its database from one ordered list, pa1–pa7, and the end-to-end check reads every phase-N-deferred.md on disk. Phase 11's SQL mutations are renumbered 111–131 (Phase 3 had 17–29).pa1–pa7, and all 75 SQL mutations caught; the queue check with its six mutations; the Worker's unit tests; the end-to-end run against local stand-ins (88 scenarios, 23 stand-in self-tests), its flaky concurrent-refresh scenario now built on a barrier; node tools/verify.mjs --local passed. Nothing called Cloudflare, nothing was deployed, production untouched.cloudflare-check --mutations, and 24 portal-module mutations in tools/handoff-mutations.mjs.docs/cloudflare/phase-11-deferred.md). Phase 11 was built ahead of its plan entry (Phases 6–8): the facts its level reads are written by tests until a real reconcile writes them.pa7 — a per-project ownership level (§204) computed from the connection, the resources and the ownership record, Client owned only when all of §203 holds and every missing fact named; §153's exit checklist, each item with its evidence; and a record that a break-glass document was made (its hash, never the text). In the portal, behind infraOrigin (so absent in production): an "Ownership and handoff" section under the Cloudflare panel with the level, what TKATI can still do, the checklist, a break-glass document the client downloads (no password, key, token or claim link: every value checked, and the whole document refused if one slips through), the steps to invite a new developer in the client's own accounts, and a disconnect flow that says "Your site stays online. Nothing is deleted." and then runs Phase 2's revoke. docs/cloudflare-client-ownership.md explains it all to the owner and to a client's next developer.docs/cloudflare/phase-11-deferred.md. Built on the branch project-a/phase-11, to be merged with Phases 3, 7 and 8.pa3 generalises TKATI's own ad_jobs: a job leases one lane (cf: plus the Cloudflare account), so one account never has two jobs in flight; claim, heartbeat, finish (honouring Retry-After) and reap; every attempt recorded. Today's ads jobs keep their order and signatures; the seven changes they do see are listed in the draft's header for the owner at promotion (M2). tools/jobs-check.mjs now runs only against local databases: its production mode is deleted, so verify.mjs no longer writes test rows to production for it.pa4 records immutable artifacts (the vendored engine's zip, manifest and SHA-256, pinned); an uploaded zip is refused unless re-packing it reproduces it byte for byte; bundle limits are checked before any upload; then the Static Assets upload, a look-up-first script upload, and the preview URL.docs/cloudflare/phase-3-deferred.md.cf_terms__* tests exist and exposed a real gap, now fixed: a terms tick was still spendable after its person became a team member, or when written in the owner's name, because who-may-confirm wasn't re-checked by person at spend time. verify.mjs always runs the two checks with a random password. The plan now describes what was built. SQL suite 205 tests green, PostgREST 15, mutations 11/11.tkati-infra Worker (infra/, its own wrangler.jsonc: production and test environments with no account yet, workers_dev and preview URLs off, Hyperdrive to the local stack as cf_integration); OAuth bound to the starting browser (a one-time start ticket, an HttpOnly __Host- cookie, PKCE S256); token exchange; the grant's accounts recorded and a grant including a TKATI account refused and revoked; the account chooser as the client's aal2 confirmation; credentials sealed with the vendored engine's cipher and versioned; single-use refresh under a lease; revocation of both tokens; the scope pin (placeholders until the owner approves the list, M6); a Cloudflare redactor; a per-request adapter that refuses any path, account or zone it didn't check. tools/infra-dev-keys.mjs makes local test keys and prints nothing (M9a, local).infraOrigin, which production doesn't have; tools/infra-portal-check.mjs proves every module and page renders as before without it, and worker-check proves no dev value reaches dist/.tools/infra-check.mjs (no network), tools/infra-e2e-check.mjs (the Worker under wrangler dev, the local stack, and local stand-ins for Cloudflare's OAuth and API): success, cancel, invalid and expired state, wrong account, the TKATI-account refusal, a tampered chooser, limited permissions, revoke → one failed call and at most one refresh → REAUTH_REQUIRED, disconnect → revoked → credentials gone, and the cross-tenant gate over every /infra/* route. 108 checks green. A second draft, pa2, adds the refresh lease and the browser binding, with its own mutations.%2e%2e could slip past, and a refresh token the database refused to store left alive at Cloudflare).aal2 in place; a refresh keeps both. So a request pinned to its session survives the step-up. The local stack now has TOTP on.docs/cloudflare/phase-2-deferred.md; verify.mjs --local prints them as deferred. Lead decision: abandoning a connection attempt that never chose an account runs at aal1 (it revokes a grant nothing uses yet).docs/cloudflare/drafts/pa1_a_client_owns_its_cloudflare_account.sql, 3,900 lines, built from the plan and the other session's 0037 draft (which it replaces). 17 tables with row-level security enabled and forced; every foreign key on delete restrict and composite with client_id; 11 door functions that only the cf_integration login may call, each checking its caller first; the state machines as enforced functions; an append-only audit that even the database owner can't rewrite or truncate.tools/cloudflare-check.mjs (218 SQL tests in 15 groups, each a real attempt the database must refuse or accept), cloudflare-http-check.mjs (the same rules over real PostgREST), cloudflare-static-check.mjs and the vendored-engine check. The engine's secrets.ts is vendored byte for byte, pinned to 746b3ccc, and used for real sealing in the tests.--mutations breaks one safeguard at a time on a fresh database (drop the one-client-per-account index, let the browser update connections, spend a confirmation with a changed price, allow DRAFT → LIVE, rewrite the audit, and six more); each named test fails under its mutation. 11 of 11, rerun by the lead after the build.verify.mjs --local (skips the two checks that write to production), supabase/config.toml for the local stack, scratch-db/rest-harness gain the draft and session claims, and account.mjs learns the kinds source, hosting, database and storage.cf_terms__* tests the plan names (T25, T26) and a client login without infrastructure authority still reading its client's rows (the plan says it shouldn't). Both are the next commit. Nothing was read from or written to production, and nothing called Cloudflare.main; 003 on worktree-public-launch-audit), and musicted's and djthub's public VITE_* values were taken from Netlify's non-secret env. Each site then got one real build, checked against the original Netlify site: tkati 8/8, tatailoring 72/72, musicted 119/119, djthub 87/87 (bodies too), biltq 24/24, dztlandscaping 44/44, david-mounting 20/20. Every build's JS bundle is byte-identical to Netlify's. There were no new 5xx or exceptions, so nothing was rolled back. The free plan runs one build at a time; about 7 of 3,000 build minutes were used.webhooks.constructEvent threw on every delivery and answered 400 even to genuine events. It was found by sending the same signed no-op event to both hosts: Netlify answered 200, the Worker 400. Nothing was lost, because no subscribed event has happened since 2026-08-28; the next checkout would have charged the card and never written the subscription. Fixed in MusicFlo 442cd3e (constructEventAsync; spec and readiness probe updated; 22/22 tests). Under wrangler dev, a signed event got 200 and a forged or unsigned one 400. The push deployed it by itself, which was the first real push-to-live (build b16d38eb, version 2bfe2448). Live after: signed 200, forged 400, unsigned 400, pages identical to Netlify.LEGACY_JWT_KEY in the app's env file. It was found by testing every local value as the HMAC key of the project's legacy anon and service-role tokens (both matched one value; nothing was printed), then piped into SUPABASE_JWT_SECRET. SUPABASE_ANON_KEY was proven the same way. NETLIFY_TOKEN was set from the app's own NETLIFY_AUTH_TOKEN, a separate token from the owner's CLI login; it works and sees the stocksplusapp team. With it, the Worker's variable names match the Netlify site's exactly, and the startup log reads hosting, dns, email, payment, storage, media, analytics ready. Then the Netlify CNAME was deleted and its domain was attached to the app's Worker as a Custom Domain. Checked through public DNS: server: cloudflare, valid certificate, home page byte-identical to Netlify's, /join/… 200, API 401 "Sign in to continue.", and the service-role test answers "Token is missing a subject." on both hosts. Two known differences: an unknown path gets a plain 404 instead of Netlify's branded page, and a POST with a body to / gets the mirror's 400. Rollback: detach the Custom Domain, then recreate CNAME app → its old Netlify address (DNS-only).docs/cloudflare/transfer-readiness-2026-09-25.md): 70 past the 60-day lock (3 unlocked), 16 not yet (2026-09-27 to 11-02), all 87 on Cloudflare nameservers, 0 transfers pending. The arròw.com and hevnz.com transfers never reached the registry. arròw.com can't move to Cloudflare Registrar at all, because it doesn't take IDNs. hevnz.coffee's nameservers have since switched. kingdomflare.com is already at Cloudflare Registrar (registered 2026-09-22) and was missing from the inventory. heavensbrewer.co is registered through Key-Systems.express.json() runs and threw, and the error middleware turned it into a 500; on Netlify the serverless normaliser hands such a body on as {}. Fixed in MusicFlo 60ca0f9: the parser's two body errors now do what the normaliser does. New tests start a real server and require the wrapper's exact answer (32/32). The push deployed it, and live all four routes now match Netlify in status and wording./api/photo-upload-url, david-mounting's Twilio and mail endpoints, and tkati /api/ebay-sync answer 503 "not configured".docs/cloudflare/phase-1-*.md, draft docs/cloudflare/drafts/0037_client_owned_infrastructure.sql; not applied). This session wrote the plan §277 asks for first: docs/cloudflare/project-a-plan.md (current and target state, reuse plan, where the code lives, data model, threat model, capability matrix, owner steps, phases 1–13, verification matrix), after four audits and a three-lens review./hub, /market, /news, profiles, /about, /join, /admin…) since its cutover. The mirror was configured with no SPA routes, but Netlify's build generated a _redirects with 42 app routes and a /* /index.html 404 fallback. The earlier checks compared files and a handful of paths, not every route. Fixed by promoting a version built from the repo (74317007), which reads those routes from the build itself. Checked against the original Netlify site (still up): 11/11 pages including /hub, /u/… and a real 404; CSP and HSTS present./about → /about/ being a 307 instead of Netlify's 301. The shared mirror now issues the 301 for exactly that case (deployed, 54c8d5f8; TA repo b163ac5).VITE_* values and refuses any secret-looking VITE_ name. FiBot bdb813db: 131/131 files, 41/42 (a hand-lowercased asset URL; real pages use the exact name). 003 34fb838 on worktree-public-launch-audit: 124 files byte-identical; routes come from the generated _redirects. Plus 024, david-mounting-assembly and tkati from the earlier entry.~/Projects/MusicFlo has 31 unpushed commits (an autopilot merge, one marked "UNVERIFIED"); ~/Family Projects/003 has 1 unpushed commit that now conflicts with origin by one commit (needs a rebase); David Mounting's launch-prep has 5; the TA clone has uncommitted edits. None were pushed, because a push now goes live. One exception: dzt's local main held 2 of the owner's commits ("The phone's back button…", "The stylesheet an email sent with itself"), which went up with Claude's push and are live.main. The dashboard set dzt's build command to npm run build, which fails because the repo has no package.json. It must be cleared. Creating the other six triggers through the API was refused by the permission system as a production deploy, so that's in §10.wrangler.jsonc, cloudflare/build.mjs and cloudflare/engine.mjs. The build turns netlify.toml into the Worker's rules, imports the functions unchanged, and publishes every tracked file minus rule-hidden paths, Markdown and host config, with Netlify's Pretty URL rewrite (relative links resolved against the page, as Netlify did). Checked against what Netlify served from fe7ff5c: the same 986 files; HTML differs only in quote style and attribute order. Five design-tool files had been deployed from a laptop with uncommitted changes. A preview of main matched live on 50 cases. Then deployed main: the 12 commits are live, and the phone/mail endpoints and /api/ask are unchanged.main fast-forwarded to the live branch (cloudflare-move, bb2dde9); its wrangler.jsonc already builds with tools/build-public.mjs.launch-prep branch (5 unpushed commits) is untouched. Every live file is byte-identical; the admin files are present but still 404 by the site's own rules. A preview matched the live Worker on 44 cases.fetch on its stores and calls it as a method, in 69 places. Workers throws "Illegal invocation" for that, which the environment interlock reported as "could not read the project's environment", and it refused to serve. The shim now installs a receiver-agnostic fetch. (2) the app builds its host at module load, reading the credential vault and Supabase's JWKS over the network. Workers forbid network I/O at startup, so every credential was "UNREADABLE". The module is now imported on the first /api request. Found by running the app locally with the real values (a mode-600 .dev.vars, deleted after) and reading its own startup log.NETLIFY_TOKEN). The staged API is identical to live on 20 method/route cases, status and body.! attempt stored an empty value (no prompt to type into); the log said missing: SUPABASE_JWT_SECRET. The second, via pbpaste, stored a value, but a check shows it is not Netlify's. The legacy service-role key is an HS256 token signed with the project's JWT secret. Sent to live, it is rejected only for "missing a subject" (signature accepted); sent to staging, it gets "Invalid token signature." A wrong secret would reject every older sign-in token, so the domain was not moved. The same two-request test confirms the right value once it's set.encryption_keys, secure_api_keys, oauth_credentials, mfa_totp, project_env_vars) is empty. The two tables that do hold ciphertext use other apps' keys: user_wallets (5 rows) uses BURNER_KEK_V1 from apps/main, and secure_keys (1 row) uses SECRETS_MASTER_KEY from fibot. its API never reads either. The local v1 key cannot damage anything.find-secrets.py now checks Cloudflare API tokens (token verify), R2 key pairs (a signed, read-only ListObjects on the bucket) and Mux (asset list). All four of the app's values pass: token active, R2 lists its media bucket, Mux accepts it. Proven with providers so far: Stripe (live, acct …SNZ), Supabase service key, Resend, Cloudflare, R2 ×2, Mux.RATE_LIMIT_TRUSTED_IP_HEADERS was x-nf-client-connection-ip, a header only Netlify's edge sets. On Cloudflare it is never present, so per-IP rate limiting would have silently stopped. It is now cf-connecting-ip, which Cloudflare sets and a client cannot forge./ and /join/… byte-identical, POSTs 400 as on Netlify. Every /api/* answers 503 NOT_CONFIGURED, which is the app's own refusal while a required value is absent.SUPABASE_JWT_SECRET is required, and without it no request is authenticated. It is a secret on Netlify (unreadable), not in any local file, and this machine's Supabase CLI login can't see project dwzjlhnwvvzaokvvaclp. SUPABASE_ANON_KEY (optional) comes from the same place.NETLIFY_TOKEN. Netlify is the app's only hosting provider (packages/providers/src/bootstrap.ts). Without the token, the app can't publish client sites anywhere. Moving the dashboard off Netlify doesn't remove the product's dependence on Netlify; that ends with Project A (client sites on Cloudflare), which is still unbuilt. Either set a Netlify token so hosting keeps working, or leave it unset and hosting is off until Project A./.netlify/functions/twilio-voice and /inbound-mail and got Cloudflare error 1101 (the Worker threw). Reproduced it: any POST to a non-function path crashed dztlandscaping and david-mounting (the Netlify rules engine), and musicted and biltq on client-side routes (the mirror's SPA fallback). Real traffic was not affected: Twilio and Resend post to /api/…, which always worked. The same logs show Resend's signed inbound deliveries accepted (200) on both tkati.com and dztlandscaping.com, which proves both RESEND_WEBHOOK_SECRETs./ for an SPA) threw.400 Bad request, missing form, even on paths a rule forces to 404; other writes get 405. Redirect rules apply to every method, but one shadowed by a folder doesn't (POST /estimates is 400, GET is 301). /.netlify/functions/<name> reaches every function except one that declares its own config.path, which only answers there. The builder now reads config.path, keeps observability on, and has --code-only to regenerate code without re-downloading the site./.netlify/functions/ask, which Netlify itself answers 404 or 400 on different tries. biltq and musicted 24/24. Then deployed dztlandscaping, david-mounting, djthub, biltq, otviews, not-found, tatailoring and musicted. Live: no 1101 anywhere; every site gives POST 400 and GET/www as before; dzt's Twilio and mail handlers reject unsigned requests (403/401) at both spellings; /api/ask, musicted's API and the careers form all answer./careers/ for Netlify Forms, but no form was ever registered on Netlify. After the move that POST answered 405, so every applicant saw "That didn't send, please call or email." Nothing was lost silently, but nobody could apply online. It's the only form on the site; Contact is a phone number and mailto: links.POST /careers/ (cloudflare-sites/tatailoring/careers.mjs). It emails the application to hello@tatailoring.com through Resend, from careers@tkati.com (tatailoring.com's mail is Zoho, not in Resend) with Reply-To set to the applicant, and the résumé attached. It answers 2xx only after Resend accepts the message, so the page never says "received" for mail that went nowhere. It enforces the page's own rules: the 5 listed positions, 8 MB, PDF/Word/image, required name/email/phone. It also has a honeypot and refuses posts from other sites (403). Nothing is stored.RESEND_API_KEY is the same proven key tkati.com uses.tkati.com/.netlify/functions/stripe-webhook, musicted.com/api/billing/webhook) are enabled at Stripe, served by Cloudflare, and reject a forged signature with 400. Neither account has had an event since August, so the signing secret is still proven only by format. The owner can use "Send test webhook" in Stripe to close that.not-found Worker. All three now return a clean 404 with a valid certificate, and www 301s to the apex. The agency domain's SES MX, send, DKIM, DMARC, app (still Netlify) and assets (R2) were kept. Apart from the owner's dashboard app, readyos and extrafreshbins, no domain points at Netlify now.SUPABASE_JWT_SECRET, and its master encryption key can't be verified; a wrong encryption key corrupts data, so no guessing); onlyspit.com + limitlessflo.com (readyos, money movement, separate project).aae564c on branch cloudflare-move (not pushed). verify.mjs passes after a declaration fix in tools/dns-snapshot.mjs. The branch is not deployed as a whole, because production lacks migrations 0033–0036 (checked with supabase migration list; 0036's tables return PGRST205). The client account would show seven load errors until they are applied. This page is still not on the live site: deploying the live build plus only this page was refused by the permission system. It goes live with the owner's deploy. Per D6 it holds no secret; the owner's login email was removed from it.supabase db push, all four clean). Checked afterwards: all nine 0036 tables answer to the service key (empty, as expected) and refuse the anon key with 401. The branch can now deploy.wrangler deploy from a clean worktree of cloudflare-move at e31383e. The tkati Worker is on version d28824c5, serving 100% of traffic. Checked live at Cloudflare's edge: / 200; /admin/office, /admin/client, /admin/account and this page 200 (their .html addresses 307 there, as documented); the deleted office home 404 (on purpose); /docs and /supabase 404; /api/inbound-mail and /api/stripe-webhook 405 to a GET; /api/ads-report-run 404; /api/enquiry 303 to /#start; www 301 keeping path and query; CSP present; /admin/* noindex. The live page has no login email. The Worker logged no 5xx or exception in the first 15 minutes. Rollback: npx wrangler rollback in that worktree.build-netlify-site.py dztlandscaping _src/dzt-deployed dztlandscaping from the live deploy's commit: 45 rules, 8 header blocks, 14 functions, 988 files. Worker dztlandscaping, served through the shared _netlify/engine.mjs.copy-netlify-env.py), then 5 secrets checked with the provider and applied (find-secrets.py --apply): Supabase service key, Twilio auth token, Resend, Groq, plus RESEND_WEBHOOK_SECRET from the same local .env (no provider check exists for that one). Left out on purpose: TWILIO_API_KEY_SECRET, because Twilio rejects it (401). Without it, lib/twilio.mjs restAuth() falls back to account SID + auth token, which Twilio accepts. The unused SUPABASE_DB_URL/_PASSWORD were also left out./admin/sw.js labelled text/javascript rather than application/javascript. POST /api/ask gave the same answer on both hosts (Supabase + Groq working).A 75.2.60.5 and CNAME www dztlandscaping.netlify.app were deleted and both hostnames attached as Custom Domains in the same call. The SES MX, send MX/SPF, Resend DKIM and DMARC were kept. Checked at Cloudflare's edge: server: cloudflare, pages 200, /README.md 404, www 301 keeping path and query, unsigned /api/twilio-voice 403 (as before), /api/ask answers, MX unchanged.https://dztlandscaping.com/api/twilio-voice, /api/twilio-sms and /voice-fallback.xml. The engine hands the function the original request, so signedUrl() rebuilds exactly that URL. Not yet seen: a real signed call or text. Watch the first one in the Worker's logs; a 403 on a real Twilio request means roll back./api/twilio-voice and not the fallback: Twilio's call log shows the call and the forward, and the Monitor has no new 11200 alert (a failed primary webhook raises one). Note: the Worker has no observability enabled, so Twilio's log is the evidence. Earlier, at 18:57 UTC (still Netlify, during the DNS move), one inbound text hit a timeout/handshake failure (11200). That text reached Twilio but likely not the CRM.A @ 75.2.60.5 + CNAME www dztlandscaping.netlify.app (DNS-only). The Netlify site is untouched.david-mounting-assembly.netlify.app), no Twilio number (its .env and Netlify env have none), no forms. Netlify holds 4 plain Supabase values, which were copied as-is. Built from the live commit fb64bf9 (a worktree at _src/david-deployed; the local repo's HEAD 4f0c53b is newer and not live). 26 rules, 16 functions, 57 public files. The 47 /app/* files are left out because the live commit closes the admin to the public (404), and the Worker does the same. Worker: david-mounting.heavensbrewer.workers.dev. Compared against live on 77 paths (every file plus the API routes): every body identical. 10 JS files are labelled text/javascript instead of application/javascript, which is harmless. When the business gets a domain, attach it as a Custom Domain; the Netlify site can then be deleted.tkati Worker, which now has all 5 real secrets. The Resend MX/send/rsend/DMARC/DKIM records were kept.server: cloudflare, / and /admin/ 200, /api/inbound-mail and /api/stripe-webhook 405 (live), /api/enquiry 303 to /#start, /api/ask answering, /docs 404, www 301 keeping the path.STRIPE_WEBHOOK_SECRET (deliberately included; provable only by a real event). Before the switch, the preview matched live on /api/ping, /api/demo, the database-backed username check and /api/music/search/spotify (identical tracks). After the switch, verified through public DNS: server: cloudflare, pages 200, API 200, CSP and XFO intact, www 301 keeping the path. Resend MX/send/rsend/DMARC/DKIM kept.via: groq-grounded as on live, with no errors in the Worker's logs. Claude's switch was refused by the permission system, so the owner does it.~/Family Projects/cloudflare-sites/find-secrets.py finds each needed secret in the owner's local env files and proves it with a read-only call to its own provider before it can be used. Stripe /v1/account, and the key must own production's price ids, which catches test-vs-live and wrong account. Supabase needs a 200 and auth-admin access to count as a service key. Also Groq models, Resend domains, Google Places and Spotify client-credentials. Webhook secrets can't be proven without a real event and are reported as such. It prints names and verdicts, never values.STRIPE_LIVE_SECRET_KEY, Supabase ×2, Groq) plus the webhook secret, unprovable; musicted 6/7 plus the webhook secret, unprovable. extrafreshbins has no Stripe key or webhook secret on this machine. The dashboard app has 3 missing, and its local file is newer than the deploy. Those two stay on Netlify.--apply. Then /api/google-reviews on the preview matched live exactly (5 reviews, 4.8, 18 total). Cut over with the certificate active: the Netlify A/CNAME were replaced by Custom Domains, and the Zoho MX/SPF/DKIM were kept. Public DNS: server: cloudflare, / and /contact/ 200, API 200, www 301 keeping the path.STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_ANON_KEY, GROQ_API_KEY. Non-secret values (Resend key, Resend webhook secret, Stripe prices, Supabase URL) are real.A @ 75.2.60.5 and CNAME www tkati.netlify.app) was refused by the permission system. The owner does it in the Cloudflare dashboard. Then set the real secrets in the Worker by hand before any future cutover.A @ 75.2.60.5 + CNAME www tkati.netlify.app restored. Public DNS returned to Netlify within a minute (server: Netlify; / 200, www 301, API 405s). The Worker's logs for the whole window show no Stripe webhook, no inbound-mail delivery and no enquiry POST, only Claude's own checks and bot scans (/api/graphql, /api/config, 404 as on Netlify). No real request failed.copy-netlify-env.py now skips secret variables and lists them for manual entry. tatailoring's Worker has a placeholder GOOGLE_MAPS_API_KEY, but its domain was never switched, so no visitor saw it.wrangler dev, no secrets) and compared with the live site: tatailoring and musicted run their Express apps unchanged; extrafreshbins matched 28/28 paths byte-for-byte (Netlify's pretty-URL rewriting, blocked internal paths, /admin → /app, the /projects 302); the dashboard app's pages are identical, and its API runs and needs its secrets.setInterval) at creation, which Workers forbid at load, so the server is created on the first request. Its API reads Netlify.env, so a shim maps it to the Worker's secrets.~/Family Projects/cloudflare-sites/README.md has one line per site (copy-netlify-env.py copies that site's Netlify env into the Worker without printing it, then wrangler deploy). Claude then compares the preview with live and switches the domain.NETLIFY_TOKEN, so the dashboard app deploys its client sites to Netlify. Leaving Netlify entirely also means changing that product code.~/Family Projects/cloudflare-sites/tatailoring has the exact live deploy files (65) and a Worker that runs TA's own Express app (repo LimitlessFlo/TA, HEAD cda7332, the same day as the live deploy) for /api/*. A wrangler deploy --dry-run builds cleanly (1.9 MB). The real deploy was refused by Claude Code's permission system ("Production Deploy"), so the owner runs it. It needs one secret, GOOGLE_MAPS_API_KEY, copied from the Netlify site's env. After that: compare against live, then cut over the Custom Domain as for djthub./api/* (its function was already a stub). hevnz.com had no working HTTPS at all.otviews and a shared not-found Worker answer 404 on every path, and www 301s to the apex. hevnz.com goes from a failed connection to a clean 404 with a valid certificate. Only apex/www web records were replaced; Shopify account/checkout, DMARC, SPF and null-DKIM records were kept.api function each; efreshbins 7; dztlandscaping 14 and david-mounting 16 (including live Twilio voice/SMS); readyos/mainset (onlyspit.com, limitlessflo.com) about 250, including wallet, staking, withdrawal and treasury functions that move money./api/*, /ws/*, sitemap/rss to Railway), SPA routes, 404 behavior, headers (djthub's CSP/HSTS/XFO), immutable assets, and www → apex 301. Code: ~/Family Projects/cloudflare-sites/_mirror/worker.mjs, one wrangler.jsonc per site./index.html, which the assets layer answers with a 307 and an empty body; it now fetches /.server: cloudflare, 200s, the API proxies answer, and www 301s keeping path and query. djthub's Zoho MX is untouched.A @ 75.2.60.5 + CNAME www <site>.netlify.app. The Netlify sites still exist.wrangler deploy.www to the apex with a 301, as Netlify did. Everything else goes to the ASSETS binding with _headers applied (CSP, immutable assets, admin no-store/noindex verified). worker-check.mjs asserts this.A 75.2.60.5, www CNAME tkati.netlify.app) and attached tkati.com and www.tkati.com to the Worker as Custom Domains. Mail records were untouched./ 200, server: cloudflare, www 301 keeping path and query, /docs 404, /api/inbound-mail 405, /api/enquiry 303 to /#start, and /api/ask answering.A @ 75.2.60.5 and CNAME www tkati.netlify.app (DNS-only). Netlify's site is still deployed.A 75.2.60.5 and www to tkati.netlify.app. Verified: tkati.com 200, www 301, /admin/ 200, /api/inbound-mail 405 (live), and dns-diff.mjs passes every mail record.tkati Worker as Custom Domains. That moves hosting off Netlify. It's a separate, deliberate change with its own checks (Stripe and Resend webhooks, the 07:10 cron, then turning Netlify's cron off).clientTransferProhibited, meaning the lock is still on or the request hasn't reached the registry. Re-check; a transfer typically takes up to 5 days after Squarespace releases it.docs/cloudflare/dns/wave2-final-records.json.dns-verify.mjs passed on all 21, and dns-diff.mjs passed for tkati.com's mail records. extrafreshbins.com, musicted.com and tkati.com load with a valid certificate through Cloudflare's configuration.xn--arrw-1sa.com, an unregistered name). The zone made under that name was deleted, and the correct xn--arrw-nqa.com was added and verified. The real domain was registered 2025-11-09, so it is transferable now, and it expires 2026-11-09. It's urgent alongside hevnz.com (Nov 27).account, maileruqw and Zoho DKIM zb19416263._domainkey; hevnz.com's Shopify account/checkout; siptheglobe.com's checkout; and the null-DKIM and HTTPS records on parked domains. All were copied. The final records for the last 18 are in docs/cloudflare/dns/wave1b-final-records.json.tools/dns-verify.mjs passed on all 18. Loading each Netlify site through Cloudflare's configuration (75.2.60.5) gave a valid certificate: dztlandscaping, tatailoring, djthub, limitlessflo and biltq 200; onlyspit 301 to limitlessflo, as today.docs/cloudflare/domains-inventory-2026-09-24.md. 65 are transferable now, 16 from Oct 7 to Nov 2, and 6 get confirmed at transfer time. extrafreshbins and dztlandscaping are family clients, held in TKATI's account "for now" by owner decision; they could move to their own accounts later (§30–31).tools/dns-snapshot.mjs → docs/cloudflare/dns/wave1-snapshot-2026-09-24.json). The 9 Netlify-DNS domains were taken from Netlify's API, which is exact. The plan is in docs/cloudflare/dns/wave1-plan.json. Netlify sites use apex A 75.2.60.5 plus www CNAME to *.netlify.app, all DNS-only.tools/dns-verify.mjs compares each zone's answers from Cloudflare's own nameservers with today's, before any switch. All 47 passed. Caveat: this network's router (192.168.40.1) rewrites some plain DNS lookups (djthq.com, limitlessff.com); those were confirmed over DNS-over-HTTPS instead.send/rsend CNAMEs, DKIM, DMARC) and 2 Netlify-hosting rows (apex, www). Saved to docs/cloudflare/tkati-dns-snapshot-2026-09-24.json. It's public DNS data, no secret. The other 15 domains on that Netlify account were not touched.docs/cloudflare/tkati.com.zone holds the 5 mail records in BIND format, for import, DNS-only. Apex and www become Worker Custom Domains.tools/dns-diff.mjs asks any nameserver for every record and fails on a difference. It warns specifically about a proxied mail CNAME. It passes against today's live DNS.whois: created 2026-08-22, clientTransferProhibited, DNSSEC unsigned. ICANN's 60-day rule makes 2026-10-21 the earliest date (B5b).wrangler secret bulk from a mode-600 temp file, deleted straight after). The values never appeared in the conversation.SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_ANON_KEY, SUPABASE_PUBLISHABLE_KEY, RESEND_API_KEY, RESEND_WEBHOOK_SECRET, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_* (3), GROQ_API_KEY.ASK_*, EBAY_*, GROQ_MODEL and STRIPE_API_VERSION aren't set on Netlify either, so both hosts use the code defaults./api/enquiry 303s to /#start exactly as Netlify does./api/ask on both hosts: identical answer, 3.4 s on Netlify and 0.7 s on Cloudflare. This logged two ordinary rows in the assistant's question log.9a2e…8c2e). wrangler login was run and approved in the browser, and whoami confirmed the right account. Cloudflare's agent setup was also run: the cloudflare@cloudflare Claude Code plugin (skills + MCP) is installed.wrangler deploy created the Worker at https://tkati.heavensbrewer.workers.dev (version 9bd7c906): 96 assets, cron 10 7 * * *. No route, so tkati.com is untouched./docs, /tools, /supabase and netlify.toml are 404.immutable; /admin/* is noindex + no-store./api/ads-report-run is 404./api/inbound-mail is 500, which is correct with no secrets.admin/home.html and its renderer were removed. Sign-in, the sidebar's Dashboard link, the client dossier's back link and this page's back link all go to /admin/office.html now.office-home.mjs and office-home.css stay, because the client dossier (admin/client.html) is built on them. routing-check.mjs caught that the dossier had become unreachable; client names in the Office's Clients list now open it.routing-check.mjs now fails if anything links to the deleted office home. verify.mjs passes.owner, active, and the only user, so the role was already right. The real cause was that
admin/home.html had no navigation. Its top line held the date and Sign out and nothing else,
so there was no route to the Mailbox, Clients, Invoices, Ads or eBay. A nav row was added under the dateline,
and an "Other screens" group in the Office sidebar. Ads and eBay had never been linked from anywhere;
routing-check.mjs now covers both, and this page too.wrangler.jsonc, worker/index.mjs and tools/build-public.mjs. The Worker imports the same function modules Netlify runs.wrangler dev (Wrangler 4.138):
/docs, /tools, /supabase, netlify.toml, .env.local and admin/README.md return 404./admin/* gets noindex and no-store; /assets/* gets immutable./api/* route returns 405 to a GET, as the Office's route probe expects./api/ads-report-run return 404.process.env unmodified and failed only on the deliberately unreachable dummy database.Cache-Control values.tools/worker-check.mjs added and wired into verify.mjs: every function routed or scheduled, no routes before cutover, no secret in config, allow-list holds, CSP identical, overrides detach. verify.mjs passes.wrangler login. No real endpoint was written to; the live-key run of wrangler dev was used for GETs only. Supabase is unchanged (D3 is still awaiting the owner). No commit was made, because the working tree holds other uncommitted work.docs/cloudflare/directive.md (5,662 lines as pasted)./admin/infrastructure.html and linked from the office home's top line.
Registered in tools/routing-check.mjs so it can't become unreachable.dig, whois, curl). Results are in §8.The brief has 277 numbered sections. This groups them by topic so you can
go straight to the right part of
docs/cloudflare/directive.md in the repo.
Sections 1–2, 47, 50–51, 157, 252–261, 276
In one line One client, one account. The client can leave without a migration.
Sections 3–5, 59–60, 66–68, 102–106, 134–140
In one line No passwords, no Global Key; OAuth; account-owned tokens only when justified.
Sections 6–14, 174–177, 187–190, 226–227
In one line Preview before the client has an account; the claim URL is a bearer credential.
Sections 15–23, 61–65, 107–110, 186, 228–229
In one line Public app, least privilege, required vs optional scopes, one-time state.
Sections 24–29, 86–92, 192–194
In one line Worker + assets by default; D1 and R2 only when needed; hybrid allowed.
Sections 30–45, 132–133, 141–146, 195
In one line Protect mail records; diff before switching; never buy without confirmation.
Sections 46, 48, 93, 100–101, 182–185
In one line Real state machines; the database is control-plane state, and Cloudflare is the truth.
Sections 49–53, 117–120, 166, 170–172, 196–198, 233–237
In one line Simple by default; "Owned by / Managed by"; no green theatre.
Sections 54–58, 129–131, 246–249
In one line The client is Super Admin; TKATI people get scoped roles only.
Sections 69–70, 160–165, 205, 262–275
In one line The server derives the account; cross-tenant tests are mandatory.
Sections 71–72, 94–99, 147–151, 199–202
In one line Artifact-first, idempotent, recorded, verified, rollback-able.
Sections 73–81, 112–116, 153–156, 203–204, 210, 238–239
In one line Source, providers and docs too; the break-glass document; chaos tests.
Sections 38–41, 82–85, 230–232
In one line The client's card pays the client's Cloudflare; never an invented price.
Sections 214–216, 222–225
In one line AI may propose; the backend enforces confirmations; no dashboard scraping.
Sections 179–181, 206–207, 220, 277
In one line Audit first, research, write the plan, then build Phase 1 and test with real accounts.
Sections 218–219, 221
In one line A separate product mode (Workers for Platforms). Not now.