backend-manager 5.6.3 → 5.7.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.
- package/CHANGELOG.md +43 -0
- package/CLAUDE.md +4 -3
- package/PROGRESS.md +34 -0
- package/docs/ai-library.md +62 -11
- package/docs/cdp-debugging.md +44 -0
- package/docs/cli-output.md +22 -10
- package/docs/mcp.md +166 -43
- package/docs/test-framework.md +2 -2
- package/package.json +1 -1
- package/plans/mcp2.md +247 -0
- package/src/cli/commands/mcp.js +8 -2
- package/src/cli/commands/serve.js +155 -29
- package/src/cli/commands/setup-tests/base-test.js +8 -0
- package/src/cli/commands/setup-tests/firebase-auth.js +26 -0
- package/src/cli/commands/setup-tests/firebase-cli.js +9 -13
- package/src/cli/commands/setup-tests/index.js +4 -0
- package/src/cli/commands/setup-tests/java-installed.js +26 -0
- package/src/cli/commands/setup.js +2 -1
- package/src/cli/commands/test.js +13 -0
- package/src/cli/index.js +14 -0
- package/src/cli/utils/ui.js +27 -5
- package/src/manager/index.js +8 -3
- package/src/manager/libraries/ai/index.js +45 -1
- package/src/manager/libraries/ai/providers/anthropic-format.js +234 -0
- package/src/manager/libraries/ai/providers/anthropic.js +28 -49
- package/src/manager/libraries/ai/providers/claude-code.js +21 -47
- package/src/manager/libraries/ai/providers/openai.js +154 -19
- package/src/manager/libraries/ai/providers/test.js +242 -0
- package/src/manager/libraries/email/data/disposable-domains.json +465 -0
- package/src/mcp/client.js +48 -13
- package/src/mcp/handler.js +222 -69
- package/src/mcp/index.js +48 -18
- package/src/mcp/tools.js +150 -0
- package/src/mcp/utils.js +108 -0
- package/src/test/fixtures/firebase-project/firebase.json +1 -1
- package/src/test/test-accounts.js +31 -0
- package/test/ai/tools-live.js +170 -0
- package/test/email/marketing-lifecycle.js +10 -5
- package/test/helpers/ai-test-provider.js +202 -0
- package/test/helpers/ai-tools-format.js +350 -0
- package/test/mcp/discovery.js +53 -0
- package/test/mcp/oauth.js +161 -0
- package/test/mcp/protocol.js +268 -0
- package/test/mcp/roles.js +168 -0
- package/test/mcp/utils.js +245 -0
- package/test/routes/marketing/webhook.js +37 -33
- package/.claude/settings.local.json +0 -12
package/plans/mcp2.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# MCP Role-Based Tool Scoping + Consumer Extensibility
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
The BEM MCP server currently works only for admins — a single `BACKEND_MANAGER_KEY` grants access to all 19 tools. Regular users can't connect at all, and consumer BEM projects have no way to add custom MCP tools. This plan adds:
|
|
6
|
+
|
|
7
|
+
1. **Role-based tool scoping** — tools tagged `admin`, `user`, or `public`; connections only see tools matching their role
|
|
8
|
+
2. **User authentication** — seamless OAuth sign-in flow + API key as long-lived credential
|
|
9
|
+
3. **Consumer MCP tools** — a single `functions/mcp.js` file in consumer projects
|
|
10
|
+
|
|
11
|
+
## Architecture
|
|
12
|
+
|
|
13
|
+
### Role Model
|
|
14
|
+
|
|
15
|
+
Every tool gets a `role` field:
|
|
16
|
+
|
|
17
|
+
| Role | Who sees it | Examples |
|
|
18
|
+
|------|-------------|---------|
|
|
19
|
+
| `admin` | Admin key connections only | firestore_read, send_email, run_cron |
|
|
20
|
+
| `user` | Authenticated users + admins | get_user, get_subscription, cancel_subscription |
|
|
21
|
+
| `public` | Everyone (including unauthenticated) | generate_uuid, health_check |
|
|
22
|
+
|
|
23
|
+
Admin sees ALL tools. User sees `user` + `public`. Unauthenticated sees only `public`. Defense-in-depth — even if someone calls an admin tool by name, the route still rejects with 403.
|
|
24
|
+
|
|
25
|
+
### Auth: How Users Connect (OAuth + Consumer's Website)
|
|
26
|
+
|
|
27
|
+
**The key insight:** users already have an API key (`api.privateKey`) on their Firestore account doc. It's long-lived, already works with `assistant.authenticate()`, and already encodes identity + access level. This is the credential.
|
|
28
|
+
|
|
29
|
+
**The UX:** Claude Code/Desktop opens a browser → user signs in on their familiar site → redirected back to Claude. Done. No copy-pasting tokens.
|
|
30
|
+
|
|
31
|
+
**The OAuth flow (step by step):**
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
1. Claude discovers endpoints:
|
|
35
|
+
GET /.well-known/oauth-authorization-server
|
|
36
|
+
→ { authorization_endpoint, token_endpoint, ... }
|
|
37
|
+
|
|
38
|
+
2. Claude opens browser to BEM authorize endpoint:
|
|
39
|
+
GET /backend-manager/mcp/authorize?redirect_uri=CLAUDE_CALLBACK&state=STATE
|
|
40
|
+
|
|
41
|
+
3. BEM authorize checks the request:
|
|
42
|
+
- If client_id === BACKEND_MANAGER_KEY → auto-redirect (admin, unchanged)
|
|
43
|
+
- Otherwise → redirect to consumer's /token page:
|
|
44
|
+
https://app.example.com/token?redirect_uri=CLAUDE_CALLBACK&state=STATE
|
|
45
|
+
|
|
46
|
+
4. User lands on their familiar website sign-in page.
|
|
47
|
+
Signs in with Google / email+password / whatever.
|
|
48
|
+
After sign-in, page gets Firebase ID token via webManager.auth().getIdToken()
|
|
49
|
+
|
|
50
|
+
5. Consumer's /token page redirects to CLAUDE_CALLBACK:
|
|
51
|
+
CLAUDE_CALLBACK?code=FIREBASE_ID_TOKEN&state=STATE
|
|
52
|
+
|
|
53
|
+
6. Claude exchanges code for access token:
|
|
54
|
+
POST /backend-manager/mcp/token
|
|
55
|
+
{ code: FIREBASE_ID_TOKEN }
|
|
56
|
+
|
|
57
|
+
7. BEM token endpoint:
|
|
58
|
+
- If code === BACKEND_MANAGER_KEY → return it as access_token (admin, unchanged)
|
|
59
|
+
- Otherwise → verify Firebase ID token with admin.auth().verifyIdToken()
|
|
60
|
+
→ look up user doc → return user's api.privateKey as access_token
|
|
61
|
+
|
|
62
|
+
8. Claude uses the API key for all future MCP requests:
|
|
63
|
+
Authorization: Bearer {api.privateKey}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Why API key, not JWT:**
|
|
67
|
+
- Firebase ID tokens expire in 1 hour — MCP connection would break constantly
|
|
68
|
+
- The API key never expires (until regenerated)
|
|
69
|
+
- `assistant.authenticate()` already handles API key → user lookup
|
|
70
|
+
- User can revoke by regenerating from account settings
|
|
71
|
+
|
|
72
|
+
**Why redirect to consumer's website instead of embedding Firebase Auth:**
|
|
73
|
+
- User sees their familiar sign-in page (branded, trusted)
|
|
74
|
+
- No need to embed Firebase Auth SDK in BEM's authorize HTML
|
|
75
|
+
- UJM already has a `/token` page — just needs a small update to support `redirect_uri` param
|
|
76
|
+
- Works with any auth provider the consumer has configured
|
|
77
|
+
|
|
78
|
+
### Auth Classification (Fast, No DB Call)
|
|
79
|
+
|
|
80
|
+
`resolveAuthInfo(token)` classifies the Bearer token for tool filtering:
|
|
81
|
+
|
|
82
|
+
- Token matches `BACKEND_MANAGER_KEY` → `role: 'admin'`
|
|
83
|
+
- Any other non-empty token → `role: 'user'` (API key from the OAuth flow)
|
|
84
|
+
- No token → `role: 'public'`
|
|
85
|
+
|
|
86
|
+
No DB call needed — actual validation happens at the route level when a tool is called.
|
|
87
|
+
|
|
88
|
+
### Consumer MCP Tools
|
|
89
|
+
|
|
90
|
+
Consumer tools live in a **single file**: `functions/mcp.js`. Keeps things simple since most consumer MCP tools are just route delegations pointing at routes already defined in `functions/routes/`.
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
// functions/mcp.js
|
|
94
|
+
module.exports = [
|
|
95
|
+
// Route delegation — points at an existing route (works on stdio + HTTP)
|
|
96
|
+
{
|
|
97
|
+
name: 'get_sponsorship',
|
|
98
|
+
description: 'Get sponsorship details by ID',
|
|
99
|
+
role: 'user',
|
|
100
|
+
method: 'GET',
|
|
101
|
+
path: 'sponsorship',
|
|
102
|
+
inputSchema: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
id: { type: 'string', description: 'Sponsorship ID' },
|
|
106
|
+
},
|
|
107
|
+
required: ['id'],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
// Handler mode — runs code directly (HTTP transport only, has full Manager context)
|
|
112
|
+
{
|
|
113
|
+
name: 'newsletter_stats',
|
|
114
|
+
description: 'Get newsletter stats for the past N days',
|
|
115
|
+
role: 'admin',
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
days: { type: 'number', description: 'Days to look back', default: 30 },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
handler: async ({ Manager, assistant, user, params, libraries }) => {
|
|
123
|
+
const cutoff = Date.now() - (params.days || 30) * 86400000;
|
|
124
|
+
const snapshot = await libraries.admin.firestore()
|
|
125
|
+
.collection('newsletters')
|
|
126
|
+
.where('metadata.created.timestampUNIX', '>=', Math.floor(cutoff / 1000))
|
|
127
|
+
.get();
|
|
128
|
+
return { total: snapshot.docs.length };
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Consumer tools with the same name as a built-in tool override it (same precedence as consumer routes).
|
|
135
|
+
|
|
136
|
+
## File Changes
|
|
137
|
+
|
|
138
|
+
### 1. `src/mcp/tools.js` — Add `role` to every tool
|
|
139
|
+
|
|
140
|
+
| Tool | Role |
|
|
141
|
+
|------|------|
|
|
142
|
+
| `firestore_read/write/query` | `admin` |
|
|
143
|
+
| `send_email`, `send_notification` | `admin` |
|
|
144
|
+
| `sync_users`, `list_campaigns`, `create_campaign` | `admin` |
|
|
145
|
+
| `get_stats`, `run_cron`, `create_post`, `create_backup`, `run_hook` | `admin` |
|
|
146
|
+
| `get_user`, `get_subscription` | `user` |
|
|
147
|
+
| `cancel_subscription`, `refund_payment` | `user` |
|
|
148
|
+
| `generate_uuid`, `health_check` | `public` |
|
|
149
|
+
|
|
150
|
+
### 2. NEW: `src/mcp/utils.js` — Shared utilities
|
|
151
|
+
|
|
152
|
+
- **`resolveAuthInfo(token)`** — classifies token → `{ role, authType, token }`. Admin key check is instant; everything else = user.
|
|
153
|
+
- **`filterToolsByRole(tools, role)`** — admin→all, user→user+public, public→public only.
|
|
154
|
+
- **`loadConsumerTools(cwd)`** — checks for `${cwd}/mcp.js`, `require()`s it if it exists, validates shape, returns array.
|
|
155
|
+
- **`buildToolMap(builtinTools, consumerTools)`** — merges into a Map; consumer tools override same-name built-ins.
|
|
156
|
+
|
|
157
|
+
### 3. `src/mcp/client.js` — Support user auth tokens
|
|
158
|
+
|
|
159
|
+
Extend constructor to accept `{ baseUrl, backendManagerKey, userToken }`.
|
|
160
|
+
|
|
161
|
+
In `call()`:
|
|
162
|
+
- Has `backendManagerKey` → current behavior (key in query/body)
|
|
163
|
+
- Has `userToken` (no backendManagerKey) → put token in `Authorization: Bearer` header + `authenticationToken` query param for GET
|
|
164
|
+
- Neither → unauthenticated request
|
|
165
|
+
|
|
166
|
+
### 4. `src/mcp/index.js` — Stdio server with role filtering
|
|
167
|
+
|
|
168
|
+
- Import utils, call `resolveAuthInfo()` to determine role
|
|
169
|
+
- Call `loadConsumerTools(options.cwd)` if `cwd` is provided
|
|
170
|
+
- Merge + filter tools by role in `ListToolsRequestSchema` handler
|
|
171
|
+
- In `CallToolRequestSchema`: `path`-based tools via BEMClient. Handler-only tools return error explaining they require HTTP transport.
|
|
172
|
+
- Accept new `options.token` for user connections
|
|
173
|
+
|
|
174
|
+
### 5. `src/mcp/handler.js` — HTTP handler with OAuth user flow
|
|
175
|
+
|
|
176
|
+
**`handleAuthorize()`** — the big change:
|
|
177
|
+
- If `client_id` matches admin key → auto-redirect (current behavior, unchanged)
|
|
178
|
+
- Otherwise → **redirect to consumer's website** for sign-in:
|
|
179
|
+
- Build consumer auth URL from `Manager.config.brand.url` (or a configurable `mcp.authUrl` in backend-manager-config.json)
|
|
180
|
+
- Redirect to: `{consumerUrl}/token?redirect_uri={originalRedirectUri}&state={state}`
|
|
181
|
+
- The consumer's `/token` page handles sign-in, gets Firebase ID token, redirects back to Claude's callback
|
|
182
|
+
|
|
183
|
+
**`handleToken()`** — exchanges Firebase ID token for API key:
|
|
184
|
+
- If code matches admin key → return as `access_token` (unchanged)
|
|
185
|
+
- Otherwise → treat code as Firebase ID token:
|
|
186
|
+
1. `admin.auth().verifyIdToken(code)` to get UID
|
|
187
|
+
2. `admin.firestore().doc('users/{uid}').get()` to get user doc
|
|
188
|
+
3. Return `user.api.privateKey` as the `access_token`
|
|
189
|
+
4. If user has no API key, generate one and save it
|
|
190
|
+
|
|
191
|
+
**`handleMcpProtocol()`** — role-based tool filtering:
|
|
192
|
+
- Extract Bearer token, call `resolveAuthInfo()`
|
|
193
|
+
- Allow public connections (no 401 for empty tokens — just show public tools)
|
|
194
|
+
- Discover + merge consumer tools (cached at module scope)
|
|
195
|
+
- Filter tools by role in `ListToolsRequestSchema`
|
|
196
|
+
- In `CallToolRequestSchema`:
|
|
197
|
+
- `path`-based tools: BEMClient HTTP call (current behavior)
|
|
198
|
+
- `handler`-based tools: execute directly with `{ Manager, assistant, user, params, libraries }`
|
|
199
|
+
|
|
200
|
+
**Rename `isValidKey()` → `isAdminKey()`** for clarity.
|
|
201
|
+
|
|
202
|
+
### 6. `src/cli/commands/mcp.js` — New CLI flags
|
|
203
|
+
|
|
204
|
+
- `--token` (`-t`): user's API key (for user-level connections)
|
|
205
|
+
- Pass `cwd: functionsDir` to `startServer()` for consumer tool discovery
|
|
206
|
+
- When `--token` is provided, create BEMClient with userToken instead of backendManagerKey
|
|
207
|
+
|
|
208
|
+
### 7. Consumer's UJM `/token` page — Small update (separate repo)
|
|
209
|
+
|
|
210
|
+
The UJM `/token` page needs to support the MCP OAuth redirect flow:
|
|
211
|
+
- Detect `redirect_uri` and `state` query params
|
|
212
|
+
- After sign-in, get Firebase ID token via `webManager.auth().getIdToken()`
|
|
213
|
+
- Redirect to `redirect_uri?code={idToken}&state={state}`
|
|
214
|
+
|
|
215
|
+
This is a small addition to the existing `/token` page layout. Files:
|
|
216
|
+
- `ultimate-jekyll-manager/src/defaults/dist/_layouts/blueprint/auth/token.html`
|
|
217
|
+
- `ultimate-jekyll-manager/src/defaults/dist/_layouts/themes/classy/frontend/pages/auth/token.html`
|
|
218
|
+
|
|
219
|
+
### 8. `docs/mcp.md` — Documentation
|
|
220
|
+
|
|
221
|
+
- Add "Roles" section explaining admin/user/public scoping
|
|
222
|
+
- Add "User Authentication" section with the OAuth flow diagram
|
|
223
|
+
- Add "Consumer MCP Tools" section with `functions/mcp.js` format + examples
|
|
224
|
+
- Add CLI examples for user connections
|
|
225
|
+
- Add guide for configuring the consumer auth URL
|
|
226
|
+
|
|
227
|
+
## Implementation Order
|
|
228
|
+
|
|
229
|
+
1. `src/mcp/utils.js` (new) — foundation utilities
|
|
230
|
+
2. `src/mcp/tools.js` — add `role` to all 19 tools
|
|
231
|
+
3. `src/mcp/client.js` — user token support
|
|
232
|
+
4. `src/mcp/index.js` — stdio role filtering + consumer tools
|
|
233
|
+
5. `src/cli/commands/mcp.js` — new CLI flags
|
|
234
|
+
6. `src/mcp/handler.js` — HTTP role filtering + OAuth user flow + consumer tool handlers
|
|
235
|
+
7. UJM `/token` page update (separate repo)
|
|
236
|
+
8. `docs/mcp.md` — documentation
|
|
237
|
+
|
|
238
|
+
## Verification
|
|
239
|
+
|
|
240
|
+
1. **Existing admin flow**: `npx mgr mcp` with `BACKEND_MANAGER_KEY` → all 19 tools listed, all callable
|
|
241
|
+
2. **User flow (stdio)**: `npx mgr mcp --token <api-key>` → only user+public tools listed
|
|
242
|
+
3. **Public flow**: `npx mgr mcp` (no key, no token) → only public tools listed
|
|
243
|
+
4. **Consumer tools**: Create `functions/mcp.js` in a consumer project → tools appear in listing
|
|
244
|
+
5. **HTTP OAuth flow**: Connect from Claude Code/Desktop → redirected to consumer site → sign in → redirected back → user tools available
|
|
245
|
+
6. **Token exchange**: POST to `/mcp/token` with Firebase ID token → returns API key as access_token
|
|
246
|
+
7. **Role enforcement**: User calling an admin tool by name → unknown tool error (tool not in filtered list)
|
|
247
|
+
8. **Run `npx mgr test`** to verify no regressions
|
package/src/cli/commands/mcp.js
CHANGED
|
@@ -18,14 +18,20 @@ class McpCommand extends BaseCommand {
|
|
|
18
18
|
|| process.env.BEM_URL
|
|
19
19
|
|| 'http://localhost:5002';
|
|
20
20
|
|
|
21
|
-
// Resolve
|
|
21
|
+
// Resolve auth credentials
|
|
22
22
|
const backendManagerKey = self.argv.key
|
|
23
23
|
|| process.env.BACKEND_MANAGER_KEY
|
|
24
24
|
|| '';
|
|
25
|
+
const userToken = self.argv.token || '';
|
|
25
26
|
|
|
26
27
|
const { startServer } = require('../../mcp/index.js');
|
|
27
28
|
|
|
28
|
-
await startServer({
|
|
29
|
+
await startServer({
|
|
30
|
+
baseUrl,
|
|
31
|
+
backendManagerKey: userToken ? '' : backendManagerKey,
|
|
32
|
+
userToken,
|
|
33
|
+
cwd: functionsDir,
|
|
34
|
+
});
|
|
29
35
|
}
|
|
30
36
|
}
|
|
31
37
|
|
|
@@ -3,6 +3,7 @@ const path = require('path');
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const chalk = require('chalk').default;
|
|
5
5
|
const powertools = require('node-powertools');
|
|
6
|
+
const jetpack = require('fs-jetpack');
|
|
6
7
|
const WatchCommand = require('./watch');
|
|
7
8
|
|
|
8
9
|
class ServeCommand extends BaseCommand {
|
|
@@ -10,10 +11,18 @@ class ServeCommand extends BaseCommand {
|
|
|
10
11
|
const self = this.main;
|
|
11
12
|
const projectDir = self.firebaseProjectPath;
|
|
12
13
|
const firebaseConfig = JSON.parse(fs.readFileSync(path.join(projectDir, 'firebase.json'), 'utf8'));
|
|
13
|
-
const port = self.argv.port || self.argv?._?.[1] || firebaseConfig?.emulators?.hosting?.port || '5000';
|
|
14
|
+
const port = parseInt(self.argv.port || self.argv?._?.[1] || firebaseConfig?.emulators?.hosting?.port || '5000', 10);
|
|
15
|
+
|
|
16
|
+
// HTTPS: proxy on the public port (5002), firebase serve on an internal port (5443).
|
|
17
|
+
// All services connect to https://localhost:5002. Disable with --no-https.
|
|
18
|
+
const httpsEnabled = self.argv.https !== false;
|
|
19
|
+
const internalPort = 5443;
|
|
14
20
|
|
|
15
21
|
// Check for port conflicts before starting server
|
|
16
|
-
const
|
|
22
|
+
const portsToCheck = httpsEnabled
|
|
23
|
+
? { 'HTTPS': port, 'internal': internalPort }
|
|
24
|
+
: { serving: port };
|
|
25
|
+
const canProceed = await this.checkAndKillBlockingProcesses(portsToCheck);
|
|
17
26
|
if (!canProceed) {
|
|
18
27
|
throw new Error('Port conflicts could not be resolved');
|
|
19
28
|
}
|
|
@@ -29,29 +38,19 @@ class ServeCommand extends BaseCommand {
|
|
|
29
38
|
// Start Stripe webhook forwarding in background
|
|
30
39
|
this.startStripeWebhookForwarding();
|
|
31
40
|
|
|
41
|
+
// Start HTTPS proxy if enabled
|
|
42
|
+
if (httpsEnabled) {
|
|
43
|
+
await this._startHttpsProxy(port, internalPort, projectDir);
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
// Set up log file in the project directory.
|
|
33
|
-
// Mirrors the emulator.js pattern: the file is truncated on boot and on every
|
|
34
|
-
// hot reload. Two reset signals are honored:
|
|
35
|
-
// 1. Sentinel file (dev.log.reset) — used by the BEM watcher when source
|
|
36
|
-
// changes in backend-manager itself trigger a reload.
|
|
37
|
-
// 2. Reload marker on stdout (`Using node@22 from host.`) — catches reloads
|
|
38
|
-
// triggered by firebase serve's own internal watcher (any change inside
|
|
39
|
-
// the consumer's functions/ directory). MUST be the line firebase-tools
|
|
40
|
-
// prints at the START of each reload cycle, not somewhere in the middle.
|
|
41
|
-
// If we rolled mid-cycle (e.g. on "Loaded functions definitions"), the
|
|
42
|
-
// tail of the reload sequence still gets captured into the fresh log;
|
|
43
|
-
// but firebase serve sometimes only emits the trailing function-initialized
|
|
44
|
-
// lines on the first cycle (subsequent cycles route those elsewhere), so
|
|
45
|
-
// we'd end up with a near-empty log. Rolling at the START of the cycle
|
|
46
|
-
// lets us capture whatever firebase-tools does emit, complete-or-not.
|
|
47
47
|
const logPath = this.getLogsPath('dev.log');
|
|
48
48
|
const resetSentinelPath = this.getTempPath('dev.log.reset');
|
|
49
|
-
// Match any node version: "Using node@22 from host.", "Using node@20 from host.", etc.
|
|
50
49
|
const RELOAD_MARKER = /Using node@\d+ from host\./;
|
|
51
50
|
const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
52
51
|
|
|
53
52
|
let currentStream = fs.createWriteStream(logPath, { flags: 'w' });
|
|
54
|
-
let reloadCount = 0;
|
|
53
|
+
let reloadCount = 0;
|
|
55
54
|
|
|
56
55
|
function rollLog() {
|
|
57
56
|
try {
|
|
@@ -59,7 +58,7 @@ class ServeCommand extends BaseCommand {
|
|
|
59
58
|
currentStream = fs.createWriteStream(logPath, { flags: 'w' });
|
|
60
59
|
oldStream.end();
|
|
61
60
|
} catch (e) {
|
|
62
|
-
// Best-effort.
|
|
61
|
+
// Best-effort.
|
|
63
62
|
}
|
|
64
63
|
}
|
|
65
64
|
|
|
@@ -72,7 +71,7 @@ class ServeCommand extends BaseCommand {
|
|
|
72
71
|
// Clean up any stale sentinel from a prior crashed serve run
|
|
73
72
|
try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* not present, ok */ }
|
|
74
73
|
|
|
75
|
-
// Poll every 500ms for the reset sentinel
|
|
74
|
+
// Poll every 500ms for the reset sentinel
|
|
76
75
|
const resetWatcher = setInterval(() => {
|
|
77
76
|
if (!fs.existsSync(resetSentinelPath)) {
|
|
78
77
|
return;
|
|
@@ -89,19 +88,27 @@ class ServeCommand extends BaseCommand {
|
|
|
89
88
|
this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
|
|
90
89
|
|
|
91
90
|
// Execute with tee to log file
|
|
91
|
+
const firebasePort = httpsEnabled ? internalPort : port;
|
|
92
|
+
const firebaseEnv = {
|
|
93
|
+
...process.env,
|
|
94
|
+
FORCE_COLOR: '1',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (httpsEnabled) {
|
|
98
|
+
// Internal calls (getApiUrl → BEMClient) loop through the HTTPS proxy with a self-signed cert
|
|
99
|
+
firebaseEnv.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
100
|
+
firebaseEnv.BEM_HTTPS_PORT = String(port);
|
|
101
|
+
}
|
|
102
|
+
|
|
92
103
|
try {
|
|
93
|
-
await powertools.execute(`firebase serve --port ${
|
|
104
|
+
await powertools.execute(`firebase serve --port ${firebasePort}`, {
|
|
94
105
|
log: false,
|
|
95
106
|
cwd: projectDir,
|
|
96
107
|
config: {
|
|
97
108
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
98
|
-
env:
|
|
109
|
+
env: firebaseEnv,
|
|
99
110
|
},
|
|
100
111
|
}, (child) => {
|
|
101
|
-
// Tee stdout to both console and log file (strip ANSI codes for clean log).
|
|
102
|
-
// Watch each chunk for the reload marker — when seen (after the initial boot),
|
|
103
|
-
// roll the log BEFORE writing this chunk so the marker becomes the first
|
|
104
|
-
// line of the fresh file.
|
|
105
112
|
child.stdout.on('data', (data) => {
|
|
106
113
|
process.stdout.write(data);
|
|
107
114
|
const text = data.toString();
|
|
@@ -114,13 +121,11 @@ class ServeCommand extends BaseCommand {
|
|
|
114
121
|
writeToLog(data);
|
|
115
122
|
});
|
|
116
123
|
|
|
117
|
-
// Tee stderr to both console and log file (strip ANSI codes for clean log)
|
|
118
124
|
child.stderr.on('data', (data) => {
|
|
119
125
|
process.stderr.write(data);
|
|
120
126
|
writeToLog(data);
|
|
121
127
|
});
|
|
122
128
|
|
|
123
|
-
// Clean up log stream + watcher when child exits
|
|
124
129
|
child.on('close', () => {
|
|
125
130
|
clearInterval(resetWatcher);
|
|
126
131
|
if (currentStream && !currentStream.destroyed) {
|
|
@@ -130,10 +135,131 @@ class ServeCommand extends BaseCommand {
|
|
|
130
135
|
});
|
|
131
136
|
});
|
|
132
137
|
} catch (error) {
|
|
133
|
-
// User pressed Ctrl+C - this is expected
|
|
134
138
|
this.log(chalk.gray('\n Server stopped.\n'));
|
|
135
139
|
}
|
|
136
140
|
}
|
|
141
|
+
|
|
142
|
+
async _startHttpsProxy(httpsPort, httpPort, projectDir) {
|
|
143
|
+
const https = require('https');
|
|
144
|
+
const http = require('http');
|
|
145
|
+
|
|
146
|
+
const certs = await this._getHttpsCerts(projectDir);
|
|
147
|
+
|
|
148
|
+
if (!certs) {
|
|
149
|
+
this.log(chalk.yellow(' HTTPS disabled — could not obtain certificates.'));
|
|
150
|
+
this.log(chalk.yellow(' Install mkcert for trusted local HTTPS: brew install mkcert && mkcert -install\n'));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const options = {
|
|
155
|
+
key: fs.readFileSync(certs.key),
|
|
156
|
+
cert: fs.readFileSync(certs.cert),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const proxy = https.createServer(options, (clientReq, clientRes) => {
|
|
160
|
+
const proxyOpts = {
|
|
161
|
+
hostname: 'localhost',
|
|
162
|
+
port: httpPort,
|
|
163
|
+
path: clientReq.url,
|
|
164
|
+
method: clientReq.method,
|
|
165
|
+
headers: {
|
|
166
|
+
...clientReq.headers,
|
|
167
|
+
'x-forwarded-proto': 'https',
|
|
168
|
+
'x-forwarded-host': clientReq.headers.host,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const proxyReq = http.request(proxyOpts, (proxyRes) => {
|
|
173
|
+
clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
174
|
+
proxyRes.pipe(clientRes, { end: true });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
proxyReq.on('error', (err) => {
|
|
178
|
+
clientRes.writeHead(502);
|
|
179
|
+
clientRes.end(`Proxy error: ${err.message}`);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
clientReq.pipe(proxyReq, { end: true });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
proxy.listen(httpsPort, () => {
|
|
186
|
+
this.log(chalk.green(` HTTPS proxy listening on https://localhost:${httpsPort}`));
|
|
187
|
+
this.log(chalk.gray(` Forwarding to http://localhost:${httpPort} (firebase serve)\n`));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
proxy.on('error', (err) => {
|
|
191
|
+
this.log(chalk.red(` HTTPS proxy error: ${err.message}`));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async _getHttpsCerts(projectDir) {
|
|
196
|
+
const tempDir = this.getTempPath();
|
|
197
|
+
|
|
198
|
+
const certsDir = path.join(tempDir, 'certs');
|
|
199
|
+
jetpack.dir(certsDir);
|
|
200
|
+
|
|
201
|
+
// Check if mkcert certificates already exist
|
|
202
|
+
const certFiles = (jetpack.find(certsDir, { matching: 'localhost*.pem' }) || []);
|
|
203
|
+
const keyFile = certFiles.find((f) => f.includes('-key.pem'));
|
|
204
|
+
const certFile = certFiles.find((f) => !f.includes('-key.pem'));
|
|
205
|
+
|
|
206
|
+
if (keyFile && certFile) {
|
|
207
|
+
this.log(chalk.gray(' Using existing mkcert certificates from .temp/certs/'));
|
|
208
|
+
return { key: keyFile, cert: certFile };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Try to generate with mkcert
|
|
212
|
+
return this._generateMkcertCerts(certsDir);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async _generateMkcertCerts(certsDir) {
|
|
216
|
+
try {
|
|
217
|
+
await powertools.execute('which mkcert', { log: false });
|
|
218
|
+
} catch (e) {
|
|
219
|
+
this.log(chalk.yellow(' mkcert not found. Install with: brew install mkcert && mkcert -install'));
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
await powertools.execute('mkcert -install', { log: false });
|
|
225
|
+
} catch (e) {
|
|
226
|
+
// CA may already be installed
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.log(chalk.gray(' Generating mkcert certificates...'));
|
|
230
|
+
|
|
231
|
+
// Get local network IP for the cert SAN
|
|
232
|
+
const os = require('os');
|
|
233
|
+
const hosts = ['localhost', '127.0.0.1', '::1'];
|
|
234
|
+
const interfaces = os.networkInterfaces();
|
|
235
|
+
|
|
236
|
+
for (const name of Object.keys(interfaces)) {
|
|
237
|
+
for (const iface of interfaces[name]) {
|
|
238
|
+
if (!iface.internal && iface.family === 'IPv4') {
|
|
239
|
+
hosts.push(iface.address);
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
await powertools.execute(`cd "${certsDir}" && mkcert ${hosts.join(' ')}`, { log: false });
|
|
247
|
+
|
|
248
|
+
const certFiles = (jetpack.find(certsDir, { matching: 'localhost*.pem' }) || []);
|
|
249
|
+
const keyFile = certFiles.find((f) => f.includes('-key.pem'));
|
|
250
|
+
const certFile = certFiles.find((f) => !f.includes('-key.pem'));
|
|
251
|
+
|
|
252
|
+
if (keyFile && certFile) {
|
|
253
|
+
this.log(chalk.green(' Trusted HTTPS certificates generated in .temp/'));
|
|
254
|
+
return { key: keyFile, cert: certFile };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return null;
|
|
258
|
+
} catch (e) {
|
|
259
|
+
this.log(chalk.yellow(` Failed to generate certificates: ${e.message}`));
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
137
263
|
}
|
|
138
264
|
|
|
139
265
|
module.exports = ServeCommand;
|
|
@@ -24,6 +24,14 @@ class BaseTest {
|
|
|
24
24
|
throw new Error('No automatic fix available for this test');
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Override to provide warning details when run() returns 'warn'.
|
|
29
|
+
* @returns {string[]}
|
|
30
|
+
*/
|
|
31
|
+
getWarning() {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
/**
|
|
28
36
|
* Get the test name (used for logging)
|
|
29
37
|
* @returns {string}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const BaseTest = require('./base-test');
|
|
2
|
+
const powertools = require('node-powertools');
|
|
3
|
+
|
|
4
|
+
class FirebaseAuthTest extends BaseTest {
|
|
5
|
+
getName() {
|
|
6
|
+
return 'firebase CLI is authenticated';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
getWarning() {
|
|
10
|
+
return [
|
|
11
|
+
'You are not logged in to Firebase.',
|
|
12
|
+
'Run: firebase login',
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async run() {
|
|
17
|
+
try {
|
|
18
|
+
await powertools.execute('firebase projects:list', { log: false });
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return 'warn';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = FirebaseAuthTest;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const BaseTest = require('./base-test');
|
|
2
|
-
const chalk = require('chalk').default;
|
|
3
2
|
const powertools = require('node-powertools');
|
|
4
3
|
|
|
5
4
|
class FirebaseCliTest extends BaseTest {
|
|
@@ -7,24 +6,21 @@ class FirebaseCliTest extends BaseTest {
|
|
|
7
6
|
return 'firebase CLI is installed';
|
|
8
7
|
}
|
|
9
8
|
|
|
9
|
+
getWarning() {
|
|
10
|
+
return [
|
|
11
|
+
'Firebase CLI is not installed.',
|
|
12
|
+
'Install with: npm install -g firebase-tools',
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
async run() {
|
|
11
17
|
try {
|
|
12
|
-
|
|
18
|
+
await powertools.execute('firebase --version', { log: false });
|
|
13
19
|
return true;
|
|
14
20
|
} catch (error) {
|
|
15
|
-
|
|
16
|
-
console.error(chalk.red('Error: ' + error.message));
|
|
17
|
-
return false;
|
|
21
|
+
return 'warn';
|
|
18
22
|
}
|
|
19
23
|
}
|
|
20
|
-
|
|
21
|
-
async fix() {
|
|
22
|
-
console.log(chalk.red(`There is no automatic fix for this check.`));
|
|
23
|
-
console.log(chalk.red(`Firebase CLI is not installed. Please install it by running:`));
|
|
24
|
-
console.log(chalk.yellow(`npm install -g firebase-tools`));
|
|
25
|
-
console.log(chalk.red(`After installation, run ${chalk.bold('npx bm setup')} again.`));
|
|
26
|
-
throw new Error('Firebase CLI not installed');
|
|
27
|
-
}
|
|
28
24
|
}
|
|
29
25
|
|
|
30
26
|
module.exports = FirebaseCliTest;
|
|
@@ -8,6 +8,8 @@ const IsFirebaseProjectTest = require('./is-firebase-project');
|
|
|
8
8
|
const NodeVersionTest = require('./node-version');
|
|
9
9
|
const NvmrcVersionTest = require('./nvmrc-version');
|
|
10
10
|
const FirebaseCLITest = require('./firebase-cli');
|
|
11
|
+
const FirebaseAuthTest = require('./firebase-auth');
|
|
12
|
+
const JavaInstalledTest = require('./java-installed');
|
|
11
13
|
const FunctionsPackageTest = require('./functions-package');
|
|
12
14
|
const FirebaseAdminTest = require('./firebase-admin');
|
|
13
15
|
const FirebaseFunctionsTest = require('./firebase-functions');
|
|
@@ -51,6 +53,8 @@ function getTests(context) {
|
|
|
51
53
|
new NodeVersionTest(context),
|
|
52
54
|
new NvmrcVersionTest(context),
|
|
53
55
|
new FirebaseCLITest(context),
|
|
56
|
+
new FirebaseAuthTest(context),
|
|
57
|
+
new JavaInstalledTest(context),
|
|
54
58
|
new FunctionsPackageTest(context),
|
|
55
59
|
new FirebaseAdminTest(context),
|
|
56
60
|
new FirebaseFunctionsTest(context),
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const BaseTest = require('./base-test');
|
|
2
|
+
const powertools = require('node-powertools');
|
|
3
|
+
|
|
4
|
+
class JavaInstalledTest extends BaseTest {
|
|
5
|
+
getName() {
|
|
6
|
+
return 'Java is installed (required by Firebase emulators)';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
getWarning() {
|
|
10
|
+
return [
|
|
11
|
+
'Java is required by the Firebase Firestore emulator (used for testing).',
|
|
12
|
+
'Install with: brew install openjdk',
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async run() {
|
|
17
|
+
try {
|
|
18
|
+
await powertools.execute('java -version', { log: false });
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return 'warn';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = JavaInstalledTest;
|