From b090b601a5d0e23d518b77b56279db42ed600f11 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 8 Aug 2026 00:08:57 -0300 Subject: [PATCH 1/5] fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13 (with monaco-editor scoped override). Closes Dependabot #189, #190. Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge — awaiting Dependabot re-scan. npm audit → 0 vulnerabilities. --- package-lock.json | 14 +++++++------- package.json | 7 +++++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 71c0d85d8fc..c061366549a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "clsx": "^2.1.1", "commander": "^15.0.0", "csv-stringify": "^6.7.0", - "dompurify": "^3.4.12", + "dompurify": "^3.4.13", "express": "^5.2.1", "fetch-socks": "^1.3.3", "fflate": "^0.8.3", @@ -17064,9 +17064,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -27576,9 +27576,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index a9904548e03..6709d273479 100644 --- a/package.json +++ b/package.json @@ -256,7 +256,7 @@ "clsx": "^2.1.1", "commander": "^15.0.0", "csv-stringify": "^6.7.0", - "dompurify": "^3.4.12", + "dompurify": "^3.4.13", "express": "^5.2.1", "fetch-socks": "^1.3.3", "fflate": "^0.8.3", @@ -395,7 +395,6 @@ ] }, "overrides": { - "dompurify": "^3.4.12", "fast-xml-parser": "^5.10.1", "sharp": "^0.35.0", "postcss": "^8.5.18", @@ -454,6 +453,10 @@ }, "xmlbuilder2": { "js-yaml": "^4.3.1" + }, + "nanoid": "^3.3.17", + "monaco-editor": { + "dompurify": "^3.4.13" } } } From 918fba5e392ce8b137976349f035597196edc440 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 8 Aug 2026 01:18:49 -0300 Subject: [PATCH 2/5] fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks) _tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential _tasks symlink can slip in via git add -A and, once pulled, checkout materializes it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks ignores the symlink too, preventing re-capture. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 4a0b192e9cb..f2738f3aa70 100644 --- a/.gitignore +++ b/.gitignore @@ -250,3 +250,8 @@ tests/homolog/.auth/ tests/homolog/ui/.auth/ homolog-report/ docker-compose.yml.bak + +# _tasks e um repo git SEPARADO (ver AGENTS.md). A linha _tasks/ (com barra) NAO +# ignora um SYMLINK chamado _tasks; /_tasks (ancorado) cobre arquivo/symlink/dir na raiz +# e impede que um git add -A recapture o symlink (incidente 2026-08-08). +/_tasks From 5f0a39409111ab2d67d3feea54e50e4537271090 Mon Sep 17 00:00:00 2001 From: ritheshcn25 Date: Thu, 13 Aug 2026 00:43:38 -0300 Subject: [PATCH 3/5] Hide health-check excluded models from /v1/models catalog (#10026) Mirror the request-time exclusion rule (provider_specific_data.excludedModels) in the unified catalog builder: a model is hidden when its provider has connections but none of them is eligible for it. Applied across the PROVIDER_MODELS, synced, custom, alias-backed, and managed-fallback loops so ghost models no longer appear as available. Co-authored-by: ritheshcn25 --- src/app/api/v1/models/catalog.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 34ccbdfbaf3..07d29b07a89 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -332,6 +332,18 @@ async function buildUnifiedModelsResponseCore( return collected; }; + // Health-check exclusions (provider_specific_data.excludedModels) are enforced + // at request time in getProviderCredentials(); mirror the same rule in the + // catalog so ghost models do not appear as available. A model is hidden when + // the provider HAS connections but NONE of them is eligible for it. + const isExcludedByProviderConnections = (providerKey: string, modelId: string) => { + const providerId = aliasToProviderId[providerKey] || providerKey; + const alias = providerIdToAlias[providerId] || providerKey; + const providerConnections = getConnectionsForProvider(providerId, alias, providerKey); + if (providerConnections.length === 0) return false; // noAuth / no DB row: keep + return !hasEligibleConnectionForModel(providerConnections, modelId); + }; + const providerSupportsModel = (providerKey: string, modelId: string) => { const providerId = aliasToProviderId[providerKey] || providerKey; const alias = providerIdToAlias[providerId] || providerKey; @@ -703,6 +715,7 @@ async function buildUnifiedModelsResponseCore( if (!providerSupportsModel(canonicalProviderId, model.id)) continue; const aliasId = `${alias}/${model.id}`; if (getModelIsHidden(canonicalProviderId, model.id)) continue; + if (isExcludedByProviderConnections(canonicalProviderId, model.id)) continue; if (shouldHidePaid(canonicalProviderId, model.id, (model as { pricing?: unknown }).pricing)) continue; @@ -797,6 +810,7 @@ async function buildUnifiedModelsResponseCore( continue; } if (getModelIsHidden(providerId, sm.id)) continue; + if (isExcludedByProviderConnections(canonicalProviderId, sm.id)) continue; // #6457: some upstream discovery catalogs (e.g. HuggingFace's live // `/v1/models`) return image/diffusion models with no modality info, // so `endpoints` below would default to ["chat"] and misrepresent @@ -1173,6 +1187,7 @@ async function buildUnifiedModelsResponseCore( if (!modelId) continue; if (model.isHidden === true) continue; if (getModelIsHidden(canonicalProviderId, modelId)) continue; + if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue; // #6328: apply hidePaidModels to user-defined custom rows too. // Custom entries do not carry pricing, so shouldHidePaid() decides // via FREE_MODEL_IDS_BY_PROVIDER — matches synced/PROVIDER_MODELS. @@ -1305,6 +1320,7 @@ async function buildUnifiedModelsResponseCore( } if (getModelIsHidden(canonicalProviderId, modelId)) continue; + if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue; // #6328: apply hidePaidModels to alias-backed rows too. Alias mappings // point at providerKey/modelId with no pricing, so shouldHidePaid() // decides via the FREE_MODEL_IDS_BY_PROVIDER catalog tier. @@ -1377,6 +1393,7 @@ async function buildUnifiedModelsResponseCore( const modelId = typeof model.id === "string" ? model.id : null; if (!modelId) continue; if (getModelIsHidden(canonicalProviderId, modelId)) continue; + if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue; // #6328: apply hidePaidModels to managed-fallback rows too. Compatible // provider fallbacks lack pricing; shouldHidePaid() decides via the // FREE_MODEL_IDS_BY_PROVIDER catalog tier. From ca23eed77cd19476141b3a39c74abee403203a68 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:45:27 -0700 Subject: [PATCH 4/5] fix(models): memoize getModelsDevPricing (event loop / healthz) (#10055) * fix(models): memoize getModelsDevPricing for /v1/models catalog resolveCatalogPricing called getModelsDevPricing once per model while building GET /v1/models. Each call re-scanned models_dev_pricing and JSON.parsed every row (~10k SQL scans + multi-GB parse work), pegging the event loop so even /healthz timed out (#9685, #10052). Memoize the parsed map until saveModelsDevPricing / clearModelsDevPricing and add a unit test for invalidation. Signed-off-by: Ravi Tharuma * fix(db): invalidate modelsDevPricing cache on DB reset (#10055) Copilot review fixes: 1. Register invalidateModelsDevPricingCache() with DB state reset system so resetDbInstance() clears the process-local memo, preventing stale pricing data from surviving across DB reset/restore operations. 2. Add test assertion verifying DB reset bypasses the memo (Copilot #10055). The process-local memo at modelsDevSync.ts:204 caches getModelsDevPricing() results until saveModelsDevPricing()/clearModelsDevPricing() to avoid re-scanning all pricing rows on every /v1/models request. Without this hook, backup restore and test DB resets would serve stale cached data from the previous connection. Tests: npm run test:unit:serial -- tests/unit/modelsDevSync-extended.test.ts --------- Signed-off-by: Ravi Tharuma Co-authored-by: Ravi Tharuma Co-authored-by: Cursor Agent --- package-lock.json | 101 +++------------------- src/lib/modelsDevSync.ts | 26 ++++++ tests/unit/modelsDevSync-extended.test.ts | 39 +++++++++ 3 files changed, 76 insertions(+), 90 deletions(-) diff --git a/package-lock.json b/package-lock.json index c061366549a..c7308935ae9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3692,9 +3692,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3711,9 +3708,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3730,9 +3724,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3749,9 +3740,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3768,9 +3756,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3787,9 +3772,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3806,9 +3788,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3825,9 +3804,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3844,9 +3820,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3869,9 +3842,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3894,9 +3864,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3919,9 +3886,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3944,9 +3908,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3969,9 +3930,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3994,9 +3952,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4019,9 +3974,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5393,9 +5345,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5412,9 +5361,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5431,9 +5377,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5450,9 +5393,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10670,9 +10610,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10690,9 +10627,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10710,9 +10644,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10730,9 +10661,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -12779,9 +12707,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "optional": true, "os": [ "linux" @@ -12795,9 +12720,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -12811,9 +12733,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "optional": true, "os": [ "linux" @@ -12827,9 +12746,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -12843,9 +12759,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "optional": true, "os": [ "linux" @@ -12859,9 +12772,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -24477,6 +24387,17 @@ "node": ">= 14" } }, + "node_modules/libxmljs2/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/libxmljs2/node_modules/cacache": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", diff --git a/src/lib/modelsDevSync.ts b/src/lib/modelsDevSync.ts index 8c34135feb9..b433fa66745 100644 --- a/src/lib/modelsDevSync.ts +++ b/src/lib/modelsDevSync.ts @@ -20,6 +20,7 @@ import { getDbInstance } from "./db/core"; import { invalidateDbCache } from "./db/readCache"; import { backupDbFile } from "./db/backup"; +import { registerDbStateResetter } from "./db/stateReset"; import { transformModelsDevToPricing, @@ -193,10 +194,32 @@ function mapCapabilityRecord(record: Record): ModelCapabilityEn }; } +/** + * Process-local memo for models.dev pricing. + * + * `resolveCatalogPricing` used to call `getModelsDevPricing()` once per model + * while building `/v1/models` (~10k+ times). Each call re-ran the full SQL scan + * and `JSON.parse`d every pricing row, pegging the event loop for minutes + * (see #9685 / #10052). Memoize until the next save/clear write. + */ +let modelsDevPricingCache: PricingByProvider | null = null; + +function invalidateModelsDevPricingCache(): void { + modelsDevPricingCache = null; +} + +// Register cache invalidation with DB state reset system so resetDbInstance() clears the memo. +registerDbStateResetter(invalidateModelsDevPricingCache); + /** * Read synced pricing from `models_dev_pricing` namespace. + * Results are memoized until `saveModelsDevPricing` / `clearModelsDevPricing`. */ export function getModelsDevPricing(): PricingByProvider { + if (modelsDevPricingCache) { + return modelsDevPricingCache; + } + const db = getDbInstance(); const rows = db .prepare("SELECT key, value FROM key_value WHERE namespace = 'models_dev_pricing'") @@ -213,6 +236,7 @@ export function getModelsDevPricing(): PricingByProvider { console.warn(`[MODELS_DEV] Corrupted pricing data for provider "${key}", skipping`); } } + modelsDevPricingCache = synced; return synced; } @@ -233,6 +257,7 @@ export function saveModelsDevPricing(data: PricingByProvider): void { }); tx(); backupDbFile("pre-write"); + invalidateModelsDevPricingCache(); invalidateDbCache("pricing"); } @@ -243,6 +268,7 @@ export function clearModelsDevPricing(): void { const db = getDbInstance(); db.prepare("DELETE FROM key_value WHERE namespace = 'models_dev_pricing'").run(); backupDbFile("pre-write"); + invalidateModelsDevPricingCache(); invalidateDbCache("pricing"); } diff --git a/tests/unit/modelsDevSync-extended.test.ts b/tests/unit/modelsDevSync-extended.test.ts index 9b96a8440b6..cd759199182 100644 --- a/tests/unit/modelsDevSync-extended.test.ts +++ b/tests/unit/modelsDevSync-extended.test.ts @@ -260,6 +260,45 @@ test("modelsDev pricing helpers persist records, skip corrupted rows, and clear assert.deepEqual(modelsDev.getModelsDevPricing(), {}); }); +test("getModelsDevPricing memoizes until save/clear (#9685)", async () => { + const modelsDev = await importFresh("pricing-memo"); + const pricing = modelsDev.transformModelsDevToPricing(MOCK_MODELS_DEV_DATA); + modelsDev.saveModelsDevPricing(pricing); + + const first = modelsDev.getModelsDevPricing(); + const second = modelsDev.getModelsDevPricing(); + assert.equal(first, second, "repeated reads must return the same memoized object"); + + // Mutating DB under the cache must not be visible until invalidation. + const db = core.getDbInstance(); + db.prepare("DELETE FROM key_value WHERE namespace = 'models_dev_pricing'").run(); + assert.equal( + modelsDev.getModelsDevPricing(), + first, + "raw SQL without save/clear must not bypass the memo" + ); + + modelsDev.clearModelsDevPricing(); + assert.deepEqual(modelsDev.getModelsDevPricing(), {}); + + modelsDev.saveModelsDevPricing(pricing); + const afterSave = modelsDev.getModelsDevPricing(); + assert.notEqual(afterSave, first, "save must invalidate the memo"); + assert.equal(afterSave.openai["gpt-4o"].input, 2.5); + + // Copilot review: DB reset must invalidate the memo so import/restore doesn't serve stale pricing. + const beforeReset = modelsDev.getModelsDevPricing(); + core.resetDbInstance(); + const afterReset = modelsDev.getModelsDevPricing(); + assert.notEqual( + afterReset, + beforeReset, + "resetDbInstance must invalidate the memo (Copilot #10055)" + ); + // Data is still on disk after resetDbInstance(), but the cache was cleared and re-read from fresh DB. + assert.equal(afterReset.openai["gpt-4o"].input, 2.5, "DB reset re-reads from fresh connection"); +}); + test("modelsDev capabilities helpers create the table, persist rows, filter by provider/model, and expose context limits", async () => { const modelsDev = await importFresh("capabilities-storage"); const capabilities = modelsDev.transformModelsDevToCapabilities(MOCK_MODELS_DEV_DATA); From 40c1204b9f531d4dda01a431eb1e1c717957a6a7 Mon Sep 17 00:00:00 2001 From: Damian Pozimski Date: Thu, 13 Aug 2026 13:10:39 +0200 Subject: [PATCH 5/5] fix(api): scale pool usage snapshot limits by member count (summed budget) --- src/app/api/quota/pools/[id]/usage/route.ts | 27 +++- .../quota-pool-usage-summed-budget.test.ts | 146 ++++++++++++++++++ 2 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 tests/unit/quota-pool-usage-summed-budget.test.ts diff --git a/src/app/api/quota/pools/[id]/usage/route.ts b/src/app/api/quota/pools/[id]/usage/route.ts index 35ef47cd93e..cb03f4a04cc 100644 --- a/src/app/api/quota/pools/[id]/usage/route.ts +++ b/src/app/api/quota/pools/[id]/usage/route.ts @@ -1,8 +1,10 @@ /** * GET /api/quota/pools/[id]/usage — pool consumption snapshot with dimensions * - * Resolves the pool's provider plan to get dimensions, then calls - * poolUsageWithDimensions on the QuotaStore interface. + * Resolves the pool's provider plan to get dimensions, scales each dimension + * limit by the pool's member-connection count (the same summed budget + * enforce.ts applies), then calls poolUsageWithDimensions on the QuotaStore + * interface. * * Auth: requireManagementAuth * Sanitization: all error responses via buildErrorBody (Hard Rule #12, B25) @@ -43,12 +45,27 @@ export async function GET(request: Request, { params }: RouteParams): Promise 0 + ? pool.connectionIds.length + : 1; + const effectiveDimensions = plan.dimensions.map((dim) => ({ + ...dim, + limit: dim.limit * accountCount, + })); + + // 4. Get the quota store and call poolUsageWithDimensions (on the interface since v3.8.12) const store = await getQuotaStore(); let snapshot: PoolUsageSnapshot; - if (plan.dimensions.length > 0) { - snapshot = await store.poolUsageWithDimensions(id, plan.dimensions); + if (effectiveDimensions.length > 0) { + snapshot = await store.poolUsageWithDimensions(id, effectiveDimensions); } else { // Fallback: no plan dimensions configured — return minimal snapshot snapshot = await store.poolUsage(id); diff --git a/tests/unit/quota-pool-usage-summed-budget.test.ts b/tests/unit/quota-pool-usage-summed-budget.test.ts new file mode 100644 index 00000000000..9d8791aea33 --- /dev/null +++ b/tests/unit/quota-pool-usage-summed-budget.test.ts @@ -0,0 +1,146 @@ +/** + * tests/unit/quota-pool-usage-summed-budget.test.ts + * + * Regression: GET /api/quota/pools/[id]/usage reported per-account plan limits + * for multi-connection pools while enforce.ts scales every dimension by the + * pool's member-connection count (summed budget, see quota-summed-budget.test.ts). + * A pool with N connections therefore looked ~N× more utilised on the dashboard + * than enforcement actually allowed: a 27-connection pool at 3% real utilisation + * rendered as 81%, and per-key `borrowing` flags tripped N× too early. + * + * The fix scales plan.dimensions by accountCount in the usage route before + * calling poolUsageWithDimensions — the same multiply enforce.ts applies — so + * the snapshot's limit, fairShare, deficit and borrowing all describe the + * budget enforcement really uses. + * + * Levels: + * A (structural): the route computes accountCount with the enforce.ts + * fallback semantics and passes the scaled dimensions to the store. + * B (logic): replicate the store's per-key math to prove that scaling the + * dimension limit corrects fairShare and the borrowing flag for a pool + * shape where the unscaled snapshot misreports both. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(fileURLToPath(import.meta.url), "..", "..", ".."); +const read = (rel: string) => readFileSync(join(ROOT, rel), "utf8"); + +const ROUTE = "src/app/api/quota/pools/[id]/usage/route.ts"; + +// --------------------------------------------------------------------------- +// Level A — structural: the route applies the summed-budget multiply +// --------------------------------------------------------------------------- + +test("usage route computes accountCount with the enforce.ts fallback semantics", () => { + const src = read(ROUTE); + assert.ok( + /Array\.isArray\(pool\.connectionIds\)\s*&&\s*pool\.connectionIds\.length\s*>\s*0/.test(src), + "route must guard connectionIds exactly like enforce.ts" + ); + assert.ok( + /\?\s*pool\.connectionIds\.length\s*:\s*1/.test(src), + "route must fall back to accountCount = 1 for legacy pools without connectionIds" + ); +}); + +test("usage route scales every dimension limit by accountCount before calling the store", () => { + const src = read(ROUTE); + assert.ok( + /limit:\s*dim\.limit\s*\*\s*accountCount/.test(src), + "route must multiply dim.limit by accountCount" + ); + assert.ok( + /poolUsageWithDimensions\(\s*id,\s*effectiveDimensions\s*\)/.test(src), + "route must pass the scaled dimensions to poolUsageWithDimensions" + ); + assert.ok( + !/poolUsageWithDimensions\(\s*id,\s*plan\.dimensions\s*\)/.test(src), + "route must NOT pass the unscaled plan.dimensions to the store" + ); +}); + +test("usage endpoint still wraps the snapshot as { usage: snapshot }", () => { + const src = read(ROUTE); + assert.ok( + /NextResponse\.json\(\s*\{\s*usage:/.test(src), + "endpoint contract from quota-pool-usage-shape.test.ts must survive the fix" + ); +}); + +// --------------------------------------------------------------------------- +// Level B — logic: scaled limits correct fairShare and borrowing +// +// Replicates the per-key math from sqliteQuotaStore.poolUsageWithDimensions: +// fairShare = (weight / 100) × planDim.limit +// borrowing = consumed > fairShare +// for a 27-connection pool where one key consumed more than its per-account +// slice but far less than its share of the summed budget. +// --------------------------------------------------------------------------- + +test("summed-budget snapshot: fairShare and borrowing describe the enforced budget", () => { + const PER_ACCOUNT_LIMIT = 66.67; // per-connection plan limit (L) + const ACCOUNT_COUNT = 27; // pool members (N) + const WEIGHT = 3.97; // key's allocation weight (%) + const CONSUMED = 10.75; // above weight% × L, far below weight% × N × L + + const perKeySnapshot = (dimLimit: number) => { + const fairShare = (WEIGHT / 100) * dimLimit; + return { fairShare, borrowing: CONSUMED > fairShare }; + }; + + // Unscaled (the bug): the key looks like a borrower at 27× the real threshold. + const unscaled = perKeySnapshot(PER_ACCOUNT_LIMIT); + assert.ok( + unscaled.borrowing, + "sanity: against the per-account limit this consumption reads as borrowing" + ); + + // Scaled (the fix): same consumption sits comfortably inside the enforced fair share. + const scaled = perKeySnapshot(PER_ACCOUNT_LIMIT * ACCOUNT_COUNT); + assert.equal( + Math.round(scaled.fairShare * 100) / 100, + Math.round(((WEIGHT / 100) * PER_ACCOUNT_LIMIT * ACCOUNT_COUNT) * 100) / 100, + "fairShare must be weight% of the summed budget" + ); + assert.equal( + scaled.borrowing, + false, + "a key inside its summed-budget fair share must not be flagged as borrowing" + ); + + // Utilisation follows the same correction: consumedTotal / limit. + const CONSUMED_TOTAL = 54.09; + const shownUnscaled = CONSUMED_TOTAL / PER_ACCOUNT_LIMIT; + const shownScaled = CONSUMED_TOTAL / (PER_ACCOUNT_LIMIT * ACCOUNT_COUNT); + assert.ok(shownUnscaled > 0.8, "sanity: the bug rendered ~81% utilisation"); + assert.ok(shownScaled < 0.035, "the fix renders the real ~3% utilisation"); +}); + +test("summed-budget snapshot: single-connection and legacy pools are unchanged", () => { + const PER_ACCOUNT_LIMIT = 1000; + + const accountCount = (pool: { connectionIds?: string[] }) => + Array.isArray(pool.connectionIds) && pool.connectionIds.length > 0 + ? pool.connectionIds.length + : 1; + + assert.equal( + PER_ACCOUNT_LIMIT * accountCount({ connectionIds: ["conn-a"] }), + PER_ACCOUNT_LIMIT, + "1-connection pool: limit unchanged" + ); + assert.equal( + PER_ACCOUNT_LIMIT * accountCount({ connectionIds: [] }), + PER_ACCOUNT_LIMIT, + "empty connectionIds: fallback to 1" + ); + assert.equal( + PER_ACCOUNT_LIMIT * accountCount({}), + PER_ACCOUNT_LIMIT, + "legacy pool without connectionIds: fallback to 1" + ); +});