backend-manager 5.6.4 → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/CLAUDE.md +4 -3
  3. package/PROGRESS.md +34 -0
  4. package/docs/ai-library.md +62 -11
  5. package/docs/cdp-debugging.md +44 -0
  6. package/docs/cli-output.md +22 -10
  7. package/docs/mcp.md +166 -43
  8. package/package.json +1 -1
  9. package/plans/mcp2.md +247 -0
  10. package/src/cli/commands/mcp.js +8 -2
  11. package/src/cli/commands/serve.js +155 -29
  12. package/src/cli/commands/setup-tests/base-test.js +8 -0
  13. package/src/cli/commands/setup-tests/firebase-auth.js +26 -0
  14. package/src/cli/commands/setup-tests/firebase-cli.js +9 -13
  15. package/src/cli/commands/setup-tests/index.js +4 -0
  16. package/src/cli/commands/setup-tests/java-installed.js +26 -0
  17. package/src/cli/commands/setup.js +2 -1
  18. package/src/cli/commands/test.js +8 -0
  19. package/src/cli/index.js +14 -0
  20. package/src/cli/utils/ui.js +27 -5
  21. package/src/manager/index.js +8 -3
  22. package/src/manager/libraries/ai/index.js +45 -1
  23. package/src/manager/libraries/ai/providers/anthropic-format.js +234 -0
  24. package/src/manager/libraries/ai/providers/anthropic.js +28 -49
  25. package/src/manager/libraries/ai/providers/claude-code.js +21 -47
  26. package/src/manager/libraries/ai/providers/openai.js +154 -19
  27. package/src/manager/libraries/ai/providers/test.js +242 -0
  28. package/src/manager/libraries/email/data/disposable-domains.json +465 -0
  29. package/src/mcp/client.js +48 -13
  30. package/src/mcp/handler.js +222 -69
  31. package/src/mcp/index.js +48 -18
  32. package/src/mcp/tools.js +150 -0
  33. package/src/mcp/utils.js +108 -0
  34. package/src/test/fixtures/firebase-project/firebase.json +1 -1
  35. package/test/ai/tools-live.js +170 -0
  36. package/test/helpers/ai-test-provider.js +202 -0
  37. package/test/helpers/ai-tools-format.js +350 -0
  38. package/test/mcp/discovery.js +53 -0
  39. package/test/mcp/oauth.js +161 -0
  40. package/test/mcp/protocol.js +268 -0
  41. package/test/mcp/roles.js +168 -0
  42. package/test/mcp/utils.js +245 -0
  43. 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
@@ -18,14 +18,20 @@ class McpCommand extends BaseCommand {
18
18
  || process.env.BEM_URL
19
19
  || 'http://localhost:5002';
20
20
 
21
- // Resolve the admin key
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({ baseUrl, backendManagerKey });
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 canProceed = await this.checkAndKillBlockingProcesses({ serving: parseInt(port, 10) });
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; // skip rolling on the first marker (initial boot, not a reload)
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. If roll fails, serve keeps running with the existing stream.
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 — cheap, no fs.watch quirks
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 ${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: { ...process.env, FORCE_COLOR: '1' },
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
- const result = await powertools.execute('firebase --version', { log: false });
18
+ await powertools.execute('firebase --version', { log: false });
13
19
  return true;
14
20
  } catch (error) {
15
- console.error(chalk.red('Firebase CLI is not installed or not accessible'));
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;
@@ -291,7 +291,8 @@ class SetupCommand extends BaseCommand {
291
291
  },
292
292
  async () => {
293
293
  return await test.fix();
294
- }
294
+ },
295
+ { details: () => test.getWarning() },
295
296
  );
296
297
  }
297
298
  }