Skip to content

feat(limits): add untrusted pool quota for anonymous + external usage#16

Merged
albanm merged 6 commits into
mainfrom
feat-better-anonymous-limits
Jun 8, 2026
Merged

feat(limits): add untrusted pool quota for anonymous + external usage#16
albanm merged 6 commits into
mainfrom
feat-better-anonymous-limits

Conversation

@albanm

@albanm albanm commented Jun 8, 2026

Copy link
Copy Markdown
Member

Add an untrusted pool quota: an aggregate cap shared by all anonymous and
external usage combined, so untrusted traffic can't consume the whole account
budget. The gateway and summary endpoints now share one quota gate
(api/src/usage/enforce.ts) that enforces, in order: account-wide → untrusted
pool → per-user/per-IP.

Why: anonymous + external callers could each stay under their own per-user
limit yet collectively exhaust the account's budget. The pool caps them as a group.

What changed

  • New usage/enforce.ts (resolveUsageIdentity + enforceQuotas) shared by the
    gateway and summary routers, replacing the duplicated inline gates.
  • recordUsage also upserts a pool:untrusted aggregate; cleanup.ts retains
    pool aggregates on account-level retention; usage history hides pool:* rows.
  • New untrusted role-quota in the settings schema (regenerates the VJSF forms
    and put-req types).
  • Drive-by, unrelated to the feature (bundled, flake fixes that surfaced here):
    reqIp wrapper removal, a chat-subagent e2e de-flake, a trace-ordering fix, and
    a .claude/settings.json MCP-permission cleanup.

Regression risks

  • reqIp now throws (→500) on a request without a valid X-Forwarded-For
    instead of falling back to 127.0.0.1. Correct behind an enforced reverse
    proxy; the API tests set the header explicitly since they bypass nginx.
  • Opt-in & backward-compatible: untrusted is not required and the gate is
    guarded, so existing settings and accounts that don't set it are unaffected.
  • External users are now additionally capped by the shared pool (not just their
    per-user external quota) once it's configured.
  • cleanup.ts: pool:* aggregates move to the longer account-level retention;
    anon:* per-IP rows keep the short retention.

albanm and others added 6 commits June 8, 2026 11:10
Wildcard-only MCP names are not valid in permission allow rules, so the
"mcp__*" entry was being skipped (flagged by /doctor). Replace it with
valid per-server wildcards covering the vuetify, playwright-test and
context7 MCP servers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed pool quota

Adds a group-level 'untrusted' quota that caps anonymous and external usage
combined, sitting between the per-user role limits and the global account cap,
so untrusted public traffic can no longer exhaust the whole account budget.

- new 'untrusted' RoleQuota in settings schema + defaults (0 = off, back-compat)
- track a shared pool:untrusted usage aggregate in recordUsage
- extract shared resolveUsageIdentity + enforceQuotas into usage/enforce.ts,
  deduplicating the gateway and summary enforcement paths
- enforce order: global -> untrusted pool -> per-user
- exclude pool:* from per-user history; retain on account-level cleanup schedule

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
reqIp from lib-express works reliably when reverse-proxy configuration
is enforced, so the try/catch fallback masked misconfiguration rather
than guarding a real runtime edge case.

The anonymous API tests bypass nginx, so they now set the
X-Forwarded-For header themselves to mimic the reverse proxy that
reqIp requires.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The test asserted that the sub-agent panel stays auto-expanded showing
"Analysis complete" as a stable state. But this conversation ends on a
trailing "done" reply from the mock main-agent (its response to the
sub-agent's tool result), which by design collapses the sub-agent panel
once the turn "ends on text" (AgentChatMessages auto-open logic). The
sub-agent's final text renders at almost the same instant the panel
collapses, so "Analysis complete" is only visible for a sub-second
transient — the assertion passed only when Playwright happened to sample
that window, making it flaky under load.

Wait for the turn to fully settle (the trailing "done" message), then
expand the collapsed panel explicitly before asserting the chain ran.
The auto-expand-while-streaming behaviour remains covered by the
single-turn test in chat-subagent.e2e.spec.ts. Verified 12x with no
flakes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The trace overview sorts entries purely by timestamp. Session-setup
entries (the system prompt and the initial tool snapshot) are recorded
before the first turn starts, so their raw timestamps predate turns[0].
The system-prompt entry was already anchored to turns[0].timestamp, but
the initial tools-changed entry kept its earlier raw timestamp, so it
sorted above the system prompt. Tests only passed because, in a fast
unit run, snapshotTools and startTurn usually land in the same
millisecond and the stable sort preserves insertion order — but ~0.1%
of runs tick the millisecond between them, floating tools-changed to
index 0 and displacing the system prompt (proven by backdating the
snapshot 1ms).

Anchor any setup entry that predates the first turn to the first turn's
timestamp, so the preamble keeps a stable document order (system prompt
first) regardless of sub-millisecond timing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@albanm albanm merged commit 47ccafc into main Jun 8, 2026
4 checks passed
@albanm albanm deleted the feat-better-anonymous-limits branch June 8, 2026 15:07
albanm added a commit that referenced this pull request Jun 9, 2026
Reconcile the trace-storage work with main's quota refactor (#16) and
streaming parallel-tool-call fix (#17):
- gateway/summary routers adopt resolveUsageIdentity/enforceQuotas from
  usage/enforce.ts while keeping trace recording and the streamed
  tool-call capture; merged the per-id toolCallIndex with streamedToolCalls
- defaultQuotas (now incl. untrusted) stays centralized in settings/service.ts;
  routers fall back to it, enforce.ts uses NonNullable<Settings['quotas']>
- settings.quotas/models remain optional (storeTraces form work)
- regenerated put-req validate.js and vjsf components from the merged schema

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant