backend-manager 5.7.0 → 5.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.7.2] - 2026-06-18
18
+
19
+ ### Fixed
20
+ - **Newsletter generation crash.** v5.5.0 refactored `marketing.beehiiv` → `marketing.newsletter` but missed renaming `beehiivConfig` at 3 sites in `newsletter.js` (lines 331, 336, 340). The `ReferenceError` crashed generation after all AI work completed, preventing the campaign doc from being written. Newsletter generation has been silently failing since June 6.
21
+ - **HTTPS proxy silent failure.** `serve.js` `_startHttpsProxy` now returns a boolean. The caller uses `httpsReady` (not `httpsEnabled`) for port and env decisions, so when cert generation fails, the server correctly falls back to plain HTTP instead of setting `BEM_HTTPS_PORT` with no proxy listening.
22
+ - **AI system prompt injection for array content blocks.** `normalizeOptions` now handles system messages with array content (content blocks) — prepends rules as a `{ type: 'text' }` block. Previously, the if/else-if chain fell through and rules were silently dropped.
23
+ - **Setup retry loop treats warns as failures.** Added `warnCount` tracking; the `allPassed` check now includes warns (`testCount + warnCount === testTotal`). Warns no longer trigger unnecessary retries with `--retry N`.
24
+ - **Copy-paste: `sender: 'electron-manager'` in setup IPC.** Changed to `'backend-manager'`.
25
+ - **Test account creation race condition.** `deleteTestUsers` now uses the emulator's bulk-clear REST API instead of individual `deleteUser()` calls. Individual deletes triggered async on-delete handlers that could clobber freshly-created accounts (80-100% repro rate). Bulk clear eliminates the race entirely.
26
+ - **Consent rules test value collision.** Changed test value from `'granted'` to `'forged'` so the write always differs from prior test state.
27
+ - **`cancel-too-young` timestampUNIX convention.** `Date.now()` (milliseconds) → `Math.floor(Date.now() / 1000)` (seconds).
28
+
29
+ ### Added
30
+ - **AI array-content test.** `normalize-options-structured-system-content-as-array-injects-rules` — covers the missing branch.
31
+ - **Auth on-delete race condition test.** `test/events/auth-delete-race.js` — proves the emulator race (clobber without mitigation) and verifies both mitigation strategies (wait-for-gone, force-delete).
32
+ - **Root package.json setup check.** Validates the project root `package.json` during `npx mgr setup`.
33
+
34
+ # [5.7.1] - 2026-06-17
35
+
36
+ ### Added
37
+ - **MCP shorthand URL.** MCP endpoint now accessible at `/mcp` in addition to `/backend-manager/mcp`. Hosting rewrites updated; consumer projects pick up on next `npx mgr setup`.
38
+ - **`/register` hosting rewrite.** Dynamic client registration endpoint now included in the default hosting rewrite pattern.
39
+
17
40
  # [5.7.0] - 2026-06-17
18
41
 
19
42
  ### Added
package/PROGRESS.md CHANGED
@@ -2,13 +2,31 @@
2
2
  > Agents and maintainers should update this file regularly to reflect the current state of the project.
3
3
 
4
4
  ## 🎯 Current Focus
5
- * **Goal:** MCP role-based tool scoping + consumer extensibility
6
- * **Current Phase:** Complete — all phases done, 44 tests passing, docs finalized
7
- * **Priority:** High
8
- * **Last Updated:** 2026-06-17 3:42 AM PDT
9
- * **Notes:** Ready to ship. Full OAuth flow verified in Claude Desktop. Role reassignment (16 admin / 2 user / 1 public), annotations, HTTPS serve, dynamic client registration all working.
5
+ * **Goal:** Root package.json proxy for running scripts from project root
6
+ * **Current Phase:** Implementation complete, untested in consumer
7
+ * **Priority:** Medium
8
+ * **Last Updated:** 2026-06-17 7:40 PM PDT
9
+ * **Notes:** New setup test `root-package-json` generates a root `package.json` with proxy scripts so `npm test`/`npm start`/etc. work from the Firebase project root (not just `functions/`). Includes `preinstall` guard to block accidental `npm install` at root. Prior audit fixes still pending commit.
10
10
 
11
11
  ## 📌 Active Task List
12
+ * [ ] Phase 3: Post-audit bug fixes
13
+ * [x] Newsletter ReferenceError: `beehiivConfig` → `newsletterRoleConfig` (committed v5.7.1)
14
+ * [x] HTTPS proxy: `serve.js` returns boolean, caller uses `httpsReady` not `httpsEnabled`
15
+ * [x] AI normalizeOptions: array-content system messages now get rules injected
16
+ * [x] Setup warn handling: `warnCount` tracked separately, warns don't trigger retry
17
+ * [x] Copy-paste fix: `sender: 'electron-manager'` → `'backend-manager'`
18
+ * [x] Test: AI array-content-blocks test added + passing (20/20)
19
+ * [x] Fix: consent rules test — write `'forged'` instead of `'granted'` to avoid value collision with prior email-preferences tests
20
+ * [x] Fix: `cancel-too-young` account `timestampUNIX` uses seconds (was ms)
21
+ * [x] Fix: auth on-delete race condition — `deleteTestUsers` uses emulator bulk-clear REST API instead of individual `deleteUser()` calls (eliminates async on-delete triggers that clobbered freshly-created accounts)
22
+ * [x] Diagnostic: auth-delete-race test — proved the race condition (80-100% clobber rate without mitigation), removed after fix verified
23
+ * [ ] Commit + publish framework fixes
24
+ * [ ] Deploy somiibo-backend + advance stuck sendAt
25
+ * [ ] Phase 4: Root package.json proxy scripts
26
+ * [x] Task 4.1: Create `root-package-json.js` setup test (proxies `projectScripts` with `cd functions &&` prefix + `preinstall` guard)
27
+ * [x] Task 4.2: Register in setup test index (after `npm-project-scripts`)
28
+ * [ ] Task 4.3: Test in consumer project (`npx mgr setup` from ultimate-jekyll-backend)
29
+ * [ ] Task 4.4: Verify `npm test` / `npm start` work from project root
12
30
 
13
31
  ## ✅ Completed Task List
14
32
  * [x] Phase 1: MCP role-based tool scoping + consumer extensibility
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.7.0",
3
+ "version": "5.7.2",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -38,10 +38,11 @@ class ServeCommand extends BaseCommand {
38
38
  // Start Stripe webhook forwarding in background
39
39
  this.startStripeWebhookForwarding();
40
40
 
41
- // Start HTTPS proxy if enabled
42
- if (httpsEnabled) {
43
- await this._startHttpsProxy(port, internalPort, projectDir);
44
- }
41
+ // Start HTTPS proxy if enabled. If certs can't be obtained, fall back to
42
+ // plain HTTP — don't set BEM_HTTPS_PORT or redirect to the internal port.
43
+ const httpsReady = httpsEnabled
44
+ ? await this._startHttpsProxy(port, internalPort, projectDir)
45
+ : false;
45
46
 
46
47
  // Set up log file in the project directory.
47
48
  const logPath = this.getLogsPath('dev.log');
@@ -88,13 +89,13 @@ class ServeCommand extends BaseCommand {
88
89
  this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
89
90
 
90
91
  // Execute with tee to log file
91
- const firebasePort = httpsEnabled ? internalPort : port;
92
+ const firebasePort = httpsReady ? internalPort : port;
92
93
  const firebaseEnv = {
93
94
  ...process.env,
94
95
  FORCE_COLOR: '1',
95
96
  };
96
97
 
97
- if (httpsEnabled) {
98
+ if (httpsReady) {
98
99
  // Internal calls (getApiUrl → BEMClient) loop through the HTTPS proxy with a self-signed cert
99
100
  firebaseEnv.NODE_TLS_REJECT_UNAUTHORIZED = '0';
100
101
  firebaseEnv.BEM_HTTPS_PORT = String(port);
@@ -148,7 +149,7 @@ class ServeCommand extends BaseCommand {
148
149
  if (!certs) {
149
150
  this.log(chalk.yellow(' HTTPS disabled — could not obtain certificates.'));
150
151
  this.log(chalk.yellow(' Install mkcert for trusted local HTTPS: brew install mkcert && mkcert -install\n'));
151
- return;
152
+ return false;
152
153
  }
153
154
 
154
155
  const options = {
@@ -190,6 +191,8 @@ class ServeCommand extends BaseCommand {
190
191
  proxy.on('error', (err) => {
191
192
  this.log(chalk.red(` HTTPS proxy error: ${err.message}`));
192
193
  });
194
+
195
+ return true;
193
196
  }
194
197
 
195
198
  async _getHttpsCerts(projectDir) {
@@ -5,7 +5,7 @@ const _ = require('lodash');
5
5
  // The expected source pattern for bm_api hosting rewrite
6
6
  // Includes /backend-manager/* routes and root-level MCP OAuth paths
7
7
  // that Claude Chat sends directly (e.g. /authorize, /token, /.well-known/*)
8
- const BM_API_SOURCE = '{/backend-manager,/backend-manager/**,/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-server,/authorize,/token}';
8
+ const BM_API_SOURCE = '{/backend-manager,/backend-manager/**,/mcp,/mcp/**,/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-server,/authorize,/token,/register}';
9
9
 
10
10
  class HostingRewritesTest extends BaseTest {
11
11
  getName() {
@@ -15,6 +15,7 @@ const FirebaseAdminTest = require('./firebase-admin');
15
15
  const FirebaseFunctionsTest = require('./firebase-functions');
16
16
  const BackendManagerTest = require('./backend-manager');
17
17
  const NpmProjectScriptsTest = require('./npm-project-scripts');
18
+ const RootPackageJsonTest = require('./root-package-json');
18
19
  const BemConfigTest = require('./bem-config');
19
20
  const ProjectIdConsistencyTest = require('./project-id-consistency');
20
21
  const ServiceAccountTest = require('./service-account');
@@ -60,6 +61,7 @@ function getTests(context) {
60
61
  new FirebaseFunctionsTest(context),
61
62
  new BackendManagerTest(context),
62
63
  new NpmProjectScriptsTest(context),
64
+ new RootPackageJsonTest(context),
63
65
  new BemConfigTest(context),
64
66
  new ServiceAccountTest(context),
65
67
  new ProjectIdConsistencyTest(context),
@@ -0,0 +1,57 @@
1
+ const BaseTest = require('./base-test');
2
+ const jetpack = require('fs-jetpack');
3
+ const path = require('path');
4
+
5
+ class RootPackageJsonTest extends BaseTest {
6
+ getName() {
7
+ return 'root package.json proxy scripts';
8
+ }
9
+
10
+ async run() {
11
+ const rootPath = path.join(this.self.firebaseProjectPath, 'package.json');
12
+
13
+ if (!jetpack.exists(rootPath)) {
14
+ return false;
15
+ }
16
+
17
+ const rootPkg = JSON.parse(jetpack.read(rootPath));
18
+ const expectedScripts = this._getExpectedScripts();
19
+ const rootScripts = rootPkg.scripts || {};
20
+
21
+ for (const [name, command] of Object.entries(expectedScripts)) {
22
+ if (rootScripts[name] !== command) {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ return true;
28
+ }
29
+
30
+ async fix() {
31
+ const rootPath = path.join(this.self.firebaseProjectPath, 'package.json');
32
+ const existing = jetpack.exists(rootPath) ? JSON.parse(jetpack.read(rootPath)) : {};
33
+ const expectedScripts = this._getExpectedScripts();
34
+
35
+ existing.name = existing.name || `${this.context.package.name || 'functions'}-root`;
36
+ existing.private = true;
37
+ existing.scripts = Object.assign(existing.scripts || {}, expectedScripts);
38
+
39
+ jetpack.write(rootPath, JSON.stringify(existing, null, 2));
40
+ }
41
+
42
+ _getExpectedScripts() {
43
+ const bemPackage = require('../../../../package.json');
44
+ const projectScripts = bemPackage.projectScripts || {};
45
+ const scripts = {};
46
+
47
+ for (const [name, command] of Object.entries(projectScripts)) {
48
+ scripts[name] = `cd functions && ${command}`;
49
+ }
50
+
51
+ scripts.preinstall = `echo "\\n Dependencies live in functions/ — run:\\n cd functions && npm install\\n" && exit 1`;
52
+
53
+ return scripts;
54
+ }
55
+ }
56
+
57
+ module.exports = RootPackageJsonTest;
@@ -31,10 +31,11 @@ class SetupCommand extends BaseCommand {
31
31
  // Reset counters so each attempt starts fresh
32
32
  self.testCount = 0;
33
33
  self.testTotal = 0;
34
+ self.warnCount = 0;
34
35
 
35
36
  await this.runSetup();
36
37
 
37
- const allPassed = self.testCount === self.testTotal;
38
+ const allPassed = self.testCount + self.warnCount === self.testTotal;
38
39
  if (allPassed) {
39
40
  return;
40
41
  }
@@ -142,10 +143,10 @@ class SetupCommand extends BaseCommand {
142
143
  // Notify parent if exists
143
144
  if (process.send) {
144
145
  process.send({
145
- sender: 'electron-manager',
146
+ sender: 'backend-manager',
146
147
  command: 'setup:complete',
147
148
  payload: {
148
- passed: self.testCount === self.testTotal,
149
+ passed: self.testCount + self.warnCount === self.testTotal,
149
150
  }
150
151
  });
151
152
  }
package/src/cli/index.js CHANGED
@@ -41,6 +41,7 @@ Main.prototype.process = async function (args) {
41
41
  self.firebaseProjectPath = self.firebaseProjectPath.match(/\/functions$/) ? self.firebaseProjectPath.replace(/\/functions$/, '') : self.firebaseProjectPath;
42
42
  self.testCount = 0;
43
43
  self.testTotal = 0;
44
+ self.warnCount = 0;
44
45
  self.default = {};
45
46
  self.packageJSON = require('../../package.json');
46
47
  self.default.version = self.packageJSON.version;
@@ -199,6 +200,7 @@ Main.prototype.test = async function(name, fn, fix, args) {
199
200
  // (an array of pre-formatted lines) set by the caller.
200
201
  if (passed === 'warn') {
201
202
  self.testTotal++;
203
+ self.warnCount++;
202
204
  printLine(self.testTotal, 'warn', '');
203
205
  const details = (args && typeof args.details === 'function') ? args.details() : (args && args.details) || [];
204
206
  for (const line of details) {
@@ -1335,7 +1335,7 @@ function requireJSON5(file, throwError) {
1335
1335
  * @returns {string|null} - Normalized MCP route path, or null if not MCP
1336
1336
  */
1337
1337
  function resolveMcpRoutePath(routePath) {
1338
- // Direct MCP paths (via /backend-manager/mcp/*)
1338
+ // Direct MCP paths (via /mcp/* or /backend-manager/mcp/*)
1339
1339
  if (routePath === 'mcp' || routePath.startsWith('mcp/')) {
1340
1340
  return routePath;
1341
1341
  }
@@ -167,7 +167,12 @@ function normalizeOptions(opts) {
167
167
  out.messages = opts.messages.map((m, i) => i === systemIdx
168
168
  ? { ...m, content: existing ? `${rules}\n\n${existing}` : rules }
169
169
  : m);
170
- } else if (systemIdx < 0) {
170
+ } else if (systemIdx >= 0) {
171
+ // Content is an array of content blocks — prepend rules as a text block
172
+ out.messages = opts.messages.map((m, i) => i === systemIdx
173
+ ? { ...m, content: [{ type: 'text', text: rules }, ...(Array.isArray(m.content) ? m.content : [])] }
174
+ : m);
175
+ } else {
171
176
  out.messages = [{ role: 'system', content: rules }, ...opts.messages];
172
177
  }
173
178
 
@@ -328,16 +328,16 @@ async function generate(Manager, assistant, settings, opts = {}) {
328
328
  // post-creation API requires Enterprise plan), but we ship it anyway
329
329
  // so the day we upgrade to Enterprise it Just Works. Failure is logged,
330
330
  // never thrown — the rest of the pipeline (GH archive, Firestore doc)
331
- // succeeds regardless. beehiivConfig was already resolved at the top
331
+ // succeeds regardless. newsletterRoleConfig was already resolved at the top
332
332
  // of the function for the initial enabled-check.
333
333
  let beehiivPostId = null;
334
334
  let beehiivFailureReason = null;
335
335
 
336
- if (host === 'github' && beehiivConfig?.enabled) {
336
+ if (host === 'github' && newsletterRoleConfig?.enabled) {
337
337
  try {
338
338
  const beehiivProvider = require('../providers/beehiiv.js');
339
339
  const result = await beehiivProvider.createPost({
340
- publicationId: beehiivConfig.publicationId, // explicit — avoids singleton-Manager dependency
340
+ publicationId: newsletterRoleConfig.publicationId, // explicit — avoids singleton-Manager dependency
341
341
  title: structure.subject,
342
342
  subject: structure.subject,
343
343
  preheader: structure.preheader,
@@ -8,7 +8,7 @@
8
8
  ],
9
9
  "rewrites": [
10
10
  {
11
- "source": "{/backend-manager,/backend-manager/**,/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-server,/authorize,/token}",
11
+ "source": "{/backend-manager,/backend-manager/**,/mcp,/mcp/**,/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-server,/authorize,/token,/register}",
12
12
  "function": "bm_api"
13
13
  }
14
14
  ]
@@ -413,7 +413,7 @@ const JOURNEY_ACCOUNTS = {
413
413
  email: '_test.cancel-too-young@{domain}',
414
414
  properties: {
415
415
  roles: {},
416
- subscription: { product: { id: 'premium', name: 'Premium' }, status: 'active', expires: getFutureExpires(), cancellation: { pending: false }, payment: { processor: 'test', resourceId: 'sub_test_fake', startDate: { timestamp: new Date().toISOString(), timestampUNIX: Date.now() } } },
416
+ subscription: { product: { id: 'premium', name: 'Premium' }, status: 'active', expires: getFutureExpires(), cancellation: { pending: false }, payment: { processor: 'test', resourceId: 'sub_test_fake', startDate: { timestamp: new Date().toISOString(), timestampUNIX: Math.floor(Date.now() / 1000) } } },
417
417
  },
418
418
  },
419
419
  // Dedicated accounts for portal validation tests
@@ -837,31 +837,72 @@ async function deleteTestUsers(admin, extraAccounts) {
837
837
  // Wipe the entire emulator Firestore up front (guarded to emulator-only).
838
838
  await flushEmulatorFirestore(admin);
839
839
 
840
- // Delete all known test accounts in parallel (built-in + project-defined).
841
- const allAccounts = { ...TEST_ACCOUNTS, ...(extraAccounts || {}) };
840
+ // Clear auth users via the emulator's bulk-clear REST API instead of
841
+ // individual deleteUser() calls. Individual deletes trigger auth:on-delete
842
+ // for EACH user, and those triggers fire asynchronously — a late on-delete
843
+ // can land AFTER the subsequent createTestAccounts() on-create and clobber
844
+ // the freshly-written doc (80% repro rate in stress tests). The bulk API
845
+ // clears the auth store without triggering event handlers at all.
846
+ const authHost = process.env.FIREBASE_AUTH_EMULATOR_HOST;
847
+ const projectId = process.env.GCLOUD_PROJECT || 'demo-test';
848
+
849
+ if (authHost) {
850
+ try {
851
+ const url = `http://${authHost}/emulator/v1/projects/${projectId}/accounts`;
852
+ await fetch(url, { method: 'DELETE' });
853
+
854
+ // Count all known accounts as deleted (the bulk API doesn't return per-user results)
855
+ const allAccounts = { ...TEST_ACCOUNTS, ...(extraAccounts || {}) };
856
+ results.deleted = Object.values(allAccounts).map(a => a.uid);
857
+ } catch (e) {
858
+ // Bulk clear failed — fall back to individual deletes
859
+ const allAccounts = { ...TEST_ACCOUNTS, ...(extraAccounts || {}) };
860
+ await _deleteAccountsIndividually(admin, allAccounts, results);
861
+ }
862
+ } else {
863
+ // Not running against emulator — fall back to individual deletes
864
+ const allAccounts = { ...TEST_ACCOUNTS, ...(extraAccounts || {}) };
865
+ await _deleteAccountsIndividually(admin, allAccounts, results);
866
+ }
867
+
868
+ // Realtime Database: wipe the `_test` namespace in full. (The Firestore-wide
869
+ // flush already ran in flushEmulatorFirestore() at the start of this function.)
870
+ // `admin.database()` throws synchronously when no Database URL is configured,
871
+ // so guard the whole thing — RTDB is optional for a project.
872
+ try {
873
+ await admin.database().ref('_test').remove();
874
+ } catch (e) {
875
+ // RTDB not configured / no database URL — ignore.
876
+ }
877
+
878
+ return {
879
+ success: results.failed.length === 0,
880
+ deleted: results.deleted.length,
881
+ skipped: results.skipped.length,
882
+ failed: results.failed.length,
883
+ errors: results.failed,
884
+ };
885
+ }
886
+
887
+ async function _deleteAccountsIndividually(admin, allAccounts, results) {
842
888
  await Promise.all(
843
889
  Object.values(allAccounts).map(async (account) => {
844
890
  try {
845
- // Delete Firebase Auth user (triggers on-delete which handles Firestore doc + count)
846
891
  await admin.auth().deleteUser(account.uid);
847
892
 
848
- // Wait for on-delete handler to delete the Firestore doc
849
893
  const maxWait = 10000;
850
894
  const interval = 200;
851
895
  let waited = 0;
852
896
 
853
897
  while (waited < maxWait) {
854
898
  const doc = await admin.firestore().doc(`users/${account.uid}`).get();
855
-
856
899
  if (!doc.exists) {
857
900
  break;
858
901
  }
859
-
860
902
  await new Promise(resolve => setTimeout(resolve, interval));
861
903
  waited += interval;
862
904
  }
863
905
 
864
- // Fallback: if on-delete didn't complete in time, delete the doc directly
865
906
  if (waited >= maxWait) {
866
907
  await admin.firestore().doc(`users/${account.uid}`).delete().catch(() => {});
867
908
  }
@@ -869,7 +910,6 @@ async function deleteTestUsers(admin, extraAccounts) {
869
910
  results.deleted.push(account.uid);
870
911
  } catch (error) {
871
912
  if (error.code === 'auth/user-not-found') {
872
- // Auth user doesn't exist, but Firestore doc might still exist - clean it up
873
913
  await admin.firestore().doc(`users/${account.uid}`).delete().catch(() => {});
874
914
  results.skipped.push(account.uid);
875
915
  } else {
@@ -878,24 +918,6 @@ async function deleteTestUsers(admin, extraAccounts) {
878
918
  }
879
919
  })
880
920
  );
881
-
882
- // Realtime Database: wipe the `_test` namespace in full. (The Firestore-wide
883
- // flush already ran in flushEmulatorFirestore() at the start of this function.)
884
- // `admin.database()` throws synchronously when no Database URL is configured,
885
- // so guard the whole thing — RTDB is optional for a project.
886
- try {
887
- await admin.database().ref('_test').remove();
888
- } catch (e) {
889
- // RTDB not configured / no database URL — ignore.
890
- }
891
-
892
- return {
893
- success: results.failed.length === 0,
894
- deleted: results.deleted.length,
895
- skipped: results.skipped.length,
896
- failed: results.failed.length,
897
- errors: results.failed,
898
- };
899
921
  }
900
922
 
901
923
  /**
@@ -0,0 +1,201 @@
1
+ const uuid = require('uuid');
2
+
3
+ /**
4
+ * Test: auth:on-delete race condition
5
+ *
6
+ * Proves the emulator's async on-delete trigger clobbers freshly-recreated docs
7
+ * when you don't wait for it to settle, and that the mitigation strategies work.
8
+ *
9
+ * The production fix uses the emulator's bulk-clear REST API in deleteTestUsers()
10
+ * to avoid triggering on-delete at all during test setup.
11
+ */
12
+ module.exports = {
13
+ description: 'auth:on-delete race condition (create → delete → recreate)',
14
+ type: 'group',
15
+ timeout: 120000,
16
+
17
+ tests: [
18
+ {
19
+ name: 'baseline-create-and-verify',
20
+ async run({ Manager, assert }) {
21
+ const admin = Manager.libraries.admin;
22
+ const testUid = '_test-race-baseline';
23
+ const testEmail = '_test.race-baseline@test.com';
24
+ const userRef = admin.firestore().doc(`users/${testUid}`);
25
+
26
+ await admin.auth().deleteUser(testUid).catch(() => {});
27
+ await pollUntilGone(userRef);
28
+
29
+ await admin.auth().createUser({
30
+ uid: testUid,
31
+ email: testEmail,
32
+ password: uuid.v4(),
33
+ emailVerified: true,
34
+ });
35
+
36
+ const ready = await pollUntilReady(userRef);
37
+ assert.ok(ready, 'on-create should complete and write api keys');
38
+
39
+ const doc = await userRef.get();
40
+ assert.ok(doc.data()?.api?.clientId, 'doc should have api.clientId');
41
+ assert.ok(doc.data()?.api?.privateKey, 'doc should have api.privateKey');
42
+
43
+ await admin.auth().deleteUser(testUid).catch(() => {});
44
+ await pollUntilGone(userRef);
45
+ },
46
+ },
47
+
48
+ {
49
+ name: 'no-wait-gets-clobbered',
50
+ async run({ Manager, assert }) {
51
+ const admin = Manager.libraries.admin;
52
+ const testUid = '_test-race-no-wait';
53
+ const testEmail = '_test.race-no-wait@test.com';
54
+ const userRef = admin.firestore().doc(`users/${testUid}`);
55
+
56
+ await admin.auth().deleteUser(testUid).catch(() => {});
57
+ await pollUntilGone(userRef);
58
+
59
+ // Create and wait for doc to be fully ready
60
+ await admin.auth().createUser({
61
+ uid: testUid, email: testEmail,
62
+ password: uuid.v4(), emailVerified: true,
63
+ });
64
+ assert.ok(await pollUntilReady(userRef), 'First create should produce api keys');
65
+
66
+ // Delete auth user — do NOT wait for doc to disappear — then recreate
67
+ await admin.auth().deleteUser(testUid);
68
+ await admin.auth().createUser({
69
+ uid: testUid, email: testEmail,
70
+ password: uuid.v4(), emailVerified: true,
71
+ });
72
+ await pollUntilReady(userRef);
73
+
74
+ // The late on-delete clobbers the doc within a few seconds
75
+ await sleep(3000);
76
+ const doc = await userRef.get();
77
+ const survived = doc.exists && !!(doc.data()?.api?.clientId);
78
+
79
+ assert.ok(!survived, 'Doc should be clobbered by late on-delete (proves the race exists)');
80
+
81
+ await admin.auth().deleteUser(testUid).catch(() => {});
82
+ await pollUntilGone(userRef);
83
+ },
84
+ },
85
+
86
+ {
87
+ name: 'wait-for-gone-survives',
88
+ async run({ Manager, assert }) {
89
+ const admin = Manager.libraries.admin;
90
+ const testUid = '_test-race-wait-gone';
91
+ const testEmail = '_test.race-wait-gone@test.com';
92
+ const userRef = admin.firestore().doc(`users/${testUid}`);
93
+
94
+ await admin.auth().deleteUser(testUid).catch(() => {});
95
+ await pollUntilGone(userRef);
96
+
97
+ await admin.auth().createUser({
98
+ uid: testUid, email: testEmail,
99
+ password: uuid.v4(), emailVerified: true,
100
+ });
101
+ assert.ok(await pollUntilReady(userRef), 'First create should produce api keys');
102
+
103
+ // Delete and WAIT for doc to disappear before recreating
104
+ await admin.auth().deleteUser(testUid);
105
+ await pollUntilGone(userRef);
106
+
107
+ await admin.auth().createUser({
108
+ uid: testUid, email: testEmail,
109
+ password: uuid.v4(), emailVerified: true,
110
+ });
111
+ assert.ok(await pollUntilReady(userRef), 'Second create should produce api keys');
112
+
113
+ await sleep(3000);
114
+ const doc = await userRef.get();
115
+ const survived = doc.exists && !!(doc.data()?.api?.clientId);
116
+
117
+ assert.ok(survived, 'Doc should survive when we wait for on-delete to settle first');
118
+
119
+ await admin.auth().deleteUser(testUid).catch(() => {});
120
+ await pollUntilGone(userRef);
121
+ },
122
+ },
123
+
124
+ {
125
+ name: 'force-delete-doc-survives',
126
+ async run({ Manager, assert }) {
127
+ const admin = Manager.libraries.admin;
128
+ const testUid = '_test-race-force-del';
129
+ const testEmail = '_test.race-force-del@test.com';
130
+ const userRef = admin.firestore().doc(`users/${testUid}`);
131
+
132
+ await admin.auth().deleteUser(testUid).catch(() => {});
133
+ await pollUntilGone(userRef);
134
+
135
+ await admin.auth().createUser({
136
+ uid: testUid, email: testEmail,
137
+ password: uuid.v4(), emailVerified: true,
138
+ });
139
+ assert.ok(await pollUntilReady(userRef), 'First create should produce api keys');
140
+
141
+ // Delete auth user AND force-delete the Firestore doc, then pause
142
+ await admin.auth().deleteUser(testUid);
143
+ await userRef.delete().catch(() => {});
144
+ await sleep(500);
145
+
146
+ await admin.auth().createUser({
147
+ uid: testUid, email: testEmail,
148
+ password: uuid.v4(), emailVerified: true,
149
+ });
150
+ assert.ok(await pollUntilReady(userRef), 'Second create should produce api keys');
151
+
152
+ await sleep(3000);
153
+ const doc = await userRef.get();
154
+ const survived = doc.exists && !!(doc.data()?.api?.clientId);
155
+
156
+ assert.ok(survived, 'Doc should survive when we force-delete the doc before recreating');
157
+
158
+ await admin.auth().deleteUser(testUid).catch(() => {});
159
+ await pollUntilGone(userRef);
160
+ },
161
+ },
162
+ ],
163
+ };
164
+
165
+ function sleep(ms) {
166
+ return new Promise(resolve => setTimeout(resolve, ms));
167
+ }
168
+
169
+ async function pollUntilReady(userRef, maxWait = 15000) {
170
+ const interval = 300;
171
+ let waited = 0;
172
+
173
+ while (waited < maxWait) {
174
+ const doc = await userRef.get();
175
+ const data = doc.exists ? doc.data() : null;
176
+ if (data?.metadata?.tag === 'auth:on-create' && data.api?.clientId && data.api?.privateKey) {
177
+ return true;
178
+ }
179
+ await sleep(interval);
180
+ waited += interval;
181
+ }
182
+
183
+ return false;
184
+ }
185
+
186
+ async function pollUntilGone(userRef, maxWait = 10000) {
187
+ const interval = 200;
188
+ let waited = 0;
189
+
190
+ while (waited < maxWait) {
191
+ const doc = await userRef.get();
192
+ if (!doc.exists) {
193
+ return true;
194
+ }
195
+ await sleep(interval);
196
+ waited += interval;
197
+ }
198
+
199
+ await userRef.delete().catch(() => {});
200
+ return false;
201
+ }
@@ -346,5 +346,38 @@ module.exports = {
346
346
  assert.equal(out.message?.content, 'hello', 'message carries last user turn');
347
347
  },
348
348
  },
349
+
350
+ {
351
+ name: 'normalize-options-structured-system-content-as-array-injects-rules',
352
+ async run({ assert }) {
353
+ const messages = [
354
+ {
355
+ role: 'system',
356
+ content: [
357
+ { type: 'text', text: 'existing instruction' },
358
+ { type: 'image', source: { type: 'url', url: 'https://example.com/img.png' } },
359
+ ],
360
+ },
361
+ { role: 'user', content: 'check order 1' },
362
+ { role: 'assistant', toolCalls: [{ id: 'c1', name: 'check_order', arguments: {} }] },
363
+ { role: 'tool', toolCallId: 'c1', content: 'shipped' },
364
+ ];
365
+ const out = normalizeOptions({ messages });
366
+
367
+ assert.equal(out.messages.length, 4, 'no turns added or dropped');
368
+ assert.equal(Array.isArray(out.messages[0].content), true, 'system content stays as array');
369
+ assert.equal(out.messages[0].content[0].type, 'text', 'rules prepended as text block');
370
+ assert.equal(
371
+ out.messages[0].content[0].text.includes(SYSTEM_PROMPT_INJECTIONS[0]),
372
+ true,
373
+ 'system rules injected into prepended text block',
374
+ );
375
+ assert.equal(out.messages[0].content[1].type, 'text', 'original text block preserved');
376
+ assert.equal(out.messages[0].content[1].text, 'existing instruction', 'original text content intact');
377
+ assert.equal(out.messages[0].content[2].type, 'image', 'original image block preserved');
378
+ assert.deepEqual(out.messages[2], messages[2], 'toolCalls turn untouched');
379
+ assert.deepEqual(out.messages[3], messages[3], 'tool result turn untouched');
380
+ },
381
+ },
349
382
  ],
350
383
  };
@@ -246,10 +246,13 @@ module.exports = {
246
246
  // Should fail - consent is protected (only signup route + webhook
247
247
  // processors can mutate it server-side; a client write would let a
248
248
  // user retroactively forge their own consent record).
249
+ // Use a value that can't match any prior state — earlier tests
250
+ // (email-preferences) may have set marketing.status to 'granted',
251
+ // and writing the SAME value is a no-op the rules correctly allow.
249
252
  await rules.expectFailure(
250
253
  db.doc(`users/${uid}`).set({
251
254
  consent: {
252
- marketing: { status: 'granted' },
255
+ marketing: { status: 'forged' },
253
256
  },
254
257
  }, { merge: true })
255
258
  );