Diminuendo implements a defense-in-depth security architecture across authentication, authorization, transport, input validation, and error handling. No single layer is relied upon exclusively — each defense operates independently and provides protection even if adjacent layers are compromised or misconfigured.
In production, every WebSocket connection must authenticate by sending an authenticate message with a JWT token. The gateway verifies tokens against Auth0’s JWKS endpoint:
The jose library handles JWKS rotation automatically — when a key ID in the token doesn’t match the cached keyset, it fetches the latest keys from the endpoint.
Asymmetric JWT verification (RS256) is computationally expensive. To avoid paying this cost on every message from a previously authenticated connection, the gateway maintains an LRU cache of verified tokens:
Cache key: SHA-256 hash of the raw JWT string (using Bun.CryptoHasher)
Cache size: 10,000 entries maximum
TTL: Derived from the token’s exp claim, capped at 5 minutes
Eviction: FIFO when at capacity; expired entries cleaned up every 60 seconds
When DEV_MODE=true, authentication is bypassed entirely. All connections are automatically authenticated as developer@example.com with tenant ID dev. This is intended exclusively for local development.
Dev mode must never be enabled in production. The gateway logs a clear message at startup: "Auth: Dev mode enabled -- all requests authenticated as developer@example.com". Monitor for this message in production logs as a misconfiguration signal.
Role assignments are stored in per-tenant SQLite databases (tenants/{tenantId}/registry.db). The MembershipService provides CRUD operations on the tenant_members table:
Copy
CREATE TABLE IF NOT EXISTS tenant_members ( tenant_id TEXT NOT NULL, user_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (tenant_id, user_id));
The system prevents removing or demoting the last owner of a tenant. Both the removeMember and set_role operations check the owner count before proceeding:
Copy
if (existingMember?.role === "owner" && message.role !== "owner") { const ownerCount = members.filter((m) => m.role === "owner").length if (ownerCount <= 1) { return { kind: "respond", data: { type: "error", code: "LAST_OWNER_PROTECTED", message: "Cannot demote the last owner of a tenant", }} }}
Cross-Site Request Forgery protection uses a three-layer defense. Each layer is checked in order; the request passes if any layer succeeds:
1
Sec-Fetch-Site Header
If the browser sends Sec-Fetch-Site: same-origin or Sec-Fetch-Site: none, the request is same-origin and passes immediately. This header cannot be spoofed by JavaScript running in the same browser — it is set by the browser itself.
2
Origin Header
If the Origin header is present, it is checked against the ALLOWED_ORIGINS configuration list. If absent (non-browser clients such as CLIs and SDKs do not send Origin), the request passes — non-browser clients are not CSRF vectors.
3
Referer Header Fallback
If the Origin check fails, the Referer header is parsed and its origin component is checked against the same allowlist. This handles edge cases where some browsers strip the Origin header on certain redirect chains.
Copy
export function checkCsrf( req: Request, allowedOrigins: readonly string[], devMode = false,): CsrfCheckResult { if (devMode) return { ok: true } const secFetchSite = req.headers.get("sec-fetch-site") if (secFetchSite === "same-origin" || secFetchSite === "none") return { ok: true } const origin = req.headers.get("origin") if (!origin) return { ok: true } // Non-browser client if (allowedOrigins.includes(origin)) return { ok: true } // Fallback: check Referer const referer = req.headers.get("referer") if (referer) { try { if (allowedOrigins.includes(new URL(referer).origin)) return { ok: true } } catch { /* malformed referer */ } } return { ok: false, reason: `Origin '${origin}' is not in the allowed list` }}
A sophisticated attacker might attempt to bypass IPv4 range checks by using IPv4-mapped IPv6 addresses. The guard handles both dotted-quad form (::ffff:127.0.0.1) and hex form (::ffff:7f00:1):
Copy
// Dotted-quad formconst v4MappedMatch = cleaned.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)if (v4MappedMatch) { return isPrivateIPv4(v4MappedMatch[1])}// Hex form — reconstruct the IPv4 address from hex groupsconst hexMappedMatch = cleaned.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)if (hexMappedMatch) { const hi = parseInt(hexMappedMatch[1], 16) const lo = parseInt(hexMappedMatch[2], 16) const ip = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}` return isPrivateIPv4(ip)}
The SSRF guard validates the hostname string, not the resolved IP address. This means it cannot protect against DNS rebinding attacks, where a hostname initially resolves to a public IP but is later re-resolved to a private IP. For full protection in high-security environments, validate the resolved IP at connect time using a custom DNS resolver or connect-time hook.
Messages exceeding 500 characters are truncated with an ellipsis. This bounds the size of error responses and prevents verbose internal errors from leaking excessive detail.
Additionally, the message router maps known error tags to safe, generic messages:
Copy
const safeMessages: Record<string, string> = { Unauthenticated: "Authentication required", Unauthorized: "Insufficient permissions", SessionNotFound: "Session not found", PodiumConnectionError: "Failed to connect to agent", DbError: "Database operation failed", InsufficientCredits: "Insufficient credits",}
A separate, IP-based rate limiter protects the authentication endpoint:
Parameter
Value
Max attempts
10 per IP
Window
60 seconds
Lockout duration
5 minutes
Max tracked IPs
10,000
The 10,000-IP bound prevents memory exhaustion from spoofed source addresses. When the map reaches capacity, the oldest entry is evicted (FIFO). On successful authentication, the IP’s record is cleared entirely:
The auth rate limiter runs a cleanup() method every 60 seconds to prune expired entries and release lockouts. This periodic maintenance ensures the tracking map does not grow unbounded even if recordSuccess is never called for some IPs.
Every incoming WebSocket message is validated against Effect Schema definitions before processing:
Copy
const decoded = Schema.decodeUnknownEither(ClientMessage)(parsed)if (decoded._tag === "Left") { ws.send(JSON.stringify({ type: "error", code: "INVALID_MESSAGE", message: "Message does not match any known schema", })) return}
The ClientMessage schema is a union of 49 message types, each with its own field requirements. Messages that do not match any variant are rejected immediately.
Session IDs are used to construct file paths for per-session SQLite databases. The resolveSessionDir function validates that the resolved path stays within the sessions base directory:
Copy
export function resolveSessionDir(sessionsBaseDir: string, sessionId: string): string { const resolved = path.resolve(sessionsBaseDir, sessionId) if (!resolved.startsWith(sessionsBaseDir + path.sep)) { throw new Error("Invalid session ID") } return resolved}
A session ID like ../../etc/passwd would resolve to a path outside sessionsBaseDir and be rejected.
The WebSocket upgrade path (/ws) validates the Origin header before upgrading the connection. Non-browser clients (which don’t send Origin) are allowed through, but browser-based connections from unauthorized origins are rejected with a 403 response.
All SQLite databases are opened with PRAGMA journal_mode = WAL. WAL mode prevents database corruption from concurrent access (reader and writer workers accessing the same file) and provides crash recovery — incomplete transactions are rolled back automatically on the next open.
synchronous = NORMAL provides a balance between durability and performance: data is safe after a process crash but may be lost on an OS crash or power failure. For a gateway managing ephemeral agent sessions, this trade-off is appropriate.
The gateway applies design principles from OpenBSD — privilege separation, capability restriction, fail-closed defaults, resource bounds, and defense in depth. Each hardening measure operates independently; no single layer is relied upon exclusively.
When the membership database is unavailable or a user’s role cannot be determined, the gateway defaults to viewer (minimum privilege) rather than member. This prevents a database outage from silently granting elevated permissions:
Copy
// middleware/auth.tsconst role: Role = yield* membership.getRole(identity.tenantId, identity.userId).pipe( Effect.map((r) => r ?? ("viewer" as Role)), Effect.catchAll(() => Effect.succeed("viewer" as Role)),)
When ALLOWED_ORIGINS is empty in production, all browser-origin requests are rejected rather than accepted. Operators must explicitly configure allowed origins:
Copy
if (config.allowedOrigins.length === 0) return false // Reject all browser origins
The gateway refuses to start with DEV_MODE=true if production environment indicators are detected (e.g., NODE_ENV=production, FLY_APP_NAME, AWS_EXECUTION_ENV, K_SERVICE). This prevents accidental bypass of authentication, CSRF protection, and rate limiting in production.
WS_COMPRESSION defaults to false. Enabling compression on WebSocket connections exposes CRIME/BREACH-class side-channel attacks. Opt-in only via WS_COMPRESSION=true.
Principle: Every allocation must be bounded. Every data structure must have a maximum size.
Data Structure
Bound
Eviction Strategy
JWT verification cache
10,000 entries
FIFO when at capacity
Auth rate limiter IPs
10,000 entries
FIFO + periodic cleanup
HTTP rate limiter buckets
50,000 entries
FIFO
Active session tracking
100,000 entries
Stops tracking (session still functions)
Per-connection rate limiters
100,000 entries
Cleaned on WS close; skips creation at capacity
Tenant DB connections
1,000 connections
LRU eviction + 30-minute idle timeout
Podium event queue (per connection)
10,000 events
Sliding window (oldest events dropped)
Webhook dedup IDs
10,000 entries
Time-bounded (5-minute TTL)
The TenantDbPool implements LRU eviction with idle timeout: connections unused for 30 minutes are closed, and when at the 1,000-connection capacity, the least recently accessed connection is evicted.The Podium event queue uses a sliding window (Queue.sliding(10_000)) rather than an unbounded queue. If an agent floods events faster than the gateway can process them, the oldest events are dropped — acceptable for streaming where only the latest state matters.
The FsGuard (src/security/fs-guard.ts) validates that all filesystem operations stay within declared writable directories. It is integrated into TenantDbPool to prevent directory traversal through tenant-controlled paths:
Copy
const fsGuard = buildFsGuard({ dataDir: config.dataDir })// Before any filesystem I/O with tenant-derived paths:fsGuard.assertWritable(tenantDir) // Throws if outside data directory
This operates as a second line of defense behind the tenant ID regex validation — even if the regex is somehow bypassed, the filesystem guard rejects paths outside the data directory.
The OutboundGuard (src/security/outbound-guard.ts) validates outbound HTTP requests against a declared hostname allowlist. It prevents the gateway from making requests to unauthorized hosts — the TypeScript analog of OpenBSD’s unveil() for network access:
After AppConfig loads all configuration at startup, sensitive environment variables (AUTH_CLIENT_SECRET, PODIUM_API_KEY, ENSEMBLE_API_KEY, GITHUB_CLIENT_SECRET, GITHUB_WEBHOOK_SECRET, etc.) are deleted from process.env. This reduces the attack surface if arbitrary code execution occurs later — secrets are only accessible through the typed AppConfig service.
The GitHub webhook HMAC secret is loaded via AppConfig at startup (githubWebhookSecret), not read from process.env at request time. This ensures the secret is validated once at startup and is not accessible via the global environment after scrubbing.
All parseInt() calls on URL parameters use a bounds-checked wrapper that returns null for NaN, negative values, or values exceeding 2^31 - 1:
Copy
function safeParseInt(value: string, min: number, max: number): number | null { const n = parseInt(value, 10) if (Number.isNaN(n) || n < min || n > max) return null return n}
This prevents NaN propagation and out-of-bounds values from reaching GitHub API calls.
All route parameters (:id, :userId, :service) are validated against a strict pattern (/^[a-zA-Z0-9_.-]{1,128}$/) before any service call. Invalid identifiers receive an immediate 400 response.
Responses from upstream services (Podium, Ensemble) are validated against Effect Schema definitions before use. This prevents a compromised upstream service from injecting malicious payloads:
Security-relevant events are logged to the AuditService for forensic analysis. The following event types are tracked:
Event
Audit Action
Location
Authentication failure
security.auth_failure
WebSocket message handler
Auth rate limit lockout
security.auth_rate_limited
WebSocket message handler
WebSocket message rate limit
security.ws_rate_limited
WebSocket message handler
CSRF rejection
security.csrf_rejected
HTTP router, WebSocket upgrade
Oversized message
security.oversized_message
WebSocket message handler
These events are stored in the per-tenant audit_log table alongside application events, enabling correlation between security incidents and application behavior.
Principle: If you don’t need it, don’t include it. Unused features should not be discoverable.Feature flags are auto-detected from the application configuration:
Feature
Detection
Disabled Behavior
GitHub integration
GITHUB_CLIENT_ID is set
/api/github/* and /api/oauth/github/* routes return 404
Prometheus metrics
METRICS_PROMETHEUS=true
/metrics endpoint not served
In dev mode, all features are enabled regardless of configuration. In production, unconfigured features are invisible — their routes are not discoverable and return standard 404 responses.
The WebSocket drain callback tracks backpressure events via the ws_drain_events metric counter. When a client receives data too slowly, Bun buffers outbound frames; the drain callback fires when the buffer is flushed. This provides observability into slow-client conditions that could lead to memory pressure.
Role values from request bodies are validated against the isValidRole() function before being passed to the MembershipService. This prevents arbitrary strings from being stored as roles: