backend-manager 5.1.2 → 5.2.0

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 (97) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/CHANGELOG.md +52 -0
  3. package/CLAUDE.md +2 -1
  4. package/README.md +30 -0
  5. package/docs/common-mistakes.md +1 -0
  6. package/docs/consent.md +333 -0
  7. package/docs/marketing-campaigns.md +41 -4
  8. package/docs/testing.md +81 -0
  9. package/package.json +1 -1
  10. package/src/cli/commands/emulator.js +62 -9
  11. package/src/cli/commands/serve.js +73 -7
  12. package/src/cli/commands/test.js +65 -1
  13. package/src/cli/commands/watch.js +15 -3
  14. package/src/defaults/CLAUDE.md +7 -5
  15. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
  16. package/src/manager/helpers/user.js +29 -0
  17. package/src/manager/index.js +111 -5
  18. package/src/manager/libraries/ai/index.js +21 -0
  19. package/src/manager/libraries/ai/providers/openai.js +75 -0
  20. package/src/manager/libraries/email/data/disposable-domains.json +20 -0
  21. package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
  22. package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
  23. package/src/manager/libraries/email/generators/lib/structure.js +19 -2
  24. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
  25. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
  26. package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
  27. package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
  28. package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
  29. package/src/manager/libraries/email/generators/newsletter.js +154 -7
  30. package/src/manager/libraries/email/providers/beehiiv.js +8 -1
  31. package/src/manager/libraries/payment/processors/stripe.js +12 -0
  32. package/src/manager/libraries/payment/processors/test.js +8 -1
  33. package/src/manager/routes/admin/infer-contact/post.js +3 -2
  34. package/src/manager/routes/admin/post/post.js +3 -3
  35. package/src/manager/routes/marketing/email-preferences/post.js +165 -37
  36. package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
  37. package/src/manager/routes/marketing/webhook/post.js +180 -0
  38. package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
  39. package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
  40. package/src/manager/routes/payments/cancel/post.js +2 -2
  41. package/src/manager/routes/payments/cancel/processors/test.js +5 -2
  42. package/src/manager/routes/payments/intent/processors/test.js +7 -3
  43. package/src/manager/routes/payments/refund/processors/test.js +4 -1
  44. package/src/manager/routes/test/health/get.js +17 -0
  45. package/src/manager/routes/user/signup/post.js +65 -1
  46. package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
  47. package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
  48. package/src/manager/schemas/marketing/webhook/post.js +7 -0
  49. package/src/manager/schemas/payments/cancel/post.js +5 -0
  50. package/src/manager/schemas/user/signup/post.js +5 -0
  51. package/src/test/run-tests.js +30 -0
  52. package/src/test/runner.js +72 -26
  53. package/src/test/test-accounts.js +94 -12
  54. package/src/test/utils/http-client.js +4 -3
  55. package/src/test/utils/test-mode-file.js +192 -0
  56. package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
  57. package/test/events/payments/journey-payments-cancel.js +4 -5
  58. package/test/events/payments/journey-payments-failure.js +0 -1
  59. package/test/events/payments/journey-payments-one-time-failure.js +6 -3
  60. package/test/events/payments/journey-payments-one-time.js +6 -3
  61. package/test/events/payments/journey-payments-plan-change.js +5 -5
  62. package/test/events/payments/journey-payments-refund-webhook.js +2 -3
  63. package/test/events/payments/journey-payments-suspend.js +4 -5
  64. package/test/events/payments/journey-payments-trial-cancel.js +3 -12
  65. package/test/events/payments/journey-payments-trial.js +2 -3
  66. package/test/events/payments/journey-payments-uid-resolution.js +2 -3
  67. package/test/functions/admin/database-read.js +0 -14
  68. package/test/functions/admin/database-write.js +0 -14
  69. package/test/functions/admin/firestore-query.js +0 -14
  70. package/test/functions/admin/firestore-read.js +0 -15
  71. package/test/functions/admin/firestore-write.js +0 -11
  72. package/test/functions/general/add-marketing-contact.js +16 -14
  73. package/test/helpers/email.js +1 -1
  74. package/test/helpers/infer-contact.js +3 -3
  75. package/test/helpers/user.js +241 -2
  76. package/test/helpers/webhook-forward.js +392 -0
  77. package/test/marketing/fixtures/clean.json +2 -3
  78. package/test/marketing/fixtures/editorial.json +2 -3
  79. package/test/marketing/fixtures/field-report.json +3 -4
  80. package/test/marketing/newsletter-generate.js +78 -54
  81. package/test/marketing/newsletter-templates.js +12 -33
  82. package/test/routes/admin/create-post.js +2 -2
  83. package/test/routes/admin/database.js +0 -13
  84. package/test/routes/admin/firestore-query.js +0 -13
  85. package/test/routes/admin/firestore.js +0 -14
  86. package/test/routes/admin/infer-contact.js +6 -3
  87. package/test/routes/admin/post.js +4 -2
  88. package/test/routes/marketing/contact.js +60 -26
  89. package/test/routes/marketing/email-preferences.js +145 -69
  90. package/test/routes/marketing/webhook-forward.js +54 -0
  91. package/test/routes/marketing/webhook.js +582 -0
  92. package/test/routes/payments/cancel.js +2 -7
  93. package/test/routes/payments/dispute-alert.js +0 -39
  94. package/test/routes/payments/refund.js +3 -1
  95. package/test/routes/payments/webhook.js +5 -26
  96. package/test/routes/test/usage.js +2 -2
  97. package/test/routes/user/signup.js +114 -0
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Test: marketing/webhook/forward route — unit-style coverage
3
+ *
4
+ * Why this exists as a unit test (not an emulator test):
5
+ *
6
+ * The forwarder is gated on Manager.isParent() (config.parent === 'self'). In real test runs
7
+ * we run AGAINST a child brand's BEM (Somiibo, etc.), so the route is invisible
8
+ * (404). To verify the fan-out logic, we exercise the route handler directly
9
+ * against a mocked admin SDK + mocked fetch, no HTTP needed.
10
+ *
11
+ * What's covered:
12
+ * - Gate: returns 404 if Manager.isParent() returns false (config.parent !== 'self')
13
+ * - Auth: returns 401 if key missing/wrong
14
+ * - Provider validation: returns 400 if missing
15
+ * - Brand iteration: reads brands collection, derives API URLs
16
+ * - URL derivation: brand.url 'https://somiibo.com' → 'https://api.somiibo.com/backend-manager/marketing/webhook?provider=X&key=Y'
17
+ * - Body forwarding: raw body POSTed to every child unchanged
18
+ * - Failure isolation: one failed child doesn't break the others
19
+ * - Brands without brand.url skipped silently
20
+ */
21
+
22
+ // Mock wonderful-fetch before requiring the route — the route does
23
+ // `require('wonderful-fetch')` at module load time.
24
+ const originalFetchPath = require.resolve('wonderful-fetch');
25
+ const fetchCalls = [];
26
+ let fetchMockBehavior = () => ({ received: true });
27
+
28
+ // Intercept require cache so our mock takes the place of wonderful-fetch
29
+ require.cache[originalFetchPath] = {
30
+ id: originalFetchPath,
31
+ filename: originalFetchPath,
32
+ loaded: true,
33
+ exports: async (url, opts) => {
34
+ fetchCalls.push({ url, opts });
35
+ return fetchMockBehavior(url, opts);
36
+ },
37
+ };
38
+
39
+ const route = require('../../src/manager/routes/marketing/webhook/forward/post.js');
40
+
41
+ // --- Test scaffolding ---
42
+
43
+ function makeFirestoreMock(brandDocs) {
44
+ return {
45
+ collection: (name) => {
46
+ if (name !== 'brands') {
47
+ throw new Error(`Unexpected collection: ${name}`);
48
+ }
49
+ return {
50
+ get: async () => ({
51
+ forEach: (cb) => {
52
+ for (const { id, data } of brandDocs) {
53
+ cb({ id, data: () => data });
54
+ }
55
+ },
56
+ }),
57
+ };
58
+ },
59
+ };
60
+ }
61
+
62
+ function makeAdminMock(brandDocs) {
63
+ const fs = makeFirestoreMock(brandDocs);
64
+ return { firestore: () => fs };
65
+ }
66
+
67
+ function makeAssistant({ query, body }) {
68
+ const responses = [];
69
+ return {
70
+ request: { query: query || {} },
71
+ ref: { req: { body: body !== undefined ? body : [] } },
72
+ log: () => {},
73
+ error: () => {},
74
+ respond: (data, opts) => {
75
+ responses.push({ data, code: opts?.code || 200 });
76
+ return { data, code: opts?.code || 200 };
77
+ },
78
+ _responses: responses,
79
+ };
80
+ }
81
+
82
+ function makeManager(configOverrides) {
83
+ const config = {
84
+ parent: 'self',
85
+ ...configOverrides,
86
+ };
87
+ return {
88
+ config,
89
+ libraries: {}, // Not used in this route — admin comes in via libraries arg
90
+ isParent: () => config.parent === 'self',
91
+ };
92
+ }
93
+
94
+ function resetFetchMock() {
95
+ fetchCalls.length = 0;
96
+ fetchMockBehavior = () => ({ received: true });
97
+ }
98
+
99
+ // Saves and restores process.env so tests don't leak side effects
100
+ function withEnv(envOverrides, fn) {
101
+ const saved = {};
102
+ for (const k of Object.keys(envOverrides)) {
103
+ saved[k] = process.env[k];
104
+ process.env[k] = envOverrides[k];
105
+ }
106
+ return Promise.resolve(fn()).finally(() => {
107
+ for (const k of Object.keys(envOverrides)) {
108
+ if (saved[k] === undefined) delete process.env[k];
109
+ else process.env[k] = saved[k];
110
+ }
111
+ });
112
+ }
113
+
114
+ module.exports = {
115
+ description: 'webhook/forward unit tests (mocked admin + fetch)',
116
+ type: 'group',
117
+ tests: [
118
+ // ─── Gating ───
119
+
120
+ {
121
+ name: 'returns-404-when-parent-not-self',
122
+ async run({ assert }) {
123
+ await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
124
+ resetFetchMock();
125
+ const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' } });
126
+ const Manager = makeManager({ parent: 'https://api.itwcreativeworks.com' }); // NOT 'self'
127
+ const admin = makeAdminMock([]);
128
+
129
+ await route({ assistant, Manager, libraries: { admin } });
130
+
131
+ assert.equal(assistant._responses.length, 1, 'should respond once');
132
+ assert.equal(assistant._responses[0].code, 404, 'should return 404');
133
+ assert.equal(fetchCalls.length, 0, 'should NOT call fetch when gated');
134
+ });
135
+ },
136
+ },
137
+
138
+ {
139
+ name: 'allows-route-when-parent-is-self',
140
+ async run({ assert }) {
141
+ await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
142
+ resetFetchMock();
143
+ const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' } });
144
+ const Manager = makeManager({ parent: 'self' });
145
+ const admin = makeAdminMock([]); // No brands — but route still reaches the fan-out step
146
+
147
+ await route({ assistant, Manager, libraries: { admin } });
148
+
149
+ assert.equal(assistant._responses[0].code, 200, 'should return 200 when parent: self');
150
+ assert.equal(assistant._responses[0].data.forwarded, 0, 'no brands means 0 forwarded');
151
+ });
152
+ },
153
+ },
154
+
155
+ // ─── Auth ───
156
+
157
+ {
158
+ name: 'rejects-missing-provider',
159
+ async run({ assert }) {
160
+ await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
161
+ resetFetchMock();
162
+ const assistant = makeAssistant({ query: { key: 'test-key' } }); // no provider
163
+ const Manager = makeManager();
164
+ const admin = makeAdminMock([]);
165
+
166
+ await route({ assistant, Manager, libraries: { admin } });
167
+
168
+ assert.equal(assistant._responses[0].code, 400, 'missing provider → 400');
169
+ });
170
+ },
171
+ },
172
+
173
+ {
174
+ name: 'rejects-missing-key',
175
+ async run({ assert }) {
176
+ await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
177
+ resetFetchMock();
178
+ const assistant = makeAssistant({ query: { provider: 'sendgrid' } }); // no key
179
+ const Manager = makeManager();
180
+ const admin = makeAdminMock([]);
181
+
182
+ await route({ assistant, Manager, libraries: { admin } });
183
+
184
+ assert.equal(assistant._responses[0].code, 401, 'missing key → 401');
185
+ });
186
+ },
187
+ },
188
+
189
+ {
190
+ name: 'rejects-wrong-key',
191
+ async run({ assert }) {
192
+ await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'real-key' }, async () => {
193
+ resetFetchMock();
194
+ const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'wrong-key' } });
195
+ const Manager = makeManager();
196
+ const admin = makeAdminMock([]);
197
+
198
+ await route({ assistant, Manager, libraries: { admin } });
199
+
200
+ assert.equal(assistant._responses[0].code, 401, 'wrong key → 401');
201
+ });
202
+ },
203
+ },
204
+
205
+ // ─── Fan-out logic ───
206
+
207
+ {
208
+ name: 'derives-api-url-from-brand-url-and-fans-out',
209
+ async run({ assert }) {
210
+ await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
211
+ resetFetchMock();
212
+ const body = [{ sg_event_id: 'evt1', event: 'group_unsubscribe', email: 't@example.com' }];
213
+ const assistant = makeAssistant({
214
+ query: { provider: 'sendgrid', key: 'test-key' },
215
+ body,
216
+ });
217
+ const Manager = makeManager();
218
+ const admin = makeAdminMock([
219
+ { id: 'somiibo', data: { brand: { id: 'somiibo', url: 'https://somiibo.com' } } },
220
+ { id: 'chatsy', data: { brand: { id: 'chatsy', url: 'https://chatsy.com' } } },
221
+ ]);
222
+
223
+ await route({ assistant, Manager, libraries: { admin } });
224
+
225
+ assert.equal(fetchCalls.length, 2, 'should fan out to both brands');
226
+ assert.equal(
227
+ fetchCalls[0].url,
228
+ 'https://api.somiibo.com/backend-manager/marketing/webhook?provider=sendgrid&key=test-key',
229
+ 'first child URL derived correctly'
230
+ );
231
+ assert.equal(
232
+ fetchCalls[1].url,
233
+ 'https://api.chatsy.com/backend-manager/marketing/webhook?provider=sendgrid&key=test-key',
234
+ 'second child URL derived correctly'
235
+ );
236
+ assert.deepEqual(fetchCalls[0].opts.body, body, 'raw body forwarded unchanged');
237
+ assert.equal(assistant._responses[0].data.succeeded, 2, '2 children succeeded');
238
+ });
239
+ },
240
+ },
241
+
242
+ {
243
+ name: 'forwards-raw-body-unchanged-for-beehiiv',
244
+ async run({ assert }) {
245
+ await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
246
+ resetFetchMock();
247
+ const body = {
248
+ id: 'beehiiv-evt1',
249
+ event: 'subscription.unsubscribed',
250
+ email: 'x@example.com',
251
+ publication_id: 'pub_abc',
252
+ };
253
+ const assistant = makeAssistant({
254
+ query: { provider: 'beehiiv', key: 'test-key' },
255
+ body,
256
+ });
257
+ const Manager = makeManager();
258
+ const admin = makeAdminMock([
259
+ { id: 'somiibo', data: { brand: { id: 'somiibo', url: 'https://somiibo.com' } } },
260
+ ]);
261
+
262
+ await route({ assistant, Manager, libraries: { admin } });
263
+
264
+ assert.equal(fetchCalls.length, 1);
265
+ assert.ok(fetchCalls[0].url.includes('provider=beehiiv'), 'provider param preserved');
266
+ assert.deepEqual(fetchCalls[0].opts.body, body, 'raw Beehiiv body forwarded unchanged');
267
+ });
268
+ },
269
+ },
270
+
271
+ {
272
+ name: 'skips-brands-without-brand-url',
273
+ async run({ assert }) {
274
+ await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
275
+ resetFetchMock();
276
+ const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' }, body: [] });
277
+ const Manager = makeManager();
278
+ const admin = makeAdminMock([
279
+ { id: 'somiibo', data: { brand: { id: 'somiibo', url: 'https://somiibo.com' } } },
280
+ { id: 'partial-brand', data: { brand: { id: 'partial-brand' /* no url */ } } },
281
+ { id: 'no-brand-key', data: { /* no brand at all */ } },
282
+ ]);
283
+
284
+ await route({ assistant, Manager, libraries: { admin } });
285
+
286
+ assert.equal(fetchCalls.length, 1, 'only the brand with a URL should be fanned to');
287
+ assert.ok(fetchCalls[0].url.includes('api.somiibo.com'), 'somiibo was the one called');
288
+ });
289
+ },
290
+ },
291
+
292
+ {
293
+ name: 'failure-isolation-one-bad-child-does-not-break-others',
294
+ async run({ assert }) {
295
+ await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
296
+ resetFetchMock();
297
+ fetchMockBehavior = (url) => {
298
+ if (url.includes('chatsy.com')) {
299
+ throw new Error('connection refused');
300
+ }
301
+ return { received: true };
302
+ };
303
+
304
+ const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' }, body: [] });
305
+ const Manager = makeManager();
306
+ const admin = makeAdminMock([
307
+ { id: 'somiibo', data: { brand: { id: 'somiibo', url: 'https://somiibo.com' } } },
308
+ { id: 'chatsy', data: { brand: { id: 'chatsy', url: 'https://chatsy.com' } } },
309
+ { id: 'dashqr', data: { brand: { id: 'dashqr', url: 'https://dashqr.com' } } },
310
+ ]);
311
+
312
+ await route({ assistant, Manager, libraries: { admin } });
313
+
314
+ assert.equal(fetchCalls.length, 3, 'all 3 children attempted');
315
+ const response = assistant._responses[0];
316
+ assert.equal(response.code, 200, 'response is still 200 — provider should not retry parent');
317
+ assert.equal(response.data.succeeded, 2, '2 children succeeded');
318
+ assert.equal(response.data.failed, 1, '1 child failed');
319
+ assert.ok(response.data.failures, 'failures array populated');
320
+ assert.equal(response.data.failures[0].brandId, 'chatsy', 'failure entry names the brand');
321
+ });
322
+ },
323
+ },
324
+
325
+ {
326
+ name: 'invalid-brand-url-counted-as-failure-not-thrown',
327
+ async run({ assert }) {
328
+ await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
329
+ resetFetchMock();
330
+ const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' }, body: [] });
331
+ const Manager = makeManager();
332
+ const admin = makeAdminMock([
333
+ { id: 'somiibo', data: { brand: { id: 'somiibo', url: 'https://somiibo.com' } } },
334
+ { id: 'broken', data: { brand: { id: 'broken', url: 'not-a-valid-url' } } },
335
+ ]);
336
+
337
+ await route({ assistant, Manager, libraries: { admin } });
338
+
339
+ const response = assistant._responses[0];
340
+ assert.equal(response.code, 200, 'route still returns 200');
341
+ assert.equal(response.data.succeeded, 1, 'somiibo succeeded');
342
+ assert.equal(response.data.failed, 1, 'broken brand counted as failed');
343
+ assert.equal(fetchCalls.length, 1, 'only the valid URL was actually fetched');
344
+ });
345
+ },
346
+ },
347
+
348
+ {
349
+ name: 'self-is-included-in-fanout',
350
+ async run({ assert }) {
351
+ // The parent's own brand IS expected to be in the brands collection.
352
+ // It should be fanned to via HTTP like any other brand, so its own
353
+ // BEM processes its own user updates the same way as siblings.
354
+ await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
355
+ resetFetchMock();
356
+ const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' }, body: [] });
357
+ const Manager = makeManager({ brand: { id: 'itw-creative-works' } });
358
+ const admin = makeAdminMock([
359
+ { id: 'itw-creative-works', data: { brand: { id: 'itw-creative-works', url: 'https://itwcreativeworks.com' } } },
360
+ { id: 'somiibo', data: { brand: { id: 'somiibo', url: 'https://somiibo.com' } } },
361
+ ]);
362
+
363
+ await route({ assistant, Manager, libraries: { admin } });
364
+
365
+ assert.equal(fetchCalls.length, 2, 'parent fans out to ALL brands including itself');
366
+ assert.ok(
367
+ fetchCalls.some(c => c.url.includes('api.itwcreativeworks.com')),
368
+ 'self IS in the fan-out target list'
369
+ );
370
+ });
371
+ },
372
+ },
373
+
374
+ {
375
+ name: 'zero-brands-handled-gracefully',
376
+ async run({ assert }) {
377
+ await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
378
+ resetFetchMock();
379
+ const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' }, body: [] });
380
+ const Manager = makeManager();
381
+ const admin = makeAdminMock([]);
382
+
383
+ await route({ assistant, Manager, libraries: { admin } });
384
+
385
+ assert.equal(fetchCalls.length, 0);
386
+ assert.equal(assistant._responses[0].code, 200, 'still 200 with no brands');
387
+ assert.equal(assistant._responses[0].data.forwarded, 0);
388
+ });
389
+ },
390
+ },
391
+ ],
392
+ };
@@ -2,24 +2,23 @@
2
2
  "_comment": "Predefined fixture for the `clean` template. Loaded via NEWSLETTER_FIXTURE=clean. Edit freely — no AI involved. Classic content shape: intro + sections[{title, body, cta, image_prompt}].",
3
3
  "subject": "Identity is the new growth lever",
4
4
  "preheader": "Three platform shifts to lock in this week.",
5
+ "summary": "LinkedIn, YouTube, and the broader creator stack are all tightening identity controls this week. The operators with documented attribution histories will outrun the rest. Documentation is no longer overhead — it's the moat.",
6
+ "tags": ["linkedin", "youtube", "platform-policy", "creator-economy", "operator-tools"],
5
7
  "intro": "Identity matters because trust is the new growth lever. Teams that handle this well will spend less time cleaning up later.",
6
8
  "sections": [
7
9
  {
8
10
  "title": "LinkedIn ships verified-profile gates for business accounts",
9
11
  "body": "LinkedIn now requires verified identity attestations on any business profile claiming more than 500 followers. Accounts with documented attribution histories sail through verification in under twelve hours. The practical implication is to lock down your attribution log this week, not next.",
10
- "cta": { "label": "Read the brief", "url": "https://somiibo.com/blog/linkedin-verification" },
11
12
  "image_prompt": "Abstract geometric illustration of a verification checkmark inside a layered profile card."
12
13
  },
13
14
  {
14
15
  "title": "Documentation is the new operator moat",
15
16
  "body": "Operator playbooks are now the difference between an account that recovers from a flag and one that gets permanently restricted. Teams running documented processes recover in days. The ones without burn weeks negotiating with support queues.",
16
- "cta": { "label": "See the playbook", "url": "https://somiibo.com/blog/operator-playbook" },
17
17
  "image_prompt": "Stack of geometric document layers casting clean shadows on a flat surface."
18
18
  },
19
19
  {
20
20
  "title": "YouTube tests creator-attribution metadata on uploads",
21
21
  "body": "A subset of channels saw a new upload checkbox this week asking whether content was assisted by automation. The checkbox is optional today. Reading the room: it will not stay optional. Creators with clear internal workflows will adapt in an afternoon.",
22
- "cta": null,
23
22
  "image_prompt": "Minimalist play button morphing into a label tag, flat geometric style."
24
23
  }
25
24
  ],
@@ -2,24 +2,23 @@
2
2
  "_comment": "Predefined fixture for the `editorial` template. Loaded via NEWSLETTER_FIXTURE=editorial. Same classic content shape as clean.json — both templates render the same data.",
3
3
  "subject": "Identity is the new growth lever",
4
4
  "preheader": "Three platform shifts to lock in this week.",
5
+ "summary": "LinkedIn, YouTube, and the broader creator stack are all tightening identity controls this week. The operators with documented attribution histories will outrun the rest. Documentation is no longer overhead — it's the moat.",
6
+ "tags": ["linkedin", "youtube", "platform-policy", "creator-economy", "operator-tools"],
5
7
  "intro": "Identity matters because trust is the new growth lever. Teams that handle this well will spend less time cleaning up later, and the patterns emerging this week tell you exactly where to focus.",
6
8
  "sections": [
7
9
  {
8
10
  "title": "LinkedIn ships verified-profile gates for business accounts",
9
11
  "body": "LinkedIn now requires verified identity attestations on any business profile claiming more than 500 followers. The rollout is fast and uneven — some accounts hit the gate inside two days of profile activity, others sit unchallenged for weeks. The pattern that matters is this: accounts with documented attribution histories sail through verification in under twelve hours. Accounts without get queued. The practical implication is to lock down your attribution log this week, not next.",
10
- "cta": { "label": "Read the brief", "url": "https://somiibo.com/blog/linkedin-verification" },
11
12
  "image_prompt": "Abstract geometric illustration of a verification checkmark inside a layered profile card."
12
13
  },
13
14
  {
14
15
  "title": "Documentation is the new operator moat",
15
16
  "body": "Operator playbooks — once dismissed as overhead — are now the difference between an account that recovers from a flag and one that gets permanently restricted. The teams already running on documented processes recover in days. The ones without burn weeks negotiating with support queues. Treat your playbook as a compliance artifact, not a knowledge-management nice-to-have.",
16
- "cta": { "label": "See the playbook", "url": "https://somiibo.com/blog/operator-playbook" },
17
17
  "image_prompt": "Stack of geometric document layers casting clean shadows on a flat surface."
18
18
  },
19
19
  {
20
20
  "title": "YouTube tests creator-attribution metadata on uploads",
21
21
  "body": "A subset of channels saw a new upload checkbox this week asking whether content was assisted by automation. The checkbox is optional today. Reading the room: it will not stay optional. Creators with clear internal workflows already labeled will adapt in an afternoon. Everyone else will spend a quarter retrofitting.",
22
- "cta": null,
23
22
  "image_prompt": "Minimalist play button morphing into a label tag, flat geometric style."
24
23
  }
25
24
  ],
@@ -1,7 +1,9 @@
1
1
  {
2
- "_comment": "Predefined fixture for the `field-report` template. Loaded via NEWSLETTER_FIXTURE=field-report. Wire-service content shape: tldr + dateline + dispatches[{kicker, headline, byline, location, lede, dispatch, dataPoints, cta, image_prompt}].",
2
+ "_comment": "Predefined fixture for the `field-report` template. Loaded via NEWSLETTER_FIXTURE=field-report. Wire-service content shape: tldr + dateline + dispatches[{kicker, headline, byline, location, lede, dispatch, dataPoints, image_prompt}].",
3
3
  "subject": "LinkedIn tightens identity — operators document everything",
4
4
  "preheader": "Three filings from this week's platform front lines.",
5
+ "summary": "Wire from the platform front: LinkedIn is rolling identity verification across every business profile, YouTube is piloting upload-time attribution metadata, and the operators with clean documentation are outrunning everyone else.",
6
+ "tags": ["linkedin", "youtube", "platform-policy", "verification", "operator-playbook"],
5
7
  "tldr": "LinkedIn is rolling out verified-account scrutiny across every business profile. Operators with paper trails will outrun the rest. Documentation is the new growth lever.",
6
8
  "dateline": "OAKLAND",
7
9
  "signoff": "— Stay sharp,\nThe Somiibo Desk",
@@ -22,7 +24,6 @@
22
24
  { "label": "AVG REVIEW TIME", "value": "14HR" },
23
25
  { "label": "WoW APPROVAL", "value": "+38%" }
24
26
  ],
25
- "cta": { "label": "READ THE BRIEF", "url": "https://somiibo.com/blog/linkedin-verification" },
26
27
  "image_prompt": "Abstract geometric illustration of a verification checkmark inside a layered profile card."
27
28
  },
28
29
  {
@@ -36,7 +37,6 @@
36
37
  { "label": "RECOVERY (DOCUMENTED)", "value": "4D" },
37
38
  { "label": "RECOVERY (UNDOC)", "value": "21D" }
38
39
  ],
39
- "cta": { "label": "SEE THE PLAYBOOK", "url": "https://somiibo.com/blog/operator-playbook" },
40
40
  "image_prompt": "Stack of geometric document layers casting clean shadows on a flat surface."
41
41
  },
42
42
  {
@@ -47,7 +47,6 @@
47
47
  "lede": "A small pilot suggests YouTube will soon ask creators to declare automated tooling and cross-posting workflows at upload time.",
48
48
  "dispatch": "A subset of channels saw a new upload checkbox this week asking whether content was assisted by automation. The checkbox is optional today. Reading the room: it will not stay optional. Creators with clear internal workflows already labeled will adapt in an afternoon. Everyone else will spend a quarter retrofitting.",
49
49
  "dataPoints": [],
50
- "cta": null,
51
50
  "image_prompt": "Minimalist play button morphing into a label tag, flat geometric style."
52
51
  }
53
52
  ]