backend-manager 5.0.127 → 5.0.129

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.md CHANGED
@@ -84,6 +84,12 @@ src/
84
84
  daily.js # Daily cron runner
85
85
  daily/{job}.js # Individual cron jobs
86
86
  routes/ # Built-in routes
87
+ admin/
88
+ post/ # POST /admin/post - Create blog posts via GitHub
89
+ post.js # Extracts images, uploads to GitHub, rewrites body to @post/ format
90
+ put.js # PUT /admin/post - Edit existing posts
91
+ templates/
92
+ post.html # Post frontmatter template
87
93
  payments/
88
94
  intent/ # POST /payments/intent
89
95
  post.js # Intent creation orchestrator
@@ -426,6 +432,28 @@ Manager.handlers.bm_api = function (mod, position) {
426
432
  | Auth Events | `events/auth/` | `{event}.js` |
427
433
  | Cron Jobs | `cron/daily/` or `hooks/cron/daily/` | `{job}.js` |
428
434
 
435
+ ## Admin Post Route
436
+
437
+ The `POST /admin/post` route creates blog posts via GitHub's API. It handles image extraction, upload, and body rewriting.
438
+
439
+ ### Image Processing Flow
440
+ 1. Receives markdown body with external image URLs (e.g., `![alt](https://images.unsplash.com/...)`)
441
+ 2. Extracts all `![alt](url)` patterns from the body using regex
442
+ 3. Downloads each image and uploads it to `src/assets/images/blog/post-{id}/` on GitHub
443
+ 4. **Rewrites the body** to replace external URLs with `@post/{filename}` format
444
+ 5. The `@post/` prefix is resolved at Jekyll build time by `jekyll-uj-powertools` to the full path
445
+
446
+ ### Key Details
447
+ - Image filenames are derived from `hyphenate(alt_text)` + downloaded extension
448
+ - Header image (`headerImageURL`) is uploaded but NOT rewritten in the body (it's in frontmatter)
449
+ - Failed image downloads are skipped — the original external URL stays in the body
450
+ - The `extractImages()` function returns a URL mapping used for body rewriting
451
+
452
+ ### Files
453
+ - `src/manager/routes/admin/post/post.js` — POST handler (create)
454
+ - `src/manager/routes/admin/post/put.js` — PUT handler (edit)
455
+ - `src/manager/routes/admin/post/templates/post.html` — Post template
456
+
429
457
  ## Testing
430
458
 
431
459
  ### Running Tests
@@ -673,6 +701,31 @@ Products can opt out of daily caps by setting `rateLimit: 'monthly'` (default is
673
701
  }
674
702
  ```
675
703
 
704
+ ### Proxy Usage (setUser + Mirrors)
705
+
706
+ Sometimes usage must be billed to a different user than the one making the request (e.g., anonymous visitors consuming an agent owner's credits). Use `setUser()` to swap the target and `addMirror()` / `setMirrors()` to write usage to additional Firestore docs:
707
+
708
+ ```js
709
+ // Switch usage target to the agent owner (fetches their user doc)
710
+ await usage.setUser(ownerUid);
711
+
712
+ // Also write usage data to the agent doc
713
+ usage.addMirror(`agents/${agentId}`);
714
+
715
+ // Now validate, increment, and update all operate on the owner's data
716
+ // update() writes to users/{ownerUid} AND agents/{agentId} in parallel
717
+ await usage.validate('credits');
718
+ usage.increment('credits');
719
+ await usage.update();
720
+ ```
721
+
722
+ **Methods:**
723
+ - `setUser(uid)` — async, fetches `users/{uid}` from Firestore, replaces `self.user`, sets `useUnauthenticatedStorage = false`
724
+ - `setMirrors(paths)` — sync, overwrites the mirror array with the given paths
725
+ - `addMirror(path)` — sync, appends a single path to the mirror array
726
+
727
+ Mirrors are write-only — `update()` writes `{ usage: self.user.usage }` (merge) to each mirror path. No reads are performed on mirrors.
728
+
676
729
  ### Reset Schedule
677
730
 
678
731
  | Target | Frequency | What happens |
package/README.md CHANGED
@@ -532,6 +532,11 @@ await usage.update();
532
532
 
533
533
  // Whitelist keys
534
534
  usage.addWhitelistKeys(['another-key']);
535
+
536
+ // Proxy usage: bill a different user and mirror writes to additional docs
537
+ await usage.setUser('owner-uid'); // Switch target user (fetches from Firestore)
538
+ usage.addMirror('agents/agent-id'); // Also write usage to this doc on update()
539
+ usage.setMirrors(['agents/a', 'orgs/b']); // Overwrite mirror list
535
540
  ```
536
541
 
537
542
  ### Middleware
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.127",
3
+ "version": "5.0.129",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -15,11 +15,13 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
15
15
  // Update last activity and geolocation
16
16
  const update = await admin.firestore().doc(`users/${user.uid}`)
17
17
  .set({
18
- activity: {
19
- lastActivity: {
18
+ metadata: {
19
+ updated: {
20
20
  timestamp: now.toISOString(),
21
21
  timestampUNIX: Math.round(now.getTime() / 1000),
22
22
  },
23
+ },
24
+ activity: {
23
25
  geolocation: {
24
26
  ip: context.ipAddress,
25
27
  language: context.locale,
@@ -23,6 +23,8 @@ function Usage(m) {
23
23
  user: '',
24
24
  }
25
25
 
26
+ self._mirrors = [];
27
+
26
28
  self.initialized = false;
27
29
  }
28
30
 
@@ -100,6 +102,39 @@ Usage.prototype.init = function (assistant, options) {
100
102
  });
101
103
  };
102
104
 
105
+ Usage.prototype.setUser = async function (uid) {
106
+ const self = this;
107
+ const admin = self.Manager.libraries.admin;
108
+
109
+ const doc = await admin.firestore().doc(`users/${uid}`).get().catch(() => null);
110
+ const data = (doc && doc.exists) ? doc.data() : {};
111
+
112
+ self.user = {
113
+ ...data,
114
+ auth: { uid: uid, ...(data.auth || {}) },
115
+ usage: data.usage || {},
116
+ subscription: data.subscription || {},
117
+ };
118
+
119
+ self.useUnauthenticatedStorage = false;
120
+
121
+ self.log(`Usage.setUser(): Switched to user ${uid}`);
122
+
123
+ return self;
124
+ };
125
+
126
+ Usage.prototype.setMirrors = function (paths) {
127
+ const self = this;
128
+ self._mirrors = Array.isArray(paths) ? paths : [];
129
+ return self;
130
+ };
131
+
132
+ Usage.prototype.addMirror = function (path) {
133
+ const self = this;
134
+ self._mirrors.push(path);
135
+ return self;
136
+ };
137
+
103
138
  Usage.prototype.validate = function (name, options) {
104
139
  const self = this;
105
140
 
@@ -357,40 +392,44 @@ Usage.prototype.update = function () {
357
392
  return new Promise(async function(resolve, reject) {
358
393
  const { admin } = Manager.libraries;
359
394
 
395
+ // Build the primary write promise
396
+ let mainWrite;
397
+
360
398
  // Write self.user to firestore or local if no user or if key is set
361
399
  if (self.useUnauthenticatedStorage) {
362
400
  if (self.options.unauthenticatedMode === 'firestore') {
363
- admin.firestore().doc(`usage/${self.key}`)
364
- .set(self.user.usage, { merge: true })
365
- .then(() => {
366
- self.log(`Usage.update(): Updated user.usage in firestore`, self.user.usage);
367
-
368
- return resolve(self.user.usage);
369
- })
370
- .catch(e => {
371
- return reject(assistant.errorify(e, {code: 500, sentry: true}));
372
- });
401
+ mainWrite = admin.firestore().doc(`usage/${self.key}`)
402
+ .set(self.user.usage, { merge: true });
373
403
  } else {
374
404
  self.storage.set(`${self.paths.user}.usage`, self.user.usage).write();
375
405
 
376
406
  self.log(`Usage.update(): Updated user.usage in local storage`, self.user.usage);
377
407
 
378
- return resolve(self.user.usage);
408
+ mainWrite = Promise.resolve();
379
409
  }
380
410
  } else {
381
- admin.firestore().doc(`users/${self.user.auth.uid}`)
411
+ mainWrite = admin.firestore().doc(`users/${self.user.auth.uid}`)
382
412
  .set({
383
413
  usage: self.user.usage,
384
- }, { merge: true })
385
- .then(() => {
386
- self.log(`Usage.update(): Updated user.usage in firestore`, self.user.usage);
387
-
388
- return resolve(self.user.usage);
389
- })
390
- .catch(e => {
391
- return reject(assistant.errorify(e, {code: 500, sentry: true}));
392
- });
414
+ }, { merge: true });
393
415
  }
416
+
417
+ // Build mirror write promises
418
+ const mirrorWrites = (self._mirrors || []).map((path) => {
419
+ return admin.firestore().doc(path)
420
+ .set({ usage: self.user.usage }, { merge: true });
421
+ });
422
+
423
+ // Execute all writes in parallel
424
+ Promise.all([mainWrite, ...mirrorWrites])
425
+ .then(() => {
426
+ self.log(`Usage.update(): Updated user.usage in firestore (+ ${mirrorWrites.length} mirrors)`, self.user.usage);
427
+
428
+ return resolve(self.user.usage);
429
+ })
430
+ .catch(e => {
431
+ return reject(assistant.errorify(e, {code: 500, sentry: true}));
432
+ });
394
433
  });
395
434
  };
396
435
 
@@ -66,9 +66,11 @@ const SCHEMA = {
66
66
  code: { type: 'string', default: '$randomId' },
67
67
  referrals: { type: 'array', default: [] },
68
68
  },
69
- activity: {
70
- lastActivity: '$timestamp:now',
69
+ metadata: {
71
70
  created: '$timestamp:now',
71
+ updated: '$timestamp:now',
72
+ },
73
+ activity: {
72
74
  geolocation: {
73
75
  ip: { type: 'string', default: null, nullable: true },
74
76
  continent: { type: 'string', default: null, nullable: true },
@@ -98,6 +98,11 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
98
98
  return assistant.respond(imageResult.message, { code: 400 });
99
99
  }
100
100
 
101
+ // Rewrite body to use @post/ prefix for extracted images
102
+ for (const { originalUrl, localFilename } of imageResult) {
103
+ settings.body = settings.body.split(originalUrl).join(`@post/${localFilename}`);
104
+ }
105
+
101
106
  // Set defaults
102
107
  const formattedContent = powertools.template(
103
108
  POST_TEMPLATE,
@@ -120,6 +125,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
120
125
 
121
126
  // Helper: Extract and upload images
122
127
  async function extractImages(assistant, octokit, settings) {
128
+ const urlMap = [];
123
129
 
124
130
  const matches = settings.body.matchAll(IMAGE_REGEX);
125
131
  const images = Array.from(matches).map(match => ({
@@ -138,7 +144,7 @@ async function extractImages(assistant, octokit, settings) {
138
144
  assistant.log('extractImages(): images', images);
139
145
 
140
146
  if (!images.length) {
141
- return;
147
+ return urlMap;
142
148
  }
143
149
 
144
150
  for (let index = 0; index < images.length; index++) {
@@ -171,7 +177,14 @@ async function extractImages(assistant, octokit, settings) {
171
177
  continue;
172
178
  }
173
179
  }
180
+
181
+ // Track successfully uploaded non-header images for body rewriting
182
+ if (!image.header) {
183
+ urlMap.push({ originalUrl: image.src, localFilename: download.filename });
184
+ }
174
185
  }
186
+
187
+ return urlMap;
175
188
  }
176
189
 
177
190
  // Helper: Download image
@@ -57,12 +57,12 @@ module.exports = async ({ assistant, Manager, user, settings, analytics, librari
57
57
  uid: uid,
58
58
  email: email,
59
59
  },
60
- activity: {
60
+ metadata: {
61
61
  created: {
62
62
  timestamp: created.toISOString(),
63
63
  timestampUNIX: Math.floor(created.getTime() / 1000),
64
64
  },
65
- lastActivity: {
65
+ updated: {
66
66
  timestamp: activity.toISOString(),
67
67
  timestampUNIX: Math.floor(activity.getTime() / 1000),
68
68
  },
@@ -68,6 +68,7 @@ service cloud.firestore {
68
68
  || isWritingField('subscription')
69
69
  || isWritingField('affiliate')
70
70
  || isWritingField('api')
71
+ || isWritingField('metadata')
71
72
  || isWritingField('usage');
72
73
  }
73
74
 
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Test: POST /admin/post
3
+ * Tests the admin create post endpoint
4
+ * Creates blog posts via GitHub with image extraction and @post/ body rewriting
5
+ * Requires admin/blogger role, GitHub API key, and repo_website config
6
+ */
7
+ const { Octokit } = require('@octokit/rest');
8
+
9
+ module.exports = {
10
+ description: 'Admin create post on GitHub',
11
+ type: 'suite',
12
+ timeout: 300000, // 5 minutes total for the suite
13
+
14
+ tests: [
15
+ // --- Validation tests (no GitHub needed) ---
16
+
17
+ {
18
+ name: 'missing-title-rejected',
19
+ auth: 'admin',
20
+ timeout: 15000,
21
+
22
+ async run({ http, assert }) {
23
+ const response = await http.post('admin/post', {
24
+ url: 'test-post',
25
+ description: 'Test description',
26
+ headerImageURL: 'https://example.com/image.jpg',
27
+ body: 'Test content',
28
+ });
29
+
30
+ assert.isError(response, 400, 'Missing title should return 400');
31
+ },
32
+ },
33
+
34
+ {
35
+ name: 'missing-url-rejected',
36
+ auth: 'admin',
37
+ timeout: 15000,
38
+
39
+ async run({ http, assert }) {
40
+ const response = await http.post('admin/post', {
41
+ title: 'Test Post',
42
+ description: 'Test description',
43
+ headerImageURL: 'https://example.com/image.jpg',
44
+ body: 'Test content',
45
+ });
46
+
47
+ assert.isError(response, 400, 'Missing URL should return 400');
48
+ },
49
+ },
50
+
51
+ {
52
+ name: 'missing-description-rejected',
53
+ auth: 'admin',
54
+ timeout: 15000,
55
+
56
+ async run({ http, assert }) {
57
+ const response = await http.post('admin/post', {
58
+ title: 'Test Post',
59
+ url: 'test-post',
60
+ headerImageURL: 'https://example.com/image.jpg',
61
+ body: 'Test content',
62
+ });
63
+
64
+ assert.isError(response, 400, 'Missing description should return 400');
65
+ },
66
+ },
67
+
68
+ {
69
+ name: 'missing-header-image-rejected',
70
+ auth: 'admin',
71
+ timeout: 15000,
72
+
73
+ async run({ http, assert }) {
74
+ const response = await http.post('admin/post', {
75
+ title: 'Test Post',
76
+ url: 'test-post',
77
+ description: 'Test description',
78
+ body: 'Test content',
79
+ });
80
+
81
+ assert.isError(response, 400, 'Missing headerImageURL should return 400');
82
+ },
83
+ },
84
+
85
+ {
86
+ name: 'missing-body-rejected',
87
+ auth: 'admin',
88
+ timeout: 15000,
89
+
90
+ async run({ http, assert }) {
91
+ const response = await http.post('admin/post', {
92
+ title: 'Test Post',
93
+ url: 'test-post',
94
+ description: 'Test description',
95
+ headerImageURL: 'https://example.com/image.jpg',
96
+ });
97
+
98
+ assert.isError(response, 400, 'Missing body should return 400');
99
+ },
100
+ },
101
+
102
+ // --- Integration: create post with inline image, verify @post/ rewriting ---
103
+
104
+ {
105
+ name: 'create-post-rewrites-body-images',
106
+ auth: 'admin',
107
+ timeout: 120000,
108
+ skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE env var not set' : false,
109
+
110
+ async run({ http, assert, state, config }) {
111
+ if (!process.env.GITHUB_TOKEN) {
112
+ assert.fail('GITHUB_TOKEN env var not set');
113
+ return;
114
+ }
115
+
116
+ if (!config.githubRepoWebsite) {
117
+ assert.fail('githubRepoWebsite not configured');
118
+ return;
119
+ }
120
+
121
+ // Parse owner/repo for cleanup later
122
+ const repoMatch = config.githubRepoWebsite.match(/github\.com\/([^/]+)\/([^/]+)/);
123
+ if (!repoMatch) {
124
+ assert.fail('Could not parse githubRepoWebsite');
125
+ return;
126
+ }
127
+
128
+ state.owner = repoMatch[1];
129
+ state.repo = repoMatch[2];
130
+
131
+ // Use a real public .jpg for the inline image
132
+ const inlineImageURL = 'https://picsum.photos/id/10/200/200.jpg';
133
+
134
+ const response = await http.post('admin/post', {
135
+ title: 'BEM Test Create Post',
136
+ url: 'bem-test-create-post',
137
+ description: 'Test post created by BEM test suite to verify @post/ body rewriting.',
138
+ headerImageURL: 'https://picsum.photos/id/1/400/300.jpg',
139
+ body: `# BEM Test Create Post\n\nSome intro text.\n\n![Test inline image](${inlineImageURL})\n\nMore text after the image.`,
140
+ postPath: 'guest',
141
+ });
142
+
143
+ assert.isSuccess(response, 'Create post should succeed');
144
+ assert.hasProperty(response, 'data.id', 'Response should have post id');
145
+ assert.hasProperty(response, 'data.path', 'Response should have post path');
146
+
147
+ // Store for cleanup
148
+ state.postId = response.data.id;
149
+ state.postPath = `${response.data.path}/${response.data.date}-bem-test-create-post.md`;
150
+ },
151
+ },
152
+
153
+ {
154
+ name: 'verify-body-has-at-post-prefix',
155
+ auth: 'admin',
156
+ timeout: 30000,
157
+ skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE env var not set' : false,
158
+
159
+ async run({ assert, state }) {
160
+ if (!state.postPath) {
161
+ return; // Previous test didn't run
162
+ }
163
+
164
+ const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
165
+
166
+ // Fetch the committed post from GitHub
167
+ const { data: fileData } = await octokit.rest.repos.getContent({
168
+ owner: state.owner,
169
+ repo: state.repo,
170
+ path: state.postPath,
171
+ });
172
+
173
+ const content = Buffer.from(fileData.content, 'base64').toString();
174
+ state.currentSha = fileData.sha;
175
+
176
+ // The body should contain @post/ prefix instead of the original external URL
177
+ assert.ok(
178
+ content.includes('@post/'),
179
+ 'Post body should contain @post/ prefix for downloaded images',
180
+ );
181
+
182
+ assert.ok(
183
+ !content.includes('picsum.photos/id/10'),
184
+ 'Post body should NOT contain the original external inline image URL',
185
+ );
186
+ },
187
+ },
188
+
189
+ // --- Auth rejection tests ---
190
+
191
+ {
192
+ name: 'unauthenticated-rejected',
193
+ auth: 'none',
194
+ timeout: 15000,
195
+
196
+ async run({ http, assert }) {
197
+ const response = await http.post('admin/post', {
198
+ title: 'Test Post',
199
+ url: 'test-post',
200
+ description: 'Test description',
201
+ headerImageURL: 'https://example.com/image.jpg',
202
+ body: 'Test content',
203
+ });
204
+
205
+ assert.isError(response, 401, 'Create post should fail without authentication');
206
+ },
207
+ },
208
+
209
+ {
210
+ name: 'non-admin-rejected',
211
+ auth: 'basic',
212
+ timeout: 15000,
213
+
214
+ async run({ http, assert }) {
215
+ const response = await http.post('admin/post', {
216
+ title: 'Test Post',
217
+ url: 'test-post',
218
+ description: 'Test description',
219
+ headerImageURL: 'https://example.com/image.jpg',
220
+ body: 'Test content',
221
+ });
222
+
223
+ assert.isError(response, 403, 'Create post should fail for non-admin user');
224
+ },
225
+ },
226
+
227
+ // --- Cleanup ---
228
+
229
+ {
230
+ name: 'cleanup',
231
+ auth: 'admin',
232
+ timeout: 60000,
233
+
234
+ async run({ state }) {
235
+ if (!process.env.GITHUB_TOKEN || !state.postPath) {
236
+ return;
237
+ }
238
+
239
+ const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
240
+
241
+ // Delete the test post
242
+ try {
243
+ const { data: fileData } = await octokit.rest.repos.getContent({
244
+ owner: state.owner,
245
+ repo: state.repo,
246
+ path: state.postPath,
247
+ });
248
+
249
+ await octokit.rest.repos.deleteFile({
250
+ owner: state.owner,
251
+ repo: state.repo,
252
+ path: state.postPath,
253
+ message: '🧹 BEM test cleanup: delete create-post test',
254
+ sha: fileData.sha,
255
+ });
256
+ } catch (e) {
257
+ // File might not exist, ignore
258
+ }
259
+
260
+ // Delete uploaded test images
261
+ const imagePath = `src/assets/images/blog/post-${state.postId}/`;
262
+ try {
263
+ const { data: dirData } = await octokit.rest.repos.getContent({
264
+ owner: state.owner,
265
+ repo: state.repo,
266
+ path: imagePath,
267
+ });
268
+
269
+ // Delete each image file
270
+ for (const file of dirData) {
271
+ await octokit.rest.repos.deleteFile({
272
+ owner: state.owner,
273
+ repo: state.repo,
274
+ path: file.path,
275
+ message: `🧹 BEM test cleanup: delete test image ${file.name}`,
276
+ sha: file.sha,
277
+ });
278
+ }
279
+ } catch (e) {
280
+ // Directory might not exist, ignore
281
+ }
282
+
283
+ // Cancel any running workflows triggered by our test commits
284
+ try {
285
+ const { data: runs } = await octokit.rest.actions.listWorkflowRunsForRepo({
286
+ owner: state.owner,
287
+ repo: state.repo,
288
+ status: 'in_progress',
289
+ per_page: 10,
290
+ });
291
+
292
+ for (const run of runs.workflow_runs) {
293
+ if (run.head_commit?.message?.includes('BEM test')) {
294
+ try {
295
+ await octokit.rest.actions.cancelWorkflowRun({
296
+ owner: state.owner,
297
+ repo: state.repo,
298
+ run_id: run.id,
299
+ });
300
+ } catch (e) {
301
+ // May already be completed, ignore
302
+ }
303
+ }
304
+ }
305
+ } catch (e) {
306
+ // Workflow cancellation failed, not critical
307
+ }
308
+ },
309
+ },
310
+ ],
311
+ };