Skip to content

feat: Supporter status on the server account, kept by RevenueCat's webhook - #51

Merged
leviFrosty merged 11 commits into
mainfrom
revenuecat-link
Oct 18, 2026
Merged

leviFrosty merged 11 commits into
mainfrom
revenuecat-link

Conversation

@leviFrosty

@leviFrosty leviFrosty commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Summary

The server account now decides whether a User is a Supporter (witness-work ADR 0029). The app links the RevenueCat customer it's logged in as to its account. ww-api stores only what RevenueCat's REST API says about that customer, and RevenueCat's webhook keeps it current. Notes Import (bearer) and route planning now answer from the account, not from an id the client sends.

Stacked on #50 (which is on #49). This diff covers only the Supporter link. App PR: leviFrosty/witness-work#780. ADR: witness-work docs/adr/0029-supporter-status-on-the-server-account.md.

Routes

Route What
POST /accounts/v1/revenuecat-customers Bearer, { appUserId }. Reads the customer from RevenueCat, links it, and answers { ok, supporter, expiresAt }
GET /accounts/v1/supporter Bearer. { ok, supporter, expiresAt, customers }
POST /revenuecat/v1/webhook RevenueCat's servers only. Checks Authorization: Bearer <REVENUECAT_WEBHOOK_AUTHORIZATION> in constant time
POST /route-planning/optimize Now bearer { start, stops }: the account's status and limits, and suspended accounts are refused. The legacy accountId body still works while auth:legacy is on, then gets 426

Bearer Notes Import drops revenueCatAppUserId (ignored if sent) and uses isAccountSupporter. Legacy Notes Import and legacy route planning work exactly as before.

How it works

  • Link: the server reads the customer and stores the answer under ADR 0014's rule. A customer RevenueCat has never seen isn't linked. Each account links at most 5 customers and each customer at most 5 accounts; past either cap, the least recently linked link goes. Repeating a link reads RevenueCat again.
  • Webhook:
    • The event is read only for its id and the customers it names (app_user_id, original_app_user_id, aliases, transferred_from, transferred_to).
    • Each named customer that an account links is read again from RevenueCat. Customers nobody linked are ignored and not stored.
    • An older read never overwrites a newer one.
    • Idempotent by event id (kept 30 days). Any RevenueCat failure returns 503, so RevenueCat retries.
  • Answering: a record less than 24 hours old is used until its expiry. An older record is read again first, and if that read fails, both routes fail closed: route planning returns 503 supporter_check_failed, Notes Import counts the User as free.
  • Storage: D1 migration 0003_revenuecat-customers adds revenuecat_customers, account_revenuecat_customers and revenuecat_webhook_events. The hourly cron prunes old event ids and customers no account links.
  • Privacy:
    • Nothing logs app user ids, account ids, tokens or webhook bodies; the webhook logs only the event type and a count.
    • Sentry's Authorization drop covers the webhook too.
    • RevenueCat reads use the fetch captured before Sentry wraps it, so no id ends up in spans.
  • Verify loop: REVENUECAT_API_URL, honored on development workers only, points reads at a local fake. The e2e suite serves that fake.

Threat model

RevenueCat's SDK treats the app user id as the customer's capability. To claim someone else's purchases you'd need their id (122 random bits, never shown or logged) plus an attested session, and the caps stop a leaked id spreading to many accounts. We considered logging in to RevenueCat as the server account id and rejected it: moving purchases needs a RevenueCat transfer, which would strip Supporter status from devices still on older app versions. Proof from store-signed transactions is deferred; ADR 0029 §2 explains why.

Test plan

  • pnpm test: 1089 passed. New: src/accounts/supporter.test.ts (26), src/routePlanning/route.bearer.test.ts (9); Notes Import and settlement tests updated
  • pnpm exec tsc --noEmit, wrangler deploy --dry-run, and wrangler deploy --env dev --dry-run
  • Verify loop: dev.mjs up with REVENUECAT_API_URL, doctor ok, pnpm test:e2e 82 passed (3 token-gated skipped), including the new src/e2e/supporter.e2e.test.ts
  • Dev worker, deployed with the migration applied to ww-accounts-dev:
    • A real RevenueCat sandbox customer (WitnessWork Dev project) was linked.
    • A dashboard promo grant reached the worker through the new webhook integration, and the account became a Supporter without re-linking.
    • Revoking the grant sent more webhooks, and the account stopped being a Supporter.
    • The dashboard's test event got a 200.
    • wrangler tail showed only {type, customers} with the Authorization header redacted.

