backend-manager 5.0.185 → 5.0.187

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,10 @@ 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.186] - 2026-04-01
18
+ ### Fixed
19
+ - Move markdown rendering and UTM link tagging to run after `_.merge()` so caller overrides to `body.message` and `email.body` are properly processed
20
+
17
21
  # [5.0.185] - 2026-04-01
18
22
  ### Changed
19
23
  - Use `_.merge` for dynamic template data so callers can override any nested field (e.g. `email.preview`, `personalization.name`, `data.body.*`)
package/CLAUDE.md CHANGED
@@ -207,6 +207,58 @@ require(functionsDir + '/node_modules/backend-manager')
207
207
  ### Prefer fs-jetpack
208
208
  Use `fs-jetpack` over `fs` or `fs-extra` for file operations.
209
209
 
210
+ ## Sanitization (XSS Prevention)
211
+
212
+ 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**.
213
+
214
+ ### How It Works
215
+ 1. **Schema fields**: Sanitized per-field during the middleware pipeline. Fields can opt out with `sanitize: false` in the schema.
216
+ 2. **Non-schema fields** (when `setupSettings: false` or `includeNonSchemaSettings: true`): All strings are sanitized with no opt-out.
217
+ 3. The middleware uses `Manager.Utilities().sanitize()` under the hood.
218
+
219
+ ### Schema Opt-Out
220
+ For fields that legitimately need HTML (rich text, email templates, etc.), set `sanitize: false` in the schema:
221
+ ```javascript
222
+ // This field will NOT be sanitized — raw HTML is preserved
223
+ htmlContent: {
224
+ types: ['string'],
225
+ default: '',
226
+ sanitize: false,
227
+ },
228
+ // This field IS sanitized (default behavior, no flag needed)
229
+ name: {
230
+ types: ['string'],
231
+ default: '',
232
+ },
233
+ ```
234
+
235
+ ### Route-Level Opt-Out
236
+ Disable sanitization entirely for a route (rare — only for routes that handle raw HTML everywhere):
237
+ ```javascript
238
+ // In functions/index.js
239
+ Manager.Middleware(req, res).run('my-route', { sanitize: false });
240
+ ```
241
+
242
+ ### Manual Sanitization (Outside Middleware)
243
+ For cron jobs, event handlers, or anywhere outside the request pipeline, use `utilities.sanitize()` directly:
244
+ ```javascript
245
+ // Available in route context
246
+ const clean = utilities.sanitize(untrustedData);
247
+
248
+ // Or via Manager
249
+ const clean = Manager.Utilities().sanitize(untrustedData);
250
+ ```
251
+ Accepts any data type — strings, objects, arrays, primitives. Walks objects/arrays recursively, strips HTML from strings, passes everything else through unchanged.
252
+
253
+ ### Route Handler Context
254
+ The middleware injects these into every route handler:
255
+ ```javascript
256
+ module.exports = async ({ Manager, assistant, analytics, usage, user, settings, libraries, utilities }) => {
257
+ // settings — already sanitized by middleware
258
+ // utilities — Manager.Utilities() instance for manual sanitization
259
+ };
260
+ ```
261
+
210
262
  ## Creating New Components
211
263
 
212
264
  ### New API Command
package/TODO-2.md CHANGED
@@ -10,10 +10,26 @@ payments/reactivate
10
10
  payments/upgrade
11
11
  * takes a subscription id and a new plan id and upgrades the user's subscription to the new plan. this can only be done if the user has an active subscription.
12
12
 
13
+ I'd call that cancellation-reversed — it pairs naturally with the existing cancellation-requested transition and clearly communicates the action (they reversed/undid the pending cancellation).
14
+
15
+ on the email we send, for payment recovered what is the category
16
+ Let me check the handler.
17
+
18
+ Read payment-recovered.js
19
+ The category is order/payment-recovered (line 13).
20
+
21
+ TODO-2.md
22
+ waht about when they request a cancel
23
+ Read cancellation-requested.js
24
+ The category is order/cancellation-requested (line 13).
25
+
13
26
  ---
14
27
  GHOSTII REVAMP
15
28
  * better logic for generating posts. better model? claude?
16
29
 
30
+ ---- MCP
31
+ * ability for consuming prjec to specify MCP functions
32
+
17
33
  -------
18
34
  UPSELL
19
35
  * products in BEM can have an UPSELL where you link another product ID and it allows you to add it to your cart OR shows you after checkout?
@@ -0,0 +1,14 @@
1
+ https://github.com/disposable-email-domains/disposable-email-domains?tab=readme-ov-file
2
+ https://www.npmjs.com/package/disposable-domains
3
+ https://github.com/tompec/disposable-email-domains
4
+
5
+ Two repos:
6
+
7
+ Repo Domains Approach
8
+ disposable-email-domains/disposable-email-domains 5,359 Curated, conservative, high confidence
9
+ ivolo/disposable-email-domains 121,569 Aggressive, aggregated from many sources, more false positives
10
+ Our current list has 854 — so even the smaller curated list is 6x larger.
11
+
12
+ For our use case (currently only used to skip marketing sync, not blocking signups), I'd recommend the 5,359 curated list — it's comprehensive enough without being overly aggressive. And if we ever do use it for blocking signups, the false positive risk is much lower.
13
+
14
+ Want to swap to that one?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.185",
3
+ "version": "5.0.187",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "start": "node src/manager/index.js",
14
+ "prepublishOnly": "node scripts/update-disposable-domains.js",
14
15
  "prepare": "node -e \"require('prepare-package')()\"",
15
16
  "prepare:watch": "node -e \"require('prepare-package/watch')()\""
16
17
  },
@@ -78,6 +79,7 @@
78
79
  "npm-api": "^1.0.1",
79
80
  "paypal-server-api": "^2.0.14",
80
81
  "pushid": "^1.0.0",
82
+ "sanitize-html": "^2.17.2",
81
83
  "shortid": "^2.2.17",
82
84
  "stripe": "^20.3.1",
83
85
  "uid-generator": "^2.0.0",
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Fetches the latest disposable email domain list from GitHub and saves it as JSON.
5
+ *
6
+ * Source: https://github.com/disposable-email-domains/disposable-email-domains
7
+ * (curated, ~5k domains, low false-positive rate)
8
+ *
9
+ * Run manually: node scripts/update-disposable-domains.js
10
+ * Runs automatically on: npm prepublishOnly
11
+ */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const SOURCE_URL = 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
16
+ const OUTPUT_PATH = path.join(__dirname, '..', 'src', 'manager', 'libraries', 'disposable-domains.json');
17
+
18
+ async function main() {
19
+ console.log('Fetching disposable domain list...');
20
+
21
+ const response = await fetch(SOURCE_URL);
22
+
23
+ if (!response.ok) {
24
+ throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
25
+ }
26
+
27
+ const text = await response.text();
28
+ const domains = text
29
+ .trim()
30
+ .split('\n')
31
+ .map(d => d.trim().toLowerCase())
32
+ .filter(Boolean);
33
+
34
+ const unique = [...new Set(domains)].sort();
35
+
36
+ fs.writeFileSync(OUTPUT_PATH, JSON.stringify(unique, null, 2) + '\n');
37
+
38
+ console.log(`Updated disposable-domains.json: ${unique.length} domains`);
39
+ }
40
+
41
+ main().catch((e) => {
42
+ console.error('Failed to update disposable domains:', e.message);
43
+ process.exit(1);
44
+ });
@@ -1,10 +1,12 @@
1
- const ERROR_TOO_MANY_ATTEMPTS = 'You have created too many accounts with our service. Please try again later.';
1
+ const { isDisposable } = require('../../libraries/email/validation.js');
2
+
3
+ const ERROR_TOO_MANY_ATTEMPTS = 'Unable to create account at this time. Please try again later.';
4
+ const ERROR_DISPOSABLE_EMAIL = 'This email domain is not allowed. Please use a different email address.';
2
5
  const MAX_SIGNUPS_PER_DAY = 2;
3
6
 
4
7
  /**
5
- * beforeUserCreated - IP Rate Limiting ONLY
8
+ * beforeUserCreated - Disposable email blocking + IP rate limiting
6
9
  *
7
- * This function ONLY handles IP rate limiting to prevent abuse.
8
10
  * User doc creation is handled by on-create.js (which fires for all user creations including Admin SDK).
9
11
  *
10
12
  * Why not create user doc here?
@@ -18,6 +20,13 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
18
20
 
19
21
  assistant.log(`beforeCreate: ${user.uid}`, { email: user.email, ip: ipAddress });
20
22
 
23
+ // Block disposable email domains
24
+ if (isDisposable(user.email)) {
25
+ assistant.error(`beforeCreate: Blocked disposable email ${user.email}`);
26
+
27
+ throw new functions.auth.HttpsError('invalid-argument', ERROR_DISPOSABLE_EMAIL);
28
+ }
29
+
21
30
  // Skip rate limiting if no IP (shouldn't happen in production)
22
31
  if (!ipAddress) {
23
32
  assistant.log(`beforeCreate: No IP address, skipping rate limit check (${Date.now() - startTime}ms)`);
@@ -35,7 +35,7 @@ Middleware.prototype.run = function (libPath, options) {
35
35
  options.setupAnalytics = typeof options.setupAnalytics === 'boolean' ? options.setupAnalytics : true;
36
36
  options.setupUsage = typeof options.setupUsage === 'boolean' ? options.setupUsage : true;
37
37
  options.setupSettings = typeof options.setupSettings === 'undefined' ? true : options.setupSettings;
38
- options.cleanSettings = typeof options.cleanSettings === 'undefined' ? true : options.cleanSettings;
38
+ options.sanitize = typeof options.sanitize === 'undefined' ? true : options.sanitize;
39
39
  options.includeNonSchemaSettings = typeof options.includeNonSchemaSettings === 'undefined' ? false : options.includeNonSchemaSettings;
40
40
  options.schema = typeof options.schema === 'undefined' ? libPath : options.schema;
41
41
  options.parseMultipartFormData = typeof options.parseMultipartFormData === 'undefined' ? true : options.parseMultipartFormData;
@@ -168,14 +168,6 @@ Middleware.prototype.run = function (libPath, options) {
168
168
  assistant.settings = merge(data, assistant.settings)
169
169
  }
170
170
 
171
- // Clean settings by looping through and trimming all strings
172
- if (options.cleanSettings) {
173
- clean(assistant.settings);
174
- }
175
-
176
- // Log
177
- assistant.log(`Middleware.process(): Resolved settings with schema=${options.schema}`, safeStringify(assistant.settings));
178
-
179
171
  // Log multipart files if they exist
180
172
  const files = assistant.request.multipartData.files || {};
181
173
  if (files) {
@@ -185,6 +177,19 @@ Middleware.prototype.run = function (libPath, options) {
185
177
  assistant.settings = data;
186
178
  }
187
179
 
180
+ // Sanitize settings — trim whitespace and strip HTML from all strings
181
+ // Respects sanitize: false on individual schema fields
182
+ if (options.sanitize) {
183
+ const schema = options.setupSettings ? Manager.Settings().schema : null;
184
+ const utilities = Manager.Utilities();
185
+
186
+ // Walk settings, skipping fields the schema marks as sanitize: false
187
+ assistant.settings = sanitizeWithSchema(utilities, assistant.settings, schema);
188
+ }
189
+
190
+ // Log
191
+ assistant.log(`Middleware.process(): Resolved settings with schema=${options.schema}`, safeStringify(assistant.settings));
192
+
188
193
  // Build context object for route handler
189
194
  const context = {
190
195
  Manager: Manager,
@@ -194,6 +199,7 @@ Middleware.prototype.run = function (libPath, options) {
194
199
  settings: assistant.settings,
195
200
  analytics: assistant.analytics,
196
201
  libraries: Manager.libraries,
202
+ utilities: Manager.Utilities(),
197
203
  };
198
204
 
199
205
  // Execute route handler
@@ -208,15 +214,29 @@ Middleware.prototype.run = function (libPath, options) {
208
214
  });
209
215
  };
210
216
 
211
- function clean(obj) {
212
- for (let key in obj) {
213
- if (typeof obj[key] === 'object') {
214
- clean(obj[key]);
215
- } else if (typeof obj[key] === 'string') {
216
- obj[key] = obj[key]
217
- .trim();
217
+ function sanitizeWithSchema(utilities, obj, schema) {
218
+ // Not an object sanitize directly
219
+ if (obj == null || typeof obj !== 'object') {
220
+ return utilities.sanitize(obj);
221
+ }
222
+
223
+ // Arrays — sanitize each item (no schema for array items)
224
+ if (Array.isArray(obj)) {
225
+ return obj.map(item => utilities.sanitize(item));
226
+ }
227
+
228
+ // Objects — walk keys, skip fields where schema says sanitize: false
229
+ const result = {};
230
+ for (const [key, value] of Object.entries(obj)) {
231
+ const schemaNode = schema ? schema[key] : null;
232
+
233
+ if (schemaNode && schemaNode.sanitize === false) {
234
+ result[key] = value;
235
+ } else {
236
+ result[key] = sanitizeWithSchema(utilities, value, schemaNode);
218
237
  }
219
238
  }
239
+ return result;
220
240
  }
221
241
 
222
242
  function stripUrl(url) {
@@ -8,6 +8,7 @@ const powertools = require('node-powertools');
8
8
  const _ = require('lodash');
9
9
  const moment = require('moment');
10
10
 
11
+
11
12
  function Settings(m) {
12
13
  const self = this;
13
14
 
@@ -129,6 +130,7 @@ Settings.prototype.resolve = function (assistant, schema, settings, options) {
129
130
  available: typeof schemaNode.available === 'undefined' ? true : schemaNode.available,
130
131
  min: typeof schemaNode.min === 'undefined' ? undefined : schemaNode.min,
131
132
  max: typeof schemaNode.max === 'undefined' ? undefined : schemaNode.max,
133
+ sanitize: typeof schemaNode.sanitize === 'undefined' ? true : schemaNode.sanitize,
132
134
  }
133
135
 
134
136
  // Update schema
@@ -1,5 +1,6 @@
1
1
  let nanoId;
2
2
  let _;
3
+ let sanitizeHtml;
3
4
 
4
5
  function Utilities(Manager) {
5
6
  const self = this;
@@ -467,4 +468,43 @@ Utilities.prototype.get = function (docPath, options) {
467
468
  });
468
469
  };
469
470
 
471
+ /**
472
+ * Sanitize input by stripping HTML tags and trimming strings.
473
+ * Accepts any data type — walks objects/arrays recursively.
474
+ *
475
+ * @param {*} input - The data to sanitize (string, object, array, or primitive)
476
+ * @returns {*} Sanitized copy (objects/arrays) or sanitized value (strings)
477
+ */
478
+ Utilities.prototype.sanitize = function (input) {
479
+ // Handle null/undefined
480
+ if (input == null) {
481
+ return input;
482
+ }
483
+
484
+ // Handle strings
485
+ if (typeof input === 'string') {
486
+ sanitizeHtml = sanitizeHtml
487
+ ? sanitizeHtml
488
+ : require('sanitize-html');
489
+ return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} }).trim();
490
+ }
491
+
492
+ // Handle arrays
493
+ if (Array.isArray(input)) {
494
+ return input.map(item => this.sanitize(item));
495
+ }
496
+
497
+ // Handle objects
498
+ if (typeof input === 'object') {
499
+ const result = {};
500
+ for (const [key, value] of Object.entries(input)) {
501
+ result[key] = this.sanitize(value);
502
+ }
503
+ return result;
504
+ }
505
+
506
+ // Numbers, booleans, etc. — pass through
507
+ return input;
508
+ };
509
+
470
510
  module.exports = Utilities;
@@ -0,0 +1,12 @@
1
+ [
2
+ "10mail.info",
3
+ "mail2me.co",
4
+ "maximail.fyi",
5
+ "maximail.vip",
6
+ "mimimail.me",
7
+ "minitts.net",
8
+ "pickmail.org",
9
+ "pickmemail.com",
10
+ "picomail.biz",
11
+ "sharebot.net"
12
+ ]