backend-manager 5.0.118 → 5.0.120

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/CHANGELOG.md CHANGED
@@ -14,6 +14,34 @@ 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.0.119] - 2026-03-07
18
+ ### Added
19
+ - `POST /marketing/email-preferences` route for unsubscribe/resubscribe via SendGrid ASM suppression groups
20
+ - HMAC signature verification (`UNSUBSCRIBE_HMAC_KEY`) on unsubscribe links to prevent forged requests
21
+ - HMAC signature generation in email library when building unsubscribe URLs
22
+ - `UNSUBSCRIBE_HMAC_KEY` environment variable in template `.env`
23
+ - Test suite for email-preferences endpoint (10 tests covering sig verification, validation, auth)
24
+
25
+ ### Changed
26
+ - Unsubscribe URL in emails no longer includes `appName` and `appUrl` params (replaced by HMAC sig)
27
+
28
+ # [5.0.118] - 2026-03-06
29
+ ### Added
30
+ - Chargebee payment processor with full pipeline support (intent, webhook, cancel, refund, portal).
31
+ - Chargebee shared library (`payment/processors/chargebee.js`) with raw HTTP API wrapper, unified subscription/one-time transformers, and both Items model (new) and Plans model (legacy) product resolution.
32
+ - Chargebee webhook processor supporting subscription lifecycle events (`subscription_created`, `subscription_cancelled`, `subscription_renewed`, `payment_failed`, `payment_refunded`, etc.) and one-time invoice events.
33
+ - Chargebee intent processor for hosted page checkout (subscriptions and one-time purchases) with deterministic item price IDs (`{itemId}-{frequency}`).
34
+ - Chargebee cancel processor with immediate cancellation during trials and end-of-term cancellation otherwise.
35
+ - Chargebee refund processor with 7-day full/prorated refund logic (matching Stripe/PayPal behavior).
36
+ - Chargebee portal processor for self-service subscription management via Chargebee Portal Sessions.
37
+ - Backwards compatibility for legacy Chargebee subscriptions: reads `cf_clientorderid`/`cf_uid` custom fields alongside new `meta_data` JSON format.
38
+ - Chargebee test suite: `to-unified-subscription`, `to-unified-one-time`, and `parse-webhook` group tests with fixtures covering all status mappings, product resolution (Items + legacy Plans), and edge cases.
39
+ - Chargebee customer name extraction from `shipping_address`/`billing_address` in webhook on-write pipeline.
40
+ - `chargebee` config keys in product templates (`itemId`, `legacyPlanIds`).
41
+
42
+ ### Changed
43
+ - `CHARGEBEE_SITE` environment variable is now set from config in Manager init (matching PayPal pattern), so the Chargebee library doesn't need a Manager reference.
44
+
17
45
  # [5.0.111] - 2026-03-05
18
46
  ### Changed
19
47
  - PayPal client ID is now read from `backend-manager-config.json` (`payment.processors.paypal.clientId`) instead of requiring a `PAYPAL_CLIENT_ID` environment variable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.118",
3
+ "version": "5.0.120",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -8,11 +8,11 @@ class RemoteconfigTemplateInJsonTest extends BaseTest {
8
8
  }
9
9
 
10
10
  async run() {
11
- return this.self.firebaseJSON?.remoteconfig?.template === 'remoteconfig.template.json';
11
+ return this.self.firebaseJSON?.remoteconfig?.template === 'functions/remoteconfig.template.json';
12
12
  }
13
13
 
14
14
  async fix() {
15
- _.set(this.self.firebaseJSON, 'remoteconfig.template', 'remoteconfig.template.json');
15
+ _.set(this.self.firebaseJSON, 'remoteconfig.template', 'functions/remoteconfig.template.json');
16
16
  jetpack.write(`${this.self.firebaseProjectPath}/firebase.json`, JSON.stringify(this.self.firebaseJSON, null, 2));
17
17
  }
18
18
  }
