backend-manager 5.1.4 → 5.2.1
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.
- package/.claude/settings.local.json +12 -0
- package/CHANGELOG.md +29 -0
- package/CLAUDE.md +2 -1
- package/README.md +15 -0
- package/docs/common-mistakes.md +1 -0
- package/docs/consent.md +333 -0
- package/docs/testing.md +36 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +44 -8
- package/src/cli/commands/serve.js +73 -7
- package/src/cli/commands/test.js +47 -1
- package/src/cli/commands/watch.js +15 -3
- package/src/manager/helpers/user.js +29 -0
- package/src/manager/index.js +29 -0
- package/src/manager/libraries/email/data/disposable-domains.json +8 -0
- package/src/manager/libraries/email/generators/newsletter.js +2 -2
- package/src/manager/libraries/email/providers/beehiiv.js +1 -0
- package/src/manager/libraries/payment/processors/stripe.js +12 -0
- package/src/manager/libraries/payment/processors/test.js +8 -1
- package/src/manager/routes/admin/infer-contact/post.js +3 -2
- package/src/manager/routes/marketing/email-preferences/post.js +165 -37
- package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
- package/src/manager/routes/marketing/webhook/post.js +180 -0
- package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
- package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
- package/src/manager/routes/payments/cancel/post.js +2 -2
- package/src/manager/routes/payments/cancel/processors/test.js +5 -2
- package/src/manager/routes/payments/intent/processors/test.js +7 -3
- package/src/manager/routes/payments/refund/processors/test.js +4 -1
- package/src/manager/routes/user/signup/post.js +65 -1
- package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
- package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
- package/src/manager/schemas/marketing/webhook/post.js +7 -0
- package/src/manager/schemas/payments/cancel/post.js +5 -0
- package/src/manager/schemas/user/signup/post.js +5 -0
- package/src/test/runner.js +61 -18
- package/src/test/test-accounts.js +94 -12
- package/src/test/utils/http-client.js +4 -3
- package/templates/_.env +1 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
- package/test/events/payments/journey-payments-cancel.js +4 -5
- package/test/events/payments/journey-payments-failure.js +0 -1
- package/test/events/payments/journey-payments-one-time-failure.js +6 -3
- package/test/events/payments/journey-payments-one-time.js +6 -3
- package/test/events/payments/journey-payments-plan-change.js +5 -5
- package/test/events/payments/journey-payments-refund-webhook.js +2 -3
- package/test/events/payments/journey-payments-suspend.js +4 -5
- package/test/events/payments/journey-payments-trial-cancel.js +3 -12
- package/test/events/payments/journey-payments-trial.js +2 -3
- package/test/events/payments/journey-payments-uid-resolution.js +2 -3
- package/test/functions/admin/database-read.js +0 -14
- package/test/functions/admin/database-write.js +0 -14
- package/test/functions/admin/firestore-query.js +0 -14
- package/test/functions/admin/firestore-read.js +0 -15
- package/test/functions/admin/firestore-write.js +0 -11
- package/test/functions/general/add-marketing-contact.js +16 -14
- package/test/helpers/email.js +1 -1
- package/test/helpers/infer-contact.js +3 -3
- package/test/helpers/user.js +241 -2
- package/test/helpers/webhook-forward.js +392 -0
- package/test/marketing/newsletter-generate.js +17 -7
- package/test/routes/admin/database.js +0 -13
- package/test/routes/admin/firestore-query.js +0 -13
- package/test/routes/admin/firestore.js +0 -14
- package/test/routes/admin/infer-contact.js +6 -3
- package/test/routes/admin/post.js +4 -2
- package/test/routes/marketing/contact.js +60 -26
- package/test/routes/marketing/email-preferences.js +145 -69
- package/test/routes/marketing/webhook-forward.js +54 -0
- package/test/routes/marketing/webhook.js +582 -0
- package/test/routes/payments/cancel.js +2 -7
- package/test/routes/payments/dispute-alert.js +0 -39
- package/test/routes/payments/refund.js +3 -1
- package/test/routes/payments/webhook.js +5 -26
- package/test/routes/test/usage.js +2 -2
- package/test/routes/user/signup.js +114 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(grep -B2 -A20 \"Generate a newsletter preview\" /tmp/bem-test-run-3.log | head -50)",
|
|
5
|
+
"Bash(sed 's/\\\\x1b\\\\[[0-9;]*m//g')",
|
|
6
|
+
"Read(//tmp/**)",
|
|
7
|
+
"Bash(TEST_EXTENDED_MODE=1 npx mgr test 2>&1 | sed 's/\\\\x1b\\\\[[0-9;]*m//g' > /tmp/bem-run-6.log; grep -E \"passing|failing|skipped\" /tmp/bem-run-6.log | tail -5)",
|
|
8
|
+
"Bash(awk -F'`' '{print $2}')",
|
|
9
|
+
"Bash(TEST_EXTENDED_MODE=1 npx mgr test 2>&1 | sed 's/\\\\x1b\\\\[[0-9;]*m//g' > /tmp/bem-cleanup-run2.log; grep -E \"\\(passing|failing|skipped\\)\" /tmp/bem-cleanup-run2.log | tail -5)"
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,35 @@ 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.1] - 2026-05-21
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`BACKEND_MANAGER_WEBHOOK_KEY` in `templates/_.env`.** The `.env` scaffold the setup CLI copies into consumer projects now declares the webhook key alongside `BACKEND_MANAGER_KEY`. Required for the new `/marketing/webhook` + `/marketing/webhook/forward` routes shipped in 5.2.0. Existing consumers should add it to their own `.env` manually.
|
|
22
|
+
|
|
23
|
+
# [5.2.0] - 2026-05-21
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **Marketing consent capture (Phase A-B).** Canonical `consent.{legal,marketing}` sub-tree on every user doc (`status` + `grantedAt` / `revokedAt` with timestamp/source/ip/text). Signup route (`src/manager/routes/user/signup/post.js`) builds the record from the client payload using server-side time + IP, defending against client-clock spoofing. `mailer.sync(uid)` is gated on `consent.marketing.status === 'granted'` — opted-out signups never enter SendGrid/Beehiiv marketing lists.
|
|
28
|
+
- **Email-preferences route (Phase D).** `POST /marketing/email-preferences` now supports authenticated opt-in/opt-out (writes user doc + hits both providers via `email.sync()`/`email.remove()`) in addition to the existing HMAC anonymous unsub flow. Anonymous unsub also writes the consent revoke on the user doc with the right `source`.
|
|
29
|
+
- **Cross-provider unsubscribe webhooks (Phase E).** New `POST /marketing/webhook?provider={sendgrid|beehiiv}&key=...` dispatcher with per-provider processor modules. SendGrid events (`unsubscribe`, `group_unsubscribe`, `spamreport`, `bounce`, `dropped`) and Beehiiv events (`subscription.unsubscribed`, `.deleted`, `.paused`) flip `consent.marketing.status` to `revoked`, attribute via `source`, and propagate to the OTHER provider. Idempotent via `marketing-webhooks/{eventId}` docs.
|
|
30
|
+
- **Parent BEM forwarder.** `POST /marketing/webhook/forward` lets a parent BEM (one with `config.parent === 'self'`) fan webhook events out to sibling brands sharing a SendGrid account or Beehiiv publication. New `Manager.getParentUrl()`, `Manager.getParentApiUrl()`, `Manager.isParent()` helpers — children store the parent's `brand.url` with NO `api.` subdomain and the helper inserts `api.` at call time.
|
|
31
|
+
- **Self-contained TEST_EXTENDED_MODE.** `src/test/runner.js` + `src/test/test-accounts.js` now do pre + post-run cleanup of SendGrid/Beehiiv contacts (the only third-party state we can't wipe at start). Pure Firestore/Auth state is still wiped only at start, per existing convention.
|
|
32
|
+
- **New docs.** `docs/consent.md` (consent system + webhook flows + migration template). `docs/testing.md` updated with the post-run-cleanup exception.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- `src/test/runner.js`, `src/test/utils/http-client.js`, `src/cli/commands/test.js`, plus emulator/serve/watch CLIs: renamed `hostingUrl` → `apiUrl` to match the rest of the codebase. Touches most test files for the matching context-API rename.
|
|
37
|
+
- `src/manager/libraries/email/generators/newsletter.js`: uses `Manager.getParentApiUrl()` instead of reading `Manager.config.parent` directly so the `'self'` sentinel and the missing `api.` subdomain are both handled in one place.
|
|
38
|
+
- `src/manager/libraries/email/providers/beehiiv.js`: exposes `getPublicationId` so generators and tests can read it without reaching into the module.
|
|
39
|
+
- Disposable-domain blacklist refreshed (8 new domains) via `prepare-package`'s pre-hook.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- `mailer.sync()` and `mailer.add()` short-circuit when the target user's `consent.marketing.status === 'revoked'` so we never re-add an opted-out user.
|
|
44
|
+
- Payment processor + cancel route touchups picked up by the runner-API rename pass — no behavior change, just keeping signatures aligned.
|
|
45
|
+
|
|
17
46
|
# [5.1.4] - 2026-05-18
|
|
18
47
|
|
|
19
48
|
### Fixed
|
package/CLAUDE.md
CHANGED
|
@@ -122,6 +122,7 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
|
|
|
122
122
|
- [docs/admin-post-route.md](docs/admin-post-route.md) — `POST/PUT /admin/post` blog creation via GitHub (image extraction + `@post/` rewriting)
|
|
123
123
|
- [docs/payment-system.md](docs/payment-system.md) — full payment pipeline: Intent → Webhook → On-Write → Transition; subscription model, statuses, `resolveSubscription()`, transition handlers, processor interface, product config, test processor
|
|
124
124
|
- [docs/marketing-campaigns.md](docs/marketing-campaigns.md) — campaign CRUD routes, recurring campaigns, generator pipeline (newsletter), template-owned schemas, asset hosting, seed campaigns
|
|
125
|
+
- [docs/consent.md](docs/consent.md) — marketing consent capture: canonical `consent.{legal,marketing}` user-doc shape, signup-form capture, account-page toggle, HMAC unsub link, SendGrid+Beehiiv webhook receivers, parent forwarder (`/marketing/webhook/forward`), migration script template
|
|
125
126
|
- [docs/mcp.md](docs/mcp.md) — Model Context Protocol server: 19 tools, stdio + HTTP transports, OAuth, Claude Chat/Code configuration
|
|
126
127
|
|
|
127
128
|
### Subsystems & Libraries
|
|
@@ -133,6 +134,6 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
|
|
|
133
134
|
|
|
134
135
|
### Testing & CLI
|
|
135
136
|
|
|
136
|
-
- [docs/testing.md](docs/testing.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels
|
|
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
138
|
- [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) — `npx mgr firestore:*` and `auth:*` commands, shared flags, examples
|
|
138
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
|
package/README.md
CHANGED
|
@@ -419,6 +419,21 @@ Built-in marketing system with multi-provider support (SendGrid + Beehiiv + FCM
|
|
|
419
419
|
|
|
420
420
|
Configure via `marketing` section in `backend-manager-config.json`. See CLAUDE.md for full documentation.
|
|
421
421
|
|
|
422
|
+
## Marketing Consent
|
|
423
|
+
|
|
424
|
+
GDPR/CASL-compliant consent capture and cross-provider unsubscribe sync.
|
|
425
|
+
|
|
426
|
+
- **Two-checkbox signup form** — separate legal (required) and marketing (optional) consent
|
|
427
|
+
- **Canonical user-doc shape** — `consent.{legal,marketing}.{status, grantedAt, revokedAt}` with full audit metadata (timestamp, source, IP, exact label text)
|
|
428
|
+
- **Server-authoritative timestamps** — client timestamps ignored, defending against clock manipulation
|
|
429
|
+
- **Account-page toggle** — `/account` notifications section lets logged-in users opt in/out, hits both SendGrid + Beehiiv
|
|
430
|
+
- **HMAC unsubscribe links** — email-footer one-click flow continues to work
|
|
431
|
+
- **Provider webhook receivers** — `POST /marketing/webhook?provider=sendgrid|beehiiv&key=X` catches unsubscribe / spam / bounce events from SendGrid and Beehiiv, writes the user doc + syncs to the OTHER provider
|
|
432
|
+
- **Parent forwarder** — single public webhook endpoint (`/marketing/webhook/forward`) on the parent BEM fans out to every brand's child BEM so each one updates its own Firestore
|
|
433
|
+
- **Guard against marketing sync without consent** — signup route and `email.add()` short-circuit when `consent.marketing.status !== 'granted'`
|
|
434
|
+
|
|
435
|
+
See [docs/consent.md](docs/consent.md) for the full architecture, source enum reference, migration script template, and provider configuration steps.
|
|
436
|
+
|
|
422
437
|
## Helper Classes
|
|
423
438
|
|
|
424
439
|
### Assistant
|
package/docs/common-mistakes.md
CHANGED
|
@@ -9,3 +9,4 @@
|
|
|
9
9
|
7. **Use short-circuit returns** — Return early from error conditions
|
|
10
10
|
8. **Increment usage before update** — Call `usage.increment()` then `usage.update()`
|
|
11
11
|
9. **Add Firestore composite indexes for new compound queries** — Any new Firestore query using multiple `.where()` clauses or `.where()` + `.orderBy()` requires a composite index. Add it to `src/cli/commands/setup-tests/helpers/required-indexes.js` (the SSOT). Consumer projects pick these up via `npx mgr setup`, which syncs them into `firestore.indexes.json`. Without the index, the query will crash with `FAILED_PRECONDITION` in production.
|
|
12
|
+
10. **Don't put test data cleanup at the END of a test** — End-of-test cleanup doesn't fire when the previous run was killed mid-execution, so the next run inherits stale state. ALL test-data cleanup belongs in the runner's pre-test phase ([../src/test/test-accounts.js](../src/test/test-accounts.js) `deleteTestUsers()` + [../src/test/runner.js](../src/test/runner.js) `setupAccounts()`). When adding a test that writes data: register the Firestore collection in `testDataCollections` (mixed prod+test data) or `testOnlyCollections` (test-only), or use the `_test-` doc-id prefix so the id-keyed pass catches it. The only acceptable trailing cleanup is within-run state isolation (one test removes a doc so the NEXT test in the same run sees a clean slate) — that's not preparing the next run, it's intra-run housekeeping. See [testing.md](testing.md) "Test Data Cleanup".
|
package/docs/consent.md
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# Marketing Consent System
|
|
2
|
+
|
|
3
|
+
Captures, stores, and synchronizes user consent for legal terms (ToS + Privacy) and marketing communications across SendGrid + Beehiiv. Designed for GDPR / CASL / CAN-SPAM compliance with full audit metadata.
|
|
4
|
+
|
|
5
|
+
This doc covers the **server-side** (BEM) part of the system. The matching frontend pieces live in [ultimate-jekyll-manager](https://github.com/itw-creative-works/ultimate-jekyll-manager) and [web-manager](https://github.com/itw-creative-works/web-manager).
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
1. **Capture explicit, affirmative consent** at signup with separate checkboxes for legal terms (required) and marketing communications (optional). Store the exact label text the user agreed to.
|
|
10
|
+
2. **Let users withdraw consent at any time** via the account-page toggle or the email-footer unsubscribe link.
|
|
11
|
+
3. **Stay in sync with provider-side actions** — when a user clicks unsubscribe in a SendGrid or Beehiiv email, the user doc updates AND the OTHER provider is also notified.
|
|
12
|
+
4. **Never re-add an unsubscribed user** — `email.add()` and `email.sync()` short-circuit when `consent.marketing.status === 'revoked'`.
|
|
13
|
+
|
|
14
|
+
## Canonical user-doc shape
|
|
15
|
+
|
|
16
|
+
Every user doc has a `consent` object with two sub-trees:
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
consent: {
|
|
20
|
+
legal: {
|
|
21
|
+
status: 'granted' | 'revoked',
|
|
22
|
+
grantedAt: { timestamp, timestampUNIX, source, ip, text },
|
|
23
|
+
},
|
|
24
|
+
marketing: {
|
|
25
|
+
status: 'granted' | 'revoked',
|
|
26
|
+
grantedAt: { timestamp, timestampUNIX, source, ip, text },
|
|
27
|
+
revokedAt: { timestamp, timestampUNIX, source, ip, text },
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Field semantics:**
|
|
33
|
+
|
|
34
|
+
- `status` — single-source-of-truth boolean state, expressed as a string enum so future states (`'pending'`, `'expired'`) don't break the schema.
|
|
35
|
+
- `grantedAt` / `revokedAt` — full audit metadata for the **most recent** transition of each kind. Both ALWAYS present on the doc; nulls live at the leaves (e.g. `grantedAt: { timestamp: null, ... }`), never at the object boundary. Matches BEM's `subscription.expires` / `payment.startDate` conventions.
|
|
36
|
+
- `legal` only has `grantedAt` (no `revokedAt`) because revoking legal consent = deleting the account.
|
|
37
|
+
- `text` records the **exact wording** the user agreed to. Critical for audit defense if a marketing label is challenged later.
|
|
38
|
+
|
|
39
|
+
**Object always present, nulls at leaves.** No special-casing required when reading — `account.consent.marketing.grantedAt.timestamp` is either an ISO string or `null`, never `undefined`.
|
|
40
|
+
|
|
41
|
+
### Source enum
|
|
42
|
+
|
|
43
|
+
| Source | Where it fires | Side |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `'signup'` | Signup form checkbox toggled at account creation | both |
|
|
46
|
+
| `'account'` | `/account` notifications page toggle | both |
|
|
47
|
+
| `'admin'` | Manual admin override | both |
|
|
48
|
+
| `'imported'` | Legacy user migration backfill | both |
|
|
49
|
+
| `'sendgrid'` | SendGrid webhook event (`group_unsubscribe`, etc.) | revoke only |
|
|
50
|
+
| `'beehiiv'` | Beehiiv webhook event (`subscription.unsubscribed`, etc.) | revoke only |
|
|
51
|
+
|
|
52
|
+
## Capture points
|
|
53
|
+
|
|
54
|
+
There are four places where consent gets recorded or updated. All four converge on the same canonical shape.
|
|
55
|
+
|
|
56
|
+
### 1. Signup form (Phase B)
|
|
57
|
+
|
|
58
|
+
[src/manager/routes/user/signup/post.js](../src/manager/routes/user/signup/post.js) — the existing `/user/signup` route now accepts a `consent` settings field:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
// Client sends (lightweight transit shape):
|
|
62
|
+
{
|
|
63
|
+
consent: {
|
|
64
|
+
legal: { granted: true, text: 'I agree to ...' },
|
|
65
|
+
marketing: { granted: false, text: 'I agree to receive ...' },
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`buildConsentRecord(assistant, settings.consent)` translates this into the canonical user-doc shape:
|
|
71
|
+
|
|
72
|
+
- `legal.granted: true` → `legal.status = 'granted'`, `grantedAt` populated with `source: 'signup'` + **server timestamp** + server-detected IP + exact label text.
|
|
73
|
+
- `marketing.granted: false` → `marketing.status = 'revoked'`, `grantedAt` all-null, `revokedAt` populated with `source: 'signup'`. (Records the explicit decline.)
|
|
74
|
+
- Missing `consent` block (legacy client) → both default to `'revoked'`.
|
|
75
|
+
|
|
76
|
+
**Server time is authoritative.** Client-supplied timestamps are ignored — defends against clock manipulation by malicious clients.
|
|
77
|
+
|
|
78
|
+
**Strict boolean check.** Only `granted === true` counts as granted. `'true'`, `1`, or other truthy values are rejected.
|
|
79
|
+
|
|
80
|
+
**Marketing sync gating.** After writing the user doc, the route checks `userRecord.consent.marketing.status === 'granted'` before calling `mailer.sync(uid)`. Declining the marketing checkbox means the user is created normally, gets transactional emails, but is NEVER added to SendGrid / Beehiiv marketing lists.
|
|
81
|
+
|
|
82
|
+
### 2. Account-page toggle (Phase D)
|
|
83
|
+
|
|
84
|
+
[src/manager/routes/marketing/email-preferences/post.js](../src/manager/routes/marketing/email-preferences/post.js) — authenticated mode.
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
POST /backend-manager/marketing/email-preferences
|
|
88
|
+
Body: { action: 'subscribe' | 'unsubscribe' }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- Requires authentication (uses the calling user's auth UID and email).
|
|
92
|
+
- Rate-limited per-user via `Manager.Usage().init(assistant)` (5/day).
|
|
93
|
+
- Writes `consent.marketing.{status, grantedAt|revokedAt}` to the user doc with `source: 'account'` + server time + server IP.
|
|
94
|
+
- Calls `mailer.sync(uid)` on subscribe, `mailer.remove(email)` on unsubscribe — hits both SendGrid + Beehiiv via the email library.
|
|
95
|
+
|
|
96
|
+
Note: `grantedAt.text` is `null` for account-page subscribes because the marketing label text is not currently passed from the frontend toggle (TODO if needed).
|
|
97
|
+
|
|
98
|
+
### 3. HMAC unsubscribe link (legacy, Phase D)
|
|
99
|
+
|
|
100
|
+
Same route, anonymous mode. The existing email-footer unsubscribe link flow:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
POST /backend-manager/marketing/email-preferences
|
|
104
|
+
Body: { email, asmId, sig, action: 'subscribe' | 'unsubscribe' }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- `sig = HMAC-SHA256(email, UNSUBSCRIBE_HMAC_KEY)` — proves we generated the link.
|
|
108
|
+
- IP-rate-limited (5/day per IP).
|
|
109
|
+
- **Also writes the user doc** if the email maps to a user — `consent.marketing.{status, revokedAt}` with `source: 'sendgrid'` (since HMAC links only appear in SendGrid email footers).
|
|
110
|
+
- Backward-compatible — old in-flight email links continue to work.
|
|
111
|
+
|
|
112
|
+
### 4. Provider webhooks (Phase E)
|
|
113
|
+
|
|
114
|
+
[src/manager/routes/marketing/webhook/post.js](../src/manager/routes/marketing/webhook/post.js) — receives unsub / spam / bounce events from SendGrid and Beehiiv.
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
POST /backend-manager/marketing/webhook?provider=sendgrid&key=<BACKEND_MANAGER_WEBHOOK_KEY>
|
|
118
|
+
POST /backend-manager/marketing/webhook?provider=beehiiv&key=<BACKEND_MANAGER_WEBHOOK_KEY>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The dispatcher loads `processors/{provider}.js`, parses the event(s), and for each event:
|
|
122
|
+
|
|
123
|
+
1. Checks `isSupported(eventType)` — filters out non-revoke events like `delivered` / `open`.
|
|
124
|
+
2. Idempotency lookup in `marketing-webhooks/{eventId}` — skips if already processed.
|
|
125
|
+
3. Calls `handleEvent({ Manager, assistant, parsed })` on the processor.
|
|
126
|
+
4. Marks the idempotency doc `status: 'completed'` (or `'failed'` with error details).
|
|
127
|
+
|
|
128
|
+
Each processor's `handleEvent` does the same shape of work:
|
|
129
|
+
|
|
130
|
+
1. Look up the user by `auth.email` in THIS brand's Firestore. Silent skip if not found (the email may belong to a sibling brand — see "Parent forwarder" below).
|
|
131
|
+
2. Write `consent.marketing.status = 'revoked'` with the appropriate `source` ('sendgrid' or 'beehiiv'), preserving `grantedAt` as informational audit history.
|
|
132
|
+
3. Call `mailer.remove(email)` to sync the unsubscribe to the OTHER provider (best-effort, idempotent on 404).
|
|
133
|
+
|
|
134
|
+
**Supported event types:**
|
|
135
|
+
|
|
136
|
+
| Provider | Event types treated as revoke |
|
|
137
|
+
|---|---|
|
|
138
|
+
| SendGrid | `unsubscribe`, `group_unsubscribe`, `spamreport`, `bounce`, `dropped` |
|
|
139
|
+
| Beehiiv | `subscription.unsubscribed`, `subscription.deleted`, `subscription.paused` |
|
|
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.
|
|
142
|
+
|
|
143
|
+
## Parent forwarder (Phase E)
|
|
144
|
+
|
|
145
|
+
[src/manager/routes/marketing/webhook/forward/post.js](../src/manager/routes/marketing/webhook/forward/post.js)
|
|
146
|
+
|
|
147
|
+
SendGrid and Beehiiv only let you configure a small number of webhook URLs (often one per account). With many brands sharing the same SendGrid account, we can't point the webhook at every brand's BEM directly. Instead:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
SendGrid → POST https://api.itwcreativeworks.com/backend-manager/marketing/webhook/forward?provider=sendgrid&key=X
|
|
151
|
+
Beehiiv → POST https://api.itwcreativeworks.com/backend-manager/marketing/webhook/forward?provider=beehiiv&key=X
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The **parent BEM** (the one whose `backend-manager-config.json` has `parent: 'self'`) exposes the forwarder route. Every other BEM has the route but it returns 404 (gated on `Manager.config.parent === 'self'`).
|
|
155
|
+
|
|
156
|
+
The parent forwarder:
|
|
157
|
+
|
|
158
|
+
1. Validates `?provider=X&key=Y` (same `BACKEND_MANAGER_WEBHOOK_KEY` env var — shared across all brands).
|
|
159
|
+
2. Reads the `brands` collection from the parent's own Firestore.
|
|
160
|
+
3. For each brand: derives the child API URL by inserting `api.` into the brand's URL (`https://somiibo.com` → `https://api.somiibo.com/backend-manager/marketing/webhook?provider=X&key=Y`).
|
|
161
|
+
4. POSTs the raw provider body to every child in parallel via `Promise.allSettled`.
|
|
162
|
+
5. Returns 200 even if some children fail — child idempotency makes provider retries safe.
|
|
163
|
+
|
|
164
|
+
### Why fan-out instead of central processing
|
|
165
|
+
|
|
166
|
+
Each brand has its own Firebase project, so its `users` collection is separate. The parent can't write to a child's Firestore. By having each child process the event against its own users, we get:
|
|
167
|
+
|
|
168
|
+
- **Correct per-brand updates** — only brands where the user actually has an account update their user docs.
|
|
169
|
+
- **Failure isolation** — one child being down doesn't block updates on the others.
|
|
170
|
+
- **Local idempotency** — each child tracks `marketing-webhooks/{eventId}` in its own Firestore.
|
|
171
|
+
- **No new schema** — no need for the parent to maintain a brand → publication map; each child filters on its own.
|
|
172
|
+
|
|
173
|
+
### Why self IS in the fan-out
|
|
174
|
+
|
|
175
|
+
The parent BEM has its own brand (e.g. `itw-creative-works`) with its own users. By fanning out via HTTP to itself like any other child, the parent's brand processes its users the same way as siblings — no special-case inline path.
|
|
176
|
+
|
|
177
|
+
### Shared-publication scenario (Beehiiv devbeans)
|
|
178
|
+
|
|
179
|
+
1. User on shared "devbeans" publication clicks unsubscribe.
|
|
180
|
+
2. Beehiiv posts event with `publication_id: pub_69c961a7...` to parent's `/marketing/webhook/forward`.
|
|
181
|
+
3. Parent fans out the raw event to all N brands.
|
|
182
|
+
4. The 6 brands sharing the devbeans publication: `getPublicationId()` matches, they each look up the user, only the brand(s) with the user write the doc and call `mailer.remove`.
|
|
183
|
+
5. The brands with dedicated publications: `getPublicationId()` mismatch, silent skip.
|
|
184
|
+
|
|
185
|
+
## Email library short-circuit
|
|
186
|
+
|
|
187
|
+
[src/manager/libraries/email/marketing/index.js](../src/manager/libraries/email/marketing/index.js)
|
|
188
|
+
|
|
189
|
+
`email.add()` and `email.sync()` check the user's `consent.marketing.status` before contacting providers. A user marked `'revoked'` is never re-added. This is the safety net against accidental re-subscription via batch syncs or campaign sends.
|
|
190
|
+
|
|
191
|
+
## Configuration
|
|
192
|
+
|
|
193
|
+
### Env vars (per brand)
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
# All brands
|
|
197
|
+
BACKEND_MANAGER_WEBHOOK_KEY="<shared-across-all-brands>"
|
|
198
|
+
|
|
199
|
+
# Existing (unchanged)
|
|
200
|
+
UNSUBSCRIBE_HMAC_KEY="<existing-value>"
|
|
201
|
+
SENDGRID_API_KEY="<account-wide>"
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The webhook key is shared because it has to be the same value the parent forwards to each child. Rotate by updating every brand's env in lockstep.
|
|
205
|
+
|
|
206
|
+
### Provider dashboard setup
|
|
207
|
+
|
|
208
|
+
**SendGrid Event Webhook** (Settings → Mail Settings → Event Webhook):
|
|
209
|
+
```
|
|
210
|
+
URL: https://api.itwcreativeworks.com/backend-manager/marketing/webhook/forward?provider=sendgrid&key=<BACKEND_MANAGER_WEBHOOK_KEY>
|
|
211
|
+
Events: Group Unsubscribe, Unsubscribe, Spam Report, Bounce, Dropped
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**Beehiiv Webhooks** (per-publication setup):
|
|
215
|
+
- Dedicated publications: point at the single brand's parent URL.
|
|
216
|
+
- Shared "devbeans" publication: point at the parent URL — fan-out handles the routing.
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
URL: https://api.itwcreativeworks.com/backend-manager/marketing/webhook/forward?provider=beehiiv&key=<BACKEND_MANAGER_WEBHOOK_KEY>
|
|
220
|
+
Events: subscription.unsubscribed, subscription.deleted, subscription.paused
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Parent vs child config
|
|
224
|
+
|
|
225
|
+
Parent's `backend-manager-config.json`:
|
|
226
|
+
```js
|
|
227
|
+
{
|
|
228
|
+
parent: 'self',
|
|
229
|
+
brand: { id: 'itw-creative-works', url: 'https://itwcreativeworks.com', ... },
|
|
230
|
+
...
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Every other brand:
|
|
235
|
+
```js
|
|
236
|
+
{
|
|
237
|
+
parent: 'https://itwcreativeworks.com', // NO `api.` subdomain — inserted at call time
|
|
238
|
+
brand: { id: 'somiibo', url: 'https://somiibo.com', ... },
|
|
239
|
+
...
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Convention:** `parent` stores the parent's brand URL (matching the format of `brand.url`), NOT the API URL. The `api.` subdomain is inserted at call time by `Manager.getParentApiUrl()`. This keeps stored config in one consistent format and lets the deployment convention (`api.` subdomain) live in one place.
|
|
244
|
+
|
|
245
|
+
**Three helpers** on the Manager instance for working with this:
|
|
246
|
+
|
|
247
|
+
- `Manager.getParentUrl()` — returns the parent's brand URL. Resolves `'self'` to `Manager.config.brand.url`.
|
|
248
|
+
- `Manager.getParentApiUrl()` — returns the parent's API URL (`https://api.{host}`). **Always live** — does NOT redirect to localhost in dev mode, because you can't run two Firebase emulators simultaneously. The parent's API is always the production URL regardless of which environment THIS brand is in.
|
|
249
|
+
- `Manager.isParent()` — boolean, true when `config.parent === 'self'`.
|
|
250
|
+
|
|
251
|
+
Only the BEM where `Manager.isParent()` returns true exposes `/marketing/webhook/forward`. Everywhere else, the route is invisible (404).
|
|
252
|
+
|
|
253
|
+
## Legacy user migration
|
|
254
|
+
|
|
255
|
+
Existing users created BEFORE the consent system has no `consent` field. They need a one-time backfill. The shape per the agreed strategy:
|
|
256
|
+
|
|
257
|
+
```js
|
|
258
|
+
// For every legacy user doc
|
|
259
|
+
{
|
|
260
|
+
consent: {
|
|
261
|
+
legal: {
|
|
262
|
+
status: 'granted', // implicit from active account
|
|
263
|
+
grantedAt: {
|
|
264
|
+
timestamp: userDoc.metadata?.created?.timestamp || null,
|
|
265
|
+
timestampUNIX: userDoc.metadata?.created?.timestampUNIX || null,
|
|
266
|
+
source: 'imported',
|
|
267
|
+
ip: userDoc.activity?.geolocation?.ip || null,
|
|
268
|
+
text: null, // don't fabricate label text
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
marketing: {
|
|
272
|
+
status: 'revoked', // no opt-in on record
|
|
273
|
+
grantedAt: { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null },
|
|
274
|
+
revokedAt: {
|
|
275
|
+
timestamp: userDoc.metadata?.created?.timestamp || null,
|
|
276
|
+
timestampUNIX: userDoc.metadata?.created?.timestampUNIX || null,
|
|
277
|
+
source: 'imported',
|
|
278
|
+
ip: userDoc.activity?.geolocation?.ip || null,
|
|
279
|
+
text: null,
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**Idempotency guard:** skip docs where `consent.legal.grantedAt.source` already has a non-null value (those went through the new signup flow or a prior migration run).
|
|
287
|
+
|
|
288
|
+
Run the migration BEFORE enabling the frontend's page-load consent guard (see UJM `ENFORCE_CONSENT_GUARD` flag in `src/assets/js/core/auth.js`). Otherwise legacy users without `consent.legal.status === 'granted'` get signed out on every page load.
|
|
289
|
+
|
|
290
|
+
After the migration: optionally run a re-opt-in drip campaign to legally recover marketing consent for the users you bulk-revoked.
|
|
291
|
+
|
|
292
|
+
## Test coverage
|
|
293
|
+
|
|
294
|
+
**BEM tests:**
|
|
295
|
+
|
|
296
|
+
- [test/helpers/user.js](../test/helpers/user.js) — 31 tests covering the canonical schema, defaults, granted/revoked states, round-tripping
|
|
297
|
+
- [test/routes/user/signup.js](../test/routes/user/signup.js) — 3 tests for signup-time consent capture (granted both, marketing declined, missing payload)
|
|
298
|
+
- [test/routes/marketing/email-preferences.js](../test/routes/marketing/email-preferences.js) — 14 tests for the email-preferences route (anonymous HMAC + authenticated)
|
|
299
|
+
- [test/routes/marketing/webhook.js](../test/routes/marketing/webhook.js) — 15+ tests covering SendGrid + Beehiiv processors against the emulator
|
|
300
|
+
- [test/routes/marketing/webhook-forward.js](../test/routes/marketing/webhook-forward.js) — verifies the forwarder route returns 404 on non-parent BEMs
|
|
301
|
+
- [test/helpers/webhook-forward.js](../test/helpers/webhook-forward.js) — 12 unit-style tests with mocked admin + fetch, covering fan-out, URL derivation, failure isolation, self-inclusion, edge cases
|
|
302
|
+
|
|
303
|
+
**Total: 75+ tests across the consent system.**
|
|
304
|
+
|
|
305
|
+
Run with `npx mgr test` (full suite) or `npx mgr test routes/marketing/webhook` (just the webhook tests).
|
|
306
|
+
|
|
307
|
+
### Provider-side cleanup (extended mode only)
|
|
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.
|
|
310
|
+
|
|
311
|
+
To keep this self-contained, the runner calls [`cleanupMarketingProviders`](../src/test/test-accounts.js) **twice** per run:
|
|
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.
|
|
319
|
+
|
|
320
|
+
## Frontend pieces (cross-references)
|
|
321
|
+
|
|
322
|
+
- **UJM signup form** — [signup.html](https://github.com/itw-creative-works/ultimate-jekyll-manager/blob/main/src/defaults/dist/_layouts/themes/classy/frontend/pages/auth/signup.html) (two consent checkboxes, inline error UX)
|
|
323
|
+
- **UJM auth library** — [libs/auth.js](https://github.com/itw-creative-works/ultimate-jekyll-manager/blob/main/src/assets/js/libs/auth.js) (`captureSignupConsent`, `validateConsent`, `reverseAccidentalSignup` for the Google-on-signin quirk)
|
|
324
|
+
- **UJM core auth listener** — [core/auth.js](https://github.com/itw-creative-works/ultimate-jekyll-manager/blob/main/src/assets/js/core/auth.js) (`ENFORCE_CONSENT_GUARD` flag, page-load silent-signout for orphan accounts)
|
|
325
|
+
- **UJM account page** — [account/index.html](https://github.com/itw-creative-works/ultimate-jekyll-manager/blob/main/src/defaults/dist/_layouts/themes/classy/frontend/pages/account/index.html) + [sections/notifications.js](https://github.com/itw-creative-works/ultimate-jekyll-manager/blob/main/src/assets/js/pages/account/sections/notifications.js)
|
|
326
|
+
- **Web Manager DEFAULT_ACCOUNT** — [modules/auth.js](https://github.com/itw-creative-works/web-manager/blob/main/src/modules/auth.js) (consent fields with `'revoked'` defaults so legacy reads don't crash)
|
|
327
|
+
|
|
328
|
+
## Future work
|
|
329
|
+
|
|
330
|
+
- **Country-aware default checkbox state** — pre-check both checkboxes in jurisdictions where it's legally permitted (e.g. US under CAN-SPAM). Out of scope for the initial rollout; TODO comment in signup.html.
|
|
331
|
+
- **Re-consent flow for material label changes** — if the marketing label text changes meaningfully, prompt existing users to re-consent (versioning via the stored `text` field).
|
|
332
|
+
- **Audit-log sub-collection** — currently only the most-recent transition is kept. If legal needs full history, add `users/{uid}/consent-history/{transition-id}`.
|
|
333
|
+
- **ECDSA / HMAC signature verification** on webhooks — SendGrid supports it, Beehiiv requires HMAC. Currently bearer-token only (`?key=`). Future hardening.
|
package/docs/testing.md
CHANGED
|
@@ -11,6 +11,42 @@ npx mgr test # Terminal 2 - runs tests
|
|
|
11
11
|
npx mgr test
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
## Test Data Cleanup — at the START of every run
|
|
15
|
+
|
|
16
|
+
**Hard rule: all LOCAL cleanup happens BEFORE the suite runs, never after.** If a previous run was killed mid-execution (Ctrl-C, OOM, emulator crash), end-of-run cleanup would never fire and the next run would inherit polluted state — broken trial-eligibility checks, leftover dispute alerts, stale webhook docs, polluted marketing-provider lists. Pre-test cleanup makes every run idempotent regardless of how the last one died.
|
|
17
|
+
|
|
18
|
+
What the runner wipes pre-test (in [src/test/test-accounts.js](../src/test/test-accounts.js) `deleteTestUsers()` and [src/test/runner.js](../src/test/runner.js) `setupAccounts()`):
|
|
19
|
+
|
|
20
|
+
1. **`meta/stats`** doc ensured (required for on-create batch writes).
|
|
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:
|
|
24
|
+
- Pass 1 — owner-keyed: `where('owner', 'in', [...testUids])` (batched at 30 uids per `in` query).
|
|
25
|
+
- 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()`).
|
|
28
|
+
|
|
29
|
+
### The one exception: third-party provider cleanup runs at start AND end
|
|
30
|
+
|
|
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.
|
|
32
|
+
|
|
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.
|
|
34
|
+
|
|
35
|
+
### When adding a new test that writes data
|
|
36
|
+
|
|
37
|
+
| If your test writes to... | Then... |
|
|
38
|
+
|---|---|
|
|
39
|
+
| A new Firestore collection (mixed test + prod data) | Add the collection name to `testDataCollections` in `src/test/test-accounts.js`. |
|
|
40
|
+
| A new Firestore collection (test-only data) | Add it to `testOnlyCollections` in `src/test/test-accounts.js`. |
|
|
41
|
+
| Realtime Database | Use a path under `_test/...` — already wiped pre-test. |
|
|
42
|
+
| Anywhere else | Use the `_test-` doc-id prefix so id-keyed pass-2 catches it. |
|
|
43
|
+
|
|
44
|
+
### Within-run state isolation is different
|
|
45
|
+
|
|
46
|
+
Per-test cleanup is still appropriate when a test sets up DB state that would pollute a **later test in the same run** — e.g. trial-eligibility's `try/finally` that removes the fake `payments-orders/_test-trial-eligibility-*` doc so the next sibling test sees a clean slate. Those stay in the test. They are *intra-run* state management, not *next-run* cleanup, and the distinction matters.
|
|
47
|
+
|
|
48
|
+
The rule: **never put cleanup at the END of a test file or suite for the purpose of preparing the next run** — for LOCAL state. If a test cleans up local Firestore/Auth data only to "leave no trace," that cleanup belongs in the runner's pre-test phase instead. Add the collection/namespace to the runner's wipe list and remove the trailing cleanup step. The third-party provider exception (see above) lives in the runner's post-suite hook, not in individual tests.
|
|
49
|
+
|
|
14
50
|
## Extended Mode (`TEST_EXTENDED_MODE`)
|
|
15
51
|
|
|
16
52
|
Several routes/handlers skip external API calls (SendGrid, Beehiiv, Stripe webhooks, dispute handlers, marketing libraries) when `process.env.TEST_EXTENDED_MODE` is unset, so unit tests don't fire real emails or webhook side effects. Set the flag to opt **in** to those side effects for a full end-to-end run.
|
package/package.json
CHANGED
|
@@ -82,15 +82,47 @@ class EmulatorCommand extends BaseCommand {
|
|
|
82
82
|
: 'BEM_TESTING=true';
|
|
83
83
|
const emulatorCommand = `${envPrefix} firebase emulators:exec --only functions,firestore,auth,database,hosting,pubsub --ui "${command}"`;
|
|
84
84
|
|
|
85
|
-
// Set up log file in the project directory
|
|
85
|
+
// Set up log file in the project directory.
|
|
86
|
+
// We use a mutable `currentStream` so the test command can request a fresh log
|
|
87
|
+
// by touching emulator.log.reset — the watcher below detects it, closes the
|
|
88
|
+
// current stream, reopens with flags: 'w' (truncating cleanly from our process'
|
|
89
|
+
// perspective, no sparse-file issue), and deletes the sentinel.
|
|
86
90
|
const logPath = path.join(projectDir, 'functions', 'emulator.log');
|
|
87
|
-
const
|
|
91
|
+
const resetSentinelPath = `${logPath}.reset`;
|
|
88
92
|
const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
89
93
|
|
|
94
|
+
let currentStream = fs.createWriteStream(logPath, { flags: 'w' });
|
|
95
|
+
|
|
96
|
+
function writeToLog(data) {
|
|
97
|
+
if (currentStream && !currentStream.destroyed) {
|
|
98
|
+
currentStream.write(stripAnsi(data.toString()));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Clean up any stale sentinel from a prior crashed emulator run
|
|
103
|
+
try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* not present, ok */ }
|
|
104
|
+
|
|
105
|
+
// Watch for the test command's request to roll the log.
|
|
106
|
+
// Poll every 500ms — cheap, no fs.watch quirks across platforms.
|
|
107
|
+
const resetWatcher = setInterval(() => {
|
|
108
|
+
if (!fs.existsSync(resetSentinelPath)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const oldStream = currentStream;
|
|
114
|
+
currentStream = fs.createWriteStream(logPath, { flags: 'w' });
|
|
115
|
+
oldStream.end();
|
|
116
|
+
fs.unlinkSync(resetSentinelPath);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
// Best-effort. If reset fails the test still runs, the log just won't be fresh.
|
|
119
|
+
}
|
|
120
|
+
}, 500);
|
|
121
|
+
|
|
90
122
|
// Write pre-emulator info to log file
|
|
91
123
|
if (process.env.TEST_EXTENDED_MODE) {
|
|
92
|
-
EXTENDED_MODE_WARNING.forEach((line) =>
|
|
93
|
-
|
|
124
|
+
EXTENDED_MODE_WARNING.forEach((line) => writeToLog(`${line}\n`));
|
|
125
|
+
writeToLog('\n');
|
|
94
126
|
}
|
|
95
127
|
|
|
96
128
|
this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
|
|
@@ -106,18 +138,22 @@ class EmulatorCommand extends BaseCommand {
|
|
|
106
138
|
// Tee stdout to both console and log file (strip ANSI codes for clean log)
|
|
107
139
|
child.stdout.on('data', (data) => {
|
|
108
140
|
process.stdout.write(data);
|
|
109
|
-
|
|
141
|
+
writeToLog(data);
|
|
110
142
|
});
|
|
111
143
|
|
|
112
144
|
// Tee stderr to both console and log file (strip ANSI codes for clean log)
|
|
113
145
|
child.stderr.on('data', (data) => {
|
|
114
146
|
process.stderr.write(data);
|
|
115
|
-
|
|
147
|
+
writeToLog(data);
|
|
116
148
|
});
|
|
117
149
|
|
|
118
|
-
// Clean up log stream when child exits
|
|
150
|
+
// Clean up log stream + watcher when child exits
|
|
119
151
|
child.on('close', () => {
|
|
120
|
-
|
|
152
|
+
clearInterval(resetWatcher);
|
|
153
|
+
if (currentStream && !currentStream.destroyed) {
|
|
154
|
+
currentStream.end();
|
|
155
|
+
}
|
|
156
|
+
try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* ok */ }
|
|
121
157
|
});
|
|
122
158
|
});
|
|
123
159
|
}
|