Skip to content

Promote Payload cutover to production - #113

Merged
keshav-epyc merged 17 commits into
productionfrom
main
Aug 27, 2026
Merged

keshav-epyc merged 17 commits into
productionfrom
main

Conversation

@keshav-epyc

Copy link
Copy Markdown
Collaborator

Production release

  • promote the tested CMS provider abstraction and Payload integration from main
  • switch production to published-only Payload reads
  • retain Strapi and its data for rollback and the observation window

Required gates completed

  • editorial freeze assumed active
  • fresh export/import complete
  • parity: 29/29 blogs, 90/90 projects, 83/83 gallery
  • order matches; zero unexplained differences
  • staging Payload acceptance checks passed

harshit-epyc and others added 17 commits August 14, 2026 18:35
Adds /tools/ai-chatbot: a visitor pastes a URL, we crawl up to 20 pages,
they chat with a bot built from that corpus, and we show a scored report on
what their site cannot answer. Verified email holders can then claim an
embed key and run the same bot on their own site.

Crawler (lib/crawl/, shared with the planned Website Grader and llms.txt
Generator):
- URL safety gate; every redirect hop re-validated, not just the seed
- robots.txt parsing, sitemap discovery via the Sitemap: directive, and
  one level of sitemap index (most real sitemaps are indexes)
- HTML to text with heading metadata in a single pass
- caps: 20 pages, 500KB/page, 20s wall clock, 5 concurrent, 3 redirects,
  identified User-Agent

Platform (lib/tools/):
- atomic daily counters, corrected from the spec's UPDATE form which
  reported "capped" on the first request of every day
- OpenRouter client with a model chain; reasoning disabled after it leaked
  chain-of-thought into answers and made replies 7x slower
- site-checks.ts: structure, crawlability and specificity, tool-agnostic

Chatbot (lib/tools/chatbot/):
- system prompt that answers only from the crawled text and says so plainly
  when it cannot
- report: three measured scores at crawl time, answerability and coverage
  from one background model call, evidence required per question
- embed keys bound to the crawled host, per-key daily cap, attribution
- email verification: HMAC'd codes, 10 min expiry, 5 attempts, single use,
  3 per email per day, 3 per session. Sending is stubbed until a provider
  exists; codes are logged

Also: CI ran on branch names this repo no longer uses, so it never ran on a
pull request. Fixed, moved to Node 22, and given a test step. 75 tests.

Known gaps are tracked in docs/ai-chatbot-plan.md. The route layer has no
automated tests, nothing prunes stored page text yet, and the privacy line
is unwritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Secrets needed, migrations, where the verification code appears while email
sending is stubbed, and how to preview the embed widget. Also records that
OpenRouter's free quota is account-wide and needs $10 of credits to be
usable, since that is what stops the tool working first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ecrawl

Origin binding no longer strips labels. Deriving an apex from a hostname needs
the Public Suffix List, and without it `random.vercel.app` reduces to
`vercel.app`, which would let one key answer for every site Vercel hosts. The
same holds for `*.myshopify.com`, `*.github.io` and `*.framer.website`. The
bound host is now stored as crawled and compared as stored: exact host, `www`,
and localhost. The subdomain wildcard is gone for the same reason.

Localhost is accepted for any key, so a customer's developer can test before
installing, and those messages are counted separately (`embed-dev:<key>`, 20 a
day) from live traffic (`embed:<bound_host>`, 50 a day). Two consequences: a
laptop can never spend a live site's allowance, and a domain that crawls three
times and claims three keys still gets 50 messages rather than 150. This
replaces `TOOLS_EMBED_ALLOW_ANY_ORIGIN`, which was a global switch we could
never hand to a customer. A rejected origin now names the bound host, because
"not available" reads as broken to whoever is installing it.

No per-IP cap on embed messages. One IP sending 50 messages is either the owner
testing after install or someone draining the quota, and there is no way to
tell them apart. Capping it punishes the first; the second costs nothing on a
free model.

Manual recrawl ships as a signed link rather than a button. The widget is shown
to the customer's own visitors, and a recrawl points twenty requests at their
server, so it cannot be something a stranger can press. The token is an HMAC of
the key, so there is no table, no expiry to sweep, and revoking the embed kills
the link. Three a day per domain. The embed's session id never changes, so the
snippet already pasted into their HTML keeps working — only the pages
underneath are swapped, and a recrawl that comes back empty is rejected rather
than blanking a live bot.

The link is shown on screen at claim time, not emailed: there is still no email
provider configured, so a link we only sent by email would reach nobody.

Also: widget history moves to sessionStorage, keyed per embed. It was held in a
plain variable, so the conversation reset every time a visitor clicked a link.

11 tests on the origin check, mostly the hosting-suffix cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ider

On a deployed environment the verification code only reaches a Worker log, so
nobody outside the team can finish the embed claim — staging exists to be
tested, and this was the one flow that could not be.

`TOOLS_REVEAL_CODES=true` returns the code in the response, and the client
prints it to the browser console rather than the page: it is a testing
affordance, not something to show a visitor.

Two conditions guard it. The flag must be set, and the send must have actually
been stubbed — so configuring RESEND_API_KEY closes it off even if the flag is
left behind. It cannot silently follow the code into production, where it would
make email verification meaningless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… redirects

Three findings from review, all confirmed against the code.

**The salt fallback made manage links forgeable.** Four call sites read
`env.TOOLS_IP_SALT ?? 'dev-salt-not-for-production'`. That constant is in the
repo, and embed keys are public by design — they sit in the customer's page
source — so anyone could compute `manageToken(key, fallback)` and recrawl a
customer's site on demand. The same fallback would have made every stored
`ip_hash` a plain hash of an address space small enough to enumerate, which is
the opposite of what the privacy line claims. There is now one `toolsSalt()`
helper returning null when unset, and all four routes return 503 rather than
running on a known secret.

**The per-session cap was not atomic.** `messages_used` was read at the top of
the message route and incremented after the answer, so eight requests fired at
once for one session all saw zero used, all called the model, and all recorded
a turn. Replaced with the same conditional-update shape the daily counters
already use: `reserveTurn` claims one before any model call and returns false at
the cap. `releaseTurn` hands it back when the model never answered, so an outage
does not cost the visitor a question. `recordTurn` becomes `saveTranscript` and
no longer touches the count.

**Redirects could outlast the crawl deadline.** `fetchText` re-armed the full
timeout on every hop, so one page with three slow redirects could run
4 × budgetMs — up to 32s against a 20s wall clock the route promises. The budget
is now an absolute deadline computed once, with each hop getting what is left.

7 tests on the reservation, including the parallel case. Note that node:sqlite
executes synchronously, so that test pins the conditional-UPDATE semantics
rather than reproducing true concurrency — which is the part that was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(tools): AI chatbot diagnostic + embeddable widget
Routes, metadata, sitemap and the markdown builders now read through a
CMS-neutral provider interface instead of importing Strapi shapes directly, with
StrapiProvider and PayloadProvider behind it. Provider selection stays an
environment setting so the rollback is a variable, not a revert.

Draft reads require CMS_MODE=draft and DEPLOYMENT_ROLE=preview together, and
anything else falls back to published, so a single mistyped variable cannot
expose drafts on the public domain. A preview deployment additionally serves
noindex, no-store, and an empty sitemap.

/api/cms/revalidate accepts signed webhooks from Payload, mapping only known
collections onto application-owned paths and purging the provider's fetch tags
alongside them. Draft saves are ignored on production deployments.

scripts/cms-parity.ts compares what the website would render from each provider,
on normalised output rather than raw API responses, so a reported difference is
one a visitor could see. Differences the migration intends are declared and
reported separately from defects: media URLs moving from Strapi derivatives at
bare paths to originals at absolute URLs (Cloudflare resizing verified live on
both forms), folded duplicate type spellings, and the corrected e-commerce slug.

Workflow and wrangler config carry the new variables per environment. Staging
becomes the preview deployment; both environments keep CMS_PROVIDER=strapi until
cutover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file was renamed from .mts to .ts so Node loads the imported providers as
CommonJS, but the package script kept the old name. Typecheck passed because tsc
does not read package.json scripts, so the break only surfaced on running it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The parity check found that PayloadProvider passed industry and platform through
as bare slugs, where Strapi returned `{ title, slug }` and the normalisers read
`.slug`. Every project therefore read as industry "other" and platform
"website", which silently breaks filtering on /projects — 86 of 90 projects
affected.

A blank alt is now treated as absent so the consumers' `??` fallback to the
document title actually fires; an empty string satisfied it and suppressed the
alt entirely.

Blog and project sorts gain legacyStrapiId as a tiebreaker, matching how Strapi
ordered documents sharing a date.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CMS moves its SEO fields into plugin-seo's `meta` group. The provider
flattens them back to metaTitle and metaDescription so pages, metadata and the
parity comparison are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Current state, the remaining steps with their acceptance checks, and the traps
that have already cost time: the migration propagation race, the shared media
bucket, generateSlug regenerating migrated slugs, omitted PATCH keys failing to
clear values, and the pnpm version constraints.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CMS provider abstraction and Payload provider
@xhawk-ai

xhawk-ai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Warning

XHawk review did not run: out of credits

I stopped before reviewing this PR because your workspace has no credits left, so nothing was posted.

Add credits at app.xhawk.ai/billing, then start the review again from XHawk and I will pick up the latest commit.

@keshav-epyc
keshav-epyc merged commit df2e264 into production Aug 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants