chore(mcp): 5.0.2 — republish so npm serves the AD-030-corrected README - #478
Merged
Conversation
No code changes. 5.0.1 went to npm 2026-08-16 11:01Z, thirteen minutes before #469 merged the corrected README at 11:14Z — so the npm package page still sells "Compound intelligence (learns over time)" and content that "compounds over time", both retired claims. npm is the last surface anywhere serving the old copy; the repo copy has been clean since #469 (gate-verified). Version bumped in package.json, server.json (both fields), and .claude-plugin/plugin.json (which had been left at 5.0.0). CHANGELOG marks 5.0.1 as released and records why 5.0.2 exists. index.ts needs no edit — SERVER_VERSION reads package.json at runtime. Release after merge: tag mcp-v5.0.2 on the squash commit -> publish workflow (npm + MCP Registry). Verified: pnpm build clean, 185/185 tests, check-mcp-server-sync green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LBY5q2MskiKMkTm6NmB49L
runyourempire
enabled auto-merge (squash)
August 17, 2026 02:44
runyourempire
added a commit
that referenced
this pull request
Aug 17, 2026
…ce key Buying Signal minted a key into Stripe customer metadata and emailed nothing. The ONLY way a customer ever saw it was /signal/success rendering it — and that page polls the webhook 4 times over ~8 seconds before giving up. Close the tab inside that window, or hit a slow webhook, and the key existed but the buyer never had it. The advertised fallback was worse than absent. "Email me my key" on that page called an endpoint that answered 503, because RESEND_API_KEY/RESEND_FROM_EMAIL were never set on the Pages project. That 503 is CORRECT code — #427 closed a real vulnerability (the endpoint used to return any address's key to anyone who asked) and deliberately refuses to fall back to returning the key. But closing the hole replaced a working-if-unsafe recovery path with one that was never switched on, and nothing anywhere reported the difference. Verified against production: the project has STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, LICENSE_PRIVATE_KEY_HEX, SITE_URL and the three price IDs — and neither RESEND_*. So the delivery model was: one page load, no durable copy, and a dead fallback. WHAT CHANGES 1. The key is emailed at purchase and at renewal (`deliverLicenseEmail`). The success page stops being the delivery mechanism and becomes a convenience. It NEVER throws. This runs off a paid Stripe webhook, and `generateAndStoreLicense` is not idempotent: a throw returns non-2xx, Stripe retries, and the retry MINTS A SECOND VALID KEY with a fresh expiry. A failed email must degrade to "use the page or recovery", never to "issue another entitlement". Awaited rather than backgrounded, because it cannot throw, so the log line is guaranteed written before we answer 200. An unprovisioned mailer logs at error level on every single sale rather than skipping silently. That is the entire lesson of the 503: correct behaviour nobody could see is indistinguishable from no behaviour at all. 2. Renewals say so. A renewal silently replaces the key the customer holds — the old one dies at its original expiry. Previously the first they learned of it was the app rejecting their key. 3. The email footer matches why it arrived. There was one shared footer reading "someone asked 4DA to recover the licence for this address", which on a purchase confirmation lands as an account-compromise warning at the happiest moment of the funnel. Purchase / renewal / recovery now each say the true thing, with matching subjects. 4. `streets_*` metadata keys become `signal_*`. STREETS was retired in June 2026. Every entitlement key still carried its name, so the LIVE licensing path read as a dead feature — and that is not hypothetical: this endpoint was believed retired for exactly that reason, and the 503 was read as expected behaviour of something switched off. Reads accept EITHER prefix, preferring `signal_`, so no customer record is orphaned and no migration is needed. Contained entirely to site/ — nothing in src-tauri/, src/ or mcp-4da-server/ ever read these keys. 5. `scripts/check-pages-secrets.cjs` — a gate for the class of bug, not the instance. A missing runtime variable is invisible to every test (not a code property) and to every CI job (cannot read a Pages secret). The only thing that can catch it is asking the live project, so that is what this does. It reads names only; Cloudflare never returns values. "Could not check" exits 2, never 0. Conflating unknown with fine is the same defect the script exists to catch. Run against production it currently exits 1 and names both missing RESEND_* variables with the fix command. Deliberately NOT in `validate`/CI: it needs a Cloudflare token, so in CI it would be permanently inconclusive — which would either fail every build or, worse, get read as a pass. It is `pnpm run ops:pages-secrets`. ALSO FIXED, UNRELATED BUT BLOCKING `check-retired-claims` was failing on main. #478's changelog quotes the retired claims verbatim to document what its republish removed — a legitimate historical quotation, which is exactly what the gate's `retired-ok:` escape hatch is for. It just never got the marker, and the violation spanned two lines so the one-line lookbehind could not cover it from above; reflowed onto one line with an inline marker (HTML comments do not render, so the changelog is unchanged to a reader). And the gate is now wired into `repo-guards`. `.ai/RULES.md` and `AGENTS.md` have been telling every agent this claim is "enforced by scripts/check-retired-claims.cjs" while nothing in CI invoked it — the claim of enforcement was itself unenforced, which is how it broke on main unnoticed. VERIFICATION 164 script tests pass, up from 141. All 23 new ones were confirmed to fail without their fix and pass with it: - neutering delivery to the old silent no-op -> 7 of 8 delivery tests red - the namespace tests pin the legacy fallback rather than assuming it: a LEGACY-only record still resolves, a legacy terminal status is still terminal (reading only signal_* would silently re-grant revoked access), the current prefix wins when both are present, and a legacy first-seen stamp survives the rename instead of being restamped. File-size gate clean. `check-retired-claims` clean. `node --check` clean on every changed JS file. YAML parses and the new step is inside `repo-guards`, the job with no path filter. STILL AN OPERATOR ACTION Setting RESEND_API_KEY + RESEND_FROM_EMAIL on the Pages project (the domain must be verified in Resend first). Until then this commit changes the failure from "silent, and the page is the only copy" to "loud in the logs of every sale, and `ops:pages-secrets` exits 1 naming it" — but no email is sent, because there is nothing to send it with. NOT DONE, DELIBERATELY Rate limiting on the recovery path, and event-id dedup on the mint path. Both need a KV namespace on the Pages project, which is infrastructure this commit cannot conjure — naming a binding that does not exist fails closed at request time on the live payment path. Dedup is the more valuable of the two: without it a duplicate `checkout.session.completed` or `invoice.paid` delivery mints a second valid key. Both remain as the dispatch comment already documents. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
runyourempire
added a commit
that referenced
this pull request
Aug 18, 2026
…ce key (#485) ## A browser tab was the only thing delivering a paid licence key Buying Signal minted a key into Stripe customer metadata and **emailed nothing**. The only way a customer ever saw it was `/signal/success` rendering it — and that page polls the webhook 4 times over ~8 seconds before giving up. Close the tab inside that window, or hit a slow webhook, and the key existed but the buyer never had it. The advertised fallback was worse than absent. The **"Email me my key"** button on that page called an endpoint answering **503**, because `RESEND_API_KEY` / `RESEND_FROM_EMAIL` were never set on the Pages project. Verified against production: it has `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `LICENSE_PRIVATE_KEY_HEX`, `SITE_URL` and the three price IDs — and neither `RESEND_*`. That 503 is *correct code*. #427 closed a real vulnerability — the endpoint used to return any address's key to anyone who asked — and deliberately refuses to fall back to returning the key. But closing the hole replaced a working-if-unsafe recovery path with one that was never switched on, and nothing reported the difference. ## What changes **1 · The key is emailed at purchase and renewal.** The success page becomes a convenience instead of the delivery mechanism. It **never throws**. This runs off a paid Stripe webhook and `generateAndStoreLicense` is not idempotent: a throw returns non-2xx, Stripe retries, and the retry **mints a second valid key** with a fresh expiry. A failed email must degrade to "use the page or recovery", never to "issue another entitlement". Awaited rather than backgrounded precisely *because* it cannot throw, so the log line is guaranteed written before we answer 200. An unprovisioned mailer logs at **error level on every single sale** rather than skipping silently. That is the whole lesson of the 503: correct behaviour nobody can see is indistinguishable from no behaviour at all. **2 · Renewals say so.** A renewal silently replaces the key the customer is holding — the old one dies at its original expiry. Previously the first they learned of it was the app rejecting their key. **3 · The footer matches why the mail arrived.** There was one shared footer reading *"someone asked 4DA to recover the licence for this address"*, which on a purchase confirmation lands as an account-compromise warning at the happiest moment of the funnel. Purchase / renewal / recovery now each say the true thing, with matching subjects. **4 · `streets_*` metadata keys become `signal_*`.** STREETS was retired in June 2026, but every entitlement key still carried its name — so the *live* licensing path read as a dead feature. That is not hypothetical: this endpoint was believed retired for exactly that reason, and its 503 was read as expected behaviour of something switched off. Reads accept **either** prefix, preferring `signal_`, so no customer record is orphaned and no migration is needed. Contained entirely to `site/` — nothing in `src-tauri/`, `src/` or `mcp-4da-server/` ever read these keys. **5 · `scripts/check-pages-secrets.cjs` — a gate for the class, not the instance.** A missing runtime variable is invisible to every test (it isn't a code property) and to every CI job (CI can't read a Pages secret). The only thing that can catch it is asking the live project, so that's what this does — names only; Cloudflare never returns values. *"Could not check" exits 2, never 0.* Conflating unknown with fine is the same defect the script exists to catch. Run against production it currently **exits 1** and names both missing variables with the fix command. Deliberately **not** in `validate`/CI — it needs a token, so there it would be permanently inconclusive, which would either fail every build or get read as a pass. It's `pnpm run ops:pages-secrets`. ## The delivery fix had this bug in it, twice over **A `Date` expiry would have sent no email at all.** `buildLicenseEmail` did `expiresAt.slice(0, 10)`, but the webhook path holds a `Date` — `generateAndStoreLicense` returns `new Date(...)` — while only the recovery path reads an ISO string out of Stripe metadata. `.slice()` on a `Date` throws, and the no-throw contract above would have caught that throw and degraded it to a logged `'error'`. So the headline path of this PR would have silently sent **nothing**, in exactly the failure mode the PR exists to end, and the tests written first would not have caught it because they all passed strings. `formatExpiry()` now normalises both shapes, and a junk expiry omits the line rather than costing the buyer their key. The first version of the maintenance-timestamp fix in this same batch had the mirror-image defect — `unwrap_or(0)` would have written "never ran" instead of failing to write — which is why both are called out rather than quietly corrected. ## The activate button did nothing, in the most-used mail client there is Real delivered mail proved it: the email linked straight to `4da://activate?key=...`, and **Gmail removes custom-scheme hrefs outright** — browser and mobile apps alike. The button rendered as a button and carried no href at all. Nothing was wrong with the app. The `4da` scheme is declared in `tauri.conf.json`, the plugin is initialised in `lib.rs`, `app_setup.rs` validates and emits `deep-link-activate`, and on a machine with 4DA installed the handler is registered correctly (`HKCU\Software\Classes\4da` → `fourda.exe "%1"`, `URL Protocol` set). The link simply never reached the OS. The email now points at **`https://4da.ai/activate`**, which no mail client sanitises, and that page performs the `4da://` handoff from an ordinary click on an ordinary web page — where the restriction does not apply, because it is a mail-client sanitiser and not a browser policy. The same desktop-handoff bridge Slack and Zoom use. Shipped in this PR so the button is never live against a 404. **The key travels in the fragment, not the query string.** A fragment is never transmitted, keeping the licence key out of Cloudflare's request logs and out of any Referer header. That mattered more than it first appears: `base.njk` injects PostHog into *every* page unconditionally, and a client-side script can read `location.hash` even though the server cannot — so `/activate` would have handed licence keys to a third-party analytics service. Added a `noAnalytics` front-matter flag and set it there. Verified both directions against the build: `activate.html` has **0** posthog references while index, privacy and success still have 14, 13 and 10. The page degrades honestly instead of looking broken: it does **not** fire the protocol navigation on load — an unprompted handoff on a machine without the app is exactly what makes these pages feel dead — and it always shows the key, a copy button, and the `Settings → License` instructions with a download link. A link carrying no key says so and names the likely cause. Links to `/activate`, not `/activate.html`: Pages 308-redirects the extension form to the clean URL, and a redirect is not somewhere to route a URL whose meaning lives in its fragment. ## Privacy policy: Stripe was not in it Checking whether the policy covered emailing at purchase (its Resend row scoped it to *"licence recovery only, when you ask for it"* — no longer true) surfaced that **Stripe appeared nowhere in `privacy.njk`**. It is the payment processor: it takes the buyer's email and card details, and the issued licence key and expiry are stored against the Stripe customer record — which is our **only** server-side copy, since the app verifies offline. Three more in the same document: - **PostHog** was absent from the third-party table, and §3.1 hedged that we *"may use privacy-respecting analytics ... if used"*. It is injected by `base.njk` on every page and `posthog.capture()` is called from `index.njk`. - The **CCPA table had two false rows**, not merely thin ones: identifiers *"Only via Store purchases"* and commercial information *"Only via Store"*. Signal checkout collects an email address and produces billing history. Added a financial-information row recording that card details reach Stripe/Shopify directly and never us. - **Retention covered App, Website and Store but not subscriptions.** Added §7.3, including the consequence a buyer needs before requesting deletion: that Stripe record is the only copy of their key we hold, so erasing it ends our ability to re-send it. **Keygen stays.** It looked stale beside offline verification, but `src-tauri/src/settings/license/keygen.rs` is live, so the row is correct. ## The sole required status check could pass a job that did not succeed Caught live on this PR, and it outweighs the delivery fix it turned up beside. `Validate Success` reported: ``` dependency results: success,success,success,success,abandoned,success ``` That `abandoned` is `Relay`. The gate was a **denylist** — it exited 1 only on `failure` or `cancelled` — so `abandoned` fell through to the success branch. Relay's conclusion was `failure`, the run's own conclusion was `failure`, and yet the one job configured as **the required status check** reported SUCCESS and the PR read `mergeable=MERGEABLE`. A human clicking merge would have landed a PR whose Relay leg never ran. GitHub emits more job results than the four that are documented, so a denylist here is unsound by construction: every value nobody thought of is a pass. Inverted to an **allowlist** — only `success` and `skipped` pass (`skipped` because the path-filtered legs legitimately skip when a change does not touch them) and everything else, known or not, blocks the merge. An empty results string now fails too, rather than being a gate that measured nothing and reported success. **Not introduced here.** The case statement dates to `bb9a843d` (#141); adding `relay` to `needs` is merely what made a job report a value outside the two it happened to check. It has been able to pass a non-succeeding job for as long as it has existed — the same failure shape as the stale-green NOTICE gate below, on the check that guards every merge. Verified against every result value: the live string above now fails naming `abandoned`, `success,skipped,success` still passes, and `failure` / `cancelled` / `timed_out` / `neutral` / empty all fail. Workflow YAML parses with the `needs` list unchanged. ## Also fixed, unrelated but blocking **`check-retired-claims` was failing on `main`.** #478's changelog quotes the retired claims verbatim to document what its republish removed — a legitimate historical quotation, which is exactly what the gate's `retired-ok:` escape hatch is for. It just never got the marker, and the violation spanned two lines so the one-line lookbehind couldn't cover it from above. Reflowed onto one line with an inline marker; HTML comments don't render, so the changelog is unchanged to a reader. **And the gate is now wired into `repo-guards`.** `.ai/RULES.md` and `AGENTS.md` have been telling every agent this is *"enforced by `scripts/check-retired-claims.cjs`"* while nothing in CI invoked it — the claim of enforcement was itself unenforced, which is how it broke on main unnoticed. **`NOTICE` was stale on `main`, and how it got there matters more than the diff.** `@tanstack/react-virtual` 3.14.5→3.14.9 and `@tanstack/virtual-core` 3.17.3→3.17.7 were missing. #460 added the NOTICE gate on 15 Aug; #450 merged on 17 Aug on a CI run **created before that gate existed**, passing in 13 seconds. `strict_required_status_checks_policy: false` permits a stale run to satisfy a required check, so the gate was never invoked and the next PR inherits both the breakage and the blame. Regenerated — but the class fix is enabling strict required status checks, which is an operator action on the ruleset. ## Verification **169 script tests pass, 0 fail** — up from 141 on `main`. All 28 new ones were confirmed to fail without their fix and pass with it: - neutering delivery back to the old silent no-op turns **7 of 8** delivery tests red - the two expiry tests fail against the original `.slice()` implementation - the three activation-link tests pin the regression rather than the implementation: the href must be https and must never be `4da://` again, the key must be in the fragment and never in the query string, and a key containing `+`, `/` and `=` must survive the round trip — a raw `+` would decode back as a space and hand the buyer a broken key - the namespace tests pin the legacy fallback rather than assuming it: a legacy-only record still resolves; a legacy *terminal* status is still terminal (reading only `signal_*` would silently re-grant revoked access); the current prefix wins when both are present; and a legacy first-seen stamp survives the rename instead of being restamped Site build clean — `privacy.html` renders at 41,384 bytes with Stripe, PostHog, the Signal retention section and the card-details disclosure all present, and both false CCPA claims gone. File-size gate clean · `check-retired-claims` clean · `node --check` clean on every changed JS file · YAML parses with the new step inside `repo-guards`, the job with no path filter. ## Still an operator action **Setting `RESEND_API_KEY` + `RESEND_FROM_EMAIL` on the Pages project.** The domain must be verified in Resend first, and `_dmarc.4da.ai` does not currently exist — worth adding in the same pass, since a licence key filtered to spam is indistinguishable to the buyer from not being sent. Current DNS is `v=spf1 include:spf.improvmx.com ~all` with MX on ImprovMX for inbound forwarding; Resend's free tier allows one verified domain. Until the variables are set, this PR changes the failure from *silent, with the page as the only copy* to *loud in the logs of every sale, with `ops:pages-secrets` exiting 1 and naming it* — but no email is sent, because there is nothing to send it with. **Enabling strict required status checks** on the ruleset, per the NOTICE finding above. ## Not done, deliberately Rate limiting on recovery, and **event-id dedup on the mint path**. Both need a KV namespace, and naming a binding that doesn't exist fails closed at request time on the live payment path. An earlier draft of this section claimed that namespace was infrastructure this PR could not conjure. That was not checked, and it is false: the available Cloudflare token carries `workers_kv (write)`, and `wrangler kv namespace list` returns `[]` — reachable, simply never created. The blocker is a missing decision, not a missing capability. It is still deliberately out of scope here, for a different and better reason: **dedup is a change to the live payment path and deserves its own tests and its own review**, not a late addition to a PR already in flight. It is also the more valuable of the two — without it a duplicate `checkout.session.completed` or `invoice.paid` delivery mints a second valid key, which is the same class of defect as the one this PR fixes. It should be the next piece of work, and there is now nothing infrastructural standing in the way. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Fq96xWyPQjx2bCCzWtsnC9 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The residual this closes
npm is the last surface anywhere still serving retired AD-030 copy. Timeline: 5.0.1 was published to npm manually at 2026-08-16 11:01Z — thirteen minutes before #469 merged the corrected README at 11:14Z. So the live npm page still carries:
| Compound intelligence (learns over time) | -- | Yes |(comparison table)The repo README has been clean since #469 (
check-retired-claimsverifies it —mcp-4da-server/README.mdis in the gate's scan set). Publishing current repo state fixes the npm surface. Tracked as item 2/3 of the AD-030 residuals in.claude/plans/PENDING-DECISION.md.Change (version metadata only, no code)
package.json5.0.1 → 5.0.2server.jsonboth version fields → 5.0.2.claude-plugin/plugin.json→ 5.0.2 (had been left stale at 5.0.0)CHANGELOG.md: 5.0.1 marked released (it is live on npm), 5.0.2 entry records why this release existssrc/index.tsuntouched —SERVER_VERSIONreads package.json at runtime (Upgrade MCP infrastructure to SDK v2 + 2026-07-28 protocol #409)Release plan after merge
Tag
mcp-v5.0.2on the squash commit →publish-mcp-server.ymlpublishes to npm (NPM_TOKEN, last exercised successfully on 4.6.5/4.6.6) + MCP Registry via OIDC. The workflow's version-matches-tag check requires exactly this bump.Verification
pnpm run buildclean,pnpm test185/185node scripts/check-mcp-server-sync.cjs— in syncnode scripts/check-retired-claims.cjs— OK (repo README clean)🤖 Generated with Claude Code
https://claude.ai/code/session_01LBY5q2MskiKMkTm6NmB49L