backend-manager 5.0.203 → 5.1.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/CLAUDE.md +100 -1529
  3. package/TODO-CHARGEBLAST.md +32 -0
  4. package/TODO-email-auth.md +14 -0
  5. package/docs/admin-post-route.md +24 -0
  6. package/docs/ai-library.md +23 -0
  7. package/docs/architecture.md +31 -0
  8. package/docs/auth-hooks.md +74 -0
  9. package/docs/cli-firestore-auth.md +59 -0
  10. package/docs/cli-logs.md +67 -0
  11. package/docs/code-patterns.md +67 -0
  12. package/docs/common-mistakes.md +11 -0
  13. package/docs/common-operations.md +64 -0
  14. package/docs/directory-structure.md +119 -0
  15. package/docs/environment-detection.md +7 -0
  16. package/docs/file-naming.md +11 -0
  17. package/docs/key-files.md +36 -0
  18. package/docs/marketing-campaigns.md +244 -0
  19. package/docs/marketing-fields.md +25 -0
  20. package/docs/mcp.md +95 -0
  21. package/docs/payment-system.md +325 -0
  22. package/docs/response-headers.md +7 -0
  23. package/docs/routes.md +126 -0
  24. package/docs/sanitization.md +61 -0
  25. package/docs/schemas.md +39 -0
  26. package/docs/stripe-webhook-forwarding.md +18 -0
  27. package/docs/testing.md +129 -0
  28. package/docs/usage-rate-limiting.md +67 -0
  29. package/package.json +8 -4
  30. package/scripts/update-disposable-domains.js +1 -1
  31. package/src/defaults/CHANGELOG.md +15 -0
  32. package/src/defaults/CLAUDE.md +8 -4
  33. package/src/defaults/docs/README.md +17 -0
  34. package/src/defaults/test/README.md +33 -0
  35. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +48 -8
  36. package/src/manager/functions/core/actions/api/admin/create-post.js +3 -27
  37. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +5 -0
  38. package/src/manager/helpers/utilities.js +21 -0
  39. package/src/manager/index.js +1 -1
  40. package/src/manager/libraries/ai/index.js +162 -0
  41. package/src/manager/libraries/ai/providers/anthropic.js +193 -0
  42. package/src/manager/libraries/ai/providers/claude-code.js +206 -0
  43. package/src/manager/libraries/ai/providers/openai.js +934 -0
  44. package/src/manager/libraries/email/data/blocked-local-parts.json +55 -0
  45. package/src/manager/libraries/email/data/blocked-local-patterns.js +11 -0
  46. package/src/manager/libraries/email/data/corporate-domains.json +23 -0
  47. package/src/manager/libraries/{disposable-domains.json → email/data/disposable-domains.json} +3 -0
  48. package/src/manager/libraries/email/generators/lib/filter.js +179 -0
  49. package/src/manager/libraries/email/generators/lib/image-host.js +231 -0
  50. package/src/manager/libraries/email/generators/lib/mjml-template.js +83 -0
  51. package/src/manager/libraries/email/generators/lib/structure.js +278 -0
  52. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +184 -0
  53. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +63 -0
  54. package/src/manager/libraries/email/generators/lib/templates/clean.js +82 -0
  55. package/src/manager/libraries/email/generators/lib/templates/editorial-helpers.js +100 -0
  56. package/src/manager/libraries/email/generators/lib/templates/editorial.js +317 -0
  57. package/src/manager/libraries/email/generators/lib/templates/field-report-helpers.js +138 -0
  58. package/src/manager/libraries/email/generators/lib/templates/field-report.js +497 -0
  59. package/src/manager/libraries/email/generators/lib/templates/index.js +28 -0
  60. package/src/manager/libraries/email/generators/lib/templates/shared.js +534 -0
  61. package/src/manager/libraries/email/generators/newsletter.js +377 -95
  62. package/src/manager/libraries/email/marketing/index.js +16 -2
  63. package/src/manager/libraries/email/providers/beehiiv.js +7 -3
  64. package/src/manager/libraries/email/validation.js +53 -38
  65. package/src/manager/libraries/openai.js +13 -932
  66. package/src/manager/routes/admin/post/deduplicate-image-alts.js +52 -0
  67. package/src/manager/routes/admin/post/post.js +10 -17
  68. package/src/manager/routes/marketing/contact/post.js +5 -1
  69. package/templates/_.env +4 -0
  70. package/templates/_.gitignore +1 -0
  71. package/templates/backend-manager-config.json +48 -4
  72. package/test/helpers/email-validation.js +141 -3
  73. package/test/helpers/slugify.js +394 -0
  74. package/test/marketing/fixtures/clean.json +31 -0
  75. package/test/marketing/fixtures/editorial.json +31 -0
  76. package/test/marketing/fixtures/field-report.json +54 -0
  77. package/test/marketing/newsletter-generate.js +731 -0
  78. package/test/marketing/newsletter-templates.js +512 -0
  79. package/test/routes/admin/deduplicate-image-alts.js +190 -0
  80. /package/src/manager/libraries/{custom-disposable-domains.json → email/data/custom-disposable-domains.json} +0 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Deduplicate alt-text across DIFFERENT image URLs.
3
+ *
4
+ * Image filenames in the admin/post route are derived from the markdown image's
5
+ * alt-text. Two images sharing alt-text would otherwise produce the same filename
6
+ * and overwrite each other on upload, causing the second image to "disappear"
7
+ * (both `@post/` references in the body resolve to the same file).
8
+ *
9
+ * Strategy: when a non-header image's alt collides with an earlier image's alt
10
+ * AND its URL is different, suffix the alt with ` (N)` (where N is the
11
+ * occurrence count). Same URL keeps its original alt — repeated embeds of the
12
+ * exact same image are not a collision and should resolve to the same file.
13
+ *
14
+ * @param {Array<{src: string, alt: string, header?: boolean}>} images — image
15
+ * entries extracted from the body (plus optional header). Mutated in place:
16
+ * `image.alt` is rewritten when a collision is detected.
17
+ * @param {string} body — markdown body string. Returned with any
18
+ * `![oldAlt](src)` rewritten to `![newAlt](src)` for collisions.
19
+ * @returns {{images: Array, body: string}} mutated images array and rewritten body.
20
+ */
21
+ module.exports = function deduplicateImageAlts(images, body) {
22
+ const seenAltByUrl = new Map();
23
+ const altCountByAlt = new Map();
24
+ let rewrittenBody = body;
25
+
26
+ for (const image of images) {
27
+ if (image.header) {
28
+ continue;
29
+ }
30
+
31
+ const existingForUrl = seenAltByUrl.get(image.src);
32
+ if (existingForUrl) {
33
+ // Same URL appeared earlier — reuse its (possibly already-suffixed) alt.
34
+ // Repeated embeds of the same image should resolve to the same upload.
35
+ image.alt = existingForUrl;
36
+ continue;
37
+ }
38
+
39
+ const count = (altCountByAlt.get(image.alt) || 0) + 1;
40
+ altCountByAlt.set(image.alt, count);
41
+
42
+ if (count > 1) {
43
+ const newAlt = `${image.alt} (${count})`;
44
+ rewrittenBody = rewrittenBody.split(`![${image.alt}](${image.src})`).join(`![${newAlt}](${image.src})`);
45
+ image.alt = newAlt;
46
+ }
47
+
48
+ seenAltByUrl.set(image.src, image.alt);
49
+ }
50
+
51
+ return { images, body: rewrittenBody };
52
+ };
@@ -11,6 +11,8 @@ const path = require('path');
11
11
  const { Octokit } = require('@octokit/rest');
12
12
  const { get, set } = require('lodash');
13
13
 
14
+ const deduplicateImageAlts = require('./deduplicate-image-alts');
15
+
14
16
  const POST_TEMPLATE = jetpack.read(`${__dirname}/templates/post.html`);
15
17
  const IMAGE_PATH_SRC = `src/assets/images/blog/post-{id}/`;
16
18
  const IMAGE_REGEX = /(?:!\[(.*?)\]\((.*?)\))/img;
@@ -63,14 +65,8 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
63
65
  return assistant.respond('Missing required parameter: body', { code: 400 });
64
66
  }
65
67
 
66
- // Fix URL
67
- settings.url = settings.url
68
- .replace(/blog\//ig, '')
69
- .replace(/^\/|\/$/g, '')
70
- .replace(/[^a-zA-Z0-9]/g, '-')
71
- .replace(/-+/g, '-')
72
- .replace(/^-+|-+$/g, '')
73
- .toLowerCase();
68
+ // Fix URL — strip blog/ prefix then slugify (slugify handles slashes/special chars)
69
+ settings.url = Manager.Utilities().slugify(settings.url.replace(/blog\//ig, ''));
74
70
 
75
71
  // Fix body
76
72
  settings.body = settings.body
@@ -159,6 +155,11 @@ async function downloadImages(assistant, settings) {
159
155
  header: true,
160
156
  });
161
157
 
158
+ // Deduplicate alt-text across different image URLs (mutates images in place,
159
+ // returns rewritten body). See deduplicate-image-alts.js for full rationale.
160
+ const dedup = deduplicateImageAlts(images, settings.body);
161
+ settings.body = dedup.body;
162
+
162
163
  assistant.log('downloadImages(): images', images);
163
164
 
164
165
  if (!images.length) {
@@ -199,7 +200,7 @@ async function downloadImages(assistant, settings) {
199
200
  // Helper: Download image
200
201
  async function downloadImage(assistant, src, alt) {
201
202
  const fetch = assistant.Manager.require('wonderful-fetch');
202
- const hyphenated = hyphenate(alt);
203
+ const hyphenated = assistant.Manager.Utilities().slugify(alt);
203
204
 
204
205
  assistant.log(`downloadImage(): src=${src}, alt=${alt}, hyphenated=${hyphenated}`);
205
206
 
@@ -325,11 +326,3 @@ function formatClone(payload) {
325
326
  return payload;
326
327
  }
327
328
 
328
- // Helper: Hyphenate string
329
- function hyphenate(s) {
330
- return s
331
- .replace(/[^a-zA-Z0-9]/g, '-')
332
- .replace(/-+/g, '-')
333
- .replace(/^-|-$/g, '')
334
- .toLowerCase();
335
- }
@@ -41,7 +41,7 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
41
41
  return assistant.respond({ success: true });
42
42
  }
43
43
 
44
- const { format, localPart, disposable } = validation.checks;
44
+ const { format, localPart, disposable, corporate } = validation.checks;
45
45
 
46
46
  if (format && !format.valid) {
47
47
  return assistant.respond('Invalid email format', { code: 400 });
@@ -55,6 +55,10 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
55
55
  return assistant.respond(`Disposable email domain not allowed: ${disposable.domain}`, { code: 400 });
56
56
  }
57
57
 
58
+ if (corporate && !corporate.valid) {
59
+ return assistant.respond(`Corporate/social-media domain not allowed: ${corporate.domain}`, { code: 400 });
60
+ }
61
+
58
62
  return assistant.respond('Email validation failed', { code: 400 });
59
63
  }
60
64
 
package/templates/_.env CHANGED
@@ -3,6 +3,7 @@
3
3
  BACKEND_MANAGER_KEY=""
4
4
  BACKEND_MANAGER_NAMESPACE=""
5
5
  BACKEND_MANAGER_OPENAI_API_KEY=""
6
+ BACKEND_MANAGER_ANTHROPIC_API_KEY=""
6
7
 
7
8
  # GitHub
8
9
  GITHUB_TOKEN=""
@@ -10,6 +11,9 @@ GITHUB_TOKEN=""
10
11
  # OpenAI
11
12
  OPENAI_API_KEY=""
12
13
 
14
+ # Anthropic
15
+ ANTHROPIC_API_KEY=""
16
+
13
17
  # Payment Processors
14
18
  PAYPAL_CLIENT_SECRET=""
15
19
  STRIPE_SECRET_KEY=""
@@ -48,6 +48,7 @@ node_modules/
48
48
  .runtimeconfig.json
49
49
  service-account*.json
50
50
  bem-reload-trigger.js
51
+ .temp/
51
52
  _legacy
52
53
 
53
54
  # ========== Custom Values ==========
@@ -7,6 +7,18 @@
7
7
  contact: {
8
8
  email: 'support@example.com',
9
9
  },
10
+ // Physical postal address — required by CAN-SPAM in commercial email footers.
11
+ // Rendered in the newsletter footer + transactional email footers.
12
+ // Structured so the renderer can format it consistently across locales
13
+ // and break onto multiple lines if needed. line2/region/postalCode are optional.
14
+ address: {
15
+ line1: '123 Main St',
16
+ line2: 'Suite 100',
17
+ city: 'City',
18
+ region: 'ST',
19
+ postalCode: '12345',
20
+ country: 'United States',
21
+ },
10
22
  images: {
11
23
  wordmark: 'https://example.com/wordmark.png',
12
24
  brandmark: 'https://example.com/wordmark.png',
@@ -17,6 +29,7 @@
17
29
  user: 'username',
18
30
  repo_website: 'https://github.com/username/backend-manager',
19
31
  },
32
+ parent: '', // URL of the parent BEM instance (e.g., 'https://api.itwcreativeworks.com') — used by newsletter generator to pull sources
20
33
  sentry: {
21
34
  dsn: 'https://d965557418748jd749d837asf00552f@o777489.ingest.sentry.io/8789941',
22
35
  },
@@ -129,14 +142,45 @@
129
142
  beehiiv: {
130
143
  enabled: false,
131
144
  // publicationId: 'pub_xxxxx', // Set to skip fuzzy-match API call
145
+ // Content pipeline. Lives under the provider that publishes the result —
146
+ // Beehiiv for newsletters, eventually SendGrid for promo blasts. The
147
+ // shape is the same regardless of provider: sources, tone, template,
148
+ // theme, sponsorships. `beehiiv.enabled: false` above disables it all.
149
+ content: {
150
+ categories: [], // e.g., ['social-media', 'marketing'] — content categories to pull from parent server
151
+ // AI-customization fields below are all optional with sensible defaults
152
+ instructions: '', // free-form text passed to the AI ("focus on X", "avoid Y", brand voice notes)
153
+ tone: 'professional', // 'professional', 'casual', 'actionable', 'witty', etc. — passed to AI prompt
154
+ template: 'clean', // 'clean' | 'editorial' | 'field-report' — layout template (each owns its own content shape and aesthetic)
155
+ theme: {
156
+ primaryColor: '#5B5BFF', // accent color: buttons, links, brand text
157
+ secondaryColor: '#1E1E2A', // body text color
158
+ accentColor: '#F6F7FB', // page background outside the 600px body
159
+ font: 'Inter, system-ui, sans-serif',
160
+ },
161
+ // AI provider defaults are hard-coded in the library (openai for structure,
162
+ // anthropic for SVG — each chosen for what each model does best). Override
163
+ // per-run only via env: NEWSLETTER_PROVIDER_STRUCTURE / NEWSLETTER_PROVIDER_SVG.
164
+ // Brand-owned sponsorship/promo slots — rendered as "sponsored" blocks inline
165
+ // with the newsletter. Use for promoting your own products, affiliate offers,
166
+ // or paid placements. Each entry can be positioned at 'top', 'middle', or 'end'.
167
+ // Per-campaign overrides via settings.sponsorships on a marketing-campaigns doc.
168
+ sponsorships: [
169
+ // {
170
+ // label: 'Sponsored', // optional eyebrow label (defaults to "Sponsored")
171
+ // headline: 'Grow your audience faster', // bold headline
172
+ // body: 'Short pitch text…', // 1-2 sentence body
173
+ // url: 'https://somiibo.com/promo', // click destination
174
+ // image: 'https://...png', // optional image URL
175
+ // ctaLabel: 'Try Somiibo', // button text (default: "Learn more")
176
+ // position: 'middle', // 'top' | 'middle' | 'end' (default: 'middle')
177
+ // },
178
+ ],
179
+ },
132
180
  },
133
181
  prune: {
134
182
  enabled: true,
135
183
  },
136
- newsletter: {
137
- enabled: false,
138
- categories: [], // e.g., ['social-media', 'marketing'] — content categories to pull from parent server
139
- },
140
184
  },
141
185
  firebaseConfig: {
142
186
  apiKey: '123-456',
@@ -5,7 +5,7 @@
5
5
  * Format, local part, and disposable tests always run (free, regex-based).
6
6
  * Mailbox verification tests require TEST_EXTENDED_MODE + ZEROBOUNCE_API_KEY.
7
7
  */
8
- const { validate, isDisposable, DEFAULT_CHECKS, ALL_CHECKS } = require('../../src/manager/libraries/email/validation.js');
8
+ const { validate, isDisposable, isCorporate, DEFAULT_CHECKS, ALL_CHECKS } = require('../../src/manager/libraries/email/validation.js');
9
9
 
10
10
  module.exports = {
11
11
  description: 'Email validation',
@@ -287,6 +287,144 @@ module.exports = {
287
287
  },
288
288
  },
289
289
 
290
+ // --- Corporate / social-media domain checks ---
291
+
292
+ {
293
+ name: 'corporate-meta-blocked',
294
+ timeout: 5000,
295
+
296
+ async run({ assert }) {
297
+ const result = await validate('ian@meta.com');
298
+
299
+ assert.equal(result.valid, false, 'meta.com should be blocked');
300
+ assert.propertyEquals(result, 'checks.corporate.blocked', true, 'Should be flagged as blocked');
301
+ assert.propertyEquals(result, 'checks.corporate.domain', 'meta.com', 'Should include blocked domain');
302
+ assert.propertyEquals(result, 'checks.corporate.reason', 'Corporate/social-media domain', 'Should have human-readable reason');
303
+ },
304
+ },
305
+
306
+ {
307
+ name: 'corporate-instagram-blocked',
308
+ timeout: 5000,
309
+
310
+ async run({ assert }) {
311
+ const result = await validate('rachel.greene@instagram.com');
312
+
313
+ assert.equal(result.valid, false, 'instagram.com should be blocked');
314
+ assert.propertyEquals(result, 'checks.corporate.blocked', true, 'Should be flagged as blocked');
315
+ },
316
+ },
317
+
318
+ {
319
+ name: 'corporate-soundcloud-blocked',
320
+ timeout: 5000,
321
+
322
+ async run({ assert }) {
323
+ const result = await validate('user@soundcloud.com');
324
+
325
+ assert.equal(result.valid, false, 'soundcloud.com should be blocked');
326
+ assert.propertyEquals(result, 'checks.corporate.blocked', true, 'Should be flagged as blocked');
327
+ },
328
+ },
329
+
330
+ {
331
+ name: 'corporate-gmail-allowed',
332
+ timeout: 5000,
333
+
334
+ async run({ assert }) {
335
+ const result = await validate('rachel.greene@gmail.com');
336
+
337
+ assert.equal(result.valid, true, 'gmail.com should NOT be flagged as corporate');
338
+ assert.propertyEquals(result, 'checks.corporate.valid', true, 'Corporate check should pass');
339
+ assert.propertyEquals(result, 'checks.corporate.blocked', false, 'Should not be blocked');
340
+ },
341
+ },
342
+
343
+ {
344
+ name: 'corporate-runs-before-localpart',
345
+ timeout: 5000,
346
+
347
+ async run({ assert }) {
348
+ // "test@meta.com" would be blocked by BOTH corporate and localPart;
349
+ // corporate runs first, so we should see corporate (not localPart) in the result.
350
+ const result = await validate('test@meta.com');
351
+
352
+ assert.equal(result.valid, false, 'Should be blocked');
353
+ assert.propertyEquals(result, 'checks.corporate.blocked', true, 'Corporate should be the failure reason');
354
+ assert.equal(result.checks.localPart, undefined, 'localPart should not run after corporate fails');
355
+ },
356
+ },
357
+
358
+ {
359
+ name: 'corporate-can-be-skipped-via-checks-option',
360
+ timeout: 5000,
361
+
362
+ async run({ assert }) {
363
+ // Allow a caller to bypass the corporate check (e.g., during signup, where Meta employees are real users)
364
+ const result = await validate('ian@meta.com', { checks: ['format', 'disposable', 'localPart'] });
365
+
366
+ assert.equal(result.valid, true, 'Without corporate check, meta.com should pass');
367
+ assert.equal(result.checks.corporate, undefined, 'corporate should not run');
368
+ },
369
+ },
370
+
371
+ // --- isCorporate helper ---
372
+
373
+ {
374
+ name: 'isCorporate-social-domain-detected',
375
+ timeout: 5000,
376
+
377
+ async run({ assert }) {
378
+ assert.equal(isCorporate('user@meta.com'), true, 'meta.com should be corporate');
379
+ assert.equal(isCorporate('user@instagram.com'), true, 'instagram.com should be corporate');
380
+ assert.equal(isCorporate('user@soundcloud.com'), true, 'soundcloud.com should be corporate');
381
+ assert.equal(isCorporate('user@tiktok.com'), true, 'tiktok.com should be corporate');
382
+ assert.equal(isCorporate('user@linkedin.com'), true, 'linkedin.com should be corporate');
383
+ },
384
+ },
385
+
386
+ {
387
+ name: 'isCorporate-legitimate-domain-passes',
388
+ timeout: 5000,
389
+
390
+ async run({ assert }) {
391
+ assert.equal(isCorporate('user@gmail.com'), false, 'gmail.com should not be corporate');
392
+ assert.equal(isCorporate('user@somiibo.com'), false, 'Custom domain should not be corporate');
393
+ assert.equal(isCorporate('user@mailinator.com'), false, 'Disposable is a separate category');
394
+ },
395
+ },
396
+
397
+ {
398
+ name: 'isCorporate-accepts-domain-only',
399
+ timeout: 5000,
400
+
401
+ async run({ assert }) {
402
+ assert.equal(isCorporate('meta.com'), true, 'Should work with bare domain');
403
+ assert.equal(isCorporate('gmail.com'), false, 'Should work with bare domain');
404
+ },
405
+ },
406
+
407
+ {
408
+ name: 'isCorporate-handles-edge-cases',
409
+ timeout: 5000,
410
+
411
+ async run({ assert }) {
412
+ assert.equal(isCorporate(''), false, 'Empty string should return false');
413
+ assert.equal(isCorporate(null), false, 'Null should return false');
414
+ assert.equal(isCorporate(undefined), false, 'Undefined should return false');
415
+ },
416
+ },
417
+
418
+ {
419
+ name: 'isCorporate-case-insensitive',
420
+ timeout: 5000,
421
+
422
+ async run({ assert }) {
423
+ assert.equal(isCorporate('user@META.COM'), true, 'Should be case-insensitive');
424
+ assert.equal(isCorporate('USER@Instagram.Com'), true, 'Should be case-insensitive');
425
+ },
426
+ },
427
+
290
428
  // --- isDisposable helper ---
291
429
 
292
430
  {
@@ -389,8 +527,8 @@ module.exports = {
389
527
  timeout: 5000,
390
528
 
391
529
  async run({ assert }) {
392
- assert.deepEqual(DEFAULT_CHECKS, ['format', 'disposable', 'localPart'], 'DEFAULT_CHECKS should be format + disposable + localPart');
393
- assert.deepEqual(ALL_CHECKS, ['format', 'disposable', 'localPart', 'mailbox'], 'ALL_CHECKS should include mailbox');
530
+ assert.deepEqual(DEFAULT_CHECKS, ['format', 'disposable', 'corporate', 'localPart'], 'DEFAULT_CHECKS should be format + disposable + corporate + localPart');
531
+ assert.deepEqual(ALL_CHECKS, ['format', 'disposable', 'corporate', 'localPart', 'mailbox'], 'ALL_CHECKS should include mailbox');
394
532
  },
395
533
  },
396
534