Skip to content

fix(licensing): close the four live holes in the payment/recovery path - #494

Merged
runyourempire merged 1 commit into
mainfrom
worktree-fix+licensing-hardening
Aug 19, 2026
Merged

fix(licensing): close the four live holes in the payment/recovery path#494
runyourempire merged 1 commit into
mainfrom
worktree-fix+licensing-hardening

Conversation

@runyourempire

Copy link
Copy Markdown
Collaborator

What this is

The server-side arc of the professional-licensing hardening audit (2026-08-19). A full sweep of the licensing pipeline — checkout → webhook → key mint → delivery email → success page → recovery → refund/chargeback — found four live defects. All four are closed here, each pinned by dependency-free tests that run in the repo-guards CI job.

The four holes

1. Checkout session ids leaked to PostHog (HIGH)

/signal/success loaded PostHog while its URL carries ?session_id=cs_.... That session id is a bearer credential: GET /api/streets/activate?session_id= returns the licence key to whoever holds it. Every purchase shipped that credential to a third party inside $current_url — and the page renders the key itself in the DOM, which session replay would record. The page now sets noAnalytics: true, same reasoning as /activate.

2. Duplicate Stripe deliveries minted extra keys (HIGH)

checkout.session.completed / invoice.paid mint a new signed key on every run, and Stripe re-sends event ids routinely. New LICENSE_KV namespace (prod 33eb6985..., preview 83ffb6f2..., bound in site/wrangler.toml) gates webhook dispatch on event.id:

  • recorded only after a handler succeeds — failures stay retryable;
  • TTL 7 days — outlives Stripe's ~3-day live retry window;
  • fails open — a KV outage degrades to yesterday's behaviour, never a dropped paid event.

3. Recovery was unmetered — a denial-of-licence-delivery vector (MED)

The unauthenticated ?email= recovery path could be looped: ~100 requests for one known customer address exhausts the Resend free tier's 100/day quota, silencing the licence-delivery mail of every real purchase that day. Now metered via LICENSE_KV:

  • 30 requests/IP/hour → honest 429 (depends only on caller behaviour, leaks nothing);
  • 5 mails/address/day → enforced inside the deferred delivery, after the constant 202 has gone out, so response body and timing stay identical in every case — the customer-existence oracle stays closed.
  • Both fail open; a broken counter store never blocks a real customer.

4. Refunded/charged-back customers could still retrieve their key (MED)

Recovery mail and the session-id lookup returned the key regardless of terminal status — quietly handing back the exact access a refund ended (keys verify offline; nothing revokes a re-delivered copy). New entitlement.isRevoked(), deliberately stricter than isTerminal:

  • refund / chargeback → recovery sends an honest "no longer active" notice (with the way back to /signal); session lookup answers 410;
  • a cancelled subscriber is not blocked — their paid tail runs to the key's own expiry, because that tail is the policy being sold.

Tests

+21 new assertions: site/lib/abuse-guards.test.mjs (new, wired into test:scripts), entitlement.test.mjs (isRevoked semantics incl. legacy streets_ prefix), license-email.test.mjs (full deliverRecoveryEmail matrix: active/refunded/chargeback/cancelled/legacy/expired/unknown/stripe-error). 62 passing locally.

What this deliberately does NOT change

  • Lifetime 2099 revocation window — a licence-format change needing the app-side lease client (which does not exist yet; /api/license/refresh currently has zero consumers). Documented as its own decision arc.
  • Monthly ~35-day cancellation tail — the sold grace design, unchanged.

Deploy note

KV namespaces already exist; the binding lands with the next wrangler pages deploy from this branch's merge. All KV consumers fail open, so deploy order cannot break the payment path.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RuQRKzs6RawLs5zxxNPBYp

Professional-licensing hardening arc, server side. Four confirmed defects,
each now closed and pinned by dependency-free tests:

1. Session-id leak to PostHog (HIGH): /signal/success loaded analytics while
   its URL carries ?session_id=cs_... — and that session id is a BEARER
   CREDENTIAL (GET /api/streets/activate?session_id= returns the licence key
   to whoever holds it). Every purchase shipped that credential to a third
   party in $current_url, and session replay would also record the rendered
   key. The page now sets noAnalytics, same as /activate.

2. Webhook event-id dedup (HIGH): a duplicate Stripe delivery of
   checkout.session.completed / invoice.paid minted ANOTHER valid signed key.
   New LICENSE_KV namespace (prod 33eb6985, preview 83ffb6f2, bound in
   wrangler.toml) gates dispatch on event.id — recorded only AFTER a handler
   succeeds so failures stay retryable, TTL 7d (outlives Stripe retries),
   and FAIL OPEN so a KV outage degrades to yesterday's behaviour, never a
   dropped paid event.

3. Recovery metering (MED): the unauthenticated ?email= path was unmetered —
   ~100 requests for ONE known customer address exhausts the Resend 100/day
   free-tier quota, silencing the licence-delivery mail of every real
   purchase that day (denial of licence delivery, not just inbox nuisance).
   Now: 30 req/IP/hour (honest 429, depends only on caller behaviour) and
   5 mails/address/day enforced INSIDE the deferred delivery, after the
   constant 202 — response body and timing stay identical in every case, so
   the customer-existence oracle stays closed. Both fail open.

4. Revoked keys were still re-delivered (MED): recovery mail and the
   session-id lookup returned the key regardless of terminal status, quietly
   handing a refunded/charged-back customer back the exact access the refund
   ended (keys verify offline; nothing revokes a re-delivered copy). New
   entitlement.isRevoked() — deliberately STRICTER than isTerminal: refund/
   chargeback block re-delivery with an honest "no longer active" notice,
   while a CANCELLED subscriber keeps retrieval until the key's own expiry,
   because that paid tail is the policy being sold.

