Compare commits
No commits in common. "0b9263297376ca61e3bfb813c783dabeee6af205" and "8cb23ea244d05589e138d5829e55900d831e3822" have entirely different histories.
0b92632973
...
8cb23ea244
|
|
@ -97,4 +97,3 @@ relationship-app.code-workspace
|
||||||
qa/README.md
|
qa/README.md
|
||||||
qa/fixtures.local.md
|
qa/fixtures.local.md
|
||||||
qa/*.local.md
|
qa/*.local.md
|
||||||
revenucat_secret_api.md
|
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,145 @@
|
||||||
# Engineering Reference Manual Review Plan — Phase 4 (2026-07-11)
|
# Engineering Reference Manual Review Plan — Phase 3 (2026-07-08)
|
||||||
|
|
||||||
**Status: 🟡 IN PROGRESS** — covering 17 commits since Phase 3 completed (`c5ead4cb`).
|
**Status: ✅ COMPLETE** — all 13 batches reviewed, 11 issues found and fixed, committed individually, and pushed to `dev`.
|
||||||
|
|
||||||
Triggered by `_null` after Phase 3 completion to catch manual drift from the
|
Triggered by `_null` after the Phase 2 sync (`213cfddb`) + review pass (`d31b58d2`).
|
||||||
last 17 commits on `dev`. Phase 3 was a full evidence-first sweep; Phase 4 is a
|
Goal: cross-check every claim in the manual against the live repo + `firestore.rules`
|
||||||
targeted re-review scoped to commits that materially changed code the manual
|
+ `functions/src/` + iOS source + Android source, batch by batch. No skipped sections.
|
||||||
references (billing, repo layout, dead-file cleanup, FCM QA tooling, copy
|
|
||||||
catalog start).
|
|
||||||
|
|
||||||
## Approach
|
## Approach
|
||||||
|
|
||||||
- **6 batches**, scoped to a category of changes from the last 17 commits.
|
- **13 batches**, each scoped to one major section of the manual.
|
||||||
- **Evidence-first.** For every claim in the manual, re-verify against the
|
- **Evidence-first.** For every factual claim (file path, function name, collection
|
||||||
live source.
|
name, cron expression, preference field, threshold, AAD string), verify against
|
||||||
- **One commit per batch** per the standing git discipline (one batch = one
|
the live source.
|
||||||
commit = one push). Each commit must be pushed before the next batch starts.
|
- **One commit per batch** per the standing git discipline (one batch = one commit = one push).
|
||||||
- **No commits for batches with no findings** — log a no-op to this file only.
|
- **Log findings to this file** as each batch completes.
|
||||||
|
- **Anchors + TOC** verified at the end (Batch 13), not per-batch, to avoid churn.
|
||||||
|
|
||||||
## Batches
|
## Batches
|
||||||
|
|
||||||
### Batch 1 — Repository layout: dead-file sweep (21392ec2) + server/ removal (42fba641)
|
### Batch 1 — Front matter + System overview + Repository layout (Android + iOS + Cloud Functions + Shared configuration)
|
||||||
- Verify `## Repository layout` (Android + iOS + Cloud Functions + Shared) doesn't
|
- Read: `## How to use this document` through `### Shared configuration` (lines ~1-260).
|
||||||
reference removed files (e.g. `NotificationHelper.kt`, `NotificationPermissionHelper.kt`,
|
- Verify all directory paths under `app/src/main/java/app/closer/`,
|
||||||
`FakeQuestionRepository.kt`, `QuestionJsonParser.kt`, `Entitlement.kt`).
|
`functions/src/`, `iphone/Closer/`, `res/drawable-*`, Hilt module list.
|
||||||
- Verify no references to the removed `server/` Express backend (was Tier 3
|
- Cross-check Android package list against the live `find app/src/main/java/app/closer -maxdepth 3 -type d`.
|
||||||
cleanup, kept the dist that the actual Cloud Functions use).
|
- Cross-check iOS file list against `ls iphone/Closer/...`.
|
||||||
- 4103c433 already dropped "dead references" — confirm nothing was missed.
|
- Cross-check Cloud Functions tree against `find functions/src -name "*.ts"`.
|
||||||
- Commit if any manual updates needed.
|
- Flag missing or renamed directories/files.
|
||||||
|
|
||||||
### Batch 2 — Billing: RevenueCat SDK 10.12.0 → 10.13.0 (8cb23ea2) + webhook HMAC-SHA256 fix (89d06eeb) + secret key gitignore (48217dd7)
|
### Batch 2 — Authentication + pairing + couples doc model + rate limit + recovery
|
||||||
- Verify `## Billing` (RevenueCat integration + Webhook + Premium-gated features):
|
- Read: `## Authentication and pairing flow` (lines ~270-370).
|
||||||
- SDK version now 10.13.0 (was 10.12.0, was 8.20.0 pre-Phase 3).
|
- Verify auth files in `data/remote/FirebaseAuthDataSource.kt`,
|
||||||
- Webhook auth: actual auth scheme is HMAC-SHA256, not the prior scheme.
|
`data/repository/FirebaseAuthRepositoryImpl.kt`, `domain/repository/AuthRepository.kt`.
|
||||||
- `revenucat_secret_api.md` is now `.gitignore`d — does the manual's
|
- Verify `couples/{coupleId}` field list against `acceptInviteCallable.ts` + the
|
||||||
"Files that must never be committed" section need this added?
|
domain `Couple.kt` model.
|
||||||
- Commit if any manual updates needed.
|
- Verify rate-limit env values against `AuthRateLimiter.kt`.
|
||||||
|
- Verify recovery phrase flow against `RecoveryKeyManager.kt` + `CoupleEncryptionManager.kt`.
|
||||||
|
|
||||||
### Batch 3 — Cloud Functions: webhook handler source-of-truth update (89d06eeb)
|
### Batch 3 — E2EE model (versions, Argon2id, Tink, sealed-answer, ECIES, single-device, iOS bridge)
|
||||||
- Verify the Cloud Functions section's description of `revenueCatWebhook`
|
- Read: `## End-to-end encryption model` (lines ~370-475).
|
||||||
matches the new auth scheme (HMAC-SHA256 signature verification).
|
- Verify each `Encryption versions` row against `firestore.rules` regex helpers.
|
||||||
- Verify any other Cloud Functions section claims that the webhook handler
|
- Verify the Argon2id v1.3 parameter names + the AAD strings against the actual
|
||||||
is the only billing touch-point (Tier 3 server/ removal is a reminder).
|
Kotlin/Swift code.
|
||||||
- Commit if any manual updates needed.
|
- Verify the `keybox:v1:` wire format against `Keybox.swift` + `ReleaseKeyEncryptor.kt`.
|
||||||
|
- Verify the `wrapReleaseKeyCallable` path against the function source.
|
||||||
|
|
||||||
### Batch 4 — Engineering conventions: copy catalog start (51efff3f, 23cd2139, 2c3c2df8) + string resource cleanup (59e03369)
|
### Batch 4 — Daily question lifecycle + Reveal flow + Thread questions
|
||||||
- Verify `### Naming` / `### Error handling` / any UI convention sections
|
- Read: `## Daily question lifecycle` (lines ~480-575).
|
||||||
reflect the new `CloserCopy` catalog pattern.
|
- Verify `assignDailyQuestion` schedule + the deterministic picker
|
||||||
- Verify no references to the 55 purged string resources.
|
(DOW → mode map, daily_fun_mc exclusion) against
|
||||||
- Verify no references to removed `core/analytics`, `core/billing`,
|
`functions/src/questions/assignDailyQuestion.ts` + `DailyModeResolver.kt`.
|
||||||
`core/navigation`, `core/notifications` packages or their submodules.
|
- Verify the schemaVersion 2 reveal flow + the read-gated `secure/payload` subdoc
|
||||||
- Commit if any manual updates needed.
|
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 5 — Notifications / FCM: qa_push.js token-selection fix (c60fa958)
|
### Batch 5 — Firestore security rules
|
||||||
- Verify `## Notifications` (FCM + TokenRegistrar) doesn't claim a different
|
- Read: `## Firestore security rules` (lines ~720-810).
|
||||||
token-selection strategy than the live code.
|
- Line-by-line verify every per-collection note in the manual against `firestore.rules`.
|
||||||
- Manual may or may not reference the QA script — check `qa/qa_push.js`.
|
- Cross-check helper-function names (`coupleEncryptionEnabled`, `isCiphertext`,
|
||||||
- Commit if any manual updates needed.
|
`isUpdatingCoupleRhythm`, `isUpdatingRecoveryWrap`, `isCoupleAnswerCreate`,
|
||||||
|
`isEncryptedPayloadUpdate`, `isSealedAnswerCreate`, `isSealedAnswerUpdate`,
|
||||||
|
`isSealedThreadAnswerCreate`, `isSealedThreadAnswerUpdate`).
|
||||||
|
- Note any rules in the file not documented, or documented rules not in file.
|
||||||
|
|
||||||
### Batch 6 — Final pass: TOC + anchor integrity
|
### Batch 6 — Cloud Functions
|
||||||
- Re-run anchor/TOC verification.
|
- Read: `## Cloud Functions` (lines ~815-845).
|
||||||
- One final commit if any cleanup needed.
|
- 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.
|
||||||
|
|
||||||
## Phase 4 results
|
### 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)
|
||||||
|
|
||||||
| Batch | Section | Issues found | Commit |
|
| Batch | Section | Issues found | 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) |
|
| 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 | 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) |
|
| 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 | Cloud Functions: webhook handler | TBD | TBD |
|
| 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 | Engineering conventions: copy catalog + string resource cleanup | TBD | TBD |
|
| 4 | Daily question lifecycle + Reveal flow + Thread questions | DOW mode table was partial (missed Saturday `mode_weekend_side_quest` - Side Quest Saturday) | `5e292e66` |
|
||||||
| 5 | Notifications / FCM: qa_push.js | TBD | TBD |
|
| 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 | TOC + anchor integrity | TBD | TBD |
|
| 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) |
|
||||||
|
|
|
||||||
23
Future.md
23
Future.md
|
|
@ -83,10 +83,6 @@ tokens R16, add-FAB `Color(0xFFB98AF4)`→`primary` R18.)_
|
||||||
|
|
||||||
## QA
|
## 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 (C1–C4) + the functions v2 deploy
|
### From R29 games review (2026-07-07) — reconciled 2026-07-08 after R30 (C1–C4) + the functions v2 deploy
|
||||||
|
|
||||||
**✅ Shipped since filed (R30 batches C1–C4 + `f2321d35`; server-side pieces live as of the 2026-07-08 deploy):**
|
**✅ Shipped since filed (R30 batches C1–C4 + `f2321d35`; server-side pieces live as of the 2026-07-08 deploy):**
|
||||||
|
|
@ -199,17 +195,14 @@ 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`
|
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
|
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.
|
nodejs24 supports gen 1, or `onUserDelete` needs migrating off the gen-1 auth trigger first.
|
||||||
- **RevenueCat webhook deploy (gated on the signing secret).** The v2 `revenueCatWebhook` is code-complete
|
- **RevenueCat webhook re-enable (when billing is set up).** RevenueCat has no account yet, so the migrated v2
|
||||||
and its export is now **live** in `functions/src/index.ts`, but it is **not deployed yet** — deploy waits on
|
`revenueCatWebhook` is **not deployed** — its export is commented out in `functions/src/index.ts` because
|
||||||
the secret. Auth is **HMAC-SHA256** (RevenueCat's webhook signature verification): the handler reads
|
`defineSecret('REVENUECAT_SIGNING_KEY')` is validated codebase-wide at deploy (a missing secret fails *every*
|
||||||
`X-RevenueCat-Webhook-Signature: t=<ts>,v1=<hex>` and verifies `HMAC-SHA256("<ts>.<rawBody>")` against
|
function's deploy, even with `--only`). To enable: ① create the RevenueCat project + webhook, ② seed the real
|
||||||
`REVENUECAT_WEBHOOK_SECRET`, constant-time, with a ±5-min timestamp replay guard. Because that
|
Ed25519 public key — `firebase functions:secrets:set REVENUECAT_SIGNING_KEY`, ③ uncomment the export,
|
||||||
`defineSecret` is validated codebase-wide at deploy (a missing secret fails *every* function's deploy, even
|
④ **delete the old dormant v1 `revenueCatWebhook` first** (still deployed; same-name v1→v2 is blocked
|
||||||
with `--only`), the secret must exist first. To deploy: ① in the RevenueCat dashboard create the webhook +
|
in-place), ⑤ `firebase deploy --only functions:revenueCatWebhook`, ⑥ point RevenueCat at the function URL and
|
||||||
enable signature verification, copy the signing secret, ② `firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET`,
|
send a test event.
|
||||||
③ **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)
|
## Roadmap — features & strategy (consolidated from the old `FUTURE.md`, 2026-06-28)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ functions/src/
|
||||||
├── log.ts # Shared `logger` + `redactToken` (B6d)
|
├── log.ts # Shared `logger` + `redactToken` (B6d)
|
||||||
├── index.ts # Admin SDK init + explicit re-exports (no glob imports)
|
├── index.ts # Admin SDK init + explicit re-exports (no glob imports)
|
||||||
├── billing/
|
├── billing/
|
||||||
│ ├── revenueCatWebhook.ts # HTTPS webhook - HMAC-SHA256 signature verify (exported, deploy gated on REVENUECAT_WEBHOOK_SECRET - see Billing)
|
│ ├── revenueCatWebhook.ts # HTTPS webhook - Ed25519 signature verify (NOT exported / not deployed - see Billing)
|
||||||
│ ├── entitlementLogic.ts # Idempotent entitlement event handlers
|
│ ├── entitlementLogic.ts # Idempotent entitlement event handlers
|
||||||
│ ├── entitlementLogic.test.ts # Vitest unit tests
|
│ ├── entitlementLogic.test.ts # Vitest unit tests
|
||||||
│ ├── syncEntitlement.ts # Callable - forced re-sync from client
|
│ ├── 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.
|
`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 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.
|
**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.
|
||||||
|
|
||||||
### Schedule
|
### Schedule
|
||||||
|
|
||||||
|
|
@ -866,7 +866,7 @@ aggregateOutcomeStats every 24 hours (default
|
||||||
|
|
||||||
### RevenueCat integration
|
### RevenueCat integration
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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`:
|
`functions/src/billing/revenueCatWebhook.ts`:
|
||||||
|
|
||||||
- **Path**: HTTPS, POST only. GETs return 405.
|
- **Path**: HTTPS, POST only. GETs return 405.
|
||||||
- **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.
|
- **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.
|
||||||
- **Body**: RevenueCat event payload. Malformed payload → 400.
|
- **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.
|
- **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.
|
- **Idempotency**: `entitlement_events/{eventId}` records processed events. Re-delivered events are dropped.
|
||||||
|
|
||||||
**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.
|
**Currently NOT deployed** -- the export is commented out in `functions/src/index.ts`. See [Webhook reliability](#webhook-reliability) for the enable steps.
|
||||||
|
|
||||||
### Premium-gated features and gate pattern
|
### 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
|
- DataStore 1.1.2
|
||||||
- Glance 1.1.1 (Today widget)
|
- Glance 1.1.1 (Today widget)
|
||||||
- Firebase BoM 33.8.0 (auth, firestore, messaging, config, analytics, crashlytics, appcheck, appcheck-playintegrity, storage, functions)
|
- Firebase BoM 33.8.0 (auth, firestore, messaging, config, analytics, crashlytics, appcheck, appcheck-playintegrity, storage, functions)
|
||||||
- RevenueCat 10.13.0
|
- RevenueCat 10.12.0
|
||||||
- Tink 1.13.0, BouncyCastle 1.78.1
|
- Tink 1.13.0, BouncyCastle 1.78.1
|
||||||
- Play Integrity 1.4.0
|
- Play Integrity 1.4.0
|
||||||
- Biometric 1.1.0
|
- Biometric 1.1.0
|
||||||
|
|
@ -1189,11 +1189,6 @@ BUILD_SUMMARY.md
|
||||||
SCRIPTS.md
|
SCRIPTS.md
|
||||||
.learnings/
|
.learnings/
|
||||||
.kotlin/
|
.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
|
> **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
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.AuthError = exports.ConfigError = exports.revenueCatWebhook = void 0;
|
exports.revenueCatWebhook = void 0;
|
||||||
exports.verifyWebhookSignature = verifyWebhookSignature;
|
|
||||||
const crypto = __importStar(require("crypto"));
|
const crypto = __importStar(require("crypto"));
|
||||||
const https_1 = require("firebase-functions/v2/https");
|
const https_1 = require("firebase-functions/v2/https");
|
||||||
const params_1 = require("firebase-functions/params");
|
const params_1 = require("firebase-functions/params");
|
||||||
|
|
@ -46,34 +45,26 @@ const log_1 = require("../log");
|
||||||
* Path: POST /revenueCatWebhook (registered as an HTTPS function)
|
* Path: POST /revenueCatWebhook (registered as an HTTPS function)
|
||||||
* Triggered by RevenueCat server-to-server events.
|
* Triggered by RevenueCat server-to-server events.
|
||||||
*
|
*
|
||||||
* Authentication — HMAC-SHA256 (RevenueCat's webhook "signature verification"):
|
* Authentication:
|
||||||
* - RevenueCat sends `X-RevenueCat-Webhook-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>`.
|
* - Verifies the Ed25519 signature in the X-Signature request header.
|
||||||
* - v1 = HMAC-SHA256(secret, "<t>.<raw_body>"), hex-encoded, computed over the RAW request
|
* - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519
|
||||||
* body bytes exactly as received (before any JSON parsing / re-serialization).
|
* public key from your RevenueCat project dashboard. It is bound as a Secret
|
||||||
* - REVENUECAT_WEBHOOK_SECRET is the integration's signing secret from the RevenueCat
|
* Manager secret (below) and injected into process.env at runtime.
|
||||||
* dashboard (Integrations → Webhooks → enable signature verification). It is bound as a
|
* - Missing key → 500 (our config error). Invalid/absent signature → 401.
|
||||||
* 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:
|
* Processing:
|
||||||
* - Parses the RevenueCat event payload.
|
* - Parses the RevenueCat event payload.
|
||||||
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
|
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
|
||||||
*/
|
*/
|
||||||
const revenueCatWebhookSecret = (0, params_1.defineSecret)('REVENUECAT_WEBHOOK_SECRET');
|
const revenueCatSigningKey = (0, params_1.defineSecret)('REVENUECAT_SIGNING_KEY');
|
||||||
/** Reject signatures whose timestamp is further than this from now (replay protection). */
|
exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatSigningKey] }, async (req, res) => {
|
||||||
const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
||||||
exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatWebhookSecret] }, async (req, res) => {
|
|
||||||
var _a;
|
var _a;
|
||||||
if (req.method !== 'POST') {
|
if (req.method !== 'POST') {
|
||||||
res.status(405).json({ error: 'method_not_allowed' });
|
res.status(405).json({ error: 'method_not_allowed' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
verifyRequest(req, Date.now());
|
verifySignature(req);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
if (err instanceof ConfigError) {
|
if (err instanceof ConfigError) {
|
||||||
|
|
@ -105,74 +96,56 @@ exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatWebhook
|
||||||
});
|
});
|
||||||
class ConfigError extends Error {
|
class ConfigError extends Error {
|
||||||
}
|
}
|
||||||
exports.ConfigError = ConfigError;
|
|
||||||
class AuthError extends Error {
|
class AuthError extends Error {
|
||||||
}
|
}
|
||||||
exports.AuthError = AuthError;
|
|
||||||
/**
|
/**
|
||||||
* Reads the secret + signature header off the request and delegates to the pure verifier.
|
* Verifies the Ed25519 signature RevenueCat sends in the X-Signature header.
|
||||||
* Throws ConfigError when our secret is unset (→500); AuthError for any auth failure (→401).
|
*
|
||||||
|
* 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.
|
||||||
*/
|
*/
|
||||||
function verifyRequest(req, nowMs) {
|
function verifySignature(req) {
|
||||||
const secret = process.env.REVENUECAT_WEBHOOK_SECRET;
|
const signingKey = process.env.REVENUECAT_SIGNING_KEY;
|
||||||
if (!secret) {
|
if (!signingKey) {
|
||||||
throw new ConfigError('REVENUECAT_WEBHOOK_SECRET environment variable is not set');
|
throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set');
|
||||||
}
|
}
|
||||||
// Firebase Functions expose the raw request body as req.rawBody (Buffer).
|
// Firebase Functions expose the raw request body as req.rawBody (Buffer).
|
||||||
const rawBody = req.rawBody;
|
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) {
|
if (!rawBody || rawBody.length === 0) {
|
||||||
throw new AuthError('request body missing');
|
throw new AuthError('request body missing');
|
||||||
}
|
}
|
||||||
if (!header) {
|
const sigHeader = req.headers['x-signature'];
|
||||||
throw new AuthError('signature header missing');
|
const signature = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader;
|
||||||
|
if (!signature) {
|
||||||
|
throw new AuthError('x-signature header missing');
|
||||||
}
|
}
|
||||||
// Parse "t=<ts>,v1=<hex>" — order-independent, tolerant of extra fields.
|
let publicKey;
|
||||||
let t;
|
try {
|
||||||
let v1;
|
publicKey = crypto.createPublicKey({
|
||||||
for (const part of header.split(',')) {
|
key: Buffer.from(signingKey, 'base64'),
|
||||||
const seg = part.trim();
|
format: 'der',
|
||||||
const eq = seg.indexOf('=');
|
type: 'spki',
|
||||||
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) {
|
catch (_a) {
|
||||||
throw new AuthError('signature header malformed');
|
throw new ConfigError('REVENUECAT_SIGNING_KEY is malformed — expected base64-encoded DER/SPKI Ed25519 public key');
|
||||||
}
|
}
|
||||||
const tsMs = Number(t) * 1000;
|
let sigBuffer;
|
||||||
if (!Number.isFinite(tsMs)) {
|
try {
|
||||||
throw new AuthError('signature timestamp invalid');
|
sigBuffer = Buffer.from(signature, 'base64');
|
||||||
}
|
}
|
||||||
if (Math.abs(nowMs - tsMs) > toleranceMs) {
|
catch (_b) {
|
||||||
throw new AuthError('signature timestamp outside tolerance');
|
throw new AuthError('x-signature is not valid base64');
|
||||||
}
|
}
|
||||||
// HMAC over the RAW body bytes, prefixed with "<t>." — never re-serialize the JSON.
|
let valid;
|
||||||
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
|
try {
|
||||||
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
|
valid = crypto.verify(null, rawBody, publicKey, sigBuffer);
|
||||||
// Constant-time compare. timingSafeEqual throws on length mismatch, so guard the length first.
|
}
|
||||||
const expectedBuf = Buffer.from(expected, 'utf8');
|
catch (_c) {
|
||||||
const providedBuf = Buffer.from(v1, 'utf8');
|
throw new AuthError('signature verification failed');
|
||||||
if (expectedBuf.length !== providedBuf.length || !crypto.timingSafeEqual(expectedBuf, providedBuf)) {
|
}
|
||||||
|
if (!valid) {
|
||||||
throw new AuthError('signature mismatch');
|
throw new AuthError('signature mismatch');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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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"}
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
"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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"revenueCatWebhook.test.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.test.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
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 = exports.revenueCatWebhook = 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 = void 0;
|
||||||
// Global v2 options (region pin, instance cap) — MUST be imported before any function module so
|
// Global v2 options (region pin, instance cap) — MUST be imported before any function module so
|
||||||
// setGlobalOptions runs before the functions below are defined.
|
// setGlobalOptions runs before the functions below are defined.
|
||||||
require("./options");
|
require("./options");
|
||||||
|
|
@ -44,13 +44,12 @@ const admin = __importStar(require("firebase-admin"));
|
||||||
if (admin.apps.length === 0) {
|
if (admin.apps.length === 0) {
|
||||||
admin.initializeApp();
|
admin.initializeApp();
|
||||||
}
|
}
|
||||||
// RevenueCat entitlement webhook. Auth = HMAC-SHA256 over the raw body (see revenueCatWebhook.ts).
|
// RevenueCat is not configured yet, so the webhook stays OUT of the deployed codebase. It binds
|
||||||
// Binds defineSecret('REVENUECAT_WEBHOOK_SECRET') — the integration's signing secret from the
|
// defineSecret('REVENUECAT_SIGNING_KEY'), and that secret is validated for the whole codebase at
|
||||||
// RevenueCat dashboard — which is validated for the whole codebase at deploy time, so the secret
|
// deploy time — exporting it would force a Secret Manager entry for a service that doesn't exist.
|
||||||
// must exist before any deploy. Seed it with:
|
// To enable billing later: uncomment this, seed the real key
|
||||||
// firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET
|
// (`firebase functions:secrets:set REVENUECAT_SIGNING_KEY`), then deploy revenueCatWebhook.
|
||||||
var revenueCatWebhook_1 = require("./billing/revenueCatWebhook");
|
// export { revenueCatWebhook } from './billing/revenueCatWebhook'
|
||||||
Object.defineProperty(exports, "revenueCatWebhook", { enumerable: true, get: function () { return revenueCatWebhook_1.revenueCatWebhook; } });
|
|
||||||
var syncEntitlement_1 = require("./billing/syncEntitlement");
|
var syncEntitlement_1 = require("./billing/syncEntitlement");
|
||||||
Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: function () { return syncEntitlement_1.syncEntitlement; } });
|
Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: function () { return syncEntitlement_1.syncEntitlement; } });
|
||||||
var onEntitlementChanged_1 = require("./billing/onEntitlementChanged");
|
var onEntitlementChanged_1 = require("./billing/onEntitlementChanged");
|
||||||
|
|
|
||||||
|
|
@ -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,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"}
|
{"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"}
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -10,30 +10,21 @@ import { logger } from '../log'
|
||||||
* Path: POST /revenueCatWebhook (registered as an HTTPS function)
|
* Path: POST /revenueCatWebhook (registered as an HTTPS function)
|
||||||
* Triggered by RevenueCat server-to-server events.
|
* Triggered by RevenueCat server-to-server events.
|
||||||
*
|
*
|
||||||
* Authentication — HMAC-SHA256 (RevenueCat's webhook "signature verification"):
|
* Authentication:
|
||||||
* - RevenueCat sends `X-RevenueCat-Webhook-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>`.
|
* - Verifies the Ed25519 signature in the X-Signature request header.
|
||||||
* - v1 = HMAC-SHA256(secret, "<t>.<raw_body>"), hex-encoded, computed over the RAW request
|
* - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519
|
||||||
* body bytes exactly as received (before any JSON parsing / re-serialization).
|
* public key from your RevenueCat project dashboard. It is bound as a Secret
|
||||||
* - REVENUECAT_WEBHOOK_SECRET is the integration's signing secret from the RevenueCat
|
* Manager secret (below) and injected into process.env at runtime.
|
||||||
* dashboard (Integrations → Webhooks → enable signature verification). It is bound as a
|
* - Missing key → 500 (our config error). Invalid/absent signature → 401.
|
||||||
* 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:
|
* Processing:
|
||||||
* - Parses the RevenueCat event payload.
|
* - Parses the RevenueCat event payload.
|
||||||
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
|
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
|
||||||
*/
|
*/
|
||||||
const revenueCatWebhookSecret = defineSecret('REVENUECAT_WEBHOOK_SECRET')
|
const revenueCatSigningKey = defineSecret('REVENUECAT_SIGNING_KEY')
|
||||||
|
|
||||||
/** Reject signatures whose timestamp is further than this from now (replay protection). */
|
|
||||||
const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000
|
|
||||||
|
|
||||||
export const revenueCatWebhook = onRequest(
|
export const revenueCatWebhook = onRequest(
|
||||||
{ secrets: [revenueCatWebhookSecret] },
|
{ secrets: [revenueCatSigningKey] },
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
if (req.method !== 'POST') {
|
if (req.method !== 'POST') {
|
||||||
res.status(405).json({ error: 'method_not_allowed' })
|
res.status(405).json({ error: 'method_not_allowed' })
|
||||||
|
|
@ -41,7 +32,7 @@ export const revenueCatWebhook = onRequest(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
verifyRequest(req, Date.now())
|
verifySignature(req)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ConfigError) {
|
if (err instanceof ConfigError) {
|
||||||
logger.error('[revenueCatWebhook] configuration error', { error: err.message })
|
logger.error('[revenueCatWebhook] configuration error', { error: err.message })
|
||||||
|
|
@ -74,83 +65,62 @@ export const revenueCatWebhook = onRequest(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export class ConfigError extends Error {}
|
class ConfigError extends Error {}
|
||||||
export class AuthError extends Error {}
|
class AuthError extends Error {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the secret + signature header off the request and delegates to the pure verifier.
|
* Verifies the Ed25519 signature RevenueCat sends in the X-Signature header.
|
||||||
* Throws ConfigError when our secret is unset (→500); AuthError for any auth failure (→401).
|
*
|
||||||
|
* 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.
|
||||||
*/
|
*/
|
||||||
function verifyRequest(req: Request, nowMs: number): void {
|
function verifySignature(req: Request): void {
|
||||||
const secret = process.env.REVENUECAT_WEBHOOK_SECRET
|
const signingKey = process.env.REVENUECAT_SIGNING_KEY
|
||||||
if (!secret) {
|
if (!signingKey) {
|
||||||
throw new ConfigError('REVENUECAT_WEBHOOK_SECRET environment variable is not set')
|
throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Firebase Functions expose the raw request body as req.rawBody (Buffer).
|
// Firebase Functions expose the raw request body as req.rawBody (Buffer).
|
||||||
const rawBody = (req as unknown as { rawBody?: Buffer }).rawBody
|
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) {
|
if (!rawBody || rawBody.length === 0) {
|
||||||
throw new AuthError('request body missing')
|
throw new AuthError('request body missing')
|
||||||
}
|
}
|
||||||
if (!header) {
|
|
||||||
throw new AuthError('signature header missing')
|
const sigHeader = req.headers['x-signature']
|
||||||
|
const signature = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader
|
||||||
|
if (!signature) {
|
||||||
|
throw new AuthError('x-signature header missing')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse "t=<ts>,v1=<hex>" — order-independent, tolerant of extra fields.
|
let publicKey: crypto.KeyObject
|
||||||
let t: string | undefined
|
try {
|
||||||
let v1: string | undefined
|
publicKey = crypto.createPublicKey({
|
||||||
for (const part of header.split(',')) {
|
key: Buffer.from(signingKey, 'base64'),
|
||||||
const seg = part.trim()
|
format: 'der',
|
||||||
const eq = seg.indexOf('=')
|
type: 'spki',
|
||||||
if (eq < 0) continue
|
})
|
||||||
const key = seg.slice(0, eq)
|
} catch {
|
||||||
const val = seg.slice(eq + 1)
|
throw new ConfigError(
|
||||||
if (key === 't') t = val
|
'REVENUECAT_SIGNING_KEY is malformed — expected base64-encoded DER/SPKI Ed25519 public key'
|
||||||
else if (key === 'v1') v1 = val
|
)
|
||||||
}
|
|
||||||
if (!t || !v1) {
|
|
||||||
throw new AuthError('signature header malformed')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tsMs = Number(t) * 1000
|
let sigBuffer: Buffer
|
||||||
if (!Number.isFinite(tsMs)) {
|
try {
|
||||||
throw new AuthError('signature timestamp invalid')
|
sigBuffer = Buffer.from(signature, 'base64')
|
||||||
}
|
} catch {
|
||||||
if (Math.abs(nowMs - tsMs) > toleranceMs) {
|
throw new AuthError('x-signature is not valid base64')
|
||||||
throw new AuthError('signature timestamp outside tolerance')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HMAC over the RAW body bytes, prefixed with "<t>." — never re-serialize the JSON.
|
let valid: boolean
|
||||||
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody])
|
try {
|
||||||
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex')
|
valid = crypto.verify(null, rawBody, publicKey, sigBuffer)
|
||||||
|
} catch {
|
||||||
|
throw new AuthError('signature verification failed')
|
||||||
|
}
|
||||||
|
|
||||||
// Constant-time compare. timingSafeEqual throws on length mismatch, so guard the length first.
|
if (!valid) {
|
||||||
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')
|
throw new AuthError('signature mismatch')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,12 @@ if (admin.apps.length === 0) {
|
||||||
admin.initializeApp()
|
admin.initializeApp()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevenueCat entitlement webhook. Auth = HMAC-SHA256 over the raw body (see revenueCatWebhook.ts).
|
// RevenueCat is not configured yet, so the webhook stays OUT of the deployed codebase. It binds
|
||||||
// Binds defineSecret('REVENUECAT_WEBHOOK_SECRET') — the integration's signing secret from the
|
// defineSecret('REVENUECAT_SIGNING_KEY'), and that secret is validated for the whole codebase at
|
||||||
// RevenueCat dashboard — which is validated for the whole codebase at deploy time, so the secret
|
// deploy time — exporting it would force a Secret Manager entry for a service that doesn't exist.
|
||||||
// must exist before any deploy. Seed it with:
|
// To enable billing later: uncomment this, seed the real key
|
||||||
// firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET
|
// (`firebase functions:secrets:set REVENUECAT_SIGNING_KEY`), then deploy revenueCatWebhook.
|
||||||
export { revenueCatWebhook } from './billing/revenueCatWebhook'
|
// export { revenueCatWebhook } from './billing/revenueCatWebhook'
|
||||||
export { syncEntitlement } from './billing/syncEntitlement'
|
export { syncEntitlement } from './billing/syncEntitlement'
|
||||||
export { onEntitlementChanged } from './billing/onEntitlementChanged'
|
export { onEntitlementChanged } from './billing/onEntitlementChanged'
|
||||||
export { sendGentleReminderCallable } from './notifications/sendGentleReminderCallable'
|
export { sendGentleReminderCallable } from './notifications/sendGentleReminderCallable'
|
||||||
|
|
|
||||||
|
|
@ -55,16 +55,8 @@ const channelId = type.includes('game') ? 'game_activity' : 'partner_activity'
|
||||||
}
|
}
|
||||||
|
|
||||||
const tk = await db.collection('users').doc(uid).collection('fcmTokens').get()
|
const tk = await db.collection('users').doc(uid).collection('fcmTokens').get()
|
||||||
// Newest-first: a device that reinstalled registers a fresh token while stale ones linger
|
const token = tk.docs[0] && tk.docs[0].data().token
|
||||||
// (FCM rejects those with "Requested entity was not found"). The real Cloud Functions send to
|
if (!token) {
|
||||||
// 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)')
|
console.error('NO_TOKEN for ' + uid + ' (launch the app once so it registers a token)')
|
||||||
process.exit(3)
|
process.exit(3)
|
||||||
}
|
}
|
||||||
|
|
@ -72,28 +64,14 @@ const channelId = type.includes('game') ? 'game_activity' : 'partner_activity'
|
||||||
const data = { type, couple_id: COUPLE, ...extras }
|
const data = { type, couple_id: COUPLE, ...extras }
|
||||||
// Distinct body so the smoke can find + tap exactly this notification (not a grouped summary).
|
// Distinct body so the smoke can find + tap exactly this notification (not a grouped summary).
|
||||||
const marker = 'QA-SMOKE:' + type
|
const marker = 'QA-SMOKE:' + type
|
||||||
const isDeadToken = (e) =>
|
const res = await admin.messaging().send({
|
||||||
e && (e.code === 'messaging/registration-token-not-registered' ||
|
token,
|
||||||
e.code === 'messaging/invalid-registration-token' ||
|
notification: { title: 'Closer', body: marker },
|
||||||
/entity was not found|not.?registered/i.test(e.message || ''))
|
android: { priority: 'high', notification: { channelId } },
|
||||||
let lastErr
|
data,
|
||||||
for (const { token } of tokens) {
|
})
|
||||||
try {
|
console.log('SENT ' + type + ' data=' + JSON.stringify(data) + ' -> ' + res)
|
||||||
const res = await admin.messaging().send({
|
process.exit(0)
|
||||||
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) => {
|
})().catch((e) => {
|
||||||
console.error('ERR ' + e.message)
|
console.error('ERR ' + e.message)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue