Adds a read-only AccountQuotaRow component that fetches cached quota data
from GET /api/usage/provider-limits (same endpoint as ProviderLimits.tsx)
and renders a compact per-connection quota summary (% remaining + reset
countdown) inside each PoolCard, below the dimensions section and before
the BurnRateChart. Fail-softs to "—" on error / empty / loading state.
A pool with N same-type connections now has an effective budget of
perAccountLimit × N per dimension. Only the limit fed to fair-share
scales — consumption (pool-keyed shared bucket) is unchanged.
Replace the (connId × modelId) upsert loop in syncQuotaCombos with a
model-grouped build: one combo per model with ALL connections as steps
and strategy "fill-first", fixing the collision where a second same-provider
connection overwrote the first (last upsert won, only one account ever used).
- Add assertSingleProvider() helper in quotaPools.ts: queries provider_connections
with DISTINCT provider WHERE id IN (...), throws if >1 provider detected.
- Call guard early in createPool (when connectionIds.length > 1) and updatePool
(when input.connectionIds.length > 1), before any DB writes.
- Add lockedProvider useMemo in PoolWizard.tsx: derived from first selected
connection; availableConnections filtered to same provider once locked.
- Show wizardSingleProviderNote i18n hint in step 1 when lockedProvider is set.
- Add wizardSingleProviderNote to en.json + pt-BR.json (parity).
- New test: tests/unit/quota-pool-single-provider.test.ts (4 cases).
- Update tests/unit/quota-multiprovider.test.ts: all D2 tests now use two
same-provider connections (both PROVIDER_A/openrouter) — the tests were
exercising connection-plumbing (scope, enforce membership, combo fan-out),
not mixed-provider behavior per se; updated to remain valid under Task 3 rule.
GET /api/quota/pools/[id]/usage returns { usage: snapshot }, but usePoolUsage
stored the whole wrapper, leaving usage.dimensions undefined. StackedAllocationBar
then dereferenced usage.dimensions[dimensionIndex] (undefined[0]) and crashed the
entire quota-share page for any pool that has allocations. Unwrap data.usage in the
hook + defensively guard usage.dimensions?.[i] / dim.perKey ?? [] in the bar.
server-ws.mjs imports ./peer-stamp.mjs but the npm-pack path didn't include it:
prepublish.ts didn't copy it and pack-artifact-policy pruned it (not in the
app/ allowlist). Without it the WS wrapper throws ERR_MODULE_NOT_FOUND on boot
and the peer-IP stamp (authz LOCAL_ONLY locality) never runs. Copy it in
prepublish + allowlist + mark required so a regression fails the pack.
Satisfies the env/docs contract check (the per-process peer-stamp secret is
referenced in code; the auto-loader generates it, so it's documented as an
optional/auto var alongside OMNIROUTE_WS_BRIDGE_SECRET).
D2 moved poolSlug collection outside the connection loop, so a pool whose only
connection was deleted still leaked its slug into resolveQuotaKeyScope — that
slug has no quotaShared-* models behind it. Gate the slug on >=1 valid member
connection (restores Phase-A2 behavior for multi-connection pools).
Docker runs the Next standalone build (run-standalone.mjs), which now prefers
server-ws.mjs — but build-next-isolated.mjs only copied run-standalone.mjs, not
the WS wrapper or its deps, so Docker fell back to bare server.js with no peer
stamp (LOCAL_ONLY routes 403, CLI token broken — fail-closed but unusable).
Co-locate all three at the standalone root so the peer stamp runs in Docker too.
(The npm package path already ships them via prepublish.ts.)
Adversarial review of the peer-IP fix surfaced: (1) CRITICAL — cliTokenAuth.ts
still derived loopback from new URL(request.url).hostname (the same spoofable
Host class), letting a remote caller with a stolen CLI token reach management
APIs via Host: 127.0.0.1; now it trusts the middleware-stamped locality verdict
(AUTHZ_HEADER_PEER_LOCALITY, a client-stripped trusted header). (2) HIGH —
isLoopbackHost mangled bare IPv6 (::1, ::ffff:127.0.0.1) via split(":")[0],
a fail-closed DoS on IPv6 deploys. (3) HIGH — the Docker entrypoint ran bare
server.js (no peer stamp); run-standalone.mjs now prefers server-ws.mjs.
The middleware runtime exposes no socket, so a prior fix derived LOCAL_ONLY
locality from the Host header — letting a remote caller send Host: 127.0.0.1
and reach spawn-capable routes (RCE class). The custom Node servers now stamp
the real socket.remoteAddress into a token-signed internal header; the policy
trusts only a stamp whose token matches this process's secret, and fails closed
otherwise. Preserves the owner-authorized loopback + private-LAN access without
trusting any client-controlled header.
- PoolCreateSchema: add optional connectionIds[] + .refine() that enforces primary membership
- PoolWizard: replace single-select dropdown with checkbox multi-select; first checked = primary (badge); step-2 adds helper note for additional connections; step-3 preview grouped by provider with +N more; POST body sends both connectionId and connectionIds
- PoolCard: optional providers[] prop renders a row of ProviderIcon (up to 3 + badge) instead of a single icon when pool has multiple connections
- i18n: 4 new keys added to both en.json and pt-BR.json (wizardConnectionsLabel, wizardPrimaryBadge, wizardAdditionalConnectionsNote, wizardPreviewMoreModels) — parity maintained (23 wizard keys each)
- Tests: quota-pool-wizard-multi.test.ts (21 tests) covering schema accept/reject, structural wizard assertions, and i18n parity
- quotaKey.ts (resolveQuotaKeyScope): iterate pool.connectionIds (fall back to
[connectionId] for un-backfilled rows); each connection contributes its own
connId + provider to the scope. poolSlugs logic unchanged (one slug per pool).
- quotaCombos.ts (syncQuotaCombos): replace single-connection resolvePoolProvider
with resolvePoolForSync that returns all connectionIds; iterate each connId to
build the desiredNames union across all providers; upsert combos pinned to the
CORRECT per-connection connId; prune against the full union so only truly stale
combos (no longer produced by any current connection) are deleted.
- enforce.ts (enforceQuotaShare + recordConsumption): both pool-matching loops
changed from equality (p.connectionId === input.connectionId) to membership
(p.connectionIds.includes) with fallback for un-backfilled rows. Fail-open
(B16) and pool-level dimension key semantics are preserved unchanged.
- quotaPools.ts: no logic change needed — connectionIds already flows through
getPool (D1); syncQuotaCombosGuarded passes poolId and syncQuotaCombos
resolves the full QuotaPool internally.
- tests/unit/quota-multiprovider.test.ts: 6 new tests covering D2 (scope,
enforce primary/secondary membership, combos 2-provider create + prune).
All 22 tests pass (14 new + 8 existing enforce + pool-connections suites).
Adds migration 086 to create the `quota_pool_connections` join table with a backfill
that seeds every existing pool's single connection_id as its first member. Updates
QuotaPool type with `connectionIds: string[]`, wires createPool/updatePool/deletePool
to maintain the join table transactionally, and keeps `connection_id` as the primary
back-compat column synced to `connectionIds[0]`.
Keys with non-empty allowedQuotas may only use models whose provider belongs
to their pools' provider set; anything outside → 403 QUOTA_ONLY.
Normal allowedModels/allowedCombos checks are bypassed for quota-exclusive keys.
Introduces src/lib/quota/quotaKey.ts with resolveQuotaKeyScope(), a
pure async helper that maps an API key's allowedQuotas pool-ID list to
the concrete connectionIds and provider slugs it is permitted to use.
Covers empty/null/undefined input, missing pools, orphaned connectionIds,
and multi-pool deduplication. No behaviour change to existing code paths.
The authz pipeline runs in the Next middleware runtime (proxy.ts -> runAuthzPipeline)
where ctx.request is a NextRequest with no .socket/.ip. requestPeerAddress therefore
returned null, so isLoopbackRequest was ALWAYS false and every LOCAL_ONLY path 403'd
even from loopback (Services/MCP/Traffic-Inspector were unusable). Read the Host
header instead — exactly what isLoopbackHost/isPrivateLanHost were built to parse —
which restores loopback and, combined with isPrivateLanHost, enables the
owner-authorized private-LAN access. Spawn-capable endpoints still require
manage-scope auth after this gate.
Services + Traffic-Inspector (LOCAL_ONLY, spawn-capable) returned 403 when the
dashboard was reached via the LAN IP (192.168.0.x) instead of loopback. Add
isPrivateLanHost (RFC1918 IPv4 + IPv6 ULA/link-local) and widen ONLY the
local-only PATH gate to accept private-LAN socket peer IPs — based on the real
socket peer address (not the spoofable Host header), so public-internet clients
present public IPs and stay blocked. The CLI-token gate stays strictly loopback;
paths remain LOCAL_ONLY-classified (Hard Rules 15/17 unchanged). Enforcement-layer
carve-out for a LAN-deployed instance, authorized by the operator.
The pool usage snapshot can come back without a dimensions array (e.g. when the
plan resolves to empty for catalog-only providers). PoolCard.computeStatus and
hasDimensions read usage.dimensions.length directly, crashing the whole page
("Cannot read properties of undefined (reading 'length')"). Normalize to [] in
PoolCard and in usePoolsUsageAggregate (dimensions/perKey).
- Capture full search results (title/url/snippet) per provider, not just URLs.
- Render one column per selected provider: metrics header + result list
(title link, snippet, url), horizontal scroll for N providers.
- Mark results whose URL appears across providers with a star (overlap).
- Remove the 4-provider cap (MAX_PROVIDERS); add Select all / Clear; compare
every configured provider. Raise max_results 5 -> 10.
- Chat (StudioConfigPane): add Provider + Model selects reusing the translator
hooks; order Endpoint -> Provider -> Model; ConfigState gains optional provider.
- Compare (CompareTab): add a user-prompt input and include it in the request
body; throttle per-column stream updates via requestAnimationFrame to stop the
UI freeze (was setColumns per chunk x N columns with an empty user message).
- Build (BuildTab + build/BuildWizard): redesign as a guided 3-step wizard
(What to test -> Configure -> Run) reusing ToolsBuilder/StructuredOutputEditor;
all run/tool-call handlers preserved.
- i18n: playground.build.* (18 keys) in en + pt-BR.
- MemoriesTab: auto-run health check on mount + poll every 30s (was manual-only).
- page: add enable/disable memory toggle (role=switch) via useMemorySettings.save({ enabled }).
- MemoryEngineStatus: show "npm install sqlite-vec" hint when vector store backend is "none".
- i18n: memory.memoryEnabled + memory.engine.vectorStoreInstallHint (en + pt-BR).
- Add compliance.eventTypes (36 labels) to en.json + pt-BR.json.
- ComplianceTab: render translated label via t.has/t fallback instead of raw entry.action.
- A2aAuditTab: render translated task state via a2aState* keys instead of raw task.state.
- Memory type/strategy dropdowns needed no i18n change — keys already exist; the
Phase 1 Select fix makes them render.
- search-tools: export modal no longer opens by default / stuck — guard on
exportOpen and drop the invalid isOpen prop the modal never read.
- logs: remove duplicate proxy/console tabs + SegmentedControl (dedicated
/dashboard/logs/proxy and /console pages already exist in the menu).
- memory: order tabs Memories -> Engine -> Playground.
- ui(Select): render children and suppress the placeholder/options branch when
children are provided — fixes the "empty" memory type/strategy dropdowns
(children were being shadowed by the component's own option list).
- test: source-level regression guards for all four.
The /dashboard/tools/agent-bridge page (Server Component) passed ALL_TARGETS
directly to AgentBridgePageClient (a Client Component). Each MitmTarget carries
a `handler: () => Promise<...>` function, which Next.js forbids across the
Server/Client boundary, raising at SSR time:
"Functions cannot be passed directly to Client Components ..."
This broke the whole page ("erro ao carregar").
Fix: introduce MitmTargetView = Omit<MitmTarget, "handler"> and pass a
sanitized array (ALL_TARGETS.map(({ handler, ...rest }) => rest)). The UI never
invokes handler, so behavior is unchanged. Adds a regression test asserting the
sanitized targets are function-free and JSON-serializable.
Remove the AuditLogTab from the dashboard logs page now that audit logs
live under the dedicated /dashboard/audit route. Update integration wiring
expectations and add metadata frontmatter to studio framework docs.