backend-manager 5.5.4 → 5.6.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 (45) hide show
  1. package/CHANGELOG.md +21 -4
  2. package/CLAUDE.md +35 -11
  3. package/README.md +6 -5
  4. package/docs/build-system.md +27 -0
  5. package/docs/cli-logs.md +2 -2
  6. package/docs/common-mistakes.md +1 -1
  7. package/docs/consent.md +4 -2
  8. package/docs/email-system.md +28 -2
  9. package/docs/environment-detection.md +1 -1
  10. package/docs/firestore.md +134 -0
  11. package/docs/logging.md +27 -0
  12. package/docs/migration.md +107 -0
  13. package/docs/routes.md +110 -14
  14. package/docs/schemas.md +121 -25
  15. package/docs/test-boot-layer.md +65 -0
  16. package/docs/{testing.md → test-framework.md} +150 -19
  17. package/docs/usage-rate-limiting.md +15 -0
  18. package/package.json +3 -2
  19. package/src/cli/commands/base-command.js +5 -5
  20. package/src/cli/commands/logs.js +2 -2
  21. package/src/cli/commands/serve.js +3 -3
  22. package/src/cli/commands/test.js +140 -0
  23. package/src/cli/commands/watch.js +4 -4
  24. package/src/defaults/CLAUDE.md +11 -0
  25. package/src/defaults/test/README.md +15 -0
  26. package/src/defaults/test/_init.js +1 -1
  27. package/src/manager/libraries/email/data/blocked-local-patterns.js +0 -2
  28. package/src/manager/libraries/email/data/custom-disposable-domains.json +47 -1
  29. package/src/manager/libraries/email/data/disposable-domains.json +3 -0
  30. package/src/manager/libraries/email/data/typo-domains.js +83 -0
  31. package/src/manager/libraries/email/validation.js +74 -8
  32. package/src/manager/libraries/email/validation.test.js +125 -0
  33. package/src/test/fixtures/firebase-project/.firebaserc +5 -0
  34. package/src/test/fixtures/firebase-project/database.rules.json +6 -0
  35. package/src/test/fixtures/firebase-project/firebase.json +49 -0
  36. package/src/test/fixtures/firebase-project/firestore.indexes.json +4 -0
  37. package/src/test/fixtures/firebase-project/firestore.rules +10 -0
  38. package/src/test/fixtures/firebase-project/functions/backend-manager-config.json +24 -0
  39. package/src/test/fixtures/firebase-project/functions/index.js +10 -0
  40. package/src/test/fixtures/firebase-project/functions/package.json +15 -0
  41. package/src/test/fixtures/firebase-project/public/index.html +5 -0
  42. package/src/test/fixtures/firebase-project/storage.rules +8 -0
  43. package/src/test/runner.js +16 -4
  44. package/test/boot/emulator-boots.js +37 -0
  45. package/test/rules/user.js +35 -30
