Skip to content

fix(signal): a browser tab was the only thing delivering a paid licence key - #485

Merged
runyourempire merged 6 commits into
mainfrom
worktree-signal-license-delivery
Aug 18, 2026
Merged

fix(signal): a browser tab was the only thing delivering a paid licence key#485
runyourempire merged 6 commits into
mainfrom
worktree-signal-license-delivery

Conversation

@runyourempire

@runyourempire runyourempire commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

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 DategenerateAndStoreLicense 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\4dafourda.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.ai/code/session_01Fq96xWyPQjx2bCCzWtsnC9

…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
runyourempire force-pushed the worktree-signal-license-delivery branch from 600a3de to 7b114b7 Compare August 17, 2026 14:03
runyourempire and others added 5 commits August 18, 2026 00:08
…rod dep bump past its own gate

`generate-notice.cjs --check` fails on main: `@tanstack/react-virtual` moved
3.14.5 -> 3.14.9 (and `@tanstack/virtual-core` 3.17.3 -> 3.17.7) without NOTICE
being regenerated. Both are in the `--prod` closure, so they belong in the
attribution file.

Not this branch's doing, but it blocks this branch — and everyone else's, since
the NOTICE step lives in `repo-guards`, which has no path filter.

HOW IT GOT PAST THE GATE, because the mechanism matters more than the two lines:

  #460 added the NOTICE step to repo-guards   2026-08-15 17:03Z
  #450 (@tanstack/react-virtual) MERGED       2026-08-17 13:39Z
  #450's repo-guards: pass, in 13 SECONDS, on run 31889094566

Thirteen seconds cannot contain a Rust toolchain install, `pnpm install
--frozen-lockfile`, `cargo metadata` and `pnpm licenses list`. That run was
created before #460 existed, so it validated against a workflow with no NOTICE
step — and then auto-merge landed it two days later on that stale green without
re-running anything.

The ruleset sets `strict_required_status_checks_policy: false`, so "up to date
with base" is not required before merging. A PR can therefore merge on checks
that passed days earlier against a different workflow, and a gate added in the
meantime never sees it. The gate did not fail here; it was never invoked, and
the next unrelated PR inherits the breakage and the blame.

Regenerating is the instance fix. The class fix is enabling strict required
status checks, which is a repository setting and an operator action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…at all

`buildLicenseEmail` did `expiresAt.slice(0, 10)`. The two callers hold different
types and always have: the webhook path has a `Date` (generateAndStoreLicense
returns `new Date(...)`), the recovery path has an ISO string out of Stripe
metadata.

`.slice()` on a Date throws. `deliverLicenseEmail` never throws by contract —
because a throw off a paid webhook makes Stripe retry and mint a second valid key
— so the exception would have been caught and returned as `'error'`. Net effect:
**every purchase and renewal email silently not sent**, logged as a generic
failure, with the success page quietly back to being the only delivery.

That is precisely the failure class this module was added to end, reintroduced by
a type mismatch one line deep. The call site guarded it with
`expiresAt?.toISOString?.() ?? expiresAt`, which works but leaves the trap armed
for the next caller who forgets.

`formatExpiry` now accepts a Date or a string, and returns '' for anything it
cannot read — so a malformed expiry omits one line instead of costing the buyer
their key. `buildExpiredEmail` gets the same tolerance for the same reason.

Two tests, both confirmed red against the raw `.slice()`: a Date renders as
`Valid until: 2027-03-04` identically to the equivalent ISO string, and a junk
value still sends with the line omitted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adding licence email at purchase/renewal made the Resend row wrong -- it
scoped Resend to "licence recovery only, when you ask for it", and we now
email unprompted on every purchase and renewal. Checking that row surfaced
three larger omissions in the same document.

Stripe was absent entirely. 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 of that key, since the App verifies it offline. A privacy policy that
omits the payment processor is not a disclosure gap to fix after launch.

PostHog was absent from the third-party table, and section 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, so
"may" was not accurate. Named it, and said what it records.

The GDPR and CCPA sections treated merchandise as the only transaction
type, which made two CCPA rows false rather than merely incomplete:
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.

Also added 7.3 Signal Subscription Data, since retention covered App,
Website and Store but not subscriptions -- including the consequence a
buyer needs to know before asking for deletion: that 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 next to offline verification, but
src-tauri/src/settings/license/keygen.rs is live, so the row is correct.

Verified by building the site: privacy.html renders 41,384 bytes with all
four disclosures present and both false claims gone.
The licence email linked straight to 4da://activate?key=... Gmail removes
custom-scheme hrefs outright -- in the browser and in its mobile apps -- so
the button rendered as a button and carried no href at all. Confirmed dead
against real delivered mail, in the most widely used client there is.

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 never reached the OS to be handled.

So 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. Same desktop-handoff
bridge Slack and Zoom use.

THE KEY TRAVELS IN THE FRAGMENT, not the query string. A fragment is never
transmitted, so the licence key stays out of Cloudflare's request logs and
out of any Referer header. That mattered more than it first looks: 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 here. Verified in 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 rather than looking broken. It does NOT fire the
protocol navigation on load -- an unprompted handoff on a machine without
the app is what makes these pages feel dead -- and it shows the key, a copy
button, and the Settings -> License instructions with a download link
regardless of whether the deep link resolves. A link with no key says so
and explains the likely cause instead of failing silently.

Links to /activate, not /activate.html: Pages 308-redirects the extension
form to the clean URL, and a redirect is not somewhere to be routing a URL
whose meaning lives in its fragment.

3 new tests, 169 script tests passing. They 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.
Caught live on this PR. Validate Success reported:

    dependency results: success,success,success,success,abandoned,success

The `abandoned` is Relay. The gate was a DENYLIST -- it exited 1 only on
`failure` or `cancelled` -- so `abandoned` fell through to the success
branch. The run's own conclusion was `failure`, Relay's conclusion was
`failure`, and yet the one job configured as the required status check
said 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 it 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.
`timed_out`, `neutral` and `abandoned` now all fail, as does an empty
results string, which would otherwise be a gate that measured nothing and
reported success.

NOT introduced by this PR. The case statement dates to bb9a843 (#141);
adding `relay` to the needs list is merely what made a job report a value
outside the two the gate happened to check. It has been able to pass a
non-succeeding job for as long as it has existed, on the check that guards
every merge -- which is the same failure shape as the stale-green NOTICE
gate this PR already documents, and worth more than the delivery fix it
turned up beside.

Verified against every result value: the exact 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.
@runyourempire
runyourempire merged commit bbda941 into main Aug 18, 2026
15 checks passed
@runyourempire
runyourempire deleted the worktree-signal-license-delivery branch August 18, 2026 02:33
runyourempire added a commit that referenced this pull request Aug 21, 2026
## The secrets gate called an undeliverable mailer "OK"

`check-pages-secrets.cjs` can only ever read variable **names** —
Cloudflare never returns values — so "configured" and "able to send"
were the same answer to it. Those come apart in one specific and
dangerous way: set `RESEND_API_KEY` and `RESEND_FROM_EMAIL` while the
sending domain is unverified, and Resend refuses every send with **403**
while the gate reports green.

That is the same conflation the script was written to refuse, pointing
the other way. Its own header says *"could not check" must never read as
"checked and fine"*; a mailer that cannot send must not read as one that
can.

**Found while provisioning Resend for real.** The account's key was
send-only and `4da.ai` was unverified, so setting the two variables at
that moment would have turned an honest `exit 1` into a green light over
a delivery path that 403s on every sale. Refusing to create that false
green is worth more than the round-trip it saves — and it is the second
false-green found in this repo's gates this week, after the one in #485.

## What it does now

A DNS lookup for `resend._domainkey.4da.ai`. No token, no secret values,
no network credentials — either the record is published or it is not.

Three-valued, like the rest of the script:

| DNS answer | Verdict | Exit |
|---|---|---|
| TXT records present | verified | 0 |
| `NXDOMAIN` / `ENOTFOUND` / `ENODATA` | genuinely absent | **1** |
| resolver unreachable (`ESERVFAIL`, timeout, unknown) | **unknown** |
**2** |

The third row is load-bearing. Reporting an unreachable resolver as "not
verified" would fail a release over a network blip and teach everyone to
ignore the gate — the exact way a gate dies.

`SENDING_DOMAIN` is hard-coded for the same reason `PROJECT` is:
Cloudflare will not disclose the value of `RESEND_FROM_EMAIL`, so the
domain to check against cannot be discovered from the project.

## Tests, which the script shipped without

A poor look for a gate whose entire purpose is not lying. Eight added,
covering every DNS outcome and both mailer states.

One is worth calling out: **`main()` must return a Promise.** If it is
ever reverted to synchronous, the `require.main` handler assigns a
Promise to `process.exitCode`, which coerces to `0` — a permanently and
silently green gate. That is precisely the failure this whole script
exists to prevent, so it is pinned.

**177 script tests pass**, up from 169 on `main`.

## Verified against live DNS, both directions

- When `resend._domainkey.4da.ai` did not exist, this read **absent
(ENOTFOUND)** — independently confirming Resend's own 403 without using
the API key at all.
- Now that the record is published, it reads **verified**.

Same code, opposite answers, tracking reality as it changed during the
session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Fq96xWyPQjx2bCCzWtsnC9
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.

1 participant