Architecture & engineering

Public API: External-Tenant Hardening Findings

Findings register from the 2026-07-07 gateway audit (Build on Agend / AHRI enablement). These are the gates between "internal apps use the API" and "an external client holds a live key". Status: OPEN · Owner: unassigned · Cross-refs: writeback-two-way-sync.md §8, ADR-001-object-registry-system-and-custom-objects.md (Security)

Context

The gateway (apps/api, 169 /v1 routes) is production-grade in structure: fail-closed unified-key auth (ag_live_*/ag_test_*), scopes, entitlement checks, audit logging and billing metering on every request. The findings below are the known gaps that become material once an external tenant (AHRI first) builds against it — and become critical once write endpoints open (a tenancy bug on reads is a leak; on writes it is data corruption).

Findings

F1 · Rate limiter is in-memory per-process — P1

  • Evidence: packages/@agend/public-api/src/rate-limit.ts:1 — per-key token bucket held in process memory. Default 60 req/min applied in packages/@agend/public-api/src/middleware.ts:541.
  • Impact: On serverless/multi-instance deploys each instance keeps its own bucket, so the effective limit is 60 × N instances and is not deterministic. An external client cannot be meaningfully throttled; a runaway client cannot be reliably contained.
  • Fix: Back the limiter with a shared store (Upstash/Redis). Note a distributed limiter already exists in the LMS app — evaluate reuse rather than a new build.
  • Gate: Before first external live key.

F2 · No RLS backstop behind the admin-client tenancy model — P1

  • Evidence: All gateway routes use the RLS-bypassing Supabase admin client (apps/api/lib/gateway-client.ts:27, with an explicit justification comment). Tenant isolation depends entirely on each handler passing auth.context.accountId (e.g. apps/api/app/v1/crm/contacts/route.ts:37).
  • Impact: A single handler that forgets or mis-threads accountId leaks (reads) or corrupts (writes) across tenants. There is no database-level second line of defence on this path.
  • Fix options (either or both):
    1. Session-scoped RLS: set the account context per request (set_config) and run gateway queries under a non-admin role with account-scoped policies.
    2. Invariant enforcement in CI: a lint rule / contract test asserting every gateway service call receives auth.context.accountId, plus cross-tenant probe tests per route.
  • Gate: Hard prerequisite for external write access (also recorded in writeback-two-way-sync.md §8 and ADR-001).

F3 · ignoreBuildErrors: true in the API app — P2

  • Evidence: apps/api/next.config.mjs sets typescript: { ignoreBuildErrors: true }.
  • Impact: Type errors ship silently in the one app whose contract external developers depend on.
  • Fix: Burn down existing errors, flip the flag, keep it flipped in CI.

F4 · X-RateLimit-Reset documented but never sent — P3

  • Evidence: The OpenAPI intro (apps/api/app/v1/openapi/route.ts:43 region) documents X-RateLimit-Reset; middleware only sets X-RateLimit-Remaining (middleware.ts).
  • Impact: Spec/implementation drift; client backoff logic written to the docs will misbehave.
  • Fix: Send the header (trivial once F1 lands, since reset time comes from the shared store) or remove it from the docs until then.

F5 · /v1/health requires auth — P3 (observed 2026-07-07)

  • Evidence: curl https://api.agend.com.au/v1/health → HTTP 401 (domain live on Vercel DNS).
  • Impact: External clients cannot run unauthenticated uptime checks against the endpoint the docs advertise as infrastructure.
  • Fix: Exempt health from key auth (return no tenant data), or document that health requires a key.

Suggested sequencing

  1. F2 invariant tests (fast, no infra) → F1 distributed limiter → F2 RLS backstop proper
  2. F3 alongside, F4/F5 as one-line fixes in the same sprint

Unchecked areas (honesty list — not audited, not cleared)

  • Webhook egress security: client-defined webhook delivery URLs are an SSRF/exfiltration surface (internal-network targets, cloud metadata endpoints). The audit did not examine @agend/webhooks delivery for URL allow/deny rules, private-range blocking or redirect handling. Audit before external tenants can register webhook endpoints.
  • Dynamic scope issuance UX: the key-management UI's behaviour when custom-object scopes (crm.{type}.*, ADR-001) become account-dynamic was not reviewed.

Verification confirmations (what's already good)

Fail-closed middleware (throws at construction without requiredScopes), HMAC-hashed keys via validate_unified_api_key RPC, per-key CORS origin allowlists, audit + metering via after() — none of these need work for external tenancy.