@@ -0,0 +1,107 @@
1
+ # Migration
2
+
3
+ Procedures for migrating old BEM consumer projects to the current format: environment variables (Part 1), legacy code patterns (Part 2), and routes/schemas (Part 3).
4
+
5
+ ## Part 1: Environment Variable Migration
6
+
7
+ Convert old config formats (runtime config / nested JSON) into individual top-level environment variables in `functions/.env`.
8
+
9
+ ### Key Mapping
10
+
11
+ | Old Path | New ENV Key |
12
+ |----------|-------------|
13
+ | `backend_manager.key` or `backendmanager.key` | `BACKEND_MANAGER_KEY` |
14
+ | `backend_manager.namespace` or `backendmanager.namespace` | `BACKEND_MANAGER_NAMESPACE` |
15
+ | `github.key` or `github.token` | `GITHUB_TOKEN` |
16
+ | `openai.key` or `openai.api_key` | `OPENAI_API_KEY` |
17
+ | `paypal.client_id` | `PAYPAL_CLIENT_ID` |
18
+ | `paypal.client_secret` | `PAYPAL_CLIENT_SECRET` |
19
+ | `stripe.secret_key` or `stripe.key` | `STRIPE_SECRET_KEY` |
20
+ | `chargebee.site` | `CHARGEBEE_SITE` |
21
+ | `chargebee.api_key` or `chargebee.key` | `CHARGEBEE_API_KEY` |
22
+ | `coinbase.api_key` or `coinbase.key` | `COINBASE_API_KEY` |
23
+ | `cloudflare.token` or `cloudflare.key` | `CLOUDFLARE_TOKEN` |
24
+ | `recaptcha.secret_key` or `recaptcha.key` | `RECAPTCHA_SECRET_KEY` |
25
+ | `sendgrid.api_key` or `sendgrid.key` | `SENDGRID_API_KEY` |
26
+ | `beehiiv.api_key` or `beehiiv.key` | `BEEHIIV_API_KEY` |
27
+ | `zerobounce.api_key` or `zerobounce.key` | `ZEROBOUNCE_API_KEY` |
28
+
29
+ ### Steps
30
+
31
+ 1. **Check for config sources**: first `functions/.runtimeconfig.json` (parse JSON); else a `RUNTIME_CONFIG` variable inside `functions/.env` (parse the object inside).
32
+ 2. **Extract key-value pairs** using the mapping table.
33
+ 3. **Backup existing `.env`** as `functions/.env.backup` if it exists.
34
+ 4. **Check existing `.env` for conflicts**: skip existing keys and warn.
35
+ 5. **Write/update `functions/.env`**: each mapped key as a top-level variable.
36
+ 6. **Delete source files**: remove `functions/.runtimeconfig.json` if it existed.
37
+ 7. **Update `functions/backend-manager-config.json`**: remove the deprecated `mailchimp` key entirely; update `brand` to the nested structure `{ name, url, contact: { email }, images: { brandmark, wordmark, combomark } }`; set `github.user` to `"itw-creative-works"`.
38
+ 8. Update `functions/.nvmrc` to `v22/*` and `functions/package.json` `engines.node` to `"22"`.
39
+ 9. Clean up `functions/.gitignore` duplicates.
40
+
41
+ ## Part 2: Legacy Code Migration
42
+
43
+ Search all `.js` files under `functions/` for legacy config reads and convert to `process.env`:
44
+
45
+ - `Manager.config.*` → `process.env.KEY_NAME`
46
+ - `RUNTIME_CONFIG` → individual `process.env` vars
47
+ - `functions.config()` → `process.env` vars
48
+
49
+ | Old Pattern | New Pattern |
50
+ |-------------|-------------|
51
+ | `Manager.config.github.key` | `process.env.GITHUB_TOKEN` |
52
+ | `Manager.config.sendgrid.key` | `process.env.SENDGRID_API_KEY` |
53
+ | `Manager.config.stripe.secret_key` | `process.env.STRIPE_SECRET_KEY` |
54
+ | `Manager.config.openai.key` | `process.env.OPENAI_API_KEY` |
55
+ | `Manager.config.paypal.client_id` | `process.env.PAYPAL_CLIENT_ID` |
56
+ | `Manager.config.paypal.client_secret` | `process.env.PAYPAL_CLIENT_SECRET` |
57
+ | `Manager.config.chargebee.site` | `process.env.CHARGEBEE_SITE` |
58
+ | `Manager.config.chargebee.api_key` | `process.env.CHARGEBEE_API_KEY` |
59
+ | `Manager.config.coinbase.api_key` | `process.env.COINBASE_API_KEY` |
60
+ | `Manager.config.cloudflare.token` | `process.env.CLOUDFLARE_TOKEN` |
61
+ | `Manager.config.recaptcha.secret_key` | `process.env.RECAPTCHA_SECRET_KEY` |
62
+ | `Manager.config.beehiiv.api_key` | `process.env.BEEHIIV_API_KEY` |
63
+ | `Manager.config.zerobounce.api_key` | `process.env.ZEROBOUNCE_API_KEY` |
64
+ | `Manager.config.backend_manager.key` | `process.env.BACKEND_MANAGER_KEY` |
65
+ | `Manager.config.backend_manager.namespace` | `process.env.BACKEND_MANAGER_NAMESPACE` |
66
+
67
+ ## Part 3: Route/Schema Migration
68
+
69
+ **IMPORTANT:** Only migrate routes that already use the middleware system in `functions/index.js`:
70
+
71
+ ```javascript
72
+ // MIGRATE these (uses Manager.Middleware)
73
+ .https.onRequest((req, res) => Manager.Middleware(req, res).run('example'));
74
+
75
+ // DO NOT migrate these (old manual route loading)
76
+ .https.onRequest(async (req, res) => {
77
+ return new (require(`${__dirname}/routes/example/index.js`))().main(Manager, req, res);
78
+ });
79
+ ```
80
+
81
+ ### Old → New Format
82
+
83
+ **Route:** constructor pattern → context-object export ([routes.md](routes.md)):
84
+
85
+ - `routes/example/index.js` → `routes/example/post.js` (or the appropriate method file)
86
+ - Remove the constructor; use `module.exports = async ({ Manager, assistant, analytics, usage, user, settings, libraries, utilities }) => {}`
87
+
88
+ **Schema:** wrapped tiers → flat ([schemas.md](schemas.md)):
89
+
90
+ - `schemas/example/index.js` → `schemas/example/post.js`
91
+ - Remove the `['defaults']:` wrapper; flatten the structure (plan adjustments move INSIDE the function, branching on `user`)
92
+ - Change the signature to the context object: `({ assistant, user, data, method, headers, geolocation, client })`
93
+ - Remove `value: undefined` noise
94
+
95
+ | Aspect | Old Format | New Format |
96
+ |--------|-----------|------------|
97
+ | Route export | `module.exports = Route` (constructor) | `module.exports = async ({ ... }) => {}` |
98
+ | Self reference | `const self = this;` | Not needed |
99
+ | File naming | `index.js` | `get.js`, `post.js`, `put.js`, `delete.js` |
100
+ | Schema wrapper | `['defaults']: { ... }` | Flat structure (no wrapper) |
101
+ | Schema params | `(assistant)` | `({ assistant, user, data, ... })` context object |
102
+
103
+ ## See also
104
+
105
+ - [routes.md](routes.md) — the current route format being migrated TO
106
+ - [schemas.md](schemas.md) — the current schema contract
107
+ - [environment-detection.md](environment-detection.md) — env var conventions
package/docs/routes.md CHANGED
@@ -36,35 +36,131 @@ module.exports = Module;
36
36
 
37
37
  ## New Route (Consumer Project)
38
38
 
39
- Create `routes/{name}/index.js`:
39
+ Routes live at `functions/routes/{path}/{method}.js` — BEM routes requests to the matching method file (`get.js` / `post.js` / `put.js` / `delete.js`), falling back to `index.js` if no method-specific file exists.
40
40
 
41
- ```javascript
42
- function Route() {}
43
-
44
- Route.prototype.main = async function (assistant) {
45
- const Manager = assistant.Manager;
46
- const usage = assistant.usage;
47
- const user = assistant.usage.user;
48
- const analytics = assistant.analytics;
49
- const settings = assistant.settings;
41
+ A route exports an **async function receiving a context object** (built by the middleware — see `src/manager/helpers/middleware.js`):
50
42
 
51
- // Check authentication if needed
43
+ ```javascript
44
+ /**
45
+ * POST /items - Create a new item
46
+ */
47
+ module.exports = async ({ Manager, assistant, analytics, usage, user, settings, libraries, utilities }) => {
52
48
  if (!user.authenticated) {
53
49
  return assistant.respond('Authentication required', { code: 401 });
54
50
  }
55
51
 
52
+ const { admin } = libraries;
53
+ const firestore = admin.firestore();
54
+
56
55
  // Track usage
57
56
  await usage.validate('requests');
58
57
  usage.increment('requests');
59
58
  await usage.update();
60
59
 
61
- // Send response
62
- assistant.respond({ success: true, data: settings });
60
+ // settings strings are whitespace-trimmed by middleware; HTML is preserved.
61
+ // Call utilities.sanitize() at the HTML-insertion site, or opt the whole
62
+ // route in to middleware HTML strip via { sanitize: true } on .run().
63
+ const id = settings.id; // auto-generated by the schema (see schemas.md)
64
+
65
+ await firestore.doc(`items/${id}`).set({ id, owner: user.auth.uid, ...settings });
66
+
67
+ return assistant.respond({ id });
63
68
  };
69
+ ```
70
+
71
+ Context object fields: `Manager`, `assistant`, `user` (from `assistant.getUser()`), `usage`, `settings` (schema-resolved), `analytics`, `libraries`, `utilities`.
72
+
73
+ ### CRUD method files
64
74
 
65
- module.exports = Route;
75
+ Every resource endpoint follows proper CRUD with **method-specific files** (plural-noun route names — `items`, not `item`):
76
+
77
+ | Method | File | Purpose | Path |
78
+ |--------|------|---------|------|
79
+ | GET | `get.js` | List all or fetch one | `/items` or `/items/{id}` |
80
+ | POST | `post.js` | Create new | `/items` |
81
+ | PUT | `put.js` | Update existing | `/items/{id}` |
82
+ | DELETE | `delete.js` | Delete existing | `/items/{id}` |
83
+
84
+ **GET routes exist for external API consumers.** The dashboard/frontend reads data directly from Firestore (faster, cheaper, no cold starts). POST/PUT/DELETE still go through Cloud Functions for server-side validation, usage tracking, and analytics.
85
+
86
+ **PUT and DELETE must verify ownership** before mutating:
87
+
88
+ ```javascript
89
+ const existing = docSnap.data();
90
+
91
+ if (existing.owner !== user.auth.uid) {
92
+ return assistant.respond('Not authorized', { code: 403 });
93
+ }
66
94
  ```
67
95
 
96
+ Immutable fields (`id`, `owner`, `stats`, `metadata.created`) should NOT be editable via PUT. ID generation (POST) and ID-from-path extraction (GET/PUT/DELETE) happen in the **schema**, not the route — see [schemas.md](schemas.md).
97
+
98
+ ### Key route patterns
99
+
100
+ - Short-circuit returns for auth/validation checks
101
+ - `settings` contains the parsed + validated request data (from schemas)
102
+ - `assistant.respond()` for ALL responses (success and error)
103
+ - `admin.firestore().doc('collection/id')` shorthand for Firestore access ([firestore.md](firestore.md))
104
+ - Timestamps under `metadata.{created,updated}` ([firestore.md](firestore.md#document-metadata))
105
+
106
+ ### Functions entry point (`functions/index.js`)
107
+
108
+ ```javascript
109
+ const Manager = (new (require('backend-manager'))).init(exports, {
110
+ setupFunctionsIdentity: false,
111
+ });
112
+ const { functions } = Manager.libraries;
113
+
114
+ /**
115
+ * @route /items
116
+ *
117
+ * @method GET /items - List all items
118
+ * @method GET /items/:itemId - Get single item
119
+ * @method POST /items - Create a new item
120
+ * @method PUT /items/:itemId - Update an item
121
+ * @method DELETE /items/:itemId - Delete an item
122
+ */
123
+ exports.items = functions
124
+ .runWith({ memory: '256MB', timeoutSeconds: 120 })
125
+ .https.onRequest((req, res) => Manager.Middleware(req, res).run('items'));
126
+ ```
127
+
128
+ The schema defaults to the route name (`.run('items')` loads `functions/schemas/items/`); pass `{ schema: 'custom' }` only when the schema path differs.
129
+
130
+ For operations that don't fit CRUD (e.g. `/items/:itemId/export`), add an **action sub-path** handled within the same Cloud Function (parsed from the request path) or as a separate function if resource needs differ significantly.
131
+
132
+ ### firebase.json routing
133
+
134
+ Make routes public with rewrites. Use the bracket syntax so sub-paths like `/items/{id}` route correctly:
135
+
136
+ ```json
137
+ {
138
+ "hosting": {
139
+ "rewrites": [
140
+ { "source": "{/items,/items/**}", "function": "items" }
141
+ ]
142
+ }
143
+ }
144
+ ```
145
+
146
+ **CRITICAL**: Without the `/**` wildcard, requests to `/items/{id}` won't reach the function.
147
+
148
+ **CRITICAL: Rewrite order matters — first match wins.** When multiple functions share a path prefix, the most specific routes MUST come first and the catch-all last, otherwise it swallows all sub-routes:
149
+
150
+ ```json
151
+ {
152
+ "hosting": {
153
+ "rewrites": [
154
+ { "source": "{/agents/*/chat,/agents/*/chat/**}", "function": "agentsChat" },
155
+ { "source": "/agents/*/conversations/**", "function": "agentsConversations" },
156
+ { "source": "{/agents,/agents/**}", "function": "agents" }
157
+ ]
158
+ }
159
+ }
160
+ ```
161
+
162
+ Order: most specific → least specific → catch-all.
163
+
68
164
  ## New Event Handler
69
165
 
70
166
  Create `src/manager/functions/core/events/{type}/{event}.js`:
package/docs/schemas.md CHANGED
@@ -1,39 +1,135 @@
1
1
  # Schemas
2
2
 
3
- Schemas define and validate the payload your routes accept. Schema names should match the route name (e.g. route `myEndpoint` schema `myEndpoint`).
3
+ Schemas define and validate the payload your routes accept. They live at `functions/schemas/{name}/{method}.js` the resolver loads the method-specific file first (`get.js` / `post.js` / …), falling back to `{name}/index.js` (see `src/manager/helpers/settings.js`). The schema name defaults to the route name (`.run('items')``functions/schemas/items/`).
4
4
 
5
- ## New Schema (Consumer Project)
5
+ ## Schema function contract
6
6
 
7
- Create `schemas/{name}/index.js`:
7
+ A schema exports a function that receives a **context object** and returns a **flat schema object**. Plan-based adjustments happen INSIDE the function (there is no tier system):
8
8
 
9
9
  ```javascript
10
- module.exports = function (assistant, settings, options) {
11
- const user = options.user;
12
-
13
- return {
14
- defaults: {
15
- fieldName: {
16
- types: ['string'],
17
- default: 'default value',
18
- required: false,
19
- },
20
- numericField: {
21
- types: ['number'],
22
- default: 10,
23
- min: 1,
24
- max: 100,
25
- },
10
+ module.exports = ({ assistant, user, data, method, headers, geolocation, client }) => {
11
+ const planId = user?.subscription?.product?.id || 'basic';
12
+ const isPremium = planId !== 'basic';
13
+
14
+ const schema = {
15
+ name: {
16
+ types: ['string'],
17
+ default: '',
18
+ required: true,
26
19
  },
27
- // Override for premium users
28
- premium: {
29
- numericField: {
30
- max: 1000,
31
- },
20
+ limit: {
21
+ types: ['number'],
22
+ default: 10,
23
+ min: 1,
24
+ max: isPremium ? 1000 : 100, // plan-based limit
32
25
  },
33
26
  };
27
+
28
+ // Premium-only field (not present for basic users)
29
+ if (isPremium) {
30
+ schema.premiumOnlyField = {
31
+ types: ['string'],
32
+ default: 'premium-feature',
33
+ };
34
+ }
35
+
36
+ return schema;
34
37
  };
35
38
  ```
36
39
 
40
+ Context fields: `assistant`, `user` (resolved user), `data` (raw request data), `method`, `headers`, `geolocation`, `client`.
41
+
42
+ ## Field Properties
43
+
44
+ - `types` — array of allowed types: `['string']`, `['number']`, `['boolean']`, `['object']`, `['array']`, `['any']`, or multiple (`['string', 'number']`)
45
+ - `default` — default value if not provided; may be a function (`default: () => ...`)
46
+ - `value` — force-set value (ignores user input — e.g. auto-generated IDs)
47
+ - `required` — `true`/`false` or a function `(assistant, settings, options) => bool`. **NEVER combine with `default`** — see the footgun below
48
+ - `min` / `max` — validation bounds (string length, number range, array length); numbers clamp
49
+ - `clean` — a RegExp (matched chars removed) or function `(value) => cleaned`
50
+ - `sanitize` — per-field opt-out (`false`) for HTML sanitization; only meaningful when the route opts in via `Manager.Middleware(req, res).run('route', { sanitize: true })`. See [sanitization.md](sanitization.md)
51
+
52
+ ### ⚠️ `required` + `default` footgun
53
+
54
+ BEM checks `required` against the ORIGINAL request value, before defaults apply — so `required: true` on a field with a `default` throws `Required key {field} is missing in settings` before the default is ever used. For fields that must be non-empty but have a derived default (like path-extracted IDs), use `min: 1` instead.
55
+
56
+ ## ID Generation (POST — Create)
57
+
58
+ IDs are auto-generated in the **schema**, NOT in the route. Use `value` to force-generate via BEM's built-in `randomId()` (14-char nanoid, 62-char alphabet, no `-` or `_`):
59
+
60
+ ```javascript
61
+ id: {
62
+ types: ['string'],
63
+ value: () => assistant.Manager.Utilities().randomId(),
64
+ },
65
+ ```
66
+
67
+ The route just reads `settings.id` — no ID generation logic needed.
68
+
69
+ ## ID Extraction from Path (GET, PUT, DELETE)
70
+
71
+ For single-item operations, extract the ID from the URL path in the **schema**:
72
+
73
+ ```javascript
74
+ // /items/{id} → split('/') = ['', 'items', '{id}'] → index 2
75
+ id: {
76
+ types: ['string'],
77
+ default: (assistant.request.path || '').split('/')[2] || '',
78
+ min: 1, // enforce non-empty (NOT required: true — see footgun above)
79
+ max: 128,
80
+ },
81
+ ```
82
+
83
+ For GET list endpoints, omit `min` so an empty ID means "list":
84
+
85
+ ```javascript
86
+ id: {
87
+ types: ['string'],
88
+ default: (assistant.request.path || '').split('/')[2] || '',
89
+ },
90
+ limit: { types: ['number'], default: 20, min: 1, max: 100 },
91
+ startAfter: { types: ['string'], default: '' },
92
+ ```
93
+
94
+ ## Dynamic Schemas
95
+
96
+ Schemas can branch on request data (e.g. different fields per type):
97
+
98
+ ```javascript
99
+ module.exports = ({ assistant, data }) => {
100
+ const type = data?.type || '';
101
+
102
+ const schema = {
103
+ name: { types: ['string'], default: undefined, required: true },
104
+ type: { types: ['string'], default: undefined, required: true },
105
+ options: {},
106
+ };
107
+
108
+ switch (type) {
109
+ case 'url':
110
+ schema.options = { url: { types: ['string'], default: undefined, required: true } };
111
+ break;
112
+ case 'text':
113
+ schema.options = { text: { types: ['string'], default: undefined, required: true } };
114
+ break;
115
+ default:
116
+ schema.options = {};
117
+ }
118
+
119
+ return schema;
120
+ };
121
+ ```
122
+
123
+ ## Reference Implementation
124
+
125
+ The comprehensive test schema exercising every field option (types, function defaults, forced `value`, conditional `required`, min/max clamping, `clean` regex + function, nested objects, plan-based fields): [`src/manager/schemas/test/schema/post.js`](../src/manager/schemas/test/schema/post.js).
126
+
37
127
  ## Field Sanitization
38
128
 
39
- Middleware always trims whitespace on string fields. HTML sanitization is **opt-in per route** — `Manager.Middleware(req, res).run('my-route', { sanitize: true })`. When opted in, fields can individually opt back out with `sanitize: false`. See [docs/sanitization.md](sanitization.md).
129
+ Middleware always trims whitespace on string fields. HTML sanitization is **opt-in per route** — `Manager.Middleware(req, res).run('my-route', { sanitize: true })`. When opted in, fields can individually opt back out with `sanitize: false`. See [sanitization.md](sanitization.md).
130
+
131
+ ## See also
132
+
133
+ - [routes.md](routes.md) — the routes consuming `settings`
134
+ - [sanitization.md](sanitization.md) — trim vs HTML-strip behavior
135
+ - [test-framework.md](test-framework.md) — schema tests (`test/routes/test/schema.js`)
@@ -0,0 +1,65 @@
1
+ # Test Framework — Boot Layer
2
+
3
+ The `boot` layer is BEM's framework self-test. When `npx mgr test` runs from the backend-manager repo itself (no `firebase.json` in cwd), the runner boots a **bundled fixture Firebase project**, brings up the emulator against it, and runs the `test/boot/` smoke suite. It replaces the old "you can't run tests from the framework repo" gap with a deterministic pass/fail. BEM's equivalent of BXM's `BXM_TEST_BOOT_PROJECT` boot layer and UJM's `UJ_TEST_BOOT_PROJECT` site-boot layer.
4
+
5
+ ## What boot tests verify
6
+
7
+ Things that ONLY break when the whole self-test path assembles correctly:
8
+ - The Firebase emulator boots against the fixture (functions, firestore, auth, hosting, …)
9
+ - The fixture's `functions/index.js` runs `Manager.init()` inside the emulator's functions runtime — i.e. the **local** `backend-manager` (symlinked in) loads and wires the built-in `bm_api` function
10
+ - The hosting rewrite routes `/backend-manager/**` → `bm_api`
11
+ - A health request returns `200` with the fixture's `projectId` (`demo-backend-manager`) and the live `bemVersion`
12
+
13
+ If the boot smoke passes, the framework at minimum *boots a consumer backend end-to-end* — catching a class of integration breaks (broken `Manager.init`, mis-wired `bm_api`, bad hosting rewrites) that no single handler test covers.
14
+
15
+ ## Test file shape
16
+
17
+ Same `{ description, type, tests }` contract as every other BEM suite — the boot suite is just scoped to the `test/boot/` directory and gated to self-test runs:
18
+
19
+ ```js
20
+ module.exports = {
21
+ description: 'Boot smoke — fixture emulator + bm_api reachable',
22
+ type: 'group',
23
+ timeout: 30000,
24
+ tests: [
25
+ {
26
+ name: 'bm_api-health-responds-over-hosting-rewrite',
27
+ async run({ http, assert }) {
28
+ const response = await http.get('backend-manager/test/health');
29
+ assert.isSuccess(response, 'bm_api /test/health should respond through the emulator');
30
+ },
31
+ },
32
+ ],
33
+ };
34
+ ```
35
+
36
+ ## The bundled fixture project
37
+
38
+ `src/test/fixtures/firebase-project/` — a minimal, committed BEM consumer backend:
39
+
40
+ - `firebase.json` + `.firebaserc` — a **`demo-` project** (`demo-backend-manager`) so the emulator NEVER touches real Firebase; emulator ports from `DEFAULT_EMULATOR_PORTS`; hosting rewrite to `bm_api`.
41
+ - `functions/index.js` — the one-line `Manager.init()` bootstrap (mirrors a real consumer).
42
+ - `functions/package.json` + `backend-manager-config.json` — fake brand/config (no real secrets).
43
+ - `firestore.rules` / `storage.rules` / `database.rules.json` / `firestore.indexes.json` — minimal locked rules (`bm_api` uses the Admin SDK, which bypasses rules).
44
+
45
+ **Runtime-only, gitignored** (never committed): before boot, the test command symlinks the local `backend-manager` (+ `firebase-admin`/`firebase-functions` from BEM's own `node_modules`) into the fixture's `functions/node_modules`, injects the fixture admin keys into the env, and generates a **throwaway RSA `service-account.json`** (emulator-only — a `demo-` project never authenticates against Google). All of this lives in `setupSelfTest()` / `linkFixtureDeps()` / `ensureFixtureServiceAccount()` in [src/cli/commands/test.js](../src/cli/commands/test.js).
46
+
47
+ ## `BEM_TEST_BOOT_PROJECT`
48
+
49
+ | Env | Purpose |
50
+ |---|---|
51
+ | `BEM_TEST_BOOT_PROJECT` | Root of a Firebase project to boot instead of the bundled fixture. Auto-set to `src/test/fixtures/firebase-project` when BEM tests itself; set it explicitly to self-test against a **real consumer** (e.g. `ultimate-jekyll-backend`) without `cd`-ing into it. |
52
+
53
+ ## What happens in a consumer run
54
+
55
+ The `boot/` smoke is **excluded from real-consumer runs** (`runner.js` `discoverTests` skips `boot/` unless `isFrameworkSelfTest`) — it targets the bundled fixture, so it would be redundant noise in a consumer's suite. Consumers run the full `routes`/`events`/`rules` suites against their own emulator as normal.
56
+
57
+ ## Why this exists
58
+
59
+ BEM has no pure-logic test layer — every `routes`/`events`/`rules` suite needs a live emulator + a real project, so they run against a real consumer. The boot layer fills the remaining gap: a fast, self-contained smoke proving the framework still boots a consumer backend from the repo itself. It's the BEM analog of "does the extension load?" (BXM) / "does the site boot?" (UJM).
60
+
61
+ ## See also
62
+
63
+ - [test-framework.md](test-framework.md) — overall harness, running/filtering, context object, assertions, auth levels
64
+ - [logging.md](logging.md) — `functions/*.log` files (the emulator/test logs the boot run writes)
65
+ - [environment-detection.md](environment-detection.md) — `Manager.isTesting()` and the environment signals