backend-manager 5.2.3 → 5.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/CLAUDE.md +3 -3
  3. package/TODO-CANCEL-EMAIL-MISSING-ORDER-ID.md +159 -0
  4. package/TODO-WEBHOOK-KEY-LEGACY-REMOVAL.md +15 -0
  5. package/TODO-WEBHOOK-KEY-UPGRADE.md +138 -0
  6. package/docs/consent.md +5 -10
  7. package/docs/sanitization.md +32 -24
  8. package/docs/schemas.md +1 -1
  9. package/docs/stripe-webhook-forwarding.md +2 -2
  10. package/docs/testing.md +8 -7
  11. package/package.json +1 -1
  12. package/scripts/test-helper-providers.js +162 -0
  13. package/src/cli/commands/base-command.js +5 -5
  14. package/src/cli/commands/emulator.js +201 -54
  15. package/src/cli/commands/test.js +80 -9
  16. package/src/manager/events/cron/daily/ghostii-auto-publisher.js +2 -2
  17. package/src/manager/events/firestore/payments-webhooks/analytics.js +2 -2
  18. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  19. package/src/manager/helpers/analytics.js +1 -1
  20. package/src/manager/helpers/middleware.js +7 -4
  21. package/src/manager/helpers/utilities.js +31 -0
  22. package/src/manager/libraries/email/generators/newsletter.js +2 -2
  23. package/src/manager/libraries/email/providers/beehiiv.js +69 -27
  24. package/src/manager/libraries/email/providers/sendgrid.js +38 -12
  25. package/src/manager/libraries/email/validation.js +1 -1
  26. package/src/manager/libraries/infer-contact.js +1 -1
  27. package/src/manager/routes/general/email/post.js +4 -2
  28. package/src/manager/routes/marketing/email-preferences/post.js +2 -2
  29. package/src/manager/routes/payments/dispute-alert/post.js +3 -3
  30. package/src/manager/routes/payments/intent/processors/test.js +2 -2
  31. package/src/manager/routes/payments/webhook/post.js +2 -2
  32. package/src/manager/routes/user/delete.js +1 -1
  33. package/src/manager/routes/user/oauth2/providers/discord.js +1 -1
  34. package/src/manager/routes/user/oauth2/providers/google.js +1 -1
  35. package/src/test/runner.js +7 -31
  36. package/src/test/test-accounts.js +8 -63
  37. package/src/test/utils/http-client.js +1 -0
  38. package/test/events/payments/journey-payments-cancel.js +4 -4
  39. package/test/events/payments/journey-payments-failure.js +2 -2
  40. package/test/events/payments/journey-payments-legacy-product.js +1 -1
  41. package/test/events/payments/journey-payments-one-time-failure.js +1 -1
  42. package/test/events/payments/journey-payments-plan-change.js +1 -1
  43. package/test/events/payments/journey-payments-refund-webhook.js +4 -4
  44. package/test/events/payments/journey-payments-suspend.js +4 -4
  45. package/test/events/payments/journey-payments-trial.js +2 -2
  46. package/test/events/payments/journey-payments-uid-resolution.js +1 -1
  47. package/test/marketing/consent-lifecycle.js +255 -0
  48. package/test/routes/payments/dispute-alert.js +13 -13
  49. package/test/routes/payments/webhook.js +3 -3
  50. /package/src/manager/routes/general/email/templates/{download-app-link.js → general/download-app-link.js} +0 -0
package/CHANGELOG.md CHANGED
@@ -14,6 +14,58 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.2.6] - 2026-05-24
18
+
19
+ ### Added
20
+
21
+ - **`scripts/test-helper-providers.js`** — small CLI for live-test verification. Run from any consumer's `functions/` dir: `node <bem>/scripts/test-helper-providers.js find <email>` (check SendGrid + Beehiiv state without dashboards) or `purge <email>` (remove from both providers). Used during end-to-end consent-pipeline testing.
22
+ - **Email-template namespacing.** `general/email` route now translates `:` in `settings.id` to a folder separator, so `general:download-app-link` resolves to `templates/general/download-app-link.js`. Existing `templates/download-app-link.js` moved into `templates/general/` to match.
23
+
24
+ ### Changed
25
+
26
+ - **Payment webhook + dispute-alert routes now accept either `BACKEND_MANAGER_WEBHOOK_KEY` (preferred) or `BACKEND_MANAGER_KEY` (legacy).** Phase 1 of the webhook-key migration plan in `TODO-WEBHOOK-KEY-UPGRADE.md` — non-breaking dual-acceptance so consumers can roll over at their own pace. `src/manager/routes/payments/webhook/post.js:28` + `src/manager/routes/payments/dispute-alert/post.js:20` validate against either env var. Test-processor self-fire in `src/manager/routes/payments/intent/processors/test.js:158` now uses `BACKEND_MANAGER_WEBHOOK_KEY` directly. All 13 payment test files (`test/routes/payments/*.js`, `test/events/payments/journey-*.js`) updated to use `BACKEND_MANAGER_WEBHOOK_KEY` for webhook URLs. `src/test/utils/http-client.js` + `src/test/runner.js` thread the new env var through the test context. `docs/stripe-webhook-forwarding.md` reflects the dual-key acceptance. Phase 2 (drop the legacy fallback) is tracked in `TODO-WEBHOOK-KEY-LEGACY-REMOVAL.md`.
27
+ - **`npx mgr emulator` no longer wraps the emulator in a keep-alive subprocess.** Previously spawned a sleep-86400 wrapper via `runWithEmulator`; now spawns the emulator child directly and uses a clean SIGINT handler. Cleaner shutdown, no orphaned subprocesses, no `node-powertools` dep on the boot path. `npx mgr test`'s auto-start path uses the same helper.
28
+
29
+ ### Fixed
30
+
31
+ - **Finished the v5.2.3 SendGrid timeout bump that missed 5 sites.** v5.2.3's CHANGELOG claimed "All 9 fetch sites updated" but actually 5 sites in `sendgrid.js` (including `upsertContacts:118` — the hot path used by `Marketing.sync()`) were still using `timeout: 15000`. Confirmed live on Somiibo: a signup with marketing consent granted silently dropped both SendGrid and Beehiiv with `Request timed out` after ~18s. Now every API call in `sendgrid.js` goes through the `SENDGRID_TIMEOUT_MS` (60s) constant. The only remaining literal is the S3 CSV download in `getSegmentContacts:578` (30s — not a SendGrid API call).
32
+ - **`beehiiv.js` timeouts unified under a new `BEEHIIV_TIMEOUT_MS` (60s) constant.** Two hot-path sites (`addSubscriber:71` and the unsub endpoint at `:455`) were still on `timeout: 15000`. Same silent-drop failure mode as SendGrid above. Every Beehiiv API call now uses the constant.
33
+ - **Audited entire BEM codebase for short timeouts. Zero prod-path timeouts under 60s remain.**
34
+ - **Bumped 6 sites from 10-15s → 60s:**
35
+ - `src/manager/libraries/email/validation.js:136` — ZeroBounce mailbox check
36
+ - `src/manager/libraries/email/generators/newsletter.js:520, 549` — parent-server `/newsletter-sources` GET + PUT
37
+ - `src/manager/routes/payments/intent/processors/test.js:163` — test-processor self-fire webhook
38
+ - `src/manager/routes/marketing/email-preferences/post.js:169, 179` — SendGrid ASM suppression POST + DELETE
39
+ - **Bumped 9 sites from 30s → 60s:**
40
+ - `src/manager/libraries/infer-contact.js:46` — PeopleDataLabs AI enrichment
41
+ - `src/manager/libraries/email/providers/sendgrid.js:578` — S3 CSV download for segment exports
42
+ - `src/manager/functions/core/actions/api/user/delete.js:34` + `src/manager/routes/user/delete.js:51` — internal sign-out fan-out
43
+ - `src/manager/events/firestore/payments-webhooks/analytics.js:228, 301` — Facebook + Reddit Conversions API
44
+ - `src/manager/routes/user/oauth2/providers/discord.js:25` + `…/google.js:30` — OAuth token-revoke
45
+ - `src/manager/helpers/analytics.js:331` — itwcw-package-analytics event fire
46
+ - **Bumped 2 sites from 30s → 120s:**
47
+ - `src/manager/events/cron/daily/ghostii-auto-publisher.js:106` — gpt-image-1 hero image generation (regularly 30-60s)
48
+ - `src/manager/events/cron/daily/ghostii-auto-publisher.js:177` — gpt-5+web-search blog post generation (regularly 60-90s)
49
+ - **Audit rule going forward:** any external-API call in BEM prod paths uses **60s minimum** (120s+ for LLM/image generation). Short timeouts are silent-failure machines in serverless code where Cloud Function timeouts already give us a generous outer bound. `_legacy/` files, `src/test/` infra, and `src/cli/` commands keep their existing timeouts — they're not on the request hot path.
50
+
51
+ # [5.2.5] - 2026-05-22
52
+
53
+ ### Added
54
+
55
+ - **`utilities.trim(input)`** in `src/manager/helpers/utilities.js`. Walks objects/arrays recursively and trims whitespace on every string. Does NOT strip HTML. Middleware uses it to clean up incoming request data without mangling URLs, Markdown, or any payload with `<`/`>`/`&`.
56
+ - **`findContact(email)`** on the SendGrid + Beehiiv providers, and **`findSubscriber(email, publicationId)`** on Beehiiv. Both extracted from the existing `removeContact`/`removeSubscriber` search-by-email logic so tests (and other call sites) can verify provider state without forcing a delete.
57
+ - **`test/marketing/consent-lifecycle.js`** — 5-phase live integration test (gated on `TEST_EXTENDED_MODE=true`) that walks two long-lived sentinel accounts through pre-check → sync → declined-stays-out → unsubscribe → validation-gate, asserting actual SendGrid + Beehiiv state at every step. Uses `pollProvider()` to handle SendGrid's async background jobs (upsert/delete can take 10-60s+ to surface).
58
+
59
+ ### Changed
60
+
61
+ - **Middleware no longer strips HTML by default.** The auto-`sanitize-html` pass on incoming request data was mangling legitimate input — URL query strings (`?a=1&b=2` → `?a=1&amp;b=2`), Markdown, and any payload containing `<`/`>`/`&`. The middleware now only trims whitespace by default. HTML sanitization is opt-in per route via `Manager.Middleware(req, res).run('route', { sanitize: true })`. Sanitize at the HTML-insertion site (`utilities.sanitize()` in your template/email render) rather than at the request boundary. The existing schema-level `sanitize: false` opt-out still works when route-level sanitize is on.
62
+ - **Test sentinel accounts `consent-granted` and `consent-declined` now use the `_test.allow_*` prefix** (`_test.allow_consent-granted@...`, `_test.allow_consent-declined@...`). The `_test.*` validation block from v5.2.3 also blocks the previous `_test.consent-*` names from reaching providers — `_test.allow_*` is the carved-out exception specifically for live-provider integration tests.
63
+ - **Beehiiv API timeouts bumped to 60s** on the two remaining stragglers (`findSubscriber`, `resolveSegmentIds`) to match the SendGrid+Beehiiv 60s convention from v5.2.3.
64
+
65
+ ### Removed
66
+
67
+ - **`cleanupMarketingProviders()` function and its pre-run/post-run hooks** in `src/test/test-accounts.js` + `src/test/runner.js`. No longer needed — the validation gate added in v5.2.3 blocks `_test.*` emails from reaching SendGrid + Beehiiv upstream, so there's nothing to clean up. The new `consent-lifecycle.js` test (which intentionally round-trips real contacts via `_test.allow_*`) manages its own pre-check force-clean inline.
68
+
17
69
  # [5.2.3] - 2026-05-22
18
70
 
19
71
  ### Added
package/CLAUDE.md CHANGED
@@ -76,7 +76,7 @@ See [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) and [docs/cli-logs.
76
76
  - **Firestore shorthand**: `admin.firestore().doc('users/abc123')` (path string) rather than `.collection('users').doc('abc123')`.
77
77
  - **Template strings for requires**: `` require(`${functionsDir}/node_modules/backend-manager`) `` rather than string concat.
78
78
  - **No backwards compatibility** unless explicitly requested.
79
- - **Routes receive sanitized data by default** see [docs/sanitization.md](docs/sanitization.md) for opt-out rules.
79
+ - **Routes receive whitespace-trimmed data; HTML is preserved.** Sanitize at the HTML-insertion site via `utilities.sanitize()`. Opt into middleware-level HTML strip per-route with `{ sanitize: true }`. See [docs/sanitization.md](docs/sanitization.md).
80
80
  - **Match schema names to route names** — if route is `myEndpoint`, schema is `myEndpoint`.
81
81
  - **Always use `assistant.respond()` for responses** — do NOT use `res.send()` directly.
82
82
  - **Add Firestore composite indexes** for any compound query (`where` + `orderBy`, or multiple `where`s) to `src/cli/commands/setup-tests/helpers/required-indexes.js` (the SSOT). Without the index, queries crash with `FAILED_PRECONDITION` in production.
@@ -113,7 +113,7 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
113
113
 
114
114
  - [docs/routes.md](docs/routes.md) — recipes for new API commands, routes, event handlers, cron jobs (with code templates)
115
115
  - [docs/schemas.md](docs/schemas.md) — schema definition format, defaults vs premium overrides
116
- - [docs/sanitization.md](docs/sanitization.md) — automatic XSS sanitization, schema opt-out (`sanitize: false`), route-level opt-out, manual `utilities.sanitize()`
116
+ - [docs/sanitization.md](docs/sanitization.md) — middleware trim-only default; opt-in HTML strip (`{ sanitize: true }`) with per-field opt-out (`sanitize: false`); manual `utilities.sanitize()` for HTML-insertion sites
117
117
  - [docs/auth-hooks.md](docs/auth-hooks.md) — consumer hooks for `before-create`/`before-signin`/`on-create`/`on-delete` (blocking + non-blocking examples)
118
118
  - [docs/common-operations.md](docs/common-operations.md) — inside-the-handler patterns: authenticate, read/write Firestore, error handling, send response, `bm_api` hook
119
119
 
@@ -134,6 +134,6 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
134
134
 
135
135
  ### Testing & CLI
136
136
 
137
- - [docs/testing.md](docs/testing.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels. **For Firestore/Auth/local state, cleanup runs at the START of every run, never at the end** if you add a test that writes Firestore data, register the collection/namespace in the runner's pre-test wipe list, don't add a trailing cleanup step. **Exception:** third-party providers we can't wipe at start (e.g. SendGrid/Beehiiv contact lists) get a symmetric pre + post cleanup hook in the runner see `cleanupMarketingProviders` and [docs/consent.md](docs/consent.md). Don't pattern-match this exception for new Firestore code.
137
+ - [docs/testing.md](docs/testing.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels. **All cleanup runs at the START of every run, never at the end.** If you add a test that writes Firestore data, register the collection/namespace in the runner's pre-test wipe list, don't add a trailing cleanup step. Marketing providers (SendGrid/Beehiiv) don't need a special exception `_test.*` emails are blocked at the validation layer so test signups never reach providers. The `_test.allow_*` carve-out exists only for the live-provider lifecycle test (`test/marketing/consent-lifecycle.js`), which manages its own teardown.
138
138
  - [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) — `npx mgr firestore:*` and `auth:*` commands, shared flags, examples
139
139
  - [docs/cli-logs.md](docs/cli-logs.md) — `npx mgr logs:read` / `logs:tail` with full flag reference and built-in Cloud Function names
@@ -0,0 +1,159 @@
1
+ # TODO: Cancel email missing Order # on non-trial cancels
2
+
3
+ ## Symptom
4
+
5
+ Observed 2026-05-23 in `_test.journey-payments-cancel@somiibo.com`'s inbox:
6
+
7
+ ```
8
+ Subject: Your subscription has been cancelled #
9
+ Body: Order #
10
+ Your subscription has been cancelled.
11
+ ```
12
+
13
+ The `#` after both `Subject` and `Order #` is bare — `order.id` rendered as empty string.
14
+
15
+ The trial-cancel sibling (`_test.journey-payments-trial-cancel@somiibo.com`) on the SAME run got the order ID correctly:
16
+
17
+ ```
18
+ Subject: Your subscription has been cancelled #3794-5306-7041
19
+ Body: Order #3794-5306-7041
20
+ ```
21
+
22
+ So this is path-specific, not a template bug.
23
+
24
+ ## Root cause (narrowed but not yet proven)
25
+
26
+ Two cancel paths feed the same `subscription-cancelled` transition handler ([src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js:17](src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js#L17)):
27
+
28
+ ```js
29
+ subject: `Your subscription has been cancelled #${order?.id || ''}`,
30
+ ```
31
+
32
+ Subject + template both render `order.id`. When `order.id` is falsy it falls through to `''`.
33
+
34
+ `order.id` is built in [src/manager/events/firestore/payments-webhooks/on-write.js:241-242](src/manager/events/firestore/payments-webhooks/on-write.js#L241-L242):
35
+
36
+ ```js
37
+ const order = {
38
+ id: orderId,
39
+ ...
40
+ };
41
+ ```
42
+
43
+ Where `orderId` (line 118) is:
44
+
45
+ ```js
46
+ orderId = library.getOrderId(resource) || passThruOrderId;
47
+ ```
48
+
49
+ `library.getOrderId(resource)` reads the order ID off the processor resource's `meta_data` field. `passThruOrderId` is a PayPal-only fallback from the hosted-page `pass_thru_content`.
50
+
51
+ **Working path** (`journey-payments-trial-cancel`): the trial cancel goes through the `/payments/cancel` endpoint, which already has the `orderId` from `users/{uid}.subscription.payment.orderId`. By the time Stripe/PayPal fires the cancellation webhook back, `meta_data.orderId` on the subscription resource is set, so `library.getOrderId(resource)` returns the order ID. Email shows the ID.
52
+
53
+ **Broken path** (`journey-payments-cancel`): this is the webhook-driven cancel (no `/payments/cancel` endpoint call — `orderDoc.requests.cancellation` is null in the test assertions). The webhook arrives with a subscription resource whose `meta_data.orderId` is empty (or `library.getOrderId(resource)` returns null/empty string). `passThruOrderId` is null because this is Stripe, not PayPal. `orderId` ends up null. `order.id = null`. Email renders `Order #`.
54
+
55
+ ## What's still unknown
56
+
57
+ 1. **Why is `meta_data.orderId` missing on the Stripe subscription resource at cancel time?** Possibilities:
58
+ - The journey test never set `meta_data` on the subscription (only on the customer or the initial intent).
59
+ - `library.setMetaData` was called during the new-subscription transition but Stripe's subscription resource didn't persist it (Stripe quirk where `metadata` on subscription vs. customer vs. invoice diverge).
60
+ - The `customer.subscription.deleted` event payload doesn't include `metadata` even when it was set — this would be a Stripe-side gotcha.
61
+ 2. **Does this affect production?** Possibly — depends on whether real users' Stripe subscription resources have `meta_data.orderId` set. Worth checking one real cancelled subscription in the Stripe dashboard.
62
+
63
+ ## Investigation steps
64
+
65
+ 1. **Re-run the cancel journey with `TEST_EXTENDED_MODE=true`** and watch the BEM logs:
66
+ ```bash
67
+ cd /Users/ian/Developer/Repositories/Somiibo/somiibo-backend/functions
68
+ TEST_EXTENDED_MODE=true npx mgr test events/payments/journey-payments-cancel
69
+ ```
70
+ Search the logs around the cancellation webhook for the resolved `orderId` value:
71
+ ```bash
72
+ npx mgr logs:read --filter "journey-payments-cancel" --limit 200 | grep -i "orderid\|order id"
73
+ ```
74
+
75
+ 2. **Inspect the raw webhook payload** stored in `payments-webhooks/{eventId}` for that test run:
76
+ ```bash
77
+ npx mgr firestore:query "payments-webhooks" --limit 20 # find the deleted-subscription event
78
+ npx mgr firestore:get "payments-webhooks/<eventId>" # inspect raw.data.object.metadata
79
+ ```
80
+ If `raw.data.object.metadata.orderId` is empty → confirms Stripe didn't include it in the event.
81
+
82
+ 3. **Check `library.getOrderId(resource)` implementation** for the Stripe library:
83
+ ```bash
84
+ grep -n "getOrderId" /Users/ian/Developer/Repositories/ITW-Creative-Works/backend-manager/src/manager/libraries/payment/stripe.js
85
+ ```
86
+ Confirm what field path it reads. If it reads only `resource.metadata.orderId`, that's the bug — should also check `resource.metadata.bm_orderId` (some processors have different conventions) or fall through to a Firestore lookup.
87
+
88
+ ## Fix options (in order of preference)
89
+
90
+ ### Option A — Fall back to userDoc on the transition side (safest)
91
+
92
+ In [subscription-cancelled.js:17](src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js#L17), if `order.id` is missing, fall back to `userDoc.subscription.payment.orderId`:
93
+
94
+ ```js
95
+ const orderId = order?.id || userDoc?.subscription?.payment?.orderId || '';
96
+
97
+ sendOrderEmail({
98
+ template: 'core/order/cancelled',
99
+ subject: `Your subscription has been cancelled${orderId ? ` #${orderId}` : ''}`,
100
+ ...
101
+ data: {
102
+ order: {
103
+ ...order,
104
+ id: orderId,
105
+ ...
106
+ },
107
+ },
108
+ });
109
+ ```
110
+
111
+ This is the **safest** fix because it works regardless of whether the bug is in `library.getOrderId`, in Stripe's event payload, or in how `meta_data` was set originally. `userDoc.subscription.payment.orderId` is written when the subscription first activates and reliably has the ID by cancel time.
112
+
113
+ The subject also degrades gracefully — `Your subscription has been cancelled` (no bare `#`) when the ID truly can't be found.
114
+
115
+ ### Option B — Fix at the source (`on-write.js`)
116
+
117
+ In [on-write.js:118](src/manager/events/firestore/payments-webhooks/on-write.js#L118), add another fallback after `library.getOrderId`:
118
+
119
+ ```js
120
+ orderId = library.getOrderId(resource)
121
+ || passThruOrderId
122
+ || userDoc?.subscription?.payment?.orderId
123
+ || null;
124
+ ```
125
+
126
+ This fixes it for ALL handlers (not just cancel), and at the cost of a Firestore read of the user doc earlier in `on-write.js` than today. May not need a new read if userDoc is already fetched by this point — confirm by reading the function top-to-bottom.
127
+
128
+ ### Option C — Fix `library.getOrderId(resource)` (Stripe library)
129
+
130
+ If investigation step 3 shows the Stripe library is reading the wrong field path, fix it there. Smallest blast radius if the bug is genuinely in one processor's resolver and not a Stripe-event-payload gotcha.
131
+
132
+ ## Recommendation
133
+
134
+ Do **A + B together** — A makes the email correct today regardless of root cause; B prevents the same gap from biting any other handler (refund, suspend, etc.). C only if investigation step 3 reveals an actual mis-read.
135
+
136
+ ## Regression test
137
+
138
+ Add an assertion to `test/events/payments/journey-payments-cancel.js` that the post-cancellation userDoc still has `subscription.payment.orderId` set AND that the cancel email's `order.id` would render non-empty. Without a regression test, the same bug will silently come back the next time someone refactors `library.getOrderId`.
139
+
140
+ Pseudo-assertion:
141
+
142
+ ```js
143
+ assert.ok(userDoc.subscription?.payment?.orderId, 'userDoc.subscription.payment.orderId should be set after cancellation');
144
+ // And if we capture the email payload (via a test-mode hook in sendOrderEmail), assert order.id !== '' in the rendered data.
145
+ ```
146
+
147
+ If `sendOrderEmail` doesn't already have a test-mode capture, that's a small enhancement worth doing once — it would let every email-emitting transition assert its rendered payload, not just cancel.
148
+
149
+ ## Affected versions
150
+
151
+ Confirmed broken: BEM 5.2.5 (current live on Somiibo as of 2026-05-23). Likely broken in earlier versions too — the `order?.id || ''` template defensive fallback was added explicitly to handle missing IDs, suggesting this gap has been latent for a while. The trial-cancel path masked it because trial cancels go through the endpoint and have `orderId` set in a different code path.
152
+
153
+ ## Related files
154
+
155
+ - [src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js](src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js) — handler with `order?.id || ''` fallback
156
+ - [src/manager/events/firestore/payments-webhooks/on-write.js:241](src/manager/events/firestore/payments-webhooks/on-write.js#L241) — `order` object construction
157
+ - [src/manager/events/firestore/payments-webhooks/on-write.js:118](src/manager/events/firestore/payments-webhooks/on-write.js#L118) — `orderId` resolution from `library.getOrderId` + pass-through fallback
158
+ - [test/events/payments/journey-payments-cancel.js](test/events/payments/journey-payments-cancel.js) — broken-path test (currently passes because it doesn't assert the email payload)
159
+ - [test/events/payments/journey-payments-trial-cancel.js](test/events/payments/journey-payments-trial-cancel.js) — working-path test
@@ -0,0 +1,15 @@
1
+ # TODO: Remove legacy BACKEND_MANAGER_KEY acceptance from webhook routes
2
+
3
+ When v5.2.x shipped, the two payment-webhook routes were switched to prefer `BACKEND_MANAGER_WEBHOOK_KEY` but still accept `BACKEND_MANAGER_KEY` as a transitional fallback so existing provider URLs (registered with the old key) keep working until OMEGA re-registers them with the new key.
4
+
5
+ Once every brand's provider URLs (Stripe / PayPal / Chargebee / Chargeblast / Coinbase / etc.) have been re-registered via OMEGA to use `BACKEND_MANAGER_WEBHOOK_KEY`, drop the legacy fallback:
6
+
7
+ ## Files to update
8
+
9
+ - `src/manager/routes/payments/webhook/post.js` — line ~28: remove the `|| key === process.env.BACKEND_MANAGER_KEY` half of the validation check.
10
+ - `src/manager/routes/payments/dispute-alert/post.js` — line ~20: same removal. Also update the docstring at line ~11 to drop the "BACKEND_MANAGER_KEY accepted as legacy fallback" note.
11
+ - `docs/stripe-webhook-forwarding.md` — drop the "BACKEND_MANAGER_KEY also accepted as a legacy fallback" parenthetical.
12
+
13
+ ## How to verify it's safe
14
+
15
+ Grep production logs (`npx mgr logs:read --fn bm_api --grep "payments/webhook" --limit 1000`) and confirm zero `401 Invalid key` hits over a meaningful window. If there are any, those are providers still on the old URL — re-register them via OMEGA before dropping the fallback.
@@ -0,0 +1,138 @@
1
+ # TODO: Payment Webhook Key Upgrade — `BACKEND_MANAGER_KEY` → `BACKEND_MANAGER_WEBHOOK_KEY`
2
+
3
+ ## Context
4
+
5
+ In BEM v5.2.0 we introduced `BACKEND_MANAGER_WEBHOOK_KEY` as a dedicated env var for third-party webhook authentication, scoped narrowly so it can be rotated independently of the admin key. The marketing routes (`/marketing/webhook`, `/marketing/webhook/forward`) use it correctly.
6
+
7
+ The **payment webhook** (`/payments/webhook`) and **dispute-alert webhook** (`/payments/dispute-alert`) still validate against `BACKEND_MANAGER_KEY` — the general-purpose admin key. This is a security misalignment: a leaked admin key could forge payment webhooks, and rotating the admin key forces every Stripe/PayPal/Chargebee webhook URL to be re-registered.
8
+
9
+ Plan: gradual rollout. Phase 1 (this BEM release) makes the payment routes accept **either** key so existing brand deploys (with only `BACKEND_MANAGER_KEY` set) keep working AND new brands can start using `BACKEND_MANAGER_WEBHOOK_KEY` immediately. Phase 2 (a later BEM release) drops the legacy fallback after every brand has been migrated.
10
+
11
+ ## Files needing the dual-key check
12
+
13
+ | File | Line | Current |
14
+ |---|---|---|
15
+ | [src/manager/routes/payments/webhook/post.js](src/manager/routes/payments/webhook/post.js) | 28 | `if (!key \|\| key !== process.env.BACKEND_MANAGER_KEY)` |
16
+ | [src/manager/routes/payments/dispute-alert/post.js](src/manager/routes/payments/dispute-alert/post.js) | 20 | `if (!key \|\| key !== process.env.BACKEND_MANAGER_KEY)` |
17
+ | [src/manager/routes/payments/intent/processors/test.js](src/manager/routes/payments/intent/processors/test.js) | 158 | `key=${process.env.BACKEND_MANAGER_KEY}` |
18
+
19
+ ## Phase 1 — Dual-key acceptance (this BEM release)
20
+
21
+ ### Validation change (webhook + dispute-alert)
22
+
23
+ Replace the single-key check with a dual-key check. Accept the request if `key` matches EITHER `BACKEND_MANAGER_WEBHOOK_KEY` OR the legacy `BACKEND_MANAGER_KEY`.
24
+
25
+ ```js
26
+ // Before
27
+ if (!key || key !== process.env.BACKEND_MANAGER_KEY) {
28
+ return assistant.respond('Invalid key', { code: 401 });
29
+ }
30
+
31
+ // After (Phase 1 — dual-key fallback)
32
+ const validKeys = [
33
+ process.env.BACKEND_MANAGER_WEBHOOK_KEY,
34
+ process.env.BACKEND_MANAGER_KEY, // legacy — remove in Phase 2
35
+ ].filter(Boolean);
36
+
37
+ if (!key || !validKeys.includes(key)) {
38
+ return assistant.respond('Invalid key', { code: 401 });
39
+ }
40
+ ```
41
+
42
+ Notes:
43
+ - `.filter(Boolean)` matters — if a brand hasn't set `BACKEND_MANAGER_WEBHOOK_KEY` yet, `validKeys` becomes `[BACKEND_MANAGER_KEY]` (single-element). If a brand HAS set it, both are accepted.
44
+ - If BOTH env vars are unset (misconfigured brand), `validKeys` is `[]` and every request 401s. That's the right behavior — explicit failure beats silent acceptance.
45
+ - Update the route header comments (lines 11 and 27 of dispute-alert, 27 of webhook) to mention both keys.
46
+
47
+ ### Test processor self-fire URL
48
+
49
+ Switch [src/manager/routes/payments/intent/processors/test.js:158](src/manager/routes/payments/intent/processors/test.js#L158) to PREFER `BACKEND_MANAGER_WEBHOOK_KEY` and fall back to `BACKEND_MANAGER_KEY`:
50
+
51
+ ```js
52
+ const webhookKey = process.env.BACKEND_MANAGER_WEBHOOK_KEY || process.env.BACKEND_MANAGER_KEY;
53
+ const webhookUrl = `${assistant.Manager.project.apiUrl}/backend-manager/payments/webhook?processor=test&key=${webhookKey}`;
54
+ ```
55
+
56
+ This way the test processor exercises the new key when it's set, and falls back to the legacy key on brands that haven't migrated yet.
57
+
58
+ ### Test infrastructure updates
59
+
60
+ **`src/test/utils/http-client.js`** — add `backendManagerWebhookKey` field alongside the existing `backendManagerKey`.
61
+
62
+ **`src/test/runner.js`** — thread `backendManagerWebhookKey` through the runner context the same way `backendManagerKey` is threaded today (3 sites).
63
+
64
+ **`src/cli/commands/test.js`** — read `BACKEND_MANAGER_WEBHOOK_KEY` from env (parallel to the existing `BACKEND_MANAGER_KEY` read), pass it through to the runner, fall back to the admin key if unset.
65
+
66
+ **Route + journey tests** — update test fixtures to prefer `BACKEND_MANAGER_WEBHOOK_KEY` when set:
67
+ - `test/routes/payments/webhook.js` — all `${config.backendManagerKey}` interpolations in `payments/webhook?...&key=` URLs
68
+ - `test/routes/payments/dispute-alert.js` — same (13 sites)
69
+ - `test/events/payments/journey-*.js` — only the `payments/webhook?...&key=` URLs (NOT the admin-auth uses elsewhere in journey tests)
70
+
71
+ Use `config.backendManagerWebhookKey || config.backendManagerKey` everywhere so both keys are exercised.
72
+
73
+ ### Docs
74
+
75
+ - **`docs/stripe-webhook-forwarding.md`** — line 7 + 18 — note the dual-key acceptance, recommend `BACKEND_MANAGER_WEBHOOK_KEY` for new setups, note legacy `BACKEND_MANAGER_KEY` is still accepted for backwards compatibility.
76
+ - **`CHANGELOG.md`** — entry under the next version. Categorize under `Changed` (NOT `BREAKING` — that's reserved for Phase 2). Spell out: "Payment webhook + dispute-alert routes now accept either `BACKEND_MANAGER_WEBHOOK_KEY` (preferred) or `BACKEND_MANAGER_KEY` (legacy). Set `BACKEND_MANAGER_WEBHOOK_KEY` in every consumer brand's `.env` and run OMEGA payment ensure before Phase 2 ships."
77
+ - **`templates/_.env`** — already declares both keys, no change.
78
+
79
+ ### Verification (manual)
80
+
81
+ 1. **Local test (no `BACKEND_MANAGER_WEBHOOK_KEY` set)** — confirm legacy fallback still passes:
82
+ ```bash
83
+ npx mgr test routes/payments/webhook routes/payments/dispute-alert events/payments
84
+ ```
85
+ Expect green.
86
+
87
+ 2. **Local test (`BACKEND_MANAGER_WEBHOOK_KEY` set to a different value)** — confirm the new key is accepted AND the legacy key still works:
88
+ ```bash
89
+ BACKEND_MANAGER_WEBHOOK_KEY=test-webhook-key npx mgr test routes/payments/webhook routes/payments/dispute-alert events/payments
90
+ ```
91
+ Expect green.
92
+
93
+ 3. **Live spot-check on one brand (Somiibo)** — deploy this BEM release to Somiibo (whose `.env` currently only has `BACKEND_MANAGER_KEY` set OR has both), trigger a Stripe test event, confirm the webhook doc lands in Firestore.
94
+
95
+ ## Phase 2 — Drop legacy fallback (future BEM release)
96
+
97
+ **Do NOT do this until every consumer brand has been migrated and verified.** Tracking checklist for migration readiness:
98
+
99
+ - [ ] Every consumer `.env` has `BACKEND_MANAGER_WEBHOOK_KEY` set (spot-check via OMEGA across all brands)
100
+ - [ ] OMEGA `payment/ensure/{stripe,paypal,chargebee}-webhook.js` updated to use `BACKEND_MANAGER_WEBHOOK_KEY` when constructing webhook URLs (see "OMEGA changes" below)
101
+ - [ ] OMEGA `--service payment` run across every brand to re-register webhook URLs at Stripe/PayPal/Chargebee with the new key
102
+ - [ ] Stale webhook URLs (the old ones with `key=BACKEND_MANAGER_KEY`) deleted from each provider — these will start 401-ing the moment Phase 2 ships
103
+ - [ ] At least one live billing cycle (Stripe charge, refund, dispute) observed working end-to-end on the new key for at least 3 brands
104
+
105
+ When all boxes are checked, Phase 2 ships as a **breaking change**:
106
+
107
+ ```js
108
+ // Phase 2 — single-key (legacy removed)
109
+ if (!key || key !== process.env.BACKEND_MANAGER_WEBHOOK_KEY) {
110
+ return assistant.respond('Invalid key', { code: 401 });
111
+ }
112
+ ```
113
+
114
+ Phase 2 CHANGELOG entry goes under `BREAKING`.
115
+
116
+ ## OMEGA changes (separate repo, needed for Phase 2 prep)
117
+
118
+ Touched files (do NOT do these in Phase 1 — they're for Phase 2 prep):
119
+
120
+ | File | Change |
121
+ |---|---|
122
+ | `/Users/ian/Developer/Repositories/ITW-Creative-Works/omega-manager/src/services/payment/ensure/stripe-webhook.js` | Line 38: swap `BACKEND_MANAGER_KEY` → `BACKEND_MANAGER_WEBHOOK_KEY`. Line 76 check + line 77 message + line 64 doc comment: update env var name. |
123
+ | `/Users/ian/Developer/Repositories/ITW-Creative-Works/omega-manager/src/services/payment/ensure/paypal-webhook.js` | Same change at lines 40, 66, 77, 78, 79. |
124
+ | `/Users/ian/Developer/Repositories/ITW-Creative-Works/omega-manager/src/services/payment/ensure/chargebee-webhook.js` | Same change at lines 34, 45, 57, 58, 59. |
125
+
126
+ **Idempotent re-registration + stale cleanup:** Each ensure file looks up existing webhooks **by URL**. Since the URL will change (the `key=` query param differs), the existing webhook will NOT match and a new one will be created. The OLD webhook (with the admin key) needs to be cleaned up — otherwise the third party will deliver to both and the old one will start failing 401 once Phase 2 ships.
127
+
128
+ Cleanup approach in each ensure file: after creating the new webhook, scan for any other webhook whose URL points at `/payments/webhook?processor={name}` (regardless of key) and delete the stale ones. Match on path prefix, not full URL. Stripe + PayPal + Chargebee all support webhook deletion via the same API methods these files already use for listing.
129
+
130
+ Helper: extract a `pathMatchesOurProcessor(url, processor)` in each ensure file — strip query string, match the path-and-processor segment.
131
+
132
+ ## Known constraints / gotchas
133
+
134
+ 1. **Phase 1 is non-breaking.** Any consumer can deploy this BEM release without touching their `.env` — the legacy key still works.
135
+ 2. **`BACKEND_MANAGER_KEY` continues to grant admin via `assistant.authenticate()`.** That hasn't changed; it's still the admin secret for internal API calls (newsletter, send-email, user/delete, ghostii cron, electron-client, oauth2 state encryption). This TODO is *only* about scoping webhook endpoints to a separate key.
136
+ 3. **`UNSUBSCRIBE_HMAC_KEY` is unrelated** and stays separate.
137
+ 4. **Admin grant is NOT triggered by the webhook routes.** Middleware auto-runs `assistant.authenticate()` via `Usage.init()`, which reads `data.backendManagerKey` from the request body. The three webhook routes get their key via `?key=` query string (NOT body), and their request bodies are third-party payloads (Stripe events, PayPal events, Chargeblast alerts) that can't contain the real admin secret. So switching the routes' validation does NOT remove any admin-grant the handlers depend on — they write to Firestore via `libraries.admin` (Admin SDK, always full access) anyway.
138
+ 5. **Test processor self-fire** must use the same key the BEM endpoint accepts, which is why the test processor URL also gets updated in Phase 1.
package/docs/consent.md CHANGED
@@ -138,7 +138,7 @@ Each processor's `handleEvent` does the same shape of work:
138
138
  | SendGrid | `unsubscribe`, `group_unsubscribe`, `spamreport`, `bounce`, `dropped` |
139
139
  | Beehiiv | `subscription.unsubscribed`, `subscription.deleted`, `subscription.paused` |
140
140
 
141
- **Beehiiv publication filter.** Each Beehiiv event includes a `publication_id`. The processor compares this against the result of `beehiivProvider.getPublicationId()` (which reads `Manager.config.marketing.beehiiv.publicationId` or fuzzy-matches against the Beehiiv API by brand name). Mismatch → silent skip. This is how shared-publication events (e.g. devbeans shared by 6 brands) get routed correctly — each brand processes only events matching its own publication.
141
+ **Beehiiv publication filter.** Each Beehiiv event includes a `publication_id`. The processor compares this against `beehiivProvider.getPublicationId()`, which reads `Manager.config.marketing.beehiiv.publicationId` (populated at brand-onboarding time by OMEGA's `beehiiv/ensure/publication.js`). Mismatch → silent skip. This is how shared-publication events (e.g. devbeans shared by 6 brands) get routed correctly — each brand processes only events matching its own publication. Brands without `publicationId` in config silently skip all Beehiiv webhook events. The same convention applies to SendGrid: `marketing.sendgrid.listId` is populated by OMEGA's `sendgrid/ensure/list.js`.
142
142
 
143
143
  ## Parent forwarder (Phase E)
144
144
 
@@ -304,18 +304,13 @@ After the migration: optionally run a re-opt-in drip campaign to legally recover
304
304
 
305
305
  Run with `npx mgr test` (full suite) or `npx mgr test routes/marketing/webhook` (just the webhook tests).
306
306
 
307
- ### Provider-side cleanup (extended mode only)
307
+ ### Live-provider tests (extended mode only)
308
308
 
309
- Normally BEM test runs are self-contained because all data lives in the local emulator. Marketing tests are the exception when `TEST_EXTENDED_MODE=true` is set, the routes make real API calls to SendGrid + Beehiiv and leave real contacts behind.
309
+ Most BEM tests are self-contained against the local emulator. The marketing-consent system has one test that's an exception [test/marketing/consent-lifecycle.js](../test/marketing/consent-lifecycle.js) which makes real API calls to SendGrid + Beehiiv to verify the full round-trip works end-to-end.
310
310
 
311
- To keep this self-contained, the runner calls [`cleanupMarketingProviders`](../src/test/test-accounts.js) **twice** per run:
311
+ The validation pipeline (`src/manager/libraries/email/validation.js`) blocks all `_test.*` emails from reaching providers via the `/^_test\.(?!allow_)/` pattern in `blocked-local-patterns.js`. The two `_test.allow_*` sentinels (`_test.allow_consent-granted` and `_test.allow_consent-declined`) used by the lifecycle test bypass that gate intentionally, and the test cleans up after itself (phase-3 removes the granted contact via `Manager.Email().remove()`).
312
312
 
313
- 1. **Pre-run** scrubs leftovers from any previous run that crashed mid-suite.
314
- 2. **Post-run** — scrubs everything this run created. Best-effort (wrapped in try/catch — cleanup failures don't change the test result).
315
-
316
- Both fire ONLY when `TEST_EXTENDED_MODE=true`. In normal mode (the default), no provider calls happen and no cleanup is needed.
317
-
318
- This is the one exception to the "Firestore cleanup runs at start only" rule documented in [docs/testing.md](testing.md) — we can't wipe SendGrid/Beehiiv state in our own Firestore wipe, so we have to ask the providers to do it for us, twice for defense in depth.
313
+ The "all cleanup runs at start, never at the end" rule documented in [docs/testing.md](testing.md) applies to all test data, including third-party providers.
319
314
 
320
315
  ## Frontend pieces (cross-references)
321
316
 
@@ -1,16 +1,25 @@
1
1
  # Sanitization (XSS Prevention)
2
2
 
3
- BEM automatically sanitizes all incoming request data stripping HTML tags and trimming whitespace from every string field. This happens in the middleware pipeline before route handlers execute, so **routes receive clean data by default**.
3
+ BEM middleware always **trims** whitespace on incoming string fields (via `utilities.trim()`). HTML sanitization is **opt-in** it's not run by default because it mangles legitimate input like URL query strings (`&` `&amp;`) and Markdown.
4
+
5
+ The expectation is that you sanitize at the **HTML-insertion site** (in the template, in the email body, etc.) — not at the request boundary.
4
6
 
5
7
  ## How It Works
6
8
 
7
- 1. **Schema fields**: Sanitized per-field during the middleware pipeline. Fields can opt out with `sanitize: false` in the schema.
8
- 2. **Non-schema fields** (when `setupSettings: false` or `includeNonSchemaSettings: true`): All strings are sanitized with no opt-out.
9
- 3. The middleware uses `Manager.Utilities().sanitize()` under the hood.
9
+ 1. **Trimming**: Every string in `settings` is whitespace-trimmed by the middleware (objects + arrays walked recursively). Always on.
10
+ 2. **HTML sanitization**: Off by default. Opt in per-route with `{ sanitize: true }` on `Manager.Middleware(...).run(...)`.
11
+ 3. **Schemas** can mark individual fields with `sanitize: false` to skip the HTML strip for that field when route-level sanitize is enabled (used for fields that legitimately need raw HTML — rich-text editors, email templates).
12
+
13
+ ## Route-Level Opt-In
14
+
15
+ ```javascript
16
+ // In functions/index.js — enable HTML strip for a specific route
17
+ Manager.Middleware(req, res).run('my-route', { sanitize: true });
18
+ ```
10
19
 
11
- ## Schema Opt-Out
20
+ When enabled, every string in `settings` is run through `sanitize-html` (strip all tags) unless the schema marks the field with `sanitize: false`.
12
21
 
13
- For fields that legitimately need HTML (rich text, email templates, etc.), set `sanitize: false` in the schema:
22
+ ## Schema Field Opt-Out (when route-level sanitize is on)
14
23
 
15
24
  ```javascript
16
25
  // This field will NOT be sanitized — raw HTML is preserved
@@ -19,43 +28,42 @@ htmlContent: {
19
28
  default: '',
20
29
  sanitize: false,
21
30
  },
22
- // This field IS sanitized (default behavior, no flag needed)
31
+ // This field IS sanitized when route-level sanitize is enabled
23
32
  name: {
24
33
  types: ['string'],
25
34
  default: '',
26
35
  },
27
36
  ```
28
37
 
29
- ## Route-Level Opt-Out
38
+ ## Manual Sanitization (Recommended)
30
39
 
31
- Disable sanitization entirely for a route (rare only for routes that handle raw HTML everywhere):
32
-
33
- ```javascript
34
- // In functions/index.js
35
- Manager.Middleware(req, res).run('my-route', { sanitize: false });
36
- ```
37
-
38
- ## Manual Sanitization (Outside Middleware)
39
-
40
- For cron jobs, event handlers, or anywhere outside the request pipeline, use `utilities.sanitize()` directly:
40
+ For most use cases particularly anywhere you're inserting user-supplied content into HTML call `utilities.sanitize()` directly at the insertion site:
41
41
 
42
42
  ```javascript
43
43
  // Available in route context
44
- const clean = utilities.sanitize(untrustedData);
44
+ const safeHtml = utilities.sanitize(untrustedData);
45
45
 
46
46
  // Or via Manager
47
- const clean = Manager.Utilities().sanitize(untrustedData);
47
+ const safeHtml = Manager.Utilities().sanitize(untrustedData);
48
48
  ```
49
49
 
50
50
  Accepts any data type — strings, objects, arrays, primitives. Walks objects/arrays recursively, strips HTML from strings, passes everything else through unchanged.
51
51
 
52
- ## Route Handler Context
52
+ ## Why HTML Sanitization Is Not the Middleware Default
53
+
54
+ Stripping HTML from every incoming string at the request boundary is too aggressive — it corrupts legitimate input:
53
55
 
54
- The middleware injects these into every route handler:
56
+ - URL query strings: `https://example.com/?a=1&b=2` becomes `https://example.com/?a=1&amp;b=2`
57
+ - Markdown / code snippets with `<`, `>`, `&` characters get mangled
58
+ - API payloads round-tripped through the system get silently rewritten
59
+
60
+ Stored XSS comes from rendering, not from receiving. Sanitize at the render site (where you know the output context — HTML body, attribute, URL, JSON) instead of at the front door.
61
+
62
+ ## Route Handler Context
55
63
 
56
64
  ```javascript
57
65
  module.exports = async ({ Manager, assistant, analytics, usage, user, settings, libraries, utilities }) => {
58
- // settings — already sanitized by middleware
59
- // utilities — Manager.Utilities() instance for manual sanitization
66
+ // settings — whitespace-trimmed by middleware; HTML preserved unless route opts in via { sanitize: true }
67
+ // utilities — Manager.Utilities() instance for manual sanitize()/trim()
60
68
  };
61
69
  ```
package/docs/schemas.md CHANGED
@@ -36,4 +36,4 @@ module.exports = function (assistant, settings, options) {
36
36
 
37
37
  ## Field Sanitization
38
38
 
39
- By default, every string field in a schema is sanitized (HTML tags stripped, whitespace trimmed) during the middleware pipeline. To preserve raw HTML (rich text, email templates), set `sanitize: false` on the field. See [docs/sanitization.md](sanitization.md).
39
+ Middleware always trims whitespace on string fields. HTML sanitization is **opt-in per route** — `Manager.Middleware(req, res).run('my-route', { sanitize: true })`. When opted in, fields can individually opt back out with `sanitize: false`. See [docs/sanitization.md](sanitization.md).
@@ -4,7 +4,7 @@ BEM auto-starts Stripe CLI webhook forwarding when running `npx mgr serve` or `n
4
4
 
5
5
  **Requirements:**
6
6
  - `STRIPE_SECRET_KEY` set in `functions/.env`
7
- - `BACKEND_MANAGER_KEY` set in `functions/.env`
7
+ - `BACKEND_MANAGER_WEBHOOK_KEY` set in `functions/.env` (`BACKEND_MANAGER_KEY` also accepted as a legacy fallback)
8
8
  - [Stripe CLI](https://stripe.com/docs/stripe-cli) installed
9
9
 
10
10
  **Standalone usage:**
@@ -15,4 +15,4 @@ npx mgr stripe
15
15
 
16
16
  If any prerequisite is missing, webhook forwarding is silently skipped with an info message.
17
17
 
18
- The forwarding URL is: `http://localhost:{hostingPort}/backend-manager/payments/webhook?processor=stripe&key={BACKEND_MANAGER_KEY}`
18
+ The forwarding URL is: `http://localhost:{hostingPort}/backend-manager/payments/webhook?processor=stripe&key={BACKEND_MANAGER_WEBHOOK_KEY}`
package/docs/testing.md CHANGED
@@ -19,18 +19,19 @@ What the runner wipes pre-test (in [src/test/test-accounts.js](../src/test/test-
19
19
 
20
20
  1. **`meta/stats`** doc ensured (required for on-create batch writes).
21
21
  2. **`users/_test-*`** Firebase Auth users + Firestore docs (delete).
22
- 3. **Marketing providers** (SendGrid + Beehiiv) leftover test contacts removed. Runs only under `TEST_EXTENDED_MODE`. Runs **before** test users are recreated so a killed run can't leave a contact pinned to an about-to-be-recreated uid.
23
- 4. **Mixed Firestore collections** — `payments-orders`, `payments-webhooks`, `payments-intents`, `payments-disputes`, `marketing-webhooks`. Two-pass cleanup per collection:
22
+ 3. **Mixed Firestore collections** — `payments-orders`, `payments-webhooks`, `payments-intents`, `payments-disputes`, `marketing-webhooks`. Two-pass cleanup per collection:
24
23
  - Pass 1 — owner-keyed: `where('owner', 'in', [...testUids])` (batched at 30 uids per `in` query).
25
24
  - Pass 2 — id-keyed: any doc whose ID starts with `_test-` (catches ownerless test docs like dispute alerts and raw test webhooks).
26
- 5. **Test-only Firestore collections** — `_test`, `_test_query` — wiped in full.
27
- 6. **Realtime Database** — the `_test` namespace removed in full (`admin.database().ref('_test').remove()`).
25
+ 4. **Test-only Firestore collections** — `_test`, `_test_query` — wiped in full.
26
+ 5. **Realtime Database** — the `_test` namespace removed in full (`admin.database().ref('_test').remove()`).
28
27
 
29
- ### The one exception: third-party provider cleanup runs at start AND end
28
+ ### Marketing-provider cleanup
30
29
 
31
- The `cleanupMarketingProviders` call also fires **after** the suite completes (extended mode only). Reason: pre-run cleanup catches what a crashed previous run left behind, but during a normal-completion run we'd still leak real SendGrid/Beehiiv contacts until the NEXT run cleaned them up. By running cleanup post-suite too, each extended-mode run leaves the provider lists in the same state it found them.
30
+ Test signups never reach SendGrid + Beehiiv. The validation pipeline (`src/manager/libraries/email/validation.js`) blocks all `_test.*` emails at the marketing-library layer via the `/^_test\.(?!allow_)/` pattern in `blocked-local-patterns.js`.
32
31
 
33
- This is **specifically** for third-party state that lives outside the local emulator. **Do not use trailing cleanup for Firestore or Auth data** those follow the start-only rule because we control the local state and pre-run cleanup is enough.
32
+ The single exception is the `_test.allow_*` prefix. Two long-lived test accounts (`_test.allow_consent-granted@...` and `_test.allow_consent-declined@...`) intentionally round-trip through SendGrid + Beehiiv as the live-provider integration sentinels. They are exercised by `test/marketing/consent-lifecycle.js`, which manages its own setup, assertions, and teardown.
33
+
34
+ All cleanup follows the start-only rule. No trailing-cleanup exception.
34
35
 
35
36
  ### When adding a new test that writes data
36
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.2.3",
3
+ "version": "5.2.6",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {