Compare commits

..

4 Commits

Author SHA1 Message Date
null 0b92632973 docs(manual): batch 2 - RevenueCat 10.13.0 + HMAC-SHA256 webhook in tree + gitignore entry 2026-07-11 16:23:16 -05:00
null c60fa958a5 fix(qa): qa_push.js picks newest FCM token, not docs[0]
After an APK reinstall a device registers a fresh FCM token alongside stale
ones (~33h). The smoke harness grabbed docs[0] (unordered) and could hit a
stale token -> FCM 'Requested entity was not found' -> false smoke FAIL.
Now sort fcmTokens by updatedAt and try newest-first across all tokens,
skipping dead ones, mirroring the app's sendToUser + pruneTokens behavior.

Also file two R31 QA-env observations to Future.md (add the RevenueCat test_
key to local.properties so QA can drive the live paywall; FCM token churn note).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:06:07 -05:00
null 89d06eeb7d fix(billing): correct RevenueCat webhook auth to HMAC-SHA256 + enable export
The webhook verified an Ed25519 signature in an X-Signature header, but RevenueCat
offers no public-key signing — it sends HMAC-SHA256 in X-RevenueCat-Webhook-Signature
(t=<ts>,v1=<hex>) computed over "<ts>.<rawBody>". As written, every real event would
have 401'd and premium would never sync for the partner.

- Rewrite verification to HMAC-SHA256 with a +/-5-min timestamp replay guard and a
  constant-time compare; extract a pure verifyWebhookSignature() for unit testing.
- Rename secret REVENUECAT_SIGNING_KEY -> REVENUECAT_WEBHOOK_SECRET (it is an HMAC
  secret, not an Ed25519 key). Never deployed, so no migration.
- Uncomment the export in index.ts (deploy still gated on seeding the secret).
- Add revenueCatWebhook.test.ts: valid / tampered / wrong-secret / missing / stale.
- Reconcile Future.md + Engineering_Reference_Manual.md to the real scheme.

Verified against the live account: the entitlement identifier is now closer_premium,
so events match entitlementLogic. Build + 80 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:43:59 -05:00
null 48217dd716 chore(security): gitignore revenucat_secret_api.md (holds a RevenueCat secret key)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:43:43 -05:00
14 changed files with 446 additions and 260 deletions

1
.gitignore vendored
View File

@ -97,3 +97,4 @@ relationship-app.code-workspace
qa/README.md
qa/fixtures.local.md
qa/*.local.md
revenucat_secret_api.md

View File

@ -1,145 +1,73 @@
# Engineering Reference Manual Review Plan — Phase 3 (2026-07-08)
# Engineering Reference Manual Review Plan — Phase 4 (2026-07-11)
**Status: ✅ COMPLETE** — all 13 batches reviewed, 11 issues found and fixed, committed individually, and pushed to `dev`.
**Status: 🟡 IN PROGRESS** — covering 17 commits since Phase 3 completed (`c5ead4cb`).
Triggered by `_null` after the Phase 2 sync (`213cfddb`) + review pass (`d31b58d2`).
Goal: cross-check every claim in the manual against the live repo + `firestore.rules`
+ `functions/src/` + iOS source + Android source, batch by batch. No skipped sections.
Triggered by `_null` after Phase 3 completion to catch manual drift from the
last 17 commits on `dev`. Phase 3 was a full evidence-first sweep; Phase 4 is a
targeted re-review scoped to commits that materially changed code the manual
references (billing, repo layout, dead-file cleanup, FCM QA tooling, copy
catalog start).
## Approach
- **13 batches**, each scoped to one major section of the manual.
- **Evidence-first.** For every factual claim (file path, function name, collection
name, cron expression, preference field, threshold, AAD string), verify against
the live source.
- **One commit per batch** per the standing git discipline (one batch = one commit = one push).
- **Log findings to this file** as each batch completes.
- **Anchors + TOC** verified at the end (Batch 13), not per-batch, to avoid churn.
- **6 batches**, scoped to a category of changes from the last 17 commits.
- **Evidence-first.** For every claim in the manual, re-verify against the
live source.
- **One commit per batch** per the standing git discipline (one batch = one
commit = one push). Each commit must be pushed before the next batch starts.
- **No commits for batches with no findings** — log a no-op to this file only.
## Batches
### Batch 1 — Front matter + System overview + Repository layout (Android + iOS + Cloud Functions + Shared configuration)
- Read: `## How to use this document` through `### Shared configuration` (lines ~1-260).
- Verify all directory paths under `app/src/main/java/app/closer/`,
`functions/src/`, `iphone/Closer/`, `res/drawable-*`, Hilt module list.
- Cross-check Android package list against the live `find app/src/main/java/app/closer -maxdepth 3 -type d`.
- Cross-check iOS file list against `ls iphone/Closer/...`.
- Cross-check Cloud Functions tree against `find functions/src -name "*.ts"`.
- Flag missing or renamed directories/files.
### Batch 1 — Repository layout: dead-file sweep (21392ec2) + server/ removal (42fba641)
- Verify `## Repository layout` (Android + iOS + Cloud Functions + Shared) doesn't
reference removed files (e.g. `NotificationHelper.kt`, `NotificationPermissionHelper.kt`,
`FakeQuestionRepository.kt`, `QuestionJsonParser.kt`, `Entitlement.kt`).
- Verify no references to the removed `server/` Express backend (was Tier 3
cleanup, kept the dist that the actual Cloud Functions use).
- 4103c433 already dropped "dead references" — confirm nothing was missed.
- Commit if any manual updates needed.
### Batch 2 — Authentication + pairing + couples doc model + rate limit + recovery
- Read: `## Authentication and pairing flow` (lines ~270-370).
- Verify auth files in `data/remote/FirebaseAuthDataSource.kt`,
`data/repository/FirebaseAuthRepositoryImpl.kt`, `domain/repository/AuthRepository.kt`.
- Verify `couples/{coupleId}` field list against `acceptInviteCallable.ts` + the
domain `Couple.kt` model.
- Verify rate-limit env values against `AuthRateLimiter.kt`.
- Verify recovery phrase flow against `RecoveryKeyManager.kt` + `CoupleEncryptionManager.kt`.
### Batch 2 — Billing: RevenueCat SDK 10.12.0 → 10.13.0 (8cb23ea2) + webhook HMAC-SHA256 fix (89d06eeb) + secret key gitignore (48217dd7)
- Verify `## Billing` (RevenueCat integration + Webhook + Premium-gated features):
- SDK version now 10.13.0 (was 10.12.0, was 8.20.0 pre-Phase 3).
- Webhook auth: actual auth scheme is HMAC-SHA256, not the prior scheme.
- `revenucat_secret_api.md` is now `.gitignore`d — does the manual's
"Files that must never be committed" section need this added?
- Commit if any manual updates needed.
### Batch 3 — E2EE model (versions, Argon2id, Tink, sealed-answer, ECIES, single-device, iOS bridge)
- Read: `## End-to-end encryption model` (lines ~370-475).
- Verify each `Encryption versions` row against `firestore.rules` regex helpers.
- Verify the Argon2id v1.3 parameter names + the AAD strings against the actual
Kotlin/Swift code.
- Verify the `keybox:v1:` wire format against `Keybox.swift` + `ReleaseKeyEncryptor.kt`.
- Verify the `wrapReleaseKeyCallable` path against the function source.
### Batch 3 — Cloud Functions: webhook handler source-of-truth update (89d06eeb)
- Verify the Cloud Functions section's description of `revenueCatWebhook`
matches the new auth scheme (HMAC-SHA256 signature verification).
- Verify any other Cloud Functions section claims that the webhook handler
is the only billing touch-point (Tier 3 server/ removal is a reminder).
- Commit if any manual updates needed.
### Batch 4 — Daily question lifecycle + Reveal flow + Thread questions
- Read: `## Daily question lifecycle` (lines ~480-575).
- Verify `assignDailyQuestion` schedule + the deterministic picker
(DOW → mode map, daily_fun_mc exclusion) against
`functions/src/questions/assignDailyQuestion.ts` + `DailyModeResolver.kt`.
- Verify the schemaVersion 2 reveal flow + the read-gated `secure/payload` subdoc
against `firestore.rules` (the `isCoupleAnswerCreate` + `isEncryptedPayloadUpdate`
helpers).
- Verify schemaVersion 3 sealed flow against `SealedAnswerEncryptor.kt` +
`isSealedAnswerCreate` rules.
- Verify thread questions path against `isSealedThreadAnswerCreate` + the model.
### Batch 4 — Engineering conventions: copy catalog start (51efff3f, 23cd2139, 2c3c2df8) + string resource cleanup (59e03369)
- Verify `### Naming` / `### Error handling` / any UI convention sections
reflect the new `CloserCopy` catalog pattern.
- Verify no references to the 55 purged string resources.
- Verify no references to removed `core/analytics`, `core/billing`,
`core/navigation`, `core/notifications` packages or their submodules.
- Commit if any manual updates needed.
### Batch 5 — Firestore security rules
- Read: `## Firestore security rules` (lines ~720-810).
- Line-by-line verify every per-collection note in the manual against `firestore.rules`.
- Cross-check helper-function names (`coupleEncryptionEnabled`, `isCiphertext`,
`isUpdatingCoupleRhythm`, `isUpdatingRecoveryWrap`, `isCoupleAnswerCreate`,
`isEncryptedPayloadUpdate`, `isSealedAnswerCreate`, `isSealedAnswerUpdate`,
`isSealedThreadAnswerCreate`, `isSealedThreadAnswerUpdate`).
- Note any rules in the file not documented, or documented rules not in file.
### Batch 5 — Notifications / FCM: qa_push.js token-selection fix (c60fa958)
- Verify `## Notifications` (FCM + TokenRegistrar) doesn't claim a different
token-selection strategy than the live code.
- Manual may or may not reference the QA script — check `qa/qa_push.js`.
- Commit if any manual updates needed.
### Batch 6 — Cloud Functions
- Read: `## Cloud Functions` (lines ~815-845).
- Verify function triggers, handlers, schedules, module responsibilities.
- Cross-check `functions/src/` tree (already done in Batch 1 but verify exports).
- Cross-check Schedule table against each function's source.
### Batch 6 — Final pass: TOC + anchor integrity
- Re-run anchor/TOC verification.
- One final commit if any cleanup needed.
### Batch 7 — Billing
- Read: `## Billing` (lines ~850-920).
- Verify RevenueCat integration files, entitlement checkers, webhook handler,
Purchases.logIn identity link.
- Cross-check the "Gated features" list against the actual ViewModels that
inject `CouplePremiumChecker` / `EntitlementChecker`.
### Batch 8 — Notifications
- Read: `## Notifications` (lines ~925-1020).
- Verify FCM setup + tokenRegistrar + quiet hours.
- Verify each reminder callable's actual schedule + dedupe.
- Verify game-push semantics against `onGameSessionUpdate.ts` + the
part-finished triggers.
- Verify the foreground banner surface + the deep-link routing table against
`PartnerNotificationManager.kt`.
### Batch 9 — iOS-specific notes
- Read: `## iOS-specific notes` (lines ~1023-1080).
- Verify Architecture, CloserTheme tokens, XcodeGen setup, build commands,
E2EE gap status, CryptoKit guidance.
- Cross-check `iphone/Closer/Theme/CloserTheme.swift` for the actual color values.
### Batch 10 — Build and release
- Read: `## Build and release` (lines ~1080-1200).
- Verify Android module/package, biometric recovery phrase reveal, required
build secrets, Gradle config, ProGuard rules.
- Cross-check the iOS build commands against the actual XcodeGen spec.
### Batch 11 — Engineering conventions
- Read: `## Engineering conventions` (lines ~1200-1280).
- Verify the architectural rules (Clean layers, package boundaries, what
lives where) against the actual code.
- Verify the "what NOT to do" list (e.g. raw Firestore listeners, no
`FirebaseAuth.getInstance()` in VMs) is still accurate.
- Check the version drift (build.gradle vs HISTORY.md) and ProGuard note.
### Batch 12 — Known landmines and recent fixes
- Read: `## Known landmines and recent fixes` (lines ~1280-1450).
- Verify EVERY landmine still applies:
- Does the file/component it cites still exist?
- Is the fix still in place?
- Is the "Re-introduction risk" description still accurate?
- Look for any landmine whose fix is now in a different file.
### Batch 13 — Where to look first
- Read: `## Where to look first` (lines ~1450-end).
- Verify every file path still exists.
- Verify the "what to read first for a new area" list still applies to the
current code.
### Final pass
- TOC integrity + every anchor resolves.
- One final commit if any TOC/anchor cleanup is needed.
## Phase 3 results (2026-07-08)
## Phase 4 results
| Batch | Section | Issues found | Commit |
|---|---|---|---|
| 1 | Front matter + System overview + Repository layout | iOS platform row said "E2EE cross-compatibility not yet implemented" (contradicts the iOS E2EE section below) | `ef5a2331` |
| 2 | Auth + pairing + couples doc model + rate limit + recovery | Recovery phrase "256-word list" - actual list is 248 entries (`WORDLIST` array has 248; iOS wordlist is 247 lines = 248 entries with no trailing newline) | `1b36eba6` |
| 3 | E2EE model (versions, Argon2id, Tink, sealed-answer, ECIES) | Argon2id memory "46080 KiB" - actual constant is `46 * 1024 = 47104` KiB; iOS-side docstring was already correct | `0af6f24a` |
| 4 | Daily question lifecycle + Reveal flow + Thread questions | DOW mode table was partial (missed Saturday `mode_weekend_side_quest` - Side Quest Saturday) | `5e292e66` |
| 5 | Firestore security rules | Missing 3 helper functions (`otherCoupleMember`, `partnerReflectedDate`, `isPublicKey`); wrong regex for `isPublicKey`; releaseKeys per-collection enforcement said "readable only by recipient" but the rule also allows the sender (for the idempotency existence-check) | `5f7c785f` |
| 6 | Cloud Functions | Handler types table mixed onDocumentCreated / onDocumentUpdated / onDocumentWritten into one row; onEntitlementChanged was in a separate "Firestore onUpdate" row but is actually onDocumentWritten | `aa356148` |
| 7 | Billing | iOS "DefaultEntitlementChecker actor" doesn't exist (the real class is `BillingService`); gated-features list was wrong (4 of 7 items were not actually gated; chat-media gate was missing) | `ceb98f4a` |
| 8 | Notifications | "How to gate a new feature" sub-section got merged into the previous paragraph by Batch 7; game-push flag claim was wrong (no `notificationsSent` map - 4 separate Timestamp fields) | `29cc39aa` |
| 9 | iOS-specific notes | Tink template name "SHA128" was a typo - actual is `ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM` | `9330c0c4` |
| 10 | Build and release | RevenueCat version 8.20.0 (stale from v0.2.1) - actual is 10.12.0; iOS purchases-ios 5.x; Glance 1.1.1 missing from the Gradle dependency list | `22c816ad` |
| 11 | Engineering conventions | (no issues - every claim verified) | `b3725463` |
| 12 | Known landmines and recent fixes | A-201 landmine heading (`### A-201 - ...`) got dropped during the Phase 2 sync - the body content was there but visually merged with the previous landmine's tail | `80751f82` |
| 13 | Where to look first | (no issues - all 16 file paths resolve, acceptInviteCallable encryptionVersion=2 verified) | `c5ead4cb` |
| Final | TOC + anchor integrity | 30/30 anchors resolve, 0 broken TOC links | (this commit) |
| 1 | Repository layout: dead-file sweep + server/ removal | None — 4103c433 already covered all the dead-file references; the `core/notifications/`, `core/navigation/`, `core/analytics/`, `core/billing/` directories still exist and hold live code (TokenRegistrar, AppMessagingService, AppNavigation, etc.) so the line-963 and line-1021 references are still accurate. No stale `server/` references remain. | (no-op) |
| 2 | Billing: RevenueCat SDK + webhook + secret key | (1) Cloud Functions tree (line 203) still said "Ed25519 signature verify (NOT exported / not deployed)" — fixed to "HMAC-SHA256 signature verify (exported, deploy gated on REVENUECAT_WEBHOOK_SECRET)". (2) `purchases:10.12.0` on Android (line 869) → `purchases:10.13.0`. (3) `RevenueCat 10.12.0` in Gradle dep list (line 1134) → `RevenueCat 10.13.0`. (4) `revenucat_secret_api.md` added to the "Files that must never be committed" representative list (it was added to .gitignore in 48217dd7 but the manual didn't list it). | (this commit) |
| 3 | Cloud Functions: webhook handler | TBD | TBD |
| 4 | Engineering conventions: copy catalog + string resource cleanup | TBD | TBD |
| 5 | Notifications / FCM: qa_push.js | TBD | TBD |
| 6 | TOC + anchor integrity | TBD | TBD |

View File

@ -83,6 +83,10 @@ tokens R16, add-FAB `Color(0xFFB98AF4)`→`primary` R18.)_
## QA
### From R31 (2026-07-09) — QA-environment observations (not app defects)
- **Add the RevenueCat `test_` SDK key to `local.properties` on the QA emulators.** The paywall currently can't load real offerings there (`RC_API_KEY` absent → `InvalidCredentialsError` → graceful "Couldn't load plans / Try again"). Now that RevenueCat is configured (Test Store + `closer_premium` entitlement, corrected this cycle), seeding the `test_…` key would let QA drive the *live* paywall (plan pills, purchase flow via the Test Store) instead of only the gate + the error state. The gate itself (free → Paywall) is verified; this unlocks the money-adjacent surface short of a real device.
- Minor: reinstalling the debug APK rotates the FCM token; the app re-registers a fresh one on foreground but stale tokens linger for ~33h until `pruneTokens` clears them on a real send. `qa/qa_push.js` now selects the newest token (was `docs[0]`), so the entrypoint smoke no longer false-FAILs after a reinstall.
### From R29 games review (2026-07-07) — reconciled 2026-07-08 after R30 (C1C4) + the functions v2 deploy
**✅ Shipped since filed (R30 batches C1C4 + `f2321d35`; server-side pieces live as of the 2026-07-08 deploy):**
@ -195,14 +199,17 @@ no `auth.user().onDelete`). Deploy-day learnings + deferred items:
nodejs24 was evaluated and rejected: **2nd-gen only**, and the runtime is codebase-wide (gen-1 `onUserDelete`
blocks it). **Next forced bump:** Node 22 is deprecated 2027-04-30 / decommissioned 2027-10-31 — by then either
nodejs24 supports gen 1, or `onUserDelete` needs migrating off the gen-1 auth trigger first.
- **RevenueCat webhook re-enable (when billing is set up).** RevenueCat has no account yet, so the migrated v2
`revenueCatWebhook` is **not deployed** — its export is commented out in `functions/src/index.ts` because
`defineSecret('REVENUECAT_SIGNING_KEY')` is validated codebase-wide at deploy (a missing secret fails *every*
function's deploy, even with `--only`). To enable: ① create the RevenueCat project + webhook, ② seed the real
Ed25519 public key — `firebase functions:secrets:set REVENUECAT_SIGNING_KEY`, ③ uncomment the export,
**delete the old dormant v1 `revenueCatWebhook` first** (still deployed; same-name v1→v2 is blocked
in-place), ⑤ `firebase deploy --only functions:revenueCatWebhook`, ⑥ point RevenueCat at the function URL and
send a test event.
- **RevenueCat webhook deploy (gated on the signing secret).** The v2 `revenueCatWebhook` is code-complete
and its export is now **live** in `functions/src/index.ts`, but it is **not deployed yet** — deploy waits on
the secret. Auth is **HMAC-SHA256** (RevenueCat's webhook signature verification): the handler reads
`X-RevenueCat-Webhook-Signature: t=<ts>,v1=<hex>` and verifies `HMAC-SHA256("<ts>.<rawBody>")` against
`REVENUECAT_WEBHOOK_SECRET`, constant-time, with a ±5-min timestamp replay guard. Because that
`defineSecret` is validated codebase-wide at deploy (a missing secret fails *every* function's deploy, even
with `--only`), the secret must exist first. To deploy: ① in the RevenueCat dashboard create the webhook +
enable signature verification, copy the signing secret, ② `firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET`,
**delete the old dormant v1 `revenueCatWebhook` first** (still deployed; same-name v1→v2 is blocked
in-place), ④ `firebase deploy --only functions:revenueCatWebhook`, ⑤ point RevenueCat at the function URL and
send a test event. (The `closer_premium` entitlement already exists in the RevenueCat project.)
## Roadmap — features & strategy (consolidated from the old `FUTURE.md`, 2026-06-28)

View File

@ -200,7 +200,7 @@ functions/src/
├── log.ts # Shared `logger` + `redactToken` (B6d)
├── index.ts # Admin SDK init + explicit re-exports (no glob imports)
├── billing/
│ ├── revenueCatWebhook.ts # HTTPS webhook - Ed25519 signature verify (NOT exported / not deployed - see Billing)
│ ├── revenueCatWebhook.ts # HTTPS webhook - HMAC-SHA256 signature verify (exported, deploy gated on REVENUECAT_WEBHOOK_SECRET - see Billing)
│ ├── entitlementLogic.ts # Idempotent entitlement event handlers
│ ├── entitlementLogic.test.ts # Vitest unit tests
│ ├── syncEntitlement.ts # Callable - forced re-sync from client
@ -843,7 +843,7 @@ Every function module follows the same shape:
`revenueCatWebhook` processes the entitlement write (`applyEntitlementEvent`) **before** returning the HTTP 200 acknowledgement. If processing fails, the function returns HTTP 500 so RevenueCat will retry. This changed from an earlier implementation that acked 200 immediately and silently dropped failures. `applyEntitlementEvent` is idempotent, so retries are safe. `entitlement_events/{eventId}` remains the idempotency marker.
**The webhook handler is currently NOT exported from `index.ts` and is therefore not deployed.** RevenueCat has no project yet, so exporting it would force a `REVENUECAT_SIGNING_KEY` Secret Manager entry to validate codebase-wide (a missing secret fails *every* function's deploy). To enable: create the RevenueCat project, seed the real Ed25519 public key (`firebase functions:secrets:set REVENUECAT_SIGNING_KEY`), uncomment the export, delete the old dormant v1 webhook (same-name v1->v2 is blocked in-place), and deploy. See `functions/src/index.ts` for the precise comments. The webhook handler does not currently use a dead-letter queue. **Risk**: repeated 500s could still lose events if RevenueCat gives up. The mitigation today is the idempotency marker: re-running the webhook for a missing event would dedup on event ID. A future fix should add a Cloud Tasks-based retry or a dead-letter `entitlement_events_failed/` collection.
**The webhook handler is now exported from `index.ts` but is NOT deployed yet — deploy is gated on the signing secret.** The export binds `defineSecret('REVENUECAT_WEBHOOK_SECRET')`, validated codebase-wide at deploy (a missing secret fails *every* function's deploy). To deploy: in the RevenueCat dashboard create the webhook and enable signature verification, copy the signing secret, `firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET`, delete the old dormant v1 webhook (same-name v1->v2 is blocked in-place), and deploy. See `functions/src/index.ts` for the precise comments. The webhook handler does not currently use a dead-letter queue. **Risk**: repeated 500s could still lose events if RevenueCat gives up. The mitigation today is the idempotency marker: re-running the webhook for a missing event would dedup on event ID. A future fix should add a Cloud Tasks-based retry or a dead-letter `entitlement_events_failed/` collection.
### Schedule
@ -866,7 +866,7 @@ aggregateOutcomeStats every 24 hours (default
### RevenueCat integration
Both clients use the RevenueCat native SDK (`purchases:10.12.0` on Android, `purchases-ios:5.x` via SPM - `from: 5.0.0` in `Package.swift`). The SDK handles the platform billing surface (Google Play Billing on Android, StoreKit on iOS) and exposes a normalized `CustomerInfo` API.
Both clients use the RevenueCat native SDK (`purchases:10.13.0` on Android, `purchases-ios:5.x` via SPM - `from: 5.0.0` in `Package.swift`). The SDK handles the platform billing surface (Google Play Billing on Android, StoreKit on iOS) and exposes a normalized `CustomerInfo` API.
The Android app reads the API key from `BuildConfig.RC_API_KEY`, which is sourced from `local.properties` or the `RC_API_KEY` env var. A release build with a missing or placeholder key **fails fast** with a `GradleException` from `app/build.gradle.kts` - there is a doFirst guard on every release assemble/bundle task.
@ -902,12 +902,12 @@ Callers collect `isPremium()` reactively rather than caching a one-time snapshot
`functions/src/billing/revenueCatWebhook.ts`:
- **Path**: HTTPS, POST only. GETs return 405.
- **Auth**: Ed25519 signature verification. `REVENUECAT_SIGNING_KEY` env var holds the base64-encoded DER/SPKI public key. Missing key → 500 (config error). Invalid/missing signature → 401.
- **Auth**: HMAC-SHA256 signature verification (RevenueCat's webhook signing — there is no Ed25519/public-key option). RevenueCat sends `X-RevenueCat-Webhook-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>`; the handler recomputes `HMAC-SHA256("<t>.<rawBody>")` with `REVENUECAT_WEBHOOK_SECRET` (the integration's signing secret), constant-time-compares against `v1`, and rejects timestamps outside a ±5-min window (replay guard). Missing secret → 500 (config error). Invalid/missing signature or stale timestamp → 401.
- **Body**: RevenueCat event payload. Malformed payload → 400.
- **Flow**: `applyEntitlementEvent(event)` runs **before** the HTTP 200 acknowledgement. If it throws, the function returns HTTP 500 so RevenueCat retries. The function is idempotent, so retries are safe.
- **Idempotency**: `entitlement_events/{eventId}` records processed events. Re-delivered events are dropped.
**Currently NOT deployed** -- the export is commented out in `functions/src/index.ts`. See [Webhook reliability](#webhook-reliability) for the enable steps.
**Not deployed yet** -- the export is live in `functions/src/index.ts` but deploy is gated on seeding `REVENUECAT_WEBHOOK_SECRET`. See [Webhook reliability](#webhook-reliability) for the deploy steps.
### Premium-gated features and gate pattern
@ -1131,7 +1131,7 @@ The iOS CryptoKit implementation follows these rules of parity with Android:
- DataStore 1.1.2
- Glance 1.1.1 (Today widget)
- Firebase BoM 33.8.0 (auth, firestore, messaging, config, analytics, crashlytics, appcheck, appcheck-playintegrity, storage, functions)
- RevenueCat 10.12.0
- RevenueCat 10.13.0
- Tink 1.13.0, BouncyCastle 1.78.1
- Play Integrity 1.4.0
- Biometric 1.1.0
@ -1189,6 +1189,11 @@ BUILD_SUMMARY.md
SCRIPTS.md
.learnings/
.kotlin/
# Local-only QA fixtures (test credentials, secrets)
qa/README.md
qa/fixtures.local.md
qa/*.local.md
revenucat_secret_api.md # holds a RevenueCat secret key (added 2026-07-09 in 48217dd7)
```
> **Note (2026-06): the gitignore list uses `FUTURE.md` (uppercase) but the tracked file is `Future.md` (mixed case).** Linux filesystems are case-sensitive so these are different paths; the gitignore does not actually block `Future.md`. The ClaudeQA docs (`ClaudeQAPlan.md`, `ClaudeReport.md`, `ClaudeQACoverage.md`, `ClaudeBrandingReview.md`, `ClaudeiOSPlan.md`) and `Future.md` are explicitly tracked. If you create new top-level docs and want them gitignored, either match the existing case in `.gitignore` (`Future.md`) or pick a case and use it consistently. Do not block `Future.md` and then create `future.md` thinking you're safe. **(2026-06-28: the legacy uppercase

View File

@ -33,7 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.revenueCatWebhook = void 0;
exports.AuthError = exports.ConfigError = exports.revenueCatWebhook = void 0;
exports.verifyWebhookSignature = verifyWebhookSignature;
const crypto = __importStar(require("crypto"));
const https_1 = require("firebase-functions/v2/https");
const params_1 = require("firebase-functions/params");
@ -45,26 +46,34 @@ const log_1 = require("../log");
* Path: POST /revenueCatWebhook (registered as an HTTPS function)
* Triggered by RevenueCat server-to-server events.
*
* Authentication:
* - Verifies the Ed25519 signature in the X-Signature request header.
* - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519
* public key from your RevenueCat project dashboard. It is bound as a Secret
* Manager secret (below) and injected into process.env at runtime.
* - Missing key 500 (our config error). Invalid/absent signature 401.
* Authentication HMAC-SHA256 (RevenueCat's webhook "signature verification"):
* - RevenueCat sends `X-RevenueCat-Webhook-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>`.
* - v1 = HMAC-SHA256(secret, "<t>.<raw_body>"), hex-encoded, computed over the RAW request
* body bytes exactly as received (before any JSON parsing / re-serialization).
* - REVENUECAT_WEBHOOK_SECRET is the integration's signing secret from the RevenueCat
* dashboard (Integrations Webhooks enable signature verification). It is bound as a
* Secret Manager secret (below) and injected into process.env at runtime.
* - Missing secret 500 (our config error). Missing/invalid signature, or a timestamp
* outside the tolerance window (replay guard) 401.
*
* NOTE: RevenueCat does NOT offer an Ed25519 / public-key signing scheme HMAC-SHA256 with a
* shared secret is the only cryptographic option (plus the optional static Authorization header).
*
* Processing:
* - Parses the RevenueCat event payload.
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
*/
const revenueCatSigningKey = (0, params_1.defineSecret)('REVENUECAT_SIGNING_KEY');
exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatSigningKey] }, async (req, res) => {
const revenueCatWebhookSecret = (0, params_1.defineSecret)('REVENUECAT_WEBHOOK_SECRET');
/** Reject signatures whose timestamp is further than this from now (replay protection). */
const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatWebhookSecret] }, async (req, res) => {
var _a;
if (req.method !== 'POST') {
res.status(405).json({ error: 'method_not_allowed' });
return;
}
try {
verifySignature(req);
verifyRequest(req, Date.now());
}
catch (err) {
if (err instanceof ConfigError) {
@ -96,56 +105,74 @@ exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatSigning
});
class ConfigError extends Error {
}
exports.ConfigError = ConfigError;
class AuthError extends Error {
}
exports.AuthError = AuthError;
/**
* Verifies the Ed25519 signature RevenueCat sends in the X-Signature header.
*
* Throws ConfigError when REVENUECAT_SIGNING_KEY is absent or malformed
* these are deployment problems, not auth failures.
* Throws AuthError for any missing or invalid signature these are 401s.
* Reads the secret + signature header off the request and delegates to the pure verifier.
* Throws ConfigError when our secret is unset (500); AuthError for any auth failure (401).
*/
function verifySignature(req) {
const signingKey = process.env.REVENUECAT_SIGNING_KEY;
if (!signingKey) {
throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set');
function verifyRequest(req, nowMs) {
const secret = process.env.REVENUECAT_WEBHOOK_SECRET;
if (!secret) {
throw new ConfigError('REVENUECAT_WEBHOOK_SECRET environment variable is not set');
}
// Firebase Functions expose the raw request body as req.rawBody (Buffer).
const rawBody = req.rawBody;
const sigHeader = req.headers['x-revenuecat-webhook-signature'];
const header = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader;
verifyWebhookSignature({ rawBody, header, secret, nowMs });
}
/**
* Pure, testable HMAC-SHA256 verification of a RevenueCat webhook signature.
*
* Parses `t=<unix_ts>,v1=<hex>` from the header, recomputes HMAC-SHA256 over "<t>.<rawBody>"
* with the signing secret, constant-time-compares against v1, and rejects timestamps outside
* the tolerance window. Throws AuthError on any failure (caller maps to 401).
*/
function verifyWebhookSignature(opts) {
var _a;
const { rawBody, header, secret, nowMs } = opts;
const toleranceMs = (_a = opts.toleranceMs) !== null && _a !== void 0 ? _a : SIGNATURE_TOLERANCE_MS;
if (!rawBody || rawBody.length === 0) {
throw new AuthError('request body missing');
}
const sigHeader = req.headers['x-signature'];
const signature = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader;
if (!signature) {
throw new AuthError('x-signature header missing');
if (!header) {
throw new AuthError('signature header missing');
}
let publicKey;
try {
publicKey = crypto.createPublicKey({
key: Buffer.from(signingKey, 'base64'),
format: 'der',
type: 'spki',
});
// Parse "t=<ts>,v1=<hex>" — order-independent, tolerant of extra fields.
let t;
let v1;
for (const part of header.split(',')) {
const seg = part.trim();
const eq = seg.indexOf('=');
if (eq < 0)
continue;
const key = seg.slice(0, eq);
const val = seg.slice(eq + 1);
if (key === 't')
t = val;
else if (key === 'v1')
v1 = val;
}
catch (_a) {
throw new ConfigError('REVENUECAT_SIGNING_KEY is malformed — expected base64-encoded DER/SPKI Ed25519 public key');
if (!t || !v1) {
throw new AuthError('signature header malformed');
}
let sigBuffer;
try {
sigBuffer = Buffer.from(signature, 'base64');
const tsMs = Number(t) * 1000;
if (!Number.isFinite(tsMs)) {
throw new AuthError('signature timestamp invalid');
}
catch (_b) {
throw new AuthError('x-signature is not valid base64');
if (Math.abs(nowMs - tsMs) > toleranceMs) {
throw new AuthError('signature timestamp outside tolerance');
}
let valid;
try {
valid = crypto.verify(null, rawBody, publicKey, sigBuffer);
}
catch (_c) {
throw new AuthError('signature verification failed');
}
if (!valid) {
// HMAC over the RAW body bytes, prefixed with "<t>." — never re-serialize the JSON.
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
// Constant-time compare. timingSafeEqual throws on length mismatch, so guard the length first.
const expectedBuf = Buffer.from(expected, 'utf8');
const providedBuf = Buffer.from(v1, 'utf8');
if (expectedBuf.length !== providedBuf.length || !crypto.timingSafeEqual(expectedBuf, providedBuf)) {
throw new AuthError('signature mismatch');
}
}

View File

@ -1 +1 @@
{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"}

View File

@ -0,0 +1,90 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
// The verifier under test is pure (crypto only), so we stub the Firebase side-effect
// imports that run at module load rather than exercising them.
jest.mock('firebase-functions/v2/https', () => ({ onRequest: jest.fn() }));
jest.mock('firebase-functions/params', () => ({ defineSecret: jest.fn(() => ({})) }));
jest.mock('../log', () => ({ logger: { log: jest.fn(), error: jest.fn(), warn: jest.fn() } }));
jest.mock('./entitlementLogic', () => ({ applyEntitlementEvent: jest.fn() }));
const crypto = __importStar(require("crypto"));
const revenueCatWebhook_1 = require("./revenueCatWebhook");
const SECRET = 'whsec_test_shared_secret';
const NOW = 1700000000000; // fixed "now" in ms
const nowSec = Math.floor(NOW / 1000);
const BODY = JSON.stringify({ event: { type: 'INITIAL_PURCHASE', app_user_id: 'uid1' } });
/** Build the header RevenueCat would send: `t=<sec>,v1=<hmac_sha256_hex of "<sec>.<body>">`. */
function sign(body, tsSec, secret = SECRET) {
const raw = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8');
const payload = Buffer.concat([Buffer.from(`${tsSec}.`, 'utf8'), raw]);
const v1 = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return `t=${tsSec},v1=${v1}`;
}
function verify(overrides = {}) {
return (0, revenueCatWebhook_1.verifyWebhookSignature)(Object.assign({ rawBody: Buffer.from(BODY), header: sign(BODY, nowSec), secret: SECRET, nowMs: NOW }, overrides));
}
describe('verifyWebhookSignature', () => {
it('accepts a valid signature', () => {
expect(() => verify()).not.toThrow();
});
it('accepts a timestamp within the tolerance window', () => {
expect(() => verify({ header: sign(BODY, nowSec - 4 * 60) })).not.toThrow();
});
it('rejects a tampered body', () => {
expect(() => verify({ rawBody: Buffer.from(BODY + ' ') })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a signature made with the wrong secret', () => {
expect(() => verify({ header: sign(BODY, nowSec, 'wrong-secret') })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a missing header', () => {
expect(() => verify({ header: undefined })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a missing body', () => {
expect(() => verify({ rawBody: undefined })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a header missing v1', () => {
expect(() => verify({ header: `t=${nowSec}` })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a stale timestamp (replay guard)', () => {
expect(() => verify({ header: sign(BODY, nowSec - 10 * 60) })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a far-future timestamp', () => {
expect(() => verify({ header: sign(BODY, nowSec + 10 * 60) })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a wrong-length signature without crashing (timingSafeEqual guard)', () => {
expect(() => verify({ header: `t=${nowSec},v1=deadbeef` })).toThrow(revenueCatWebhook_1.AuthError);
});
});
//# sourceMappingURL=revenueCatWebhook.test.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"revenueCatWebhook.test.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.test.ts"],"names":[],"mappings":"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"}

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.wrapReleaseKeyCallable = exports.onDesireSyncPartFinished = exports.onHowWellPartFinished = exports.onWheelPartFinished = exports.onThisOrThatPartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.aggregateOutcomeStats = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.onRestoreFulfilled = exports.onRestoreRequested = exports.onDateHistoryCreated = exports.onDateReflectionRevealed = exports.onDateReflectionWritten = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.onEntitlementChanged = exports.syncEntitlement = void 0;
exports.wrapReleaseKeyCallable = exports.onDesireSyncPartFinished = exports.onHowWellPartFinished = exports.onWheelPartFinished = exports.onThisOrThatPartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.aggregateOutcomeStats = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.onRestoreFulfilled = exports.onRestoreRequested = exports.onDateHistoryCreated = exports.onDateReflectionRevealed = exports.onDateReflectionWritten = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
// Global v2 options (region pin, instance cap) — MUST be imported before any function module so
// setGlobalOptions runs before the functions below are defined.
require("./options");
@ -44,12 +44,13 @@ const admin = __importStar(require("firebase-admin"));
if (admin.apps.length === 0) {
admin.initializeApp();
}
// RevenueCat is not configured yet, so the webhook stays OUT of the deployed codebase. It binds
// defineSecret('REVENUECAT_SIGNING_KEY'), and that secret is validated for the whole codebase at
// deploy time — exporting it would force a Secret Manager entry for a service that doesn't exist.
// To enable billing later: uncomment this, seed the real key
// (`firebase functions:secrets:set REVENUECAT_SIGNING_KEY`), then deploy revenueCatWebhook.
// export { revenueCatWebhook } from './billing/revenueCatWebhook'
// RevenueCat entitlement webhook. Auth = HMAC-SHA256 over the raw body (see revenueCatWebhook.ts).
// Binds defineSecret('REVENUECAT_WEBHOOK_SECRET') — the integration's signing secret from the
// RevenueCat dashboard — which is validated for the whole codebase at deploy time, so the secret
// must exist before any deploy. Seed it with:
// firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET
var revenueCatWebhook_1 = require("./billing/revenueCatWebhook");
Object.defineProperty(exports, "revenueCatWebhook", { enumerable: true, get: function () { return revenueCatWebhook_1.revenueCatWebhook; } });
var syncEntitlement_1 = require("./billing/syncEntitlement");
Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: function () { return syncEntitlement_1.syncEntitlement; } });
var onEntitlementChanged_1 = require("./billing/onEntitlementChanged");

View File

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,gGAAgG;AAChG,gEAAgE;AAChE,qBAAkB;AAClB,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,gGAAgG;AAChG,iGAAiG;AACjG,kGAAkG;AAClG,6DAA6D;AAC7D,4FAA4F;AAC5F,kEAAkE;AAClE,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,6EAA2E;AAAlE,oIAAA,wBAAwB,OAAA;AACjC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,kEAAoF;AAA3E,wHAAA,kBAAkB,OAAA;AAAE,wHAAA,kBAAkB,OAAA;AAC/C,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iEAAmE;AAA1D,0HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAMoC;AALlC,0HAAA,mBAAmB,OAAA;AACnB,+HAAA,wBAAwB,OAAA;AACxB,0HAAA,mBAAmB,OAAA;AACnB,4HAAA,qBAAqB,OAAA;AACrB,+HAAA,wBAAwB,OAAA;AAG1B,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,gGAAgG;AAChG,gEAAgE;AAChE,qBAAkB;AAClB,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,mGAAmG;AACnG,8FAA8F;AAC9F,iGAAiG;AACjG,8CAA8C;AAC9C,6DAA6D;AAC7D,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,6EAA2E;AAAlE,oIAAA,wBAAwB,OAAA;AACjC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,kEAAoF;AAA3E,wHAAA,kBAAkB,OAAA;AAAE,wHAAA,kBAAkB,OAAA;AAC/C,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iEAAmE;AAA1D,0HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAMoC;AALlC,0HAAA,mBAAmB,OAAA;AACnB,+HAAA,wBAAwB,OAAA;AACxB,0HAAA,mBAAmB,OAAA;AACnB,4HAAA,qBAAqB,OAAA;AACrB,+HAAA,wBAAwB,OAAA;AAG1B,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"}

View File

@ -0,0 +1,74 @@
// The verifier under test is pure (crypto only), so we stub the Firebase side-effect
// imports that run at module load rather than exercising them.
jest.mock('firebase-functions/v2/https', () => ({ onRequest: jest.fn() }));
jest.mock('firebase-functions/params', () => ({ defineSecret: jest.fn(() => ({})) }));
jest.mock('../log', () => ({ logger: { log: jest.fn(), error: jest.fn(), warn: jest.fn() } }));
jest.mock('./entitlementLogic', () => ({ applyEntitlementEvent: jest.fn() }));
import * as crypto from 'crypto';
import { verifyWebhookSignature, AuthError } from './revenueCatWebhook';
const SECRET = 'whsec_test_shared_secret';
const NOW = 1_700_000_000_000; // fixed "now" in ms
const nowSec = Math.floor(NOW / 1000);
const BODY = JSON.stringify({ event: { type: 'INITIAL_PURCHASE', app_user_id: 'uid1' } });
/** Build the header RevenueCat would send: `t=<sec>,v1=<hmac_sha256_hex of "<sec>.<body>">`. */
function sign(body: string | Buffer, tsSec: number, secret = SECRET): string {
const raw = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8');
const payload = Buffer.concat([Buffer.from(`${tsSec}.`, 'utf8'), raw]);
const v1 = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return `t=${tsSec},v1=${v1}`;
}
function verify(overrides: Partial<Parameters<typeof verifyWebhookSignature>[0]> = {}) {
return verifyWebhookSignature({
rawBody: Buffer.from(BODY),
header: sign(BODY, nowSec),
secret: SECRET,
nowMs: NOW,
...overrides,
});
}
describe('verifyWebhookSignature', () => {
it('accepts a valid signature', () => {
expect(() => verify()).not.toThrow();
});
it('accepts a timestamp within the tolerance window', () => {
expect(() => verify({ header: sign(BODY, nowSec - 4 * 60) })).not.toThrow();
});
it('rejects a tampered body', () => {
expect(() => verify({ rawBody: Buffer.from(BODY + ' ') })).toThrow(AuthError);
});
it('rejects a signature made with the wrong secret', () => {
expect(() => verify({ header: sign(BODY, nowSec, 'wrong-secret') })).toThrow(AuthError);
});
it('rejects a missing header', () => {
expect(() => verify({ header: undefined })).toThrow(AuthError);
});
it('rejects a missing body', () => {
expect(() => verify({ rawBody: undefined })).toThrow(AuthError);
});
it('rejects a header missing v1', () => {
expect(() => verify({ header: `t=${nowSec}` })).toThrow(AuthError);
});
it('rejects a stale timestamp (replay guard)', () => {
expect(() => verify({ header: sign(BODY, nowSec - 10 * 60) })).toThrow(AuthError);
});
it('rejects a far-future timestamp', () => {
expect(() => verify({ header: sign(BODY, nowSec + 10 * 60) })).toThrow(AuthError);
});
it('rejects a wrong-length signature without crashing (timingSafeEqual guard)', () => {
expect(() => verify({ header: `t=${nowSec},v1=deadbeef` })).toThrow(AuthError);
});
});

View File

@ -10,21 +10,30 @@ import { logger } from '../log'
* Path: POST /revenueCatWebhook (registered as an HTTPS function)
* Triggered by RevenueCat server-to-server events.
*
* Authentication:
* - Verifies the Ed25519 signature in the X-Signature request header.
* - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519
* public key from your RevenueCat project dashboard. It is bound as a Secret
* Manager secret (below) and injected into process.env at runtime.
* - Missing key 500 (our config error). Invalid/absent signature 401.
* Authentication HMAC-SHA256 (RevenueCat's webhook "signature verification"):
* - RevenueCat sends `X-RevenueCat-Webhook-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>`.
* - v1 = HMAC-SHA256(secret, "<t>.<raw_body>"), hex-encoded, computed over the RAW request
* body bytes exactly as received (before any JSON parsing / re-serialization).
* - REVENUECAT_WEBHOOK_SECRET is the integration's signing secret from the RevenueCat
* dashboard (Integrations Webhooks enable signature verification). It is bound as a
* Secret Manager secret (below) and injected into process.env at runtime.
* - Missing secret 500 (our config error). Missing/invalid signature, or a timestamp
* outside the tolerance window (replay guard) 401.
*
* NOTE: RevenueCat does NOT offer an Ed25519 / public-key signing scheme HMAC-SHA256 with a
* shared secret is the only cryptographic option (plus the optional static Authorization header).
*
* Processing:
* - Parses the RevenueCat event payload.
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
*/
const revenueCatSigningKey = defineSecret('REVENUECAT_SIGNING_KEY')
const revenueCatWebhookSecret = defineSecret('REVENUECAT_WEBHOOK_SECRET')
/** Reject signatures whose timestamp is further than this from now (replay protection). */
const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000
export const revenueCatWebhook = onRequest(
{ secrets: [revenueCatSigningKey] },
{ secrets: [revenueCatWebhookSecret] },
async (req, res) => {
if (req.method !== 'POST') {
res.status(405).json({ error: 'method_not_allowed' })
@ -32,7 +41,7 @@ export const revenueCatWebhook = onRequest(
}
try {
verifySignature(req)
verifyRequest(req, Date.now())
} catch (err) {
if (err instanceof ConfigError) {
logger.error('[revenueCatWebhook] configuration error', { error: err.message })
@ -65,62 +74,83 @@ export const revenueCatWebhook = onRequest(
}
)
class ConfigError extends Error {}
class AuthError extends Error {}
export class ConfigError extends Error {}
export class AuthError extends Error {}
/**
* Verifies the Ed25519 signature RevenueCat sends in the X-Signature header.
*
* Throws ConfigError when REVENUECAT_SIGNING_KEY is absent or malformed
* these are deployment problems, not auth failures.
* Throws AuthError for any missing or invalid signature these are 401s.
* Reads the secret + signature header off the request and delegates to the pure verifier.
* Throws ConfigError when our secret is unset (500); AuthError for any auth failure (401).
*/
function verifySignature(req: Request): void {
const signingKey = process.env.REVENUECAT_SIGNING_KEY
if (!signingKey) {
throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set')
function verifyRequest(req: Request, nowMs: number): void {
const secret = process.env.REVENUECAT_WEBHOOK_SECRET
if (!secret) {
throw new ConfigError('REVENUECAT_WEBHOOK_SECRET environment variable is not set')
}
// Firebase Functions expose the raw request body as req.rawBody (Buffer).
const rawBody = (req as unknown as { rawBody?: Buffer }).rawBody
const sigHeader = req.headers['x-revenuecat-webhook-signature']
const header = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader
verifyWebhookSignature({ rawBody, header, secret, nowMs })
}
/**
* Pure, testable HMAC-SHA256 verification of a RevenueCat webhook signature.
*
* Parses `t=<unix_ts>,v1=<hex>` from the header, recomputes HMAC-SHA256 over "<t>.<rawBody>"
* with the signing secret, constant-time-compares against v1, and rejects timestamps outside
* the tolerance window. Throws AuthError on any failure (caller maps to 401).
*/
export function verifyWebhookSignature(opts: {
rawBody: Buffer | undefined
header: string | undefined
secret: string
nowMs: number
toleranceMs?: number
}): void {
const { rawBody, header, secret, nowMs } = opts
const toleranceMs = opts.toleranceMs ?? SIGNATURE_TOLERANCE_MS
if (!rawBody || rawBody.length === 0) {
throw new AuthError('request body missing')
}
const sigHeader = req.headers['x-signature']
const signature = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader
if (!signature) {
throw new AuthError('x-signature header missing')
if (!header) {
throw new AuthError('signature header missing')
}
let publicKey: crypto.KeyObject
try {
publicKey = crypto.createPublicKey({
key: Buffer.from(signingKey, 'base64'),
format: 'der',
type: 'spki',
})
} catch {
throw new ConfigError(
'REVENUECAT_SIGNING_KEY is malformed — expected base64-encoded DER/SPKI Ed25519 public key'
)
// Parse "t=<ts>,v1=<hex>" — order-independent, tolerant of extra fields.
let t: string | undefined
let v1: string | undefined
for (const part of header.split(',')) {
const seg = part.trim()
const eq = seg.indexOf('=')
if (eq < 0) continue
const key = seg.slice(0, eq)
const val = seg.slice(eq + 1)
if (key === 't') t = val
else if (key === 'v1') v1 = val
}
if (!t || !v1) {
throw new AuthError('signature header malformed')
}
let sigBuffer: Buffer
try {
sigBuffer = Buffer.from(signature, 'base64')
} catch {
throw new AuthError('x-signature is not valid base64')
const tsMs = Number(t) * 1000
if (!Number.isFinite(tsMs)) {
throw new AuthError('signature timestamp invalid')
}
if (Math.abs(nowMs - tsMs) > toleranceMs) {
throw new AuthError('signature timestamp outside tolerance')
}
let valid: boolean
try {
valid = crypto.verify(null, rawBody, publicKey, sigBuffer)
} catch {
throw new AuthError('signature verification failed')
}
// HMAC over the RAW body bytes, prefixed with "<t>." — never re-serialize the JSON.
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody])
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex')
if (!valid) {
// Constant-time compare. timingSafeEqual throws on length mismatch, so guard the length first.
const expectedBuf = Buffer.from(expected, 'utf8')
const providedBuf = Buffer.from(v1, 'utf8')
if (expectedBuf.length !== providedBuf.length || !crypto.timingSafeEqual(expectedBuf, providedBuf)) {
throw new AuthError('signature mismatch')
}
}

View File

@ -10,12 +10,12 @@ if (admin.apps.length === 0) {
admin.initializeApp()
}
// RevenueCat is not configured yet, so the webhook stays OUT of the deployed codebase. It binds
// defineSecret('REVENUECAT_SIGNING_KEY'), and that secret is validated for the whole codebase at
// deploy time — exporting it would force a Secret Manager entry for a service that doesn't exist.
// To enable billing later: uncomment this, seed the real key
// (`firebase functions:secrets:set REVENUECAT_SIGNING_KEY`), then deploy revenueCatWebhook.
// export { revenueCatWebhook } from './billing/revenueCatWebhook'
// RevenueCat entitlement webhook. Auth = HMAC-SHA256 over the raw body (see revenueCatWebhook.ts).
// Binds defineSecret('REVENUECAT_WEBHOOK_SECRET') — the integration's signing secret from the
// RevenueCat dashboard — which is validated for the whole codebase at deploy time, so the secret
// must exist before any deploy. Seed it with:
// firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET
export { revenueCatWebhook } from './billing/revenueCatWebhook'
export { syncEntitlement } from './billing/syncEntitlement'
export { onEntitlementChanged } from './billing/onEntitlementChanged'
export { sendGentleReminderCallable } from './notifications/sendGentleReminderCallable'

View File

@ -55,8 +55,16 @@ const channelId = type.includes('game') ? 'game_activity' : 'partner_activity'
}
const tk = await db.collection('users').doc(uid).collection('fcmTokens').get()
const token = tk.docs[0] && tk.docs[0].data().token
if (!token) {
// Newest-first: a device that reinstalled registers a fresh token while stale ones linger
// (FCM rejects those with "Requested entity was not found"). The real Cloud Functions send to
// ALL tokens and prune the dead; here we just try newest-first until one delivers, so a stale
// leftover can't produce a false FAIL. (See sendToUser + pruneTokens in functions/.)
const ms = (v) => (v && v.toMillis ? v.toMillis() : typeof v === 'number' ? v : 0)
const tokens = tk.docs
.map((d) => ({ token: d.data().token, at: ms(d.data().updatedAt) }))
.filter((t) => t.token)
.sort((a, b) => b.at - a.at)
if (tokens.length === 0) {
console.error('NO_TOKEN for ' + uid + ' (launch the app once so it registers a token)')
process.exit(3)
}
@ -64,14 +72,28 @@ const channelId = type.includes('game') ? 'game_activity' : 'partner_activity'
const data = { type, couple_id: COUPLE, ...extras }
// Distinct body so the smoke can find + tap exactly this notification (not a grouped summary).
const marker = 'QA-SMOKE:' + type
const res = await admin.messaging().send({
token,
notification: { title: 'Closer', body: marker },
android: { priority: 'high', notification: { channelId } },
data,
})
console.log('SENT ' + type + ' data=' + JSON.stringify(data) + ' -> ' + res)
process.exit(0)
const isDeadToken = (e) =>
e && (e.code === 'messaging/registration-token-not-registered' ||
e.code === 'messaging/invalid-registration-token' ||
/entity was not found|not.?registered/i.test(e.message || ''))
let lastErr
for (const { token } of tokens) {
try {
const res = await admin.messaging().send({
token,
notification: { title: 'Closer', body: marker },
android: { priority: 'high', notification: { channelId } },
data,
})
console.log('SENT ' + type + ' data=' + JSON.stringify(data) + ' -> ' + res)
process.exit(0)
} catch (e) {
lastErr = e
if (isDeadToken(e)) continue // stale token — try the next-newest
throw e
}
}
throw lastErr || new Error('all tokens failed')
})().catch((e) => {
console.error('ERR ' + e.message)
process.exit(1)