https://github.com/itw-creative-works/backend-manager
Backend manager by ITW Creative Works. Built to optimize Firebase development.
https://github.com/itw-creative-works/backend-manager
api backend express firebase js
Last synced: about 1 month ago
JSON representation
Backend manager by ITW Creative Works. Built to optimize Firebase development.
- Host: GitHub
- URL: https://github.com/itw-creative-works/backend-manager
- Owner: itw-creative-works
- License: mit
- Created: 2019-12-15T12:14:22.000Z (over 6 years ago)
- Default Branch: main
- Last Pushed: 2026-05-22T00:43:13.000Z (about 1 month ago)
- Last Synced: 2026-05-22T08:17:18.869Z (about 1 month ago)
- Topics: api, backend, express, firebase, js
- Language: JavaScript
- Homepage: https://itwcreativeworks.com
- Size: 2.79 MB
- Stars: 2
- Watchers: 1
- Forks: 0
- Open Issues: 14
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
Site | NPM Module | GitHub Repo
Backend Manager (BEM) is an NPM module for Firebase developers that instantly implements powerful backend features including authentication, rate limiting, analytics, and more.
## Installation
```bash
npm install backend-manager
```
**Requirements:**
- Node.js 22
- Firebase project with Firestore and Authentication enabled
- `service-account.json` - Firebase service account credentials
- `backend-manager-config.json` - BEM configuration file
## Quick Start
Create `functions/index.js`:
```javascript
const Manager = (new (require('backend-manager'))).init(exports, {
setupFunctionsIdentity: true,
});
const { functions } = Manager.libraries;
// Create a custom function
exports.myEndpoint = functions
.runWith({ memory: '256MB', timeoutSeconds: 120 })
.https.onRequest((req, res) => Manager.Middleware(req, res).run('myEndpoint', { /* options */ }));
```
Create `functions/routes/myEndpoint/index.js`:
```javascript
function Route() {}
Route.prototype.main = async function (assistant) {
const Manager = assistant.Manager;
const user = assistant.usage.user;
const settings = assistant.settings;
assistant.log('Request data:', assistant.request.data);
// Return response
assistant.respond({ success: true, timestamp: new Date().toISOString() });
};
module.exports = Route;
```
Create `functions/schemas/myEndpoint/index.js`:
```javascript
module.exports = function (assistant) {
return {
defaults: {
message: {
types: ['string'],
default: 'Hello World',
},
},
};
};
```
Run the setup command:
```bash
npx mgr setup
```
## Initialization Options
```javascript
const Manager = (new (require('backend-manager'))).init(exports, options);
```
| Option | Default | Description |
|--------|---------|-------------|
| `initialize` | `true` | Initialize Firebase Admin SDK |
| `projectType` | `'firebase'` | `'firebase'` for Cloud Functions, `'custom'` for Express server |
| `setupFunctions` | `true` | Setup built-in Cloud Functions (`bm_api`, etc.) |
| `setupFunctionsIdentity` | `true` | Setup auth event functions (onCreate, onDelete, beforeCreate, beforeSignIn) |
| `setupFunctionsLegacy` | `false` | Setup legacy admin functions |
| `setupServer` | `true` | Setup custom Express server for routes |
| `routes` | `'/routes'` | Directory for custom route handlers |
| `schemas` | `'/schemas'` | Directory for schema definitions |
| `resourceZone` | `'us-central1'` | Firebase/GCP region |
| `sentry` | `true` | Enable Sentry error tracking |
| `serviceAccountPath` | `'service-account.json'` | Path to Firebase service account |
| `backendManagerConfigPath` | `'backend-manager-config.json'` | Path to BEM config file |
| `initializeLocalStorage` | `false` | Initialize local lowdb storage on startup |
| `checkNodeVersion` | `true` | Validate Node.js version on startup |
| `express.bodyParser.json` | `{ limit: '100kb' }` | Express JSON body parser options |
| `express.bodyParser.urlencoded` | `{ limit: '100kb', extended: true }` | Express URL-encoded options |
## Configuration File
Create `backend-manager-config.json` in your functions directory:
```json5
{
brand: {
id: 'my-app',
name: 'My Brand',
url: 'https://example.com',
contact: {
email: 'support@example.com',
},
images: {
wordmark: 'https://example.com/wordmark.png',
brandmark: 'https://example.com/brandmark.png',
combomark: 'https://example.com/combomark.png',
},
},
sentry: {
dsn: 'https://xxx@xxx.ingest.sentry.io/xxx',
},
googleAnalytics: {
id: 'G-XXXXXXXXXX',
secret: 'your-ga4-secret',
},
backend_manager: {
key: 'your-admin-key',
namespace: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
},
firebaseConfig: {
apiKey: 'xxx',
authDomain: 'project-id.firebaseapp.com',
projectId: 'project-id',
storageBucket: 'project-id.appspot.com',
messagingSenderId: '123456789',
appId: '1:123:web:456',
measurementId: 'G-XXXXXXXXXX',
},
}
```
## Creating Custom Functions
### Routes
Routes handle HTTP requests. Create files in your `routes/` directory:
**Structure:**
- `routes/{name}/index.js` - Handles all HTTP methods
- `routes/{name}/get.js` - Handles GET requests only
- `routes/{name}/post.js` - Handles POST requests only
- (also supports `put.js`, `delete.js`, `patch.js`)
**Route File Pattern:**
```javascript
function Route() {}
Route.prototype.main = async function (assistant) {
// Access Manager and helpers
const Manager = assistant.Manager;
const usage = assistant.usage;
const user = assistant.usage.user;
const analytics = assistant.analytics;
const settings = assistant.settings;
// Access request data
const data = assistant.request.data; // Merged body + query
const body = assistant.request.body; // POST body
const query = assistant.request.query; // Query params
const headers = assistant.request.headers;
const method = assistant.request.method;
const geolocation = assistant.request.geolocation; // { ip, country, region, city, latitude, longitude }
const client = assistant.request.client; // { userAgent, language, platform, mobile }
// Check authentication
if (!user.authenticated) {
return assistant.respond('Authentication required', { code: 401 });
}
// Check admin role
if (!user.roles.admin) {
return assistant.respond('Admin required', { code: 403 });
}
// Track analytics
analytics.event('my_event', { action: 'test' });
// Validate usage limits
await usage.validate('requests');
usage.increment('requests');
await usage.update();
// Send response
assistant.respond({ success: true, data: settings });
};
module.exports = Route;
```
### Schemas
Schemas define and validate request parameters with defaults and plan-based limits:
```javascript
module.exports = function (assistant, settings, options) {
const user = options.user;
return {
// Default values for all plans
defaults: {
message: {
types: ['string'],
default: 'Hello',
required: false,
},
count: {
types: ['number'],
default: 10,
min: 1,
max: 100,
},
format: {
types: ['string'],
default: 'json',
// Dynamic required based on other settings
required: (assistant, settings) => settings.output === 'file',
// Clean/sanitize input
clean: (value) => value.toLowerCase().trim(),
},
},
// Override defaults for premium plan
premium: {
count: {
types: ['number'],
default: 100,
max: 1000,
},
},
};
};
```
**Schema Property Options:**
| Property | Type | Description |
|----------|------|-------------|
| `types` | `string[]` | Allowed types: `'string'`, `'number'`, `'boolean'`, `'object'`, `'array'` |
| `default` | `any` | Default value if not provided |
| `required` | `boolean \| function` | Whether the field is required |
| `clean` | `RegExp \| function` | Sanitize/transform the value |
| `min` | `number` | Minimum value (for numbers) |
| `max` | `number` | Maximum value (for numbers) |
| `available` | `boolean` | Whether the field is available |
### Middleware Options
```javascript
Manager.Middleware(req, res).run('routeName', {
authenticate: true, // Authenticate user (default: true)
setupAnalytics: true, // Initialize analytics (default: true)
setupUsage: true, // Initialize usage tracking (default: true)
setupSettings: true, // Resolve settings from schema (default: true)
schema: 'routeName', // Schema file to use (default: same as route)
parseMultipartFormData: true, // Parse multipart uploads (default: true)
routesDir: '/routes', // Custom routes directory
schemasDir: '/schemas', // Custom schemas directory
});
```
## Hook System
Intercept and modify `bm_api` requests before/after processing:
```javascript
const Manager = (new (require('backend-manager'))).init(exports, {});
Manager.handlers.bm_api = function (mod, position) {
const assistant = mod.assistant;
return new Promise(async function(resolve, reject) {
const command = mod.assistant.request.data.command || '';
const payload = mod.assistant.request.data.payload || {};
assistant.log('Intercepted bm_api', position, command, payload);
// Handle specific commands
if (command === 'user:sign-up') {
if (position === 'pre') {
// Before sign-up: validate, modify payload, etc.
assistant.log('Pre sign-up hook');
} else if (position === 'post') {
// After sign-up: send notifications, etc.
assistant.log('Post sign-up hook');
}
}
// Handle all commands
if (command === '*') {
if (position === 'pre') {
// Before any command
} else if (position === 'post') {
// After any command
}
}
return resolve();
});
};
```
## Built-in Functions
### HTTP API (`bm_api`)
The main API endpoint accepts commands in the format `category:action`:
```javascript
// POST to https://us-central1-{project}.cloudfunctions.net/bm_api
{
"command": "general:generate-uuid",
"payload": {
"version": "4"
},
"apiKey": "optional-api-key"
}
```
**Available Commands:**
| Category | Commands |
|----------|----------|
| `admin` | `firestore-write`, `firestore-read`, `firestore-query`, `database-write`, `database-read`, `send-email`, `send-notification`, `payment-processor`, `backup`, `cron`, `create-post`, `edit-post`, `get-stats`, `run-hook`, `sync-users`, `write-repo-content` |
| `user` | `sign-up`, `delete`, `oauth2`, `resolve`, `get-subscription-info`, `get-active-sessions`, `sign-out-all-sessions`, `create-custom-token`, `regenerate-api-keys`, `submit-feedback`, `validate-settings` |
| `general` | `generate-uuid`, `send-email`, `fetch-post` |
| `handler` | `create-post` |
| `firebase` | `get-providers` |
| `test` | `authenticate`, `webhook`, `lab`, `redirect` |
| `special` | `setup-electron-manager-client` |
### Auth Events
| Function | Trigger | Description |
|----------|---------|-------------|
| `bm_authBeforeCreate` | `beforeUserCreated` | Runs before user creation, can block signup |
| `bm_authBeforeSignIn` | `beforeUserSignedIn` | Runs before sign-in, can block login |
| `bm_authOnCreate` | `onCreate` | Runs after user creation, creates user document |
| `bm_authOnDelete` | `onDelete` | Runs when user is deleted, cleanup |
### Firestore Events
| Function | Trigger | Description |
|----------|---------|-------------|
| `bm_notificationsOnWrite` | `onWrite` | Triggers on `notifications/{id}` changes |
### Cron Jobs
| Function | Schedule | Description |
|----------|----------|-------------|
| `bm_cronDaily` | Every 24 hours | Runs daily jobs from `cron/daily/` and `hooks/cron/daily/` |
**Creating Custom Cron Jobs:**
Create `hooks/cron/daily/myJob.js` in your functions directory:
```javascript
function Job() {}
Job.prototype.main = function () {
const self = this;
const Manager = self.Manager;
const assistant = self.assistant;
return new Promise(async function(resolve, reject) {
assistant.log('Running my daily job...');
// Your job logic here
return resolve();
});
};
module.exports = Job;
```
## Marketing & Campaigns
Built-in marketing system with multi-provider support (SendGrid + Beehiiv + FCM push).
- **Contact management** — add, sync, remove contacts across providers with custom field syncing
- **Campaign CRUD** — `POST/GET/PUT/DELETE /marketing/campaign` with calendar-backed scheduling
- **Recurring campaigns** — seasonal sales, newsletters with automatic sendAt advancement
- **Newsletter generator** — AI-assembled newsletters from parent server content sources
- **Segment SSOT** — 22 segment definitions resolved to provider IDs at runtime
- **UTM auto-tagging** — brand domain links tagged automatically in marketing + transactional emails
- **Contact pruning** — monthly 2-stage re-engagement + deletion of inactive contacts
- **Template variables** — `{brand.name}`, `{holiday.name}`, `{season.name}`, `{date.*}` resolved at send time
Configure via `marketing` section in `backend-manager-config.json`. See CLAUDE.md for full documentation.
## Marketing Consent
GDPR/CASL-compliant consent capture and cross-provider unsubscribe sync.
- **Two-checkbox signup form** — separate legal (required) and marketing (optional) consent
- **Canonical user-doc shape** — `consent.{legal,marketing}.{status, grantedAt, revokedAt}` with full audit metadata (timestamp, source, IP, exact label text)
- **Server-authoritative timestamps** — client timestamps ignored, defending against clock manipulation
- **Account-page toggle** — `/account` notifications section lets logged-in users opt in/out, hits both SendGrid + Beehiiv
- **HMAC unsubscribe links** — email-footer one-click flow continues to work
- **Provider webhook receivers** — `POST /marketing/webhook?provider=sendgrid|beehiiv&key=X` catches unsubscribe / spam / bounce events from SendGrid and Beehiiv, writes the user doc + syncs to the OTHER provider
- **Parent forwarder** — single public webhook endpoint (`/marketing/webhook/forward`) on the parent BEM fans out to every brand's child BEM so each one updates its own Firestore
- **Guard against marketing sync without consent** — signup route and `email.add()` short-circuit when `consent.marketing.status !== 'granted'`
See [docs/consent.md](docs/consent.md) for the full architecture, source enum reference, migration script template, and provider configuration steps.
## Helper Classes
### Assistant
Handles request/response lifecycle, authentication, and logging.
```javascript
const assistant = Manager.Assistant({ req, res });
// Authentication
const user = await assistant.authenticate();
// Returns: { authenticated, auth: { uid, email }, roles, plan, ... }
// Request data
assistant.request.data; // Merged body + query
assistant.request.body; // POST body
assistant.request.query; // Query params
assistant.request.headers; // Request headers
assistant.request.method; // HTTP method
assistant.request.geolocation; // { ip, country, region, city, latitude, longitude }
assistant.request.client; // { userAgent, language, platform, mobile }
// Response
assistant.respond({ success: true }); // 200 JSON
assistant.respond({ success: true }, { code: 201 }); // Custom status
assistant.respond('https://example.com', { code: 302 }); // Redirect
// Errors
assistant.errorify('Something went wrong', { code: 500, sentry: true });
assistant.respond(new Error('Bad request'), { code: 400 });
// Logging
assistant.log('Info message');
assistant.warn('Warning message');
assistant.error('Error message');
assistant.debug('Debug message');
// Environment
assistant.isDevelopment(); // true in emulator
assistant.isProduction(); // true in production
assistant.isTesting(); // true when running tests
// File uploads
const { fields, files } = await assistant.parseMultipartFormData();
```
### User
Creates user objects with default properties:
```javascript
const userProps = Manager.User(existingData, { defaults: true }).properties;
// User structure:
{
auth: { uid, email, temporary },
subscription: {
product: { id, name }, // product from config ('basic', 'premium', etc.)
status: 'active', // active | suspended | cancelled
expires: { timestamp, timestampUNIX },
trial: { claimed, expires: {...} },
cancellation: { pending, date: {...} },
limits: {},
payment: { processor, resourceId, frequency, startDate, updatedBy }
},
roles: { admin, betaTester, developer },
affiliate: { code, referrals, referrer },
metadata: { created, updated },
activity: { geolocation, client },
api: { clientId, privateKey },
usage: { requests: { monthly, daily, total, last } },
personal: { birthday, gender, location, name, company, telephone },
oauth2: {}
}
// Methods
userProps.merge(otherUser); // Merge with another user object
```
### Analytics
Send events to Google Analytics 4:
```javascript
const analytics = Manager.Analytics({
assistant: assistant,
uuid: user.auth.uid,
});
analytics.event('purchase', {
item_id: 'product-123',
value: 29.99,
currency: 'USD',
});
```
**Auto-tracked User Properties:**
- `app_version`, `device_category`, `operating_system`, `platform`
- `authenticated`, `subscription_id`, `subscription_trial_claimed`, `activity_created`
- `country`, `city`, `language`, `age`, `gender`
### Usage
Track and limit API usage:
```javascript
const usage = await Manager.Usage().init(assistant, {
app: 'my-app', // App ID for limits
key: 'custom-key', // Optional custom key (default: user UID or IP)
whitelistKeys: ['admin-key'], // Keys that bypass limits
unauthenticatedMode: 'firestore', // 'firestore' or 'local'
refetch: false, // Force refetch app limits
log: true, // Enable logging
});
// Check and validate limits
const currentUsage = usage.getUsage('requests'); // Get current monthly usage
const limit = usage.getLimit('requests'); // Get plan limit (monthly)
await usage.validate('requests'); // Throws if over daily or monthly limit
// Increment usage (increments monthly, daily, and total counters)
usage.increment('requests', 1);
usage.set('requests', 0); // Reset monthly to specific value
// Save to Firestore
await usage.update();
// Whitelist keys
usage.addWhitelistKeys(['another-key']);
// Proxy usage: bill a different user and mirror writes to additional docs
await usage.setUser('owner-uid'); // Switch target user (fetches from Firestore)
usage.addMirror('agents/agent-id'); // Also write usage to this doc on update()
usage.setMirrors(['agents/a', 'orgs/b']); // Overwrite mirror list
```
### Middleware
Process requests through the middleware pipeline:
```javascript
// In your function definition
exports.myEndpoint = functions
.https.onRequest((req, res) => Manager.Middleware(req, res).run('myEndpoint', {
authenticate: true,
setupAnalytics: true,
setupUsage: true,
setupSettings: true,
schema: 'myEndpoint',
}));
```
The middleware automatically:
1. Parses multipart form data
2. Logs request details
3. Loads route handler (method-specific or index.js)
4. Authenticates user
5. Initializes usage tracking
6. Sets up analytics
7. Resolves settings from schema
8. Calls your route handler
### Settings
Resolve and validate request settings against a schema:
```javascript
const settings = Manager.Settings().resolve(assistant, schema, inputSettings, {
dir: '/schemas',
schema: 'mySchema',
user: user,
checkRequired: true,
});
// Timestamp constants
const timestamp = Manager.Settings().constant('timestamp');
// { types: ['string'], value: undefined, default: '2024-01-01T00:00:00.000Z' }
const timestampUNIX = Manager.Settings().constant('timestampUNIX');
// { types: ['number'], value: undefined, default: 1704067200 }
const timestampFULL = Manager.Settings().constant('timestampFULL');
// { timestamp: {...}, timestampUNIX: {...} }
```
### Utilities
Batch operations and helper functions:
```javascript
const utilities = Manager.Utilities();
// Batch iterate Firestore collection
const results = await utilities.iterateCollection(
async ({ docs }, batch, totalCount) => {
for (const doc of docs) {
// Process each document
}
return { processed: docs.length };
},
{
collection: 'users',
batchSize: 1000,
maxBatches: 10,
where: [{ field: 'subscription.product.id', operator: '==', value: 'premium' }],
orderBy: { field: 'metadata.created.timestamp', direction: 'desc' },
startAfter: 'lastDocId',
log: true,
}
);
// Batch iterate Firebase Auth users
await utilities.iterateUsers(
async ({ users, pageToken }, batch) => {
for (const user of users) {
// Process each auth user
}
},
{
batchSize: 1000,
maxBatches: Infinity,
log: true,
}
);
// Get document with owner user
const { document, user } = await utilities.getDocumentWithOwnerUser('posts/abc123', {
owner: 'owner',
resolve: {
schema: 'posts',
assistant: assistant,
checkRequired: false,
},
});
// Generate random ID
const id = utilities.randomId({ size: 14 }); // 'A1b2C3d4E5f6G7'
// Cached Firestore read
const doc = await utilities.get('users/abc123', {
maxAge: 1000 * 60 * 5, // 5 minute cache
format: 'data', // 'raw' or 'data'
});
```
### Metadata
Add timestamps and tags to documents:
```javascript
const metadata = Manager.Metadata(document);
document.metadata = metadata.set({ tag: 'my-operation' });
// {
// updated: { timestamp: '...', timestampUNIX: ... },
// tag: 'my-operation'
// }
```
### Local Storage
Persistent JSON storage using lowdb:
```javascript
const storage = Manager.storage({
name: 'myStorage', // Storage name (default: 'main')
temporary: false, // Use OS temp directory (default: false)
clear: true, // Clear on dev startup (default: true)
log: false, // Enable logging
});
// lowdb API
storage.set('key', 'value').write();
const value = storage.get('key').value();
storage.set('nested.path', { data: true }).write();
```
## Authentication
BEM supports multiple authentication methods (checked in order):
1. **Bearer Token (JWT)**
```
Authorization: Bearer
```
2. **API Key**
```javascript
{ apiKey: 'user-private-key' }
// or
{ authenticationToken: 'user-private-key' }
```
3. **Backend Manager Key** (Admin access)
```javascript
{ backendManagerKey: 'your-backend-manager-key' }
```
4. **Session Cookie**
```
Cookie: __session=
```
**Authenticated User Object:**
```javascript
const user = await assistant.authenticate();
{
authenticated: true,
auth: { uid: 'abc123', email: 'user@example.com' },
roles: { admin: false, betaTester: false, developer: false },
subscription: { product: { id: 'basic', name: 'Basic' }, status: 'active', ... },
api: { clientId: '...', privateKey: '...' },
// ... other user properties
}
```
## CLI Commands
BEM includes a CLI for development and deployment:
```bash
# Install globally or use npx
npm install -g backend-manager
# or
npx backend-manager
```
| Command | Description |
|---------|-------------|
| `bem setup` | Run Firebase project setup and validation |
| `bem serve` | Start local Firebase emulator |
| `bem deploy` | Deploy functions to Firebase |
| `bem test [paths...]` | Run integration tests |
| `bem emulator` | Start Firebase emulator (keep-alive mode) |
| `bem stripe` | Start Stripe CLI webhook forwarding to local server |
| `bem version`, `bem v` | Show BEM version |
| `bem clear` | Clear cache and temp files |
| `bem install`, `bem i` | Install BEM (local or production) |
| `bem clean:npm` | Clean and reinstall npm modules |
| `bem firestore:indexes:get` | Get Firestore indexes |
| `bem cwd` | Show current working directory |
| `bem firestore:get ` | Read a Firestore document |
| `bem firestore:set ''` | Write/merge a Firestore document |
| `bem firestore:query ` | Query a Firestore collection |
| `bem firestore:delete ` | Delete a Firestore document |
| `bem auth:get ` | Get an Auth user by UID or email |
| `bem auth:list` | List Auth users |
| `bem auth:delete ` | Delete an Auth user |
| `bem auth:set-claims ''` | Set custom claims on an Auth user |
| `bem logs:read` | Fetch Cloud Function logs from Google Cloud Logging |
| `bem logs:tail` | Stream live Cloud Function logs |
All Firestore and Auth commands support `--emulator` to target the local emulator, `--force` to skip confirmation, and `--raw` for compact JSON output.
Logs commands support `--fn ` (function name filter), `--severity `, `--since ` (read only), `--limit ` (read only), and `--raw`. Requires `gcloud` CLI installed and authenticated.
## Environment Variables
Set these in your `functions/.env` file:
| Variable | Description |
|----------|-------------|
| `BACKEND_MANAGER_KEY` | Admin authentication key |
| `STRIPE_SECRET_KEY` | Stripe secret key (enables auto webhook forwarding in `serve`/`emulator`) |
## Response Headers
BEM attaches metadata to responses:
```
bm-properties: {"code":200,"tag":"functionName/executionId","usage":{...},"schema":{...}}
```
## Testing
BEM includes an integration test framework that runs against the Firebase emulator.
### Running Tests
```bash
# Option 1: Two terminals (recommended for development)
npx mgr emulator # Terminal 1 - keeps emulator running
npx mgr test # Terminal 2 - runs tests
# Option 2: Single command (auto-starts emulator, shuts down after)
npx mgr test
```
### Extended Mode (real APIs)
Set `TEST_EXTENDED_MODE=true` on the **test command** to opt into real external API calls (SendGrid, Beehiiv, Stripe webhook handlers, marketing libraries). The flag flows automatically to the running emulator via `/.temp/test-mode.json` — no need to set it on the emulator too:
```bash
# Terminal 1 — start once, no flag needed
npx mgr emulator
# Terminal 2 — toggle freely between runs
TEST_EXTENDED_MODE=true npx mgr test ... # extended mode
npx mgr test ... # normal mode (next run flips back)
```
See [docs/testing.md](docs/testing.md#extended-mode-test_extended_mode) for the full mechanism.
### Filtering Tests
```bash
npx mgr test rules/ # Run rules tests (both BEM and project)
npx mgr test bem:rules/ # Only BEM's rules tests
npx mgr test project:rules/ # Only project's rules tests
npx mgr test user/ admin/ # Multiple paths
```
### Log Files
BEM CLI commands automatically save output to log files in the project's `functions/` directory (alongside firebase-tools' own `*-debug.log` files so everything is grep-able from one place):
- **`functions/serve.log`** — Output from `npx mgr serve`
- **`functions/emulator.log`** — Full emulator + Cloud Functions output (`npx mgr emulator`)
- **`functions/test.log`** — Test runner output (`npx mgr test`, when running against an existing emulator)
- **`functions/logs.log`** — Cloud Function logs (`npx mgr logs:read` or `npx mgr logs:tail`)
Logs are overwritten on each run and gitignored via `*.log`. Use them to debug failing tests or review function output. Transient internal artifacts (reset sentinels, watch trigger, `test-mode.json`) live separately in `/.temp/`.
### Test Locations
- **BEM core tests:** `test/`
- **Project tests:** `functions/test/bem/`
Use `bem:` or `project:` prefix to filter by source.
### Writing Tests
**Suite** - Sequential tests with shared state (stops on first failure):
```javascript
// test/functions/user/sign-up.js
module.exports = {
description: 'User signup flow with affiliate tracking',
type: 'suite',
tests: [
{
name: 'verify-referrer-exists',
async run({ firestore, assert, state, accounts }) {
state.referrerUid = accounts.referrer.uid;
const doc = await firestore.get(`users/${state.referrerUid}`);
assert.ok(doc, 'Referrer should exist');
},
},
{
name: 'call-user-signup-with-affiliate',
async run({ http, assert, state }) {
const response = await http.as('referred').command('user:sign-up', {
attribution: { affiliate: { code: 'TESTREF' } },
});
assert.isSuccess(response);
},
},
],
};
```
**Group** - Independent tests (continues even if one fails):
```javascript
// test/functions/admin/firestore-write.js
module.exports = {
description: 'Admin Firestore write operation',
type: 'group',
tests: [
{
name: 'admin-auth-succeeds',
auth: 'admin',
async run({ http, assert }) {
const response = await http.command('admin:firestore-write', {
path: '_test/doc',
document: { test: 'value' },
});
assert.isSuccess(response);
},
},
{
name: 'unauthenticated-rejected',
auth: 'none',
async run({ http, assert }) {
const response = await http.command('admin:firestore-write', {
path: '_test/doc',
document: { test: 'value' },
});
assert.isError(response, 401);
},
},
],
};
```
**Auth levels:** `none`, `user`/`basic`, `admin`, `premium-active`, `premium-expired`
See `CLAUDE.md` for complete test API documentation.
## Subscription System
BEM includes a built-in payment/subscription system with Stripe and PayPal integration.
### Subscription Statuses
| Status | Meaning | User can delete account? |
|--------|---------|--------------------------|
| `active` | Subscription is current and valid (includes trialing) | No (unless `product.id === 'basic'`) |
| `suspended` | Payment failed (Stripe: `past_due`, `unpaid`) | No |
| `cancelled` | Subscription terminated (Stripe: `canceled`, `incomplete`, `incomplete_expired`) | Yes |
### Stripe Status Mapping
| Stripe Status | `subscription.status` | Notes |
|---|---|---|
| `active` | `active` | Normal active subscription |
| `trialing` | `active` | `trial.claimed = true` |
| `past_due` | `suspended` | Payment failed, retrying |
| `unpaid` | `suspended` | Payment failed |
| `canceled` | `cancelled` | Subscription terminated |
| `incomplete` | `cancelled` | Never completed initial payment |
| `incomplete_expired` | `cancelled` | Expired before completion |
| `active` + `cancel_at_period_end` | `active` | `cancellation.pending = true` |
### PayPal Status Mapping
| PayPal Status | `subscription.status` | Notes |
|---|---|---|
| `ACTIVE` | `active` | Normal active subscription |
| `SUSPENDED` | `suspended` | Payment failed or manually suspended |
| `CANCELLED` | `cancelled` | Subscription terminated |
| `EXPIRED` | `cancelled` | Billing cycles completed |
### Product Configuration
Products are defined in `config.payment.products` with flat prices and per-processor IDs:
```javascript
payment: {
products: [
{ id: 'basic', name: 'Basic', type: 'subscription', limits: { requests: 10 } },
{
id: 'plus', name: 'Plus', type: 'subscription',
limits: { requests: 100 }, trial: { days: 14 },
prices: { monthly: 28, annually: 276 }, // also supports 'weekly' and 'daily'
stripe: { productId: 'prod_xxx' },
paypal: { productId: 'PROD-abc123' },
},
{
id: 'boost', name: 'Boost Pack', type: 'one-time',
prices: { once: 9.99 },
stripe: { productId: 'prod_yyy' },
},
],
}
```
### Unified Subscription Object
The same subscription shape is stored in `users/{uid}.subscription` and `payments-orders/{orderId}.subscription`:
```javascript
subscription: {
product: {
id: 'basic', // product ID from config ('basic', 'premium', etc.)
name: 'Basic', // display name from config
},
status: 'active', // 'active' | 'suspended' | 'cancelled'
expires: { timestamp, timestampUNIX },
trial: {
claimed: false, // has user EVER used a trial
expires: { timestamp, timestampUNIX },
},
cancellation: {
pending: false, // true = cancel at period end
date: { timestamp, timestampUNIX },
},
payment: {
processor: null, // 'stripe' | 'paypal' | etc.
resourceId: null, // provider subscription ID (e.g., 'sub_xxx')
frequency: null, // 'monthly' | 'annually' | 'weekly' | 'daily'
startDate: { timestamp, timestampUNIX },
updatedBy: {
event: { name: null, id: null },
date: { timestamp, timestampUNIX },
},
},
}
```
### Access Check Patterns
```javascript
// Is premium (paid)?
user.subscription.status === 'active' && user.subscription.product.id !== 'basic'
// Is on trial?
user.subscription.trial.claimed && user.subscription.status === 'active'
// Has pending cancellation?
user.subscription.cancellation.pending === true
// Payment failed?
user.subscription.status === 'suspended'
```
### resolveSubscription(account)
Static method on the `User` helper that derives calculated subscription fields. Returns only fields that require derivation logic — raw data lives on the account object directly.
```javascript
const User = require('backend-manager/src/manager/helpers/user');
const resolved = User.resolveSubscription(account);
// Returns: { plan, active, trialing, cancelling }
```
| Field | Type | Description |
|-------|------|-------------|
| `plan` | `string` | Effective plan ID right now (`'basic'` if cancelled/suspended) |
| `active` | `boolean` | Has paid access (product is not `'basic'` and status is `'active'`) |
| `trialing` | `boolean` | In active trial (status `'active'` + claimed + unexpired) |
| `cancelling` | `boolean` | Cancellation pending (status `'active'` + `cancellation.pending`) |
The same function exists as `auth.resolveSubscription(account)` in [web-manager](https://github.com/itw-creative-works/web-manager) with identical logic and return shape.
## Final Words
If you are still having difficulty, we would love for you to post a question to [the Backend Manager issues page](https://github.com/itw-creative-works/backend-manager/issues). It is much easier to answer questions that include your code and relevant files! So if you can provide them, we'd be extremely grateful (and more likely to help you find the answer!)
## Projects Using this Library
[Somiibo](https://somiibo.com/): A Social Media Bot with an open-source module library.
[JekyllUp](https://jekyllup.com/): A website devoted to sharing the best Jekyll themes.
[Slapform](https://slapform.com/): A backend processor for your HTML forms on static sites.
[SoundGrail Music App](https://app.soundgrail.com/): A resource for producers, musicians, and DJs.
[Hammock Report](https://hammockreport.com/): An API for exploring and listing backyard products.
Ask us to have your project listed! :)
## License
ISC