@@ -161,7 +161,11 @@ Email.prototype.build = async function (settings) {
161
161
  const sendAt = normalizeSendAt(settings.sendAt);
162
162
 
163
163
  // Build unsubscribe URL
164
- const unsubscribeUrl = `${Manager.project.websiteUrl}/portal/account/email-preferences?email=${encode(to[0].email)}&asmId=${encode(groupId)}&templateId=${encode(templateId)}&appName=${brandData.name}&appUrl=${brandData.url}`;
164
+ // Generate HMAC signature for unsubscribe link verification
165
+ const crypto = require('crypto');
166
+ const unsubSig = crypto.createHmac('sha256', process.env.UNSUBSCRIBE_HMAC_KEY).update(to[0].email.toLowerCase()).digest('hex');
167
+
168
+ const unsubscribeUrl = `${Manager.project.websiteUrl}/portal/email-preferences?email=${encode(to[0].email)}&asmId=${encode(groupId)}&templateId=${encode(templateId)}&sig=${unsubSig}`;
165
169
 
166
170
  // Build signoff
167
171
  const signoff = settings?.data?.signoff || {};
@@ -0,0 +1,108 @@
1
+ /**
2
+ * POST /marketing/email-preferences - Update email preferences
3
+ * Public endpoint — no authentication required
4
+ *
5
+ * Supports two actions:
6
+ * - "unsubscribe": Adds email to SendGrid ASM suppression group
7
+ * - "resubscribe": Removes email from SendGrid ASM suppression group
8
+ */
9
+ const fetch = require('wonderful-fetch');
10
+ const crypto = require('crypto');
11
+
12
+ const RATE_LIMIT = 5;
13
+
14
+ module.exports = async ({ assistant, Manager, settings, analytics }) => {
15
+
16
+ // Extract parameters
17
+ const email = (settings.email || '').trim().toLowerCase();
18
+ const asmId = parseInt(settings.asmId, 10);
19
+ const action = settings.action || 'unsubscribe';
20
+
21
+ // Validate email
22
+ if (!email) {
23
+ return assistant.respond('Email is required', { code: 400 });
24
+ }
25
+
26
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
27
+ if (!emailRegex.test(email)) {
28
+ return assistant.respond('Invalid email format', { code: 400 });
29
+ }
30
+
31
+ // Validate ASM group ID
32
+ if (!asmId || isNaN(asmId)) {
33
+ return assistant.respond('ASM group ID is required', { code: 400 });
34
+ }
35
+
36
+ // Validate action
37
+ if (action !== 'unsubscribe' && action !== 'resubscribe') {
38
+ return assistant.respond('Invalid action', { code: 400 });
39
+ }
40
+
41
+ // Verify HMAC signature (proves the link was generated by our server)
42
+ const expectedSig = crypto.createHmac('sha256', process.env.UNSUBSCRIBE_HMAC_KEY).update(email).digest('hex');
43
+ if (settings.sig !== expectedSig) {
44
+ return assistant.respond('Invalid signature', { code: 403 });
45
+ }
46
+
47
+ // Initialize Usage for rate limiting (key: IP forces unauthenticated storage always)
48
+ const usage = await Manager.Usage().init(assistant, {
49
+ unauthenticatedMode: 'firestore',
50
+ key: assistant.request.geolocation.ip,
51
+ });
52
+
53
+ // Rate limiting (manual check since email-preferences isn't in product limits)
54
+ const currentUsage = usage.getUsage('email-preferences');
55
+ if (currentUsage >= RATE_LIMIT) {
56
+ return assistant.respond('Rate limit exceeded', { code: 429 });
57
+ }
58
+ usage.increment('email-preferences');
59
+ await usage.update();
60
+
61
+ // Skip external API calls in test mode unless TEST_EXTENDED_MODE is set
62
+ const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
63
+
64
+ if (!shouldCallExternalAPIs) {
65
+ assistant.log('marketing/email-preferences: Skipping SendGrid (BEM_TESTING=true, TEST_EXTENDED_MODE not set)');
66
+ return assistant.respond({ success: true });
67
+ }
68
+
69
+ // Call SendGrid ASM API
70
+ try {
71
+ if (action === 'unsubscribe') {
72
+ // Add email to suppression group
73
+ await fetch(`https://api.sendgrid.com/v3/asm/groups/${asmId}/suppressions`, {
74
+ method: 'POST',
75
+ response: 'json',
76
+ headers: {
77
+ 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
78
+ },
79
+ timeout: 10000,
80
+ body: {
81
+ recipient_emails: [email],
82
+ },
83
+ });
84
+ } else {
85
+ // Remove email from suppression group
86
+ await fetch(`https://api.sendgrid.com/v3/asm/groups/${asmId}/suppressions/${encodeURIComponent(email)}`, {
87
+ method: 'DELETE',
88
+ response: 'json',
89
+ headers: {
90
+ 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
91
+ },
92
+ timeout: 10000,
93
+ });
94
+ }
95
+ } catch (e) {
96
+ assistant.log(`SendGrid ASM ${action} error:`, e);
97
+ return assistant.respond('Failed to process your request', { code: 500 });
98
+ }
99
+
100
+ // Log result
101
+ assistant.log('marketing/email-preferences result:', { email, asmId, action });
102
+
103
+ // Track analytics
104
+ analytics.event('marketing/email-preferences', { action });
105
+
106
+ // Generic success (no sensitive info leakage)
107
+ return assistant.respond({ success: true });
108
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Schema for POST /marketing/email-preferences
3
+ */
4
+ module.exports = () => ({
5
+ email: { types: ['string'], default: undefined, required: true },
6
+ asmId: { types: ['string', 'number'], default: undefined, required: true },
7
+ action: { types: ['string'], default: 'unsubscribe' },
8
+ sig: { types: ['string'], default: undefined, required: true },
9
+ });
package/templates/_.env CHANGED
@@ -23,6 +23,7 @@ RECAPTCHA_SECRET_KEY=""
23
23
  SENDGRID_API_KEY=""
24
24
  BEEHIIV_API_KEY=""
25
25
  ZEROBOUNCE_API_KEY=""
26
+ UNSUBSCRIBE_HMAC_KEY=""
26
27
 
27
28
  # ========== Custom Values ==========
28
29
  # Add your custom environment variables below this line
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Test: POST /marketing/email-preferences
3
+ * Tests the email preferences endpoint for unsubscribe/resubscribe via SendGrid ASM
4
+ *
5
+ * Set TEST_EXTENDED_MODE=true to run tests against real SendGrid ASM API
6
+ * (requires SENDGRID_API_KEY env var)
7
+ */
8
+ const crypto = require('crypto');
9
+
10
+ const TEST_EMAIL = 'rachel.greene+bem-unsub@gmail.com';
11
+ const TEST_ASM_ID = '24077';
12
+
13
+ function generateSig(email) {
14
+ return crypto.createHmac('sha256', process.env.UNSUBSCRIBE_HMAC_KEY).update(email.toLowerCase()).digest('hex');
15
+ }
16
+
17
+ module.exports = {
18
+ description: 'Marketing email-preferences (POST unsubscribe/resubscribe)',
19
+ type: 'group',
20
+ tests: [
21
+ // Test 1: Successful unsubscribe with valid sig
22
+ {
23
+ name: 'unsubscribe-valid-sig-succeeds',
24
+ auth: 'none',
25
+ timeout: 15000,
26
+
27
+ async run({ http, assert }) {
28
+ const sig = generateSig(TEST_EMAIL);
29
+
30
+ const response = await http.post('marketing/email-preferences', {
31
+ email: TEST_EMAIL,
32
+ asmId: TEST_ASM_ID,
33
+ action: 'unsubscribe',
34
+ sig: sig,
35
+ });
36
+
37
+ assert.isSuccess(response, 'Unsubscribe with valid sig should succeed');
38
+ assert.propertyEquals(response, 'data.success', true, 'success should be true');
39
+ },
40
+ },
41
+
42
+ // Test 2: Successful resubscribe with valid sig
43
+ {
44
+ name: 'resubscribe-valid-sig-succeeds',
45
+ auth: 'none',
46
+ timeout: 15000,
47
+
48
+ async run({ http, assert }) {
49
+ const sig = generateSig(TEST_EMAIL);
50
+
51
+ const response = await http.post('marketing/email-preferences', {
52
+ email: TEST_EMAIL,
53
+ asmId: TEST_ASM_ID,
54
+ action: 'resubscribe',
55
+ sig: sig,
56
+ });
57
+
58
+ assert.isSuccess(response, 'Resubscribe with valid sig should succeed');
59
+ assert.propertyEquals(response, 'data.success', true, 'success should be true');
60
+ },
61
+ },
62
+
63
+ // Test 3: Invalid sig rejected
64
+ {
65
+ name: 'invalid-sig-rejected',
66
+ auth: 'none',
67
+ timeout: 15000,
68
+
69
+ async run({ http, assert }) {
70
+ const response = await http.post('marketing/email-preferences', {
71
+ email: TEST_EMAIL,
72
+ asmId: TEST_ASM_ID,
73
+ action: 'unsubscribe',
74
+ sig: 'invalid-signature-value',
75
+ });
76
+
77
+ assert.isError(response, 403, 'Invalid sig should return 403');
78
+ },
79
+ },
80
+
81
+ // Test 4: Missing sig rejected (schema requires it)
82
+ {
83
+ name: 'missing-sig-rejected',
84
+ auth: 'none',
85
+ timeout: 15000,
86
+
87
+ async run({ http, assert }) {
88
+ const response = await http.post('marketing/email-preferences', {
89
+ email: TEST_EMAIL,
90
+ asmId: TEST_ASM_ID,
91
+ action: 'unsubscribe',
92
+ });
93
+
94
+ assert.isError(response, 400, 'Missing sig should return 400');
95
+ },
96
+ },
97
+
98
+ // Test 5: Missing email rejected
99
+ {
100
+ name: 'missing-email-rejected',
101
+ auth: 'none',
102
+ timeout: 15000,
103
+
104
+ async run({ http, assert }) {
105
+ const response = await http.post('marketing/email-preferences', {
106
+ asmId: TEST_ASM_ID,
107
+ action: 'unsubscribe',
108
+ sig: 'anything',
109
+ });
110
+
111
+ assert.isError(response, 400, 'Missing email should return 400');
112
+ },
113
+ },
114
+
115
+ // Test 6: Invalid email format rejected
116
+ {
117
+ name: 'invalid-email-rejected',
118
+ auth: 'none',
119
+ timeout: 15000,
120
+
121
+ async run({ http, assert }) {
122
+ const sig = generateSig('not-an-email');
123
+
124
+ const response = await http.post('marketing/email-preferences', {
125
+ email: 'not-an-email',
126
+ asmId: TEST_ASM_ID,
127
+ action: 'unsubscribe',
128
+ sig: sig,
129
+ });
130
+
131
+ assert.isError(response, 400, 'Invalid email format should return 400');
132
+ },
133
+ },
134
+
135
+ // Test 7: Missing asmId rejected
136
+ {
137
+ name: 'missing-asmid-rejected',
138
+ auth: 'none',
139
+ timeout: 15000,
140
+
141
+ async run({ http, assert }) {
142
+ const sig = generateSig(TEST_EMAIL);
143
+
144
+ const response = await http.post('marketing/email-preferences', {
145
+ email: TEST_EMAIL,
146
+ action: 'unsubscribe',
147
+ sig: sig,
148
+ });
149
+
150
+ assert.isError(response, 400, 'Missing asmId should return 400');
151
+ },
152
+ },
153
+
154
+ // Test 8: Invalid action rejected
155
+ {
156
+ name: 'invalid-action-rejected',
157
+ auth: 'none',
158
+ timeout: 15000,
159
+
160
+ async run({ http, assert }) {
161
+ const sig = generateSig(TEST_EMAIL);
162
+
163
+ const response = await http.post('marketing/email-preferences', {
164
+ email: TEST_EMAIL,
165
+ asmId: TEST_ASM_ID,
166
+ action: 'delete',
167
+ sig: sig,
168
+ });
169
+
170
+ assert.isError(response, 400, 'Invalid action should return 400');
171
+ },
172
+ },
173
+
174
+ // Test 9: Sig for different email rejected (proves per-email sig)
175
+ {
176
+ name: 'wrong-email-sig-rejected',
177
+ auth: 'none',
178
+ timeout: 15000,
179
+
180
+ async run({ http, assert }) {
181
+ // Generate sig for a different email
182
+ const sig = generateSig('someone-else@gmail.com');
183
+
184
+ const response = await http.post('marketing/email-preferences', {
185
+ email: TEST_EMAIL,
186
+ asmId: TEST_ASM_ID,
187
+ action: 'unsubscribe',
188
+ sig: sig,
189
+ });
190
+
191
+ assert.isError(response, 403, 'Sig for different email should return 403');
192
+ },
193
+ },
194
+
195
+ // Test 10: Authenticated user also works (sig is checked regardless of auth)
196
+ {
197
+ name: 'authenticated-user-with-valid-sig-succeeds',
198
+ auth: 'user',
199
+ timeout: 15000,
200
+
201
+ async run({ http, assert }) {
202
+ const sig = generateSig(TEST_EMAIL);
203
+
204
+ const response = await http.post('marketing/email-preferences', {
205
+ email: TEST_EMAIL,
206
+ asmId: TEST_ASM_ID,
207
+ action: 'unsubscribe',
208
+ sig: sig,
209
+ });
210
+
211
+ assert.isSuccess(response, 'Authenticated user with valid sig should succeed');
212
+ assert.propertyEquals(response, 'data.success', true, 'success should be true');
213
+ },
214
+ },
215
+ ],
216
+ };