backend-manager 5.9.3 → 5.9.5

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,19 @@ 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.9.5] - 2026-06-21
18
+
19
+ ### Fixed
20
+ - **Suspended subscription cancel dead zone.** Users with `subscription.status: 'suspended'` were trapped — the subscribe endpoint required them to cancel first, but the cancel endpoint only accepted `active` subscriptions. Cancel now accepts both `active` and `suspended`. When the processor rejects a suspended cancel (subscription already dead on their end), falls back to directly resetting the user doc to `cancelled`.
21
+
22
+ ### Security
23
+ - **Supply-chain audited installs.** All `npm install` calls in CLI commands (`install`, setup-tests) now route through `safeInstall()` which prefixes Socket Firewall (`sfw`) when available, blocking confirmed malware before packages reach disk.
24
+
25
+ # [5.9.4] - 2026-06-21
26
+
27
+ ### Fixed
28
+ - **Download-app-link email had blank body.** Template passed `data: {}` so the card rendered logo + signoff + footer but no content. Now populates title, message, and "Download Now" button linking to `{brand.url}/download`.
29
+
17
30
  # [5.9.3] - 2026-06-21
18
31
 
19
32
  ### Changed
package/CLAUDE.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  > **Note for contributors and Claude:** This file is the architectural overview — identity, top-level conventions, and a map to deep references. The **meat** (per-subsystem APIs, behavior tables, recipes) lives in `docs/<topic>.md`. When extending or adding content, write it in the matching `docs/*.md` file and cross-link from here — do NOT inline it. If a topic doesn't have a doc yet, create one. Goal: keep this file under 250 lines.
4
4
 
5
+ > **Mirrored structure:** BEM, UJM, BXM, and EM CLAUDE.md files mirror each other — shared sections (Supply-Chain Security, Development Workflow, File Conventions, etc.) appear in the **same order at the same position** across all four. When adding a section that applies to multiple frameworks, insert it in the same spot in all of them.
6
+
5
7
  ## Identity
6
8
 
7
9
  Backend Manager (BEM) is a comprehensive framework for building modern Firebase Cloud Functions backends. Sister project to Electron Manager (EM), Browser Extension Manager (BXM), and Ultimate Jekyll Manager (UJM). Provides a single `Manager.init(exports, {...})` bootstrap that wires built-in functions (`bm_api`, auth events, cron jobs), helper classes (Assistant, User, Analytics, Usage, Middleware, Settings, Utilities, Metadata), payment processor integrations (Stripe / PayPal), Firestore-trigger pipelines, marketing campaign automation, an MCP server, and a CLI for emulator/deploy/logs/auth/Firestore operations.
@@ -94,6 +96,10 @@ See [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) and [docs/cli-logs.
94
96
  - **If the user reports an error**, check the emulator/test output for the root cause before guessing.
95
97
  - **Live-test UI changes via CDP.** When working on admin dashboards or browser-facing endpoints, use the `chrome-devtools` MCP tools (screenshots, click, evaluate JS, console logs) to verify the change works in the running browser. See `~/.claude/mcp-server/servers/chrome-devtools/CLAUDE.md`.
96
98
 
99
+ ## Supply-Chain Security
100
+
101
+ All `npm install` calls in CLI commands (`npx mgr i`, `npx mgr setup`, setup-tests) route through the `safeInstall()` helper (`src/cli/utils/safe-install.js`). It prefixes `sfw` (Socket Firewall) when installed — blocking confirmed malware at the network level before packages reach disk. Falls back to plain npm if sfw isn't available. CI workflows install sfw globally and run `sfw npm install`/`sfw npm ci`. Installs will **fail if sfw detects confirmed malware** in any package in the dependency tree; non-critical CVEs and quality warnings pass through.
102
+
97
103
  ## File Conventions
98
104
 
99
105
  - **CommonJS** throughout. `prepare-package` copies `src/` → `dist/` 1:1 (no transforms).
package/PROGRESS.md CHANGED
@@ -2,11 +2,11 @@
2
2
  > Agents and maintainers should update this file regularly to reflect the current state of the project.
3
3
 
4
4
  ## 🎯 Current Focus
5
- * **Goal:** Ghostii feed sources cleanup + Somiibo consumer config
6
- * **Current Phase:** Fixes applied + tested, pending commit + publish
5
+ * **Goal:** Fix suspended subscription dead zone users with `suspended` status couldn't cancel or re-subscribe
6
+ * **Current Phase:** Fix applied + tested (24/24 passing), pending commit + publish
7
7
  * **Priority:** High
8
- * **Last Updated:** 2026-06-19 3:05 AM PDT
9
- * **Notes:** Renamed Firestore collection `ghostii-feed-items` `ghostii-sources`. Fixed `FieldValue.serverTimestamp()` BEM metadata pattern. Added 5 marketing/social feeds to extended tests. All tests passing (20/20 standard, 9/9 extended). Somiibo consumer has feeds configured + `ghostii-sources.md` evaluation doc. Needs BEM publish + consumer deploy.
8
+ * **Last Updated:** 2026-06-21 5:50 PM PDT
9
+ * **Notes:** Cancel endpoint now accepts `suspended` subscriptions. Added fallback: if processor rejects (subscription already dead on their end), directly resets user doc to `cancelled`. Traced from live Somiibo user `t9AeAe7QUhNXAUYRV1vUbOU0QVV2` stuck in limbo. Needs BEM publish + Somiibo deploy.
10
10
 
11
11
  ## 📌 Active Task List
12
12
  * [ ] Phase 6: Setup scaffolds essential configs for fresh projects
@@ -43,6 +43,13 @@
43
43
  * [ ] Task 5.17: Publish BEM with feed support
44
44
  * [ ] Task 5.12: Publish BEM with feed support
45
45
  * [ ] Task 5.13: Configure consumer project(s) with `$feed:` sources
46
+ * [x] Phase 7: Fix suspended subscription cancel dead zone
47
+ * [x] Task 7.1: Trace user `t9AeAe7QUhNXAUYRV1vUbOU0QVV2` — identified asymmetric status gates
48
+ * [x] Task 7.2: Update cancel gate to accept `suspended` alongside `active`
49
+ * [x] Task 7.3: Add fallback — direct Firestore reset when processor rejects suspended cancel
50
+ * [x] Task 7.4: Add `cancel-suspended` test account + `allows-suspended-subscription` test
51
+ * [x] Task 7.5: All 24 payment tests passing
52
+ * [ ] Task 7.6: Publish BEM + deploy Somiibo
46
53
  * [ ] Phase 3: Post-audit bug fixes
47
54
  * [x] Newsletter ReferenceError: `beehiivConfig` → `newsletterRoleConfig` (committed v5.7.1)
48
55
  * [x] HTTPS proxy: `serve.js` returns boolean, caller uses `httpsReady` not `httpsEnabled`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.9.3",
3
+ "version": "5.9.5",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -0,0 +1,97 @@
1
+ # Suspended Subscription Dead Zone
2
+
3
+ ## Issue
4
+
5
+ A user with a `suspended` subscription was trapped in a loop:
6
+ - **Trying to subscribe** → "You already have a subscription. Please cancel your existing subscription before purchasing a new one." (400)
7
+ - **Trying to cancel** → "No active paid subscription found" (400)
8
+
9
+ No way out.
10
+
11
+ ## Affected User
12
+
13
+ - **UID:** `t9AeAe7QUhNXAUYRV1vUbOU0QVV2`
14
+ - **Brand:** Somiibo
15
+
16
+ The user had two stale subscriptions from different processors:
17
+
18
+ | Field | Processor | Status | Resource ID | Last Webhook |
19
+ |---|---|---|---|---|
20
+ | `subscription` | PayPal | `suspended` | `I-Y41DMAGNWGP1` | `BILLING.SUBSCRIPTION.SUSPENDED` (2026-04-19) |
21
+ | `plan` (legacy) | Chargebee | `suspended` | `169xhEU1pYXAt3dsj` | `subscription-profile-fixer` (2024-02-28) |
22
+
23
+ PayPal suspended the subscription (likely failed payment) but never sent a `BILLING.SUBSCRIPTION.CANCELLED` webhook. PayPal doesn't auto-cancel suspended subscriptions — it leaves them in limbo until the user resolves payment or manually cancels in PayPal. The local status stayed `suspended` forever.
24
+
25
+ ## Root Cause
26
+
27
+ Asymmetric status gates between the intent and cancel endpoints:
28
+
29
+ ### Intent gate (`POST /payments/intent`)
30
+ ```js
31
+ // Blocked if product is NOT basic AND status is NOT cancelled
32
+ if (subProductId !== 'basic' && subStatus !== 'cancelled') { ... }
33
+ ```
34
+ - `active` → blocked (correct)
35
+ - `suspended` → blocked (correct — user should cancel first)
36
+ - `cancelled` → allowed (correct)
37
+
38
+ ### Cancel gate (`POST /payments/cancel`)
39
+ ```js
40
+ // Blocked if status is NOT active
41
+ if (subscription.status !== 'active') { ... }
42
+ ```
43
+ - `active` → allowed (correct)
44
+ - `suspended` → **blocked** (the bug)
45
+ - `cancelled` → blocked (correct — nothing to cancel)
46
+
47
+ The intent gate treats `suspended` as "has a subscription" (must cancel first), but the cancel gate treats `suspended` as "no subscription to cancel." Dead zone.
48
+
49
+ ## The Fix
50
+
51
+ ### 1. Cancel gate now accepts `suspended`
52
+
53
+ ```js
54
+ // Before
55
+ if (!subscription || subscription.status !== 'active' || ...)
56
+
57
+ // After
58
+ if (!subscription || (subscription.status !== 'active' && subscription.status !== 'suspended') || ...)
59
+ ```
60
+
61
+ ### 2. Fallback for dead processor subscriptions
62
+
63
+ When cancelling a `suspended` subscription, the processor API call might fail — the subscription could be expired/deleted on the processor's side while our local state is stale. The cancel endpoint now catches processor errors for suspended subscriptions and directly resets the user doc:
64
+
65
+ ```js
66
+ if (subscription.status === 'suspended') {
67
+ // Processor rejected — subscription is already dead on their end
68
+ // Directly reset the user doc to cancelled
69
+ await admin.firestore().doc(`users/${uid}`).set({
70
+ subscription: {
71
+ status: 'cancelled',
72
+ product: { id: 'basic', name: 'Basic' },
73
+ cancellation: { pending: false, date: { timestamp, timestampUNIX } },
74
+ },
75
+ }, { merge: true });
76
+ }
77
+ ```
78
+
79
+ ### 3. New test coverage
80
+
81
+ - Test account `cancel-suspended` with `status: 'suspended'` and test processor payment details
82
+ - Test `allows-suspended-subscription` verifying the cancel endpoint accepts and processes suspended subscriptions
83
+
84
+ ## Status Matrix (after fix)
85
+
86
+ | Status | Can subscribe? | Can cancel? | Can delete account? |
87
+ |---|---|---|---|
88
+ | `active` (basic) | Yes | No (nothing to cancel) | Yes |
89
+ | `active` (paid) | No (cancel first) | Yes | No |
90
+ | `suspended` | No (cancel first) | **Yes** (fixed) | No |
91
+ | `cancelled` | Yes | No (already cancelled) | Yes |
92
+
93
+ ## Files Changed
94
+
95
+ - `src/manager/routes/payments/cancel/post.js` — gate + fallback
96
+ - `src/test/test-accounts.js` — `cancel-suspended` account
97
+ - `test/routes/payments/cancel.js` — `allows-suspended-subscription` test
@@ -5,6 +5,7 @@ const powertools = require('node-powertools');
5
5
  const Npm = require('npm-api');
6
6
  const jetpack = require('fs-jetpack');
7
7
  const wonderfulVersion = require('wonderful-version');
8
+ const { safeInstall } = require('../utils/safe-install');
8
9
 
9
10
  class InstallCommand extends BaseCommand {
10
11
  async execute(type) {
@@ -110,12 +111,12 @@ class InstallCommand extends BaseCommand {
110
111
  const command = name;
111
112
  this.log('Running ', command);
112
113
 
113
- return await powertools.execute(command, { log: true })
114
+ return await safeInstall(command, { log: true })
114
115
  .catch((e) => {
115
116
  throw e;
116
117
  });
117
118
  }
118
-
119
+
119
120
  if (name.indexOf('file:') > -1) {
120
121
  v = '';
121
122
  } else if (!version) {
@@ -133,7 +134,7 @@ class InstallCommand extends BaseCommand {
133
134
  const command = `npm i ${name}${v}${t}`;
134
135
  this.log('Running ', command);
135
136
 
136
- return await powertools.execute(command, { log: true })
137
+ return await safeInstall(command, { log: true })
137
138
  .catch((e) => {
138
139
  throw e;
139
140
  });
@@ -4,6 +4,7 @@ const wonderfulVersion = require('wonderful-version');
4
4
  const powertools = require('node-powertools');
5
5
  const Npm = require('npm-api');
6
6
  const helpers = require('./helpers');
7
+ const { safeInstall } = require('../../utils/safe-install');
7
8
 
8
9
  class BackendManagerTest extends BaseTest {
9
10
  getName() {
@@ -72,7 +73,7 @@ class BackendManagerTest extends BaseTest {
72
73
  console.log('Running ', command);
73
74
 
74
75
  // Execute
75
- await powertools.execute(command, { log: true });
76
+ await safeInstall(command);
76
77
  }
77
78
  }
78
79
 
@@ -3,6 +3,7 @@ const chalk = require('chalk').default;
3
3
  const wonderfulVersion = require('wonderful-version');
4
4
  const powertools = require('node-powertools');
5
5
  const helpers = require('./helpers');
6
+ const { safeInstall } = require('../../utils/safe-install');
6
7
 
7
8
  class FirebaseAdminTest extends BaseTest {
8
9
  getName() {
@@ -64,7 +65,7 @@ class FirebaseAdminTest extends BaseTest {
64
65
  console.log('Running ', command);
65
66
 
66
67
  // Execute
67
- await powertools.execute(command, { log: true });
68
+ await safeInstall(command);
68
69
  }
69
70
  }
70
71
 
@@ -3,6 +3,7 @@ const chalk = require('chalk').default;
3
3
  const wonderfulVersion = require('wonderful-version');
4
4
  const powertools = require('node-powertools');
5
5
  const helpers = require('./helpers');
6
+ const { safeInstall } = require('../../utils/safe-install');
6
7
 
7
8
  class FirebaseFunctionsTest extends BaseTest {
8
9
  getName() {
@@ -64,7 +65,7 @@ class FirebaseFunctionsTest extends BaseTest {
64
65
  console.log('Running ', command);
65
66
 
66
67
  // Execute
67
- await powertools.execute(command, { log: true });
68
+ await safeInstall(command);
68
69
  }
69
70
  }
70
71
 
@@ -0,0 +1,13 @@
1
+ const { execute } = require('node-powertools');
2
+
3
+ let _hasSfw;
4
+ async function safeInstall(command, options) {
5
+ if (_hasSfw === undefined) {
6
+ _hasSfw = await execute('sfw --version', { log: false }).then(() => true).catch(() => false);
7
+ }
8
+ const isInstall = /^npm\s+(install|i)\b/.test(command);
9
+ const prefix = (_hasSfw && isInstall) ? 'sfw ' : '';
10
+ return execute(`${prefix}${command}`, options || { log: true });
11
+ }
12
+
13
+ module.exports = { safeInstall };
@@ -1,4 +1,9 @@
1
1
  module.exports = function (payload, config) {
2
+ const brandName = config.brand.name || '';
3
+ const brandUrl = config.brand.url || '';
4
+ const downloadUrl = `${brandUrl}/download`;
5
+ const name = payload.name || '';
6
+
2
7
  return {
3
8
  // spamFilter: {
4
9
  // ip: 3,
@@ -8,14 +13,26 @@ module.exports = function (payload, config) {
8
13
  payload: {
9
14
  to: {
10
15
  email: payload.email,
11
- name: payload.name,
16
+ name: name,
12
17
  },
13
18
  sender: 'marketing',
14
19
  categories: ['download'],
15
- subject: `Free ${config.brand.name} download link for ${payload.name || 'you'}!`,
20
+ subject: `${name || 'Hey'}, your ${brandName} download link is ready!`,
16
21
  template: 'card',
17
22
  copy: false,
18
- data: {},
23
+ data: {
24
+ email: {
25
+ preview: `Your ${brandName} download link is inside`,
26
+ },
27
+ content: {
28
+ title: 'Your download link is ready!',
29
+ message: `Thanks for your interest in **${brandName}**! Click the button below to head to the download page and get started.`,
30
+ button: {
31
+ url: downloadUrl,
32
+ text: 'Download Now',
33
+ },
34
+ },
35
+ },
19
36
  }
20
37
  }
21
38
  }
@@ -3,6 +3,11 @@
3
3
  * Sends a download link for the app
4
4
  */
5
5
  module.exports = function (payload, config) {
6
+ const brandName = config.brand.name || '';
7
+ const brandUrl = config.brand.url || '';
8
+ const downloadUrl = `${brandUrl}/download`;
9
+ const name = payload.name || '';
10
+
6
11
  return {
7
12
  // spamFilter: {
8
13
  // ip: 3,
@@ -12,14 +17,26 @@ module.exports = function (payload, config) {
12
17
  payload: {
13
18
  to: {
14
19
  email: payload.email,
15
- name: payload.name,
20
+ name: name,
16
21
  },
17
22
  sender: 'marketing',
18
23
  categories: ['download'],
19
- subject: `${payload.name || 'Hey'}, your ${config.brand.name} download link is ready!`,
24
+ subject: `${name || 'Hey'}, your ${brandName} download link is ready!`,
20
25
  template: 'card',
21
26
  copy: false,
22
- data: {},
27
+ data: {
28
+ email: {
29
+ preview: `Your ${brandName} download link is inside`,
30
+ },
31
+ content: {
32
+ title: 'Your download link is ready!',
33
+ message: `Thanks for your interest in **${brandName}**! Click the button below to head to the download page and get started.`,
34
+ button: {
35
+ url: downloadUrl,
36
+ text: 'Download Now',
37
+ },
38
+ },
39
+ },
23
40
  }
24
41
  }
25
42
  }
@@ -26,8 +26,8 @@ module.exports = async ({ assistant, user, settings }) => {
26
26
 
27
27
  const subscription = user.subscription;
28
28
 
29
- // Require an active, paid subscription
30
- if (!subscription || subscription.status !== 'active' || subscription.product?.id === 'basic') {
29
+ // Require an active or suspended paid subscription
30
+ if (!subscription || (subscription.status !== 'active' && subscription.status !== 'suspended') || subscription.product?.id === 'basic') {
31
31
  assistant.log(`Cancel rejected: uid=${uid}, status=${subscription?.status}, product=${subscription?.product?.id}`);
32
32
  return assistant.respond('No active paid subscription found', { code: 400 });
33
33
  }
@@ -69,6 +69,26 @@ module.exports = async ({ assistant, user, settings }) => {
69
69
  try {
70
70
  await processorModule.cancelAtPeriodEnd({ resourceId, uid, subscription, assistant });
71
71
  } catch (e) {
72
+ // If the subscription is suspended and the processor rejects (subscription already dead/gone),
73
+ // directly reset the user's subscription to cancelled so they can re-subscribe
74
+ if (subscription.status === 'suspended') {
75
+ assistant.log(`Processor cancel failed for suspended subscription (${e.message}), resetting directly`);
76
+ const admin = assistant.Manager.libraries.admin;
77
+ const now = powertools.timestamp(new Date(), { output: 'string' });
78
+ const nowUNIX = powertools.timestamp(now, { output: 'unix' });
79
+
80
+ await admin.firestore().doc(`users/${uid}`).set({
81
+ subscription: {
82
+ status: 'cancelled',
83
+ product: { id: 'basic', name: 'Basic' },
84
+ cancellation: { pending: false, date: { timestamp: now, timestampUNIX: nowUNIX } },
85
+ },
86
+ }, { merge: true });
87
+
88
+ assistant.log(`Directly cancelled suspended subscription for uid=${uid}`);
89
+ return assistant.respond({ success: true });
90
+ }
91
+
72
92
  assistant.log(`Failed to cancel subscription via ${processor}: ${e.message}`);
73
93
  return assistant.respond(`Failed to cancel subscription: ${e.message}`, { code: 500, sentry: true });
74
94
  }
@@ -416,6 +416,15 @@ const JOURNEY_ACCOUNTS = {
416
416
  subscription: { product: { id: 'premium', name: 'Premium' }, status: 'active', expires: getFutureExpires(), cancellation: { pending: false }, payment: { processor: 'test', resourceId: 'sub_test_fake', startDate: { timestamp: new Date().toISOString(), timestampUNIX: Math.floor(Date.now() / 1000) } } },
417
417
  },
418
418
  },
419
+ 'cancel-suspended': {
420
+ id: 'cancel-suspended',
421
+ uid: '_test-cancel-suspended',
422
+ email: '_test.cancel-suspended@{domain}',
423
+ properties: {
424
+ roles: {},
425
+ subscription: { product: { id: 'premium', name: 'Premium' }, status: 'suspended', expires: getPastExpires(), cancellation: { pending: false }, payment: { processor: 'test', resourceId: 'sub_test_suspended', startDate: getPastExpires() } },
426
+ },
427
+ },
419
428
  // Dedicated accounts for portal validation tests
420
429
  'portal-no-processor': {
421
430
  id: 'portal-no-processor',
@@ -90,6 +90,29 @@ module.exports = {
90
90
  },
91
91
  },
92
92
 
93
+ {
94
+ name: 'allows-suspended-subscription',
95
+ async run({ http, assert, config, accounts, firestore, waitFor, skip }) {
96
+ const uid = accounts['cancel-suspended'].uid;
97
+
98
+ const response = await http.as('cancel-suspended').post('backend-manager/payments/cancel', {
99
+ confirmed: true,
100
+ reason: 'Subscription was suspended',
101
+ skipGuards: true,
102
+ });
103
+
104
+ assert.isSuccess(response, 'Should allow cancelling a suspended subscription');
105
+ assert.equal(response.data.success, true, 'Should return success: true');
106
+
107
+ // Verify the subscription was reset to cancelled (via fallback or webhook)
108
+ await waitFor(async () => {
109
+ const userDoc = await firestore.get(`users/${uid}`);
110
+ return userDoc?.subscription?.status === 'cancelled'
111
+ || userDoc?.subscription?.cancellation?.pending === true;
112
+ }, 15000, 500);
113
+ },
114
+ },
115
+
93
116
  {
94
117
  name: 'succeeds-with-test-processor',
95
118
  async run({ http, assert, config, accounts, firestore, waitFor, skip }) {