backend-manager 5.0.182 → 5.0.184

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,11 @@ 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.184] - 2026-03-31
18
+ ### Changed
19
+ - Renamed email template shortcuts from `main/` to `core/` prefix across constants and all consumer files
20
+ - Added new templates: `core/plain` and `core/marketing/promotional`
21
+
17
22
  # [5.0.177] - 2026-03-29
18
23
  ### Changed
19
24
  - `payment-recovered` transition now sends email to internal team only — customer no longer receives a "Payment received" notification
package/CLAUDE.md CHANGED
@@ -1278,6 +1278,100 @@ assistant.isProduction() // true when ENVIRONMENT === 'production'
1278
1278
  assistant.isTesting() // true when running tests (via npx bm test)
1279
1279
  ```
1280
1280
 
1281
+ ## Model Context Protocol (MCP)
1282
+
1283
+ BEM includes a built-in MCP server that exposes BEM routes as tools for Claude Chat, Claude Code, and other MCP clients.
1284
+
1285
+ ### Architecture
1286
+
1287
+ Two transport modes:
1288
+ - **Stdio** (local): `npx bm mcp` — for Claude Code / Claude Desktop
1289
+ - **Streamable HTTP** (remote): `POST /backend-manager/mcp` — for Claude Chat (stateless, Firebase Functions compatible)
1290
+
1291
+ ### Available Tools (19)
1292
+
1293
+ | Tool | Route | Description |
1294
+ |------|-------|-------------|
1295
+ | `firestore_read` | `GET /admin/firestore` | Read a Firestore document by path |
1296
+ | `firestore_write` | `POST /admin/firestore` | Write/merge a Firestore document |
1297
+ | `firestore_query` | `POST /admin/firestore/query` | Query a collection with where/orderBy/limit |
1298
+ | `send_email` | `POST /admin/email` | Send transactional email via SendGrid |
1299
+ | `send_notification` | `POST /admin/notification` | Send push notification via FCM |
1300
+ | `get_user` | `GET /user` | Get authenticated user info |
1301
+ | `get_subscription` | `GET /user/subscription` | Get subscription info for a user |
1302
+ | `sync_users` | `POST /admin/users/sync` | Sync user data across systems |
1303
+ | `list_campaigns` | `GET /marketing/campaign` | List marketing campaigns |
1304
+ | `create_campaign` | `POST /marketing/campaign` | Create a marketing campaign |
1305
+ | `get_stats` | `GET /admin/stats` | Get system statistics |
1306
+ | `cancel_subscription` | `POST /payments/cancel` | Cancel subscription at period end |
1307
+ | `refund_payment` | `POST /payments/refund` | Process a refund |
1308
+ | `run_cron` | `POST /admin/cron` | Trigger a cron job by ID |
1309
+ | `create_post` | `POST /admin/post` | Create a blog post |
1310
+ | `create_backup` | `POST /admin/backup` | Create a Firestore backup |
1311
+ | `run_hook` | `POST /admin/hook` | Execute a custom hook |
1312
+ | `generate_uuid` | `POST /general/uuid` | Generate a UUID |
1313
+ | `health_check` | `GET /test/health` | Check server health |
1314
+
1315
+ ### Authentication
1316
+
1317
+ - **Stdio (local):** Reads `BACKEND_MANAGER_KEY` from `functions/.env` automatically
1318
+ - **HTTP (remote):** OAuth 2.1 Authorization Code flow with PKCE. Claude Chat handles the flow — user pastes BEM key once on the authorize page. If `OAuth Client ID` is set to the BEM key in the connector config, the authorize step auto-approves.
1319
+
1320
+ ### Hosting Rewrites
1321
+
1322
+ The `npx bm setup` command automatically adds required Firebase Hosting rewrites for MCP OAuth:
1323
+ ```json
1324
+ {
1325
+ "source": "{/backend-manager,/backend-manager/**,/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-server,/authorize,/token}",
1326
+ "function": "bm_api"
1327
+ }
1328
+ ```
1329
+
1330
+ ### CLI Usage
1331
+
1332
+ ```bash
1333
+ npx bm mcp # Start stdio MCP server (for Claude Code)
1334
+ ```
1335
+
1336
+ ### Claude Code Configuration
1337
+
1338
+ Add to `.claude/settings.json`:
1339
+ ```json
1340
+ {
1341
+ "mcpServers": {
1342
+ "backend-manager": {
1343
+ "command": "npx",
1344
+ "args": ["bm", "mcp"],
1345
+ "cwd": "/path/to/consumer-project"
1346
+ }
1347
+ }
1348
+ }
1349
+ ```
1350
+
1351
+ ### Claude Chat Configuration
1352
+
1353
+ 1. Go to Settings → Custom Connectors → Add
1354
+ 2. **URL:** `https://api.yourdomain.com/backend-manager/mcp`
1355
+ 3. **OAuth Client ID:** your `BACKEND_MANAGER_KEY` (enables auto-approve)
1356
+ 4. **OAuth Client Secret:** your `BACKEND_MANAGER_KEY`
1357
+
1358
+ ### Key Files
1359
+
1360
+ | Purpose | File |
1361
+ |---------|------|
1362
+ | Tool definitions | `src/mcp/tools.js` |
1363
+ | HTTP handler (stateless + OAuth) | `src/mcp/handler.js` |
1364
+ | Stdio server | `src/mcp/index.js` |
1365
+ | HTTP client | `src/mcp/client.js` |
1366
+ | CLI command | `src/cli/commands/mcp.js` |
1367
+ | MCP route interception | `src/manager/index.js` (`_handleMcp`, `resolveMcpRoutePath`) |
1368
+ | Hosting rewrites setup | `src/cli/commands/setup-tests/hosting-rewrites.js` |
1369
+
1370
+ ### Adding New Tools
1371
+
1372
+ 1. Add the tool definition to `src/mcp/tools.js` with `name`, `description`, `method`, `path`, and `inputSchema`
1373
+ 2. The tool automatically maps to the corresponding BEM route via the HTTP client — no handler code needed
1374
+
1281
1375
  ## Response Headers
1282
1376
 
1283
1377
  BEM automatically sets `bm-properties` header with:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.182",
3
+ "version": "5.0.184",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -71,7 +71,7 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
71
71
  email.send({
72
72
  sender: 'marketing',
73
73
  to: userDoc,
74
- template: 'main/order/abandoned-cart',
74
+ template: 'core/order/abandoned-cart',
75
75
  subject: `Complete your ${brandName} ${productName} checkout`,
76
76
  categories: ['order/abandoned-cart', `order/abandoned-cart/reminder-${reminderIndex + 1}`],
77
77
  copy: false,
@@ -225,7 +225,7 @@ function sendDisputeEmail({ alert, match, result, alertId, assistant }) {
225
225
  sender: 'internal',
226
226
  to: brandEmail,
227
227
  subject: subject,
228
- template: 'main/basic/card',
228
+ template: 'core/card',
229
229
  categories: ['order/dispute-alert'],
230
230
  copy: true,
231
231
  data: {
@@ -16,7 +16,7 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
16
16
  assistant.log(`Transition [one-time/purchase-completed]: uid=${uid}, resourceId=${after.payment?.resourceId}, discount=${hasPromoDiscount ? discount.code : 'none'}`);
17
17
 
18
18
  sendOrderEmail({
19
- template: 'main/order/confirmation',
19
+ template: 'core/order/confirmation',
20
20
  subject: `Your ${brandName} ${productName} order #${order?.id || ''}`,
21
21
  categories: ['order/confirmation'],
22
22
  userDoc,
@@ -8,7 +8,7 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
8
8
  assistant.log(`Transition [subscription/cancellation-requested]: uid=${uid}, product=${after.product?.id}, cancelDate=${after.cancellation?.date?.timestamp}`);
9
9
 
10
10
  sendOrderEmail({
11
- template: 'main/order/cancellation-requested',
11
+ template: 'core/order/cancellation-requested',
12
12
  subject: `Your cancellation is confirmed #${order?.id || ''}`,
13
13
  categories: ['order/cancellation-requested'],
14
14
  userDoc,
@@ -18,7 +18,7 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
18
18
  assistant.log(`Transition [subscription/new-subscription]: uid=${uid}, product=${after.product?.id}, frequency=${after.payment?.frequency}, trial=${isTrial}, discount=${hasPromoDiscount ? discount.code : 'none'}`);
19
19
 
20
20
  sendOrderEmail({
21
- template: 'main/order/confirmation',
21
+ template: 'core/order/confirmation',
22
22
  subject: `Your ${brandName} ${planName} order #${order?.id || ''}`,
23
23
  categories: ['order/confirmation'],
24
24
  userDoc,
@@ -8,7 +8,7 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
8
8
  assistant.log(`Transition [subscription/payment-failed]: uid=${uid}, product=${after.product?.id}, previousStatus=${before?.status}`);
9
9
 
10
10
  sendOrderEmail({
11
- template: 'main/order/payment-failed',
11
+ template: 'core/order/payment-failed',
12
12
  subject: `Payment failed for order #${order?.id || ''}`,
13
13
  categories: ['order/payment-failed'],
14
14
  userDoc,
@@ -8,7 +8,7 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
8
8
  assistant.log(`Transition [subscription/payment-recovered]: uid=${uid}, product=${after.product?.id}`);
9
9
 
10
10
  sendOrderEmail({
11
- template: 'main/order/payment-recovered',
11
+ template: 'core/order/payment-recovered',
12
12
  subject: `Payment received for order #${order?.id || ''}`,
13
13
  categories: ['order/payment-recovered'],
14
14
  internalOnly: true,
@@ -14,7 +14,7 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
14
14
  assistant.log(`Transition [subscription/payment-refunded]: uid=${uid}, product=${after?.product?.id}, amount=${refundDetails?.amount} ${refundDetails?.currency}, reason=${refundDetails?.reason || 'none'}`);
15
15
 
16
16
  sendOrderEmail({
17
- template: 'main/order/refunded',
17
+ template: 'core/order/refunded',
18
18
  subject: `Your payment has been refunded #${order?.id || ''}`,
19
19
  categories: ['order/refunded'],
20
20
  userDoc,
@@ -9,7 +9,7 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
9
9
  assistant.log(`Transition [subscription/plan-changed]: uid=${uid}, ${before.product?.id} → ${after.product?.id} (${direction})`);
10
10
 
11
11
  sendOrderEmail({
12
- template: 'main/order/plan-changed',
12
+ template: 'core/order/plan-changed',
13
13
  subject: `Your plan has been updated #${order?.id || ''}`,
14
14
  categories: ['order/plan-changed'],
15
15
  userDoc,
@@ -13,7 +13,7 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
13
13
  const hasFutureExpiry = !isTrial && after.expires?.timestamp && new Date(after.expires.timestamp) > new Date();
14
14
 
15
15
  sendOrderEmail({
16
- template: 'main/order/cancelled',
16
+ template: 'core/order/cancelled',
17
17
  subject: `Your subscription has been cancelled #${order?.id || ''}`,
18
18
  categories: ['order/cancelled'],
19
19
  userDoc,
@@ -13,7 +13,7 @@ module.exports = function (payload, config) {
13
13
  sender: 'marketing',
14
14
  categories: ['download'],
15
15
  subject: `Free ${config.brand.name} download link for ${payload.name || 'you'}!`,
16
- template: 'main/misc/app-download-link',
16
+ template: 'core/misc/app-download-link',
17
17
  copy: false,
18
18
  data: {},
19
19
  }
@@ -8,23 +8,30 @@
8
8
  // Template shortcut map — callers use readable paths instead of SendGrid IDs
9
9
  // Paths mirror the email website structure: {category}/{subcategory}/{name}
10
10
  const TEMPLATES = {
11
- // v2 templates
12
- 'main/basic/card': 'd-1cd2eee44b6340268c964cd7971d49b9',
13
- 'main/engagement/feedback': 'd-319ab5c9d5074b21926a93562d6f41f6',
14
- 'main/misc/app-download-link': 'd-fc8b4834d7e1472896fe7e46152029f4',
15
- 'main/order/confirmation': 'd-5371ac2b4e3b490bbce51bfc2922ece8',
16
- 'main/order/payment-failed': 'd-e56af0ac62364bfb9e50af02854e2cd3',
17
- 'main/order/payment-recovered': 'd-d6dbd17a260a4755b34a852ba09c2454',
18
- 'main/order/cancellation-requested': 'd-78074f3e8c844146bf263b86fc8d5ecf',
19
- 'main/order/cancelled': 'd-39041132e6b24e5ebf0e95bce2d94dba',
20
- 'main/order/plan-changed': 'd-399086311bbb48b4b77bc90b20fb9d0a',
21
- 'main/order/trial-ending': 'd-af8ab499cbfb4d56918b4118f44343b0',
22
- 'main/order/refunded': 'd-aa47fdbffa2b4ca9b73b6256e963e49f',
23
- 'main/order/abandoned-cart': 'd-d8b3fa67e2b44b398dc280d0576bf1b7',
11
+ // Default templates
12
+ 'core/card': 'd-1cd2eee44b6340268c964cd7971d49b9',
13
+ 'core/plain': 'd-1d99985c1f0e40ff99d130c94047b080',
14
+
15
+ // Global templates
16
+ 'core/engagement/feedback': 'd-319ab5c9d5074b21926a93562d6f41f6',
17
+ 'core/misc/app-download-link': 'd-fc8b4834d7e1472896fe7e46152029f4',
18
+ 'core/marketing/promotional': 'd-5fbaf210b0aa498e9167dfd8ae8e08d0',
19
+ 'core/order/confirmation': 'd-5371ac2b4e3b490bbce51bfc2922ece8',
20
+ 'core/order/payment-failed': 'd-e56af0ac62364bfb9e50af02854e2cd3',
21
+ 'core/order/payment-recovered': 'd-d6dbd17a260a4755b34a852ba09c2454',
22
+ 'core/order/cancellation-requested': 'd-78074f3e8c844146bf263b86fc8d5ecf',
23
+ 'core/order/cancelled': 'd-39041132e6b24e5ebf0e95bce2d94dba',
24
+ 'core/order/plan-changed': 'd-399086311bbb48b4b77bc90b20fb9d0a',
25
+ 'core/order/trial-ending': 'd-af8ab499cbfb4d56918b4118f44343b0',
26
+ 'core/order/refunded': 'd-aa47fdbffa2b4ca9b73b6256e963e49f',
27
+ 'core/order/abandoned-cart': 'd-d8b3fa67e2b44b398dc280d0576bf1b7',
28
+
29
+ // Brand-specific templates are NOT registered here.
30
+ // Each consuming project should hardcode its own brand-specific template IDs directly.
24
31
  };
25
32
 
26
- // "default" resolves to the basic card template
27
- TEMPLATES['default'] = TEMPLATES['main/basic/card'];
33
+ // "default" resolves to the default card template
34
+ TEMPLATES['default'] = TEMPLATES['core/card'];
28
35
 
29
36
  // Group shortcut map — SendGrid ASM group IDs
30
37
  // Rename these in SendGrid dashboard to match the comments
@@ -17,7 +17,7 @@ module.exports = function (payload, config) {
17
17
  sender: 'marketing',
18
18
  categories: ['download'],
19
19
  subject: `Free ${config.brand.name} download link for ${payload.name || 'you'}!`,
20
- template: 'main/misc/app-download-link',
20
+ template: 'core/misc/app-download-link',
21
21
  copy: false,
22
22
  data: {},
23
23
  }
@@ -378,7 +378,7 @@ function sendFeedbackEmail(assistant, uid) {
378
378
  sender: 'hello',
379
379
  categories: ['engagement/feedback'],
380
380
  subject: `Want to share your feedback about ${Manager.config.brand.name}?`,
381
- template: 'main/engagement/feedback',
381
+ template: 'core/engagement/feedback',
382
382
  copy: false,
383
383
  sendAt: moment().add(10, 'days').unix(),
384
384
  })
@@ -93,7 +93,7 @@ function handleAuthorize(req, res, options) {
93
93
  const Manager = options.Manager;
94
94
 
95
95
  // Auto-approve if client_id matches the BEM key
96
- if (isValidKey(client_id, Manager) && redirect_uri) {
96
+ if (isValidKey(client_id) && redirect_uri) {
97
97
  const url = new URL(redirect_uri);
98
98
  url.searchParams.set('code', client_id);
99
99
  if (state) {
@@ -151,7 +151,7 @@ function handleAuthorize(req, res, options) {
151
151
  const redirectUri = body.redirect_uri || '';
152
152
  const postState = body.state || '';
153
153
 
154
- if (!isValidKey(key, Manager)) {
154
+ if (!isValidKey(key)) {
155
155
  res.writeHead(403, { 'Content-Type': 'text/html' });
156
156
  res.end('<html><body style="background:#111;color:#e55;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh"><h2>Invalid key. Go back and try again.</h2></body></html>');
157
157
  return;
@@ -188,7 +188,7 @@ function handleToken(req, res, options) {
188
188
  const Manager = options.Manager;
189
189
 
190
190
  // The code, client_secret, or client_id IS the backendManagerKey — validate any
191
- if (!isValidKey(code, Manager)) {
191
+ if (!isValidKey(code)) {
192
192
  return sendJson(res, 401, {
193
193
  error: 'invalid_grant',
194
194
  error_description: 'Invalid authorization code.',
@@ -213,7 +213,7 @@ async function handleMcpProtocol(req, res, options) {
213
213
  const authHeader = req.headers.authorization || '';
214
214
  const key = authHeader.replace(/^Bearer\s+/i, '');
215
215
 
216
- if (!isValidKey(key, Manager)) {
216
+ if (!isValidKey(key)) {
217
217
  // Return 401 with OAuth discovery hint
218
218
  const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'https';
219
219
  const host = req.headers['x-forwarded-host'] || req.headers.host || '';
@@ -321,8 +321,8 @@ async function handleMcpProtocol(req, res, options) {
321
321
  * Validate a key against the configured backendManagerKey.
322
322
  * Returns false if either the key or the config key is empty/missing.
323
323
  */
324
- function isValidKey(key, Manager) {
325
- const configKey = Manager.config?.backendManagerKey;
324
+ function isValidKey(key) {
325
+ const configKey = process.env.BACKEND_MANAGER_KEY || '';
326
326
  return !!key && !!configKey && key === configKey;
327
327
  }
328
328