Tests: +21 across abuse-guards.test.mjs (new, wired into test:scripts),
entitlement.test.mjs and license-email.test.mjs — 62 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuQRKzs6RawLs5zxxNPBYp
@runyourempire
runyourempire enabled auto-merge (squash) August 19, 2026 02:54
@runyourempire
runyourempire merged commit 8e1a8a2 into main Aug 19, 2026
10 checks passed
@runyourempire
runyourempire deleted the worktree-fix+licensing-hardening branch August 19, 2026 03:01
runyourempire added a commit that referenced this pull request Aug 19, 2026
… key-in-logs, +6 (#495)

## What this is

Wave 2 of the licensing-pipeline hardening audit (Wave 1 = #494,
merged+deployed). A deeper adversarial sweep — **three parallel hunters
(site / app / frontend) + direct analysis** — found several more issues
across the whole pipeline. Every confirmed finding here is fixed and
test-covered; two items requiring an app release or a product decision
are documented for the operator (not code-fixable this pass).

## Fixed & verified

### Server (Cloudflare Pages)
- **H1 (HIGH) — session-lookup returned ANY customer's key with no
payment check.** `GET ?session_id=` trusted a session id minted at
*creation* (pre-payment) and resolved the customer by *buyer-typed
email*, so starting a checkout with a victim's email and abandoning it
(no payment) let an attacker read that victim's offline-verifiable key.
Now: gate on `sessionProvesPurchase` (status complete / paid), bind to
`session.customer` (never an email list), and time-box the session id to
24h (`sessionWithinWindow`). Pure predicates unit-tested.
- **M4 (MED) — no `charge.dispute.closed` handler.** A *won* dispute
left a paying monthly subscriber terminal forever (the isTerminal guard
then blocks every renewal). `handleDisputeClosed` restores `active` on
`status === 'won'`, only from the exact `chargeback` state.
- **M3 (MED) — `notify.js` unauthenticated + unmetered** → unbounded
Stripe customer creation. LICENSE_KV per-IP limiter (20/hr), fail-open,
separate key namespace.
- **CSP + no-referrer (L6/L7)** — strict CSP (`default-src 'none'`,
`connect-src 'self'`) on the two key-bearing pages so an injected script
can't exfiltrate the key, and the session-id credential never leaks in a
Referer.
- **checkout.js** writes `signal_tier` (was legacy `streets_tier`) —
hygiene.

### App (Rust)
- **Key-in-production-logs (MED)** — the full
`fourda://activate?key=<SECRET>` was logged at info/warn across 9 sites
and written to the `security_events` DB, so the bearer key landed in
`data_dir/logs/*.log` in cleartext (support bundles, cloud-synced
folders). New `utils::redact_deep_link` (byte-safe vs hostile UTF-8)
masks the `key=` value everywhere.
- **Expiry never enforced + settings.json tamper (MED)** —
`has_license_key_available` returned true for any non-empty key, so an
*expired* `4DA-` key kept granting Signal forever (a cancelled monthly
subscriber never downgraded) and pasting `tier:"signal"`+garbage
unlocked Signal. New `key_is_usable` verifies signature **and** expiry.
Makes Terms §4.5 true for subscriptions for the first time.
- **Recovery auto-activated an unverified key (F3)** — removed (the live
server never returns a key here; it was dead + dangerous if `4da.ai`
were repointed).
- **Forgeable Keygen cache (F2)** — the doc comments claimed it was
cryptographic; it isn't (bare SHA-256). Corrected the claims + added a
named adversarial test pinning the real honesty-box behaviour.
- **Trial 45-vs-14 lie** — `start_trial` now reports the real value from
`get_trial_status`.
- **Terms §5.3** reworded from the impossible "the licence is
deactivated" to the enforceable "the licence terminates; continued use
is not permitted."

## Verified
- **Rust**: 25 license tests + 22 url tests pass (6 new), `cargo fmt` +
`clippy --lib` clean, no size breaches.
- **Site**: 64 lib tests pass (session-predicate + revoked-matrix
added).

## Documented for the operator (not code-fixable this pass)
- **Deep-link activation consent (MED)** — a website can silently swap a
paying user's licence for the attacker's own valid key (DoS/griefing).
The fix is a UI consent flow, and the project's verification rule
requires UI/entitlement changes to be tested against the running app;
deferred as a focused, live-verified follow-up with the design ready
(email-match refusal + confirmation modal). This is the top open
security item.
- **Lease-client arc** — real lifetime-refund revocation + killing the
monthly re-paste. Needs an app release + a NETWORK.md/ADR decision.
- **Signal-feed "isPro" gate** — per tier-truth #473 that feed is
free-floor, so this is a vestigial sales-tease, NOT a data leak (all
actually-sold features enforce server-side). Product call, not a patch.

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

https://claude.ai/code/session_01RuQRKzs6RawLs5zxxNPBYp

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
runyourempire added a commit that referenced this pull request Aug 20, 2026
…ired STREETS brand (#497)

## Why

`/api/streets/activate` is the production Signal licence endpoint
wearing the name of a product retired in June 2026. Every reader — human
and agent — kept mistaking the payment path for curriculum leftovers;
the operator was one dashboard click from deleting the live Stripe event
destination because its URL reads as STREETS junk. The label is the
hazard. This PR removes it.

## What

- **Canonical route is now `/api/license/activate`** (beside the
existing `/api/license/refresh`). `functions/api/streets/activate.js`
becomes a 3-line re-export shim: pre-rename desktop builds have the
recovery URL baked into the binary, and the Stripe event destination
points at the old URL until its one-time dashboard edit. Both routes
verified present in the compiled Worker (`wrangler pages functions
build`).
- **Desktop app recovery URL** (`settings_commands_license.rs`)
repointed to the new path.
- **`setup-signal.mjs` now checks all SIX handled events** —
`charge.refunded`, `charge.dispute.created`, `charge.dispute.closed`
were missing from its checklist while the deployed handler (#494/#495)
handles them. It also finds the destination on either path.
- **`streets.4da.ai` dropped from CORS** — NXDOMAIN (verified against
1.1.1.1); nothing has served from it since STREETS retired.
- **Latent e2e bug fixed in passing:** the invalid-tier test POSTed to
`/api/streets/checkout`, a route that has never existed on Cloudflare
(checkout is `/api/signal/checkout`) — it was asserting against the
static 404 page, not the handler's 400 branch.
- `'No STREETS license found'` 404 now matches the endpoint's other 404
bodies.
- Docs repointed: NETWORK.md (with a legacy-path note for pre-rename
builds), DECISIONS.md AD-028 code pointer, validate.yml comment,
generate-license.mjs, verify-ed25519-equivalence.mjs.

## What is deliberately NOT touched

- **Stripe customer metadata** — the `streets_*` → `signal_*` namespace
migration already shipped with a legacy read-fallback in
`lib/entitlement.js`; no customer record is affected by this PR.
- **`lib/recovery-email.js` historical comment** — file is claimed by a
peer lane (`fix-open-code-17-21`); the comment describes the 2026-08-14
era when the old path was canonical, so it stays accurate as written.
- **App-internal `streets_engine` / `streets_localization` /
`docs/streets`** — retired-curriculum machinery unrelated to payments;
renaming the DB column is a schema migration and a separate decision.

## Operator follow-up (after this deploys to 4da.ai)

One edit on the existing Stripe event destination (Workbench → Webhooks
→ ⋯ → Update details): set URL to `https://4da.ai/api/license/activate`
and select all six events (`checkout.session.completed`, `invoice.paid`,
`customer.subscription.deleted`, `charge.refunded`,
`charge.dispute.created`, `charge.dispute.closed`). Same signing secret
— no env change, no gap. This simultaneously closes the open P0
(destination only subscribes to 3 of 6 events). If a WAF rate-limit rule
was ever created for `/api/streets/activate`, add the new path to it.

## Verification

- `wrangler pages functions build` compiles; both `api/license/activate`
and `api/streets/activate` present in the bundle
- 64/64 site lib tests, 1242/1242 frontend tests, cargo fmt + clippy
clean (pre-push gate)
- `check-file-sizes.cjs` clean (warnings only, none from this diff)

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

https://claude.ai/code/session_01U2x3UAWbQvqU2DiQL4pSK9

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
runyourempire added a commit that referenced this pull request Aug 21, 2026
…that lied (#500)

## Finishing the two hand-offs from #499

Both files were **claimed in flight by peer lanes** when the surrounding
work landed, so they were documented and deliberately left alone rather
than handing an active peer a mid-flight merge conflict. Both lanes have
since released them.

### Badge overflow

`Subscription renewed` is **20 characters** against a ~16 budget, and
the email header has no media query to save it — Gmail strips `<style>`
in several contexts, so the three header cells must fit unaided on a
320px phone. It overflowed, wrapped the row and squeezed the wordmark.
Now **`Renewal`**.

The test pinning the old string failed, which is exactly what it was
for. Rather than just swap the literal, the assertion now checks the
**budget**: it extracts whatever badge was rendered and fails if it
exceeds `BADGE_MAX_CHARS`. The next person who writes a more descriptive
badge fails *here* instead of in a customer's inbox.

**Revert-checked** — restoring `Subscription renewed` makes it fail,
naming both the offending string and its length.

### A comment that lied

`license/activate.js` said *"there is no event-id dedup store"* while
dedup has existed since **#494** — twenty lines from the `LICENSE_KV`
call that implements it. **#497** moved the file and carried the wrong
comment along with it.

The fix is deliberately **not deletion**. The guard that comment sits
above is still load-bearing, and saying so is the whole point:

- **dedup** suppresses a *redelivery of the same event id*
- **this guard** catches a genuinely *new* `invoice.paid` arriving after
a refund or dispute

Two different defences against two different failures. The comment now
says which is which, instead of denying that the other one exists.

## Verification

**212 script tests pass.** The badge guard is revert-checked in both
directions.

🤖 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