Deploy notes

  • The tag deploy applies migration 0003 to ww-accounts before it deploys.
  • REVENUECAT_WEBHOOK_AUTHORIZATION is already set on prod and dev. Copies are in the Agents vault as "ww-api RevenueCat webhook secret (prod)" and "(dev)".
  • After prod ships: add the production webhook integration in RevenueCat's WitnessWork project. The steps are in AGENTS.md → Supporter status (RevenueCat).

@leviFrosty

Copy link
Copy Markdown
Owner Author

Review pass (security, correctness, compatibility, privacy). Fixes pushed to this branch:

  • 90e91c8 fix(revenuecat): RevenueCat reads had no deadline, so a hung RevenueCat held route planning, Notes Import kickoff, links and webhook deliveries open. Every read now gives up after 5 s (headers and body), legacy checks included. The webhook reads the customers an event names all at once.
  • 07e2389 fix(accounts): a renewing Supporter was counted as free, and refused route planning, from their expiry until the record was a day old whenever the RENEWAL webhook was late. A record whose end passed since it was read is now read once more. Stale records are read in parallel, and one that finds a Supporter answers even if another read failed. The link batch also puts the customer row back, so the hourly prune can't race it into an FK error.
  • 8498af0 fix(accounts): every link reads RevenueCat at up to 600 a minute per IP, enough for one account to spend the worker's shared RevenueCat rate limit. Links now also count against the register tier (60 a minute) per account.
  • 484876b e2e: a renewal at its end with no webhook, and a hung RevenueCat (503 after about 5 s).
  • 7cc4bf2, f8af60c: CI was red. The 1 MiB blob test hit its 5 s timeout twice, so the link-limit test now uses a small limiter, and the blob test gets 15 s.

Verified: pnpm test 1095 passed, tsc, both dry-runs, the verify loop with pnpm test:e2e (84 passed, 3 token-gated skipped), and CI green. The dev worker was redeployed from this branch (no new migration; 0003 was already applied to ww-accounts-dev). Smoke results: the webhook gives 401 without the secret, and 200 then duplicate with it. The Android device link passed against this branch; details are on leviFrosty/witness-work#780.

… webhook

Links the RevenueCat customer an app is logged in as to its account
(POST /accounts/v1/revenuecat-customers), stores only what RevenueCat's
REST API says about it, and keeps that current from RevenueCat's webhook
(POST /revenuecat/v1/webhook), which reads every linked customer an event
names again. isAccountSupporter answers from those records, so bearer
Notes Import no longer takes revenueCatAppUserId (witness-work ADR 0029).
A bearer request is the token's account: suspended accounts are refused,
Supporter status is the account's, and limits are per account. The legacy
body with accountId keeps asking RevenueCat while auth:legacy is on and is
426 upgrade_required once it's off.
REVENUECAT_API_URL (development workers only) points account reads at a
local fake that pnpm test:e2e serves; the verify launcher sets a synthetic
webhook secret.
No RevenueCat read had a deadline, so a hung RevenueCat held route
planning, Notes Import's kickoff, links, and webhook deliveries open. Every
read now fails as unreachable after 5 seconds, headers and body together,
and the webhook reads the customers an event names all at once.
A subscription renews at its end, but the stored record kept answering
'expired' until its read was a day old, so a renewing Supporter counted as
free (Notes Import) and was refused route planning whenever the RENEWAL
webhook came late or not at all. A record whose end passed since it was
read is now read once more before it answers.

Stale records are read together, and one that finds a Supporter answers
even if another read failed. Linking puts the customer row back inside its
batch, so the hourly prune can't delete it between the read and the link.
Every link reads RevenueCat, and the read tier allowed 600 a minute per IP,
enough for one account to spend the worker's shared RevenueCat rate limit
and fail everyone's Supporter checks. Links now also count against the
register tier (60 a minute) per account.
Sixty real links per run made the suite slow enough on CI that the
1 MiB blob test, running alongside, passed its 5-second timeout.
It takes 2 to 4 seconds on CI and failed the 5-second default twice.
@leviFrosty
leviFrosty changed the base branch from account-activity to main October 18, 2026 07:19
@leviFrosty
leviFrosty merged commit 67f12a0 into main Oct 18, 2026
1 of 2 checks passed
leviFrosty added a commit that referenced this pull request Oct 18, 2026
- feat: Supporter status on the server account, kept by RevenueCat webhooks (#51)
- feat: account activity log and signed-in devices (#50)
- feat: attested account sessions with bearer tokens for Buddies and Notes Import (#49)
- feat: move attestation to /attestation/v1 and add readable link paths (#47)
- feat: use readable payload and signature names in signed envelopes (#46)
- docs: trim one-time setup steps from the agent guide (#48)
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