{"openapi":"3.1.0","info":{"title":"olladns API","description":"DNS filter control plane. Configure policies, blocklists, tenants, users, and read query analytics. Designed for agent-driven use via MCP.","contact":{"name":"olladns","email":"admin@olladns.com"},"license":{"name":""},"version":"0.72.0"},"servers":[{"url":"https://api.olladns.com","description":"Production"},{"url":"http://localhost:8080","description":"Local dev"}],"paths":{"/api/v1/analytics/ai-candidates":{"get":{"tags":["analytics"],"summary":"NOTE ON A DELIBERATE SEMANTIC CHANGE, so nobody \"fixes\" it back.","description":"Shadow-AI discovery: domains resolved on YOUR network that look like AI services but are not in the catalog yet. \n\nThis is the half a domain list cannot do. Ranked by lexical AI signals, co-occurrence with known AI tools on the same device, and volume, after excluding OS/CDN/telemetry noise. Deliberately high-recall and short — feed it to an agent (or read it) to name what these actually are, then record the verdict. \n\n`co_occurrence` is 0 when it cannot discriminate (a single device, or every device using AI); treat a 0 as 'no information', not 'no relationship'. \n\nA high score is a QUESTION, not a verdict: names containing 'infer' or 'prompt' match for unrelated reasons.","operationId":"ai_candidates","parameters":[{"name":"hours","in":"query","description":"Lookback hours (default 24; use 720 for a month)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"limit","in":"query","description":"Max rows (default 20, max 1000)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AiCandidateRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/analytics/block-rate":{"get":{"tags":["analytics"],"description":"Headline single-number: what percent of this tenant's queries got blocked in the window. Useful for the dashboard tile and for executive reporting. A sudden change (especially up) often indicates a policy change or a new threat campaign.","operationId":"block_rate","parameters":[{"name":"hours","in":"query","description":"Lookback hours (default 24)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}}],"responses":{"200":{"description":"Overall blocked / total ratio for the window","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlockRateRow"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/analytics/blocked":{"get":{"tags":["analytics"],"description":"Most-blocked domains in the window, each with a sample reason (which blocklist or custom rule caught it). Use to answer 'what's getting blocked?' from a tenant's view. The reason text is AGH-shaped — e.g. 'FilterListId:102' for blocklist 102, or 'CustomFiltering' for a manual rule.","operationId":"blocked","parameters":[{"name":"hours","in":"query","description":"Lookback hours (default 24)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"limit","in":"query","description":"Max rows (capped 1000, default 20)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}}],"responses":{"200":{"description":"Top blocked domains with primary block reason","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BlockedRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/analytics/by-reason":{"get":{"tags":["analytics"],"description":"Histogram of blocks grouped by reason — answers 'which of my controls is doing the work?' Each row is a reason string with count and unique-domains. Use to retire dead blocklists (zero hits) or to spot when one rule over-blocks.","operationId":"by_reason","parameters":[{"name":"hours","in":"query","description":"Lookback hours (default 24)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}}],"responses":{"200":{"description":"Block events grouped by reason (blocklist, custom rule, parental, etc)","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ByReasonRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/analytics/dashboard-bundle":{"get":{"tags":["analytics"],"description":"Dashboard Overview screen's 5 calls in one round-trip. Server runs the underlying queries concurrently. Cuts cold-load network time from 5×RTT to 1×RTT — significant for non-US users where each RTT is 200ms+.","operationId":"dashboard_bundle","parameters":[{"name":"hours","in":"query","description":"Lookback hours (default 24)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"limit","in":"query","description":"Per-sublist limit (default 8)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DashboardBundle"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/analytics/export":{"get":{"tags":["analytics"],"description":"Bulk query-log dump for SOC handover, audit prep, or spreadsheet analysis. Returns the full raw queries (ts, client_ip, query_name, qtype, rcode, latency, ai_tool, blocked, block_reason, filter_list_id) — not aggregated. `format=csv` (default, with header + Content-Disposition attachment) for spreadsheets, `format=ndjson` for SIEM ingest. Cap of 10M rows per call; for larger windows, call repeatedly with from/to slicing.","operationId":"export","parameters":[{"name":"from","in":"query","description":"RFC3339 lower bound (default: hours-ago)","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","description":"RFC3339 upper bound (default: now)","required":false,"schema":{"type":"string"}},{"name":"hours","in":"query","description":"Lookback if from/to absent (default 24)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"format","in":"query","description":"'csv' (default) or 'ndjson'","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Row cap (max 10_000_000)","required":false,"schema":{"type":"integer","format":"int64","minimum":0}}],"responses":{"200":{"description":"Streamed CSV or NDJSON of DNS queries","content":{"text/csv":{}}},"400":{"description":"Unknown format"}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/analytics/latency":{"get":{"tags":["analytics"],"description":"DNS upstream-resolution latency percentiles (p50/p95/p99) in MICROSECONDS, with total sample count. Use to spot resolver degradation or to publish SLO numbers. Numbers are AGH-internal — they don't include network RTT from the end-user to dns.olladns.com.","operationId":"latency","parameters":[{"name":"hours","in":"query","description":"Lookback hours (default 24)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}}],"responses":{"200":{"description":"Latency percentiles (microseconds)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LatencyRow"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/analytics/summary":{"get":{"tags":["analytics"],"description":"One-row overview of DNS traffic for the window: total queries, unique clients, unique domains, AI-tool queries, blocked queries, NXDOMAIN count. Cheap (single ClickHouse aggregate). Use for headline metrics on a status page or the agent's session-opener orientation.","operationId":"summary","parameters":[{"name":"hours","in":"query","description":"Lookback window in hours (default 24)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"from","in":"query","description":"Absolute RFC3339 start (overrides hours)","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","description":"Absolute RFC3339 end (defaults to now)","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Single-row roll-up over the window","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SummaryRow"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/analytics/top-ai-tools":{"get":{"tags":["analytics"],"description":"AI-tool query counts in the window. olladns classifies query names against a curated dictionary of AI vendor domains and tags each query with `ai_tool`. Currently nine vendors: ChatGPT, Claude, Gemini, DeepSeek, Cursor, Windsurf, Copilot, Perplexity, Mistral. Matching is by DNS suffix, so subdomains of a listed domain count. Reports AI usage the resolver can see; a tool whose domains are not in the dictionary is invisible here, so absence of a vendor is not evidence it is unused. Per-tool count + unique clients.","operationId":"top_ai_tools","parameters":[{"name":"hours","in":"query","description":"Lookback hours (default 24)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"limit","in":"query","description":"Max rows (capped 1000, default 20)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"group_by","in":"query","description":"One of `tool` (default), `vendor` or `category`. `vendor` aggregates a company's tools together (Gemini + Code Assist under Google); `category` answers 'how much coding-assistant traffic' rather than 'how much of each product'. The grouped value is returned in the `tool` field either way.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Per-AI-tool query counts (ChatGPT, Claude, Gemini, etc)","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TopAiRow"}}}}},"400":{"description":"Unrecognised `group_by`"}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/analytics/top-clients":{"get":{"tags":["analytics"],"description":"Noisiest client IPs in the window. Each row gives query count and unique-domain count. Use to spot devices doing unusual volumes (potential malware beaconing, scanner, or just a busy CI runner). Per-device names land when the per-device-identification feature ships.","operationId":"top_clients","parameters":[{"name":"hours","in":"query","description":"Lookback hours (default 24)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"limit","in":"query","description":"Max rows (capped 1000, default 20)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TopClientRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/analytics/top-devices":{"get":{"tags":["analytics"],"description":"Per-device query counts in the window. Use to answer 'which of my devices is busiest?' or 'is Bob's laptop beaconing?'. Queries on bare tenant URLs (no device slug configured) appear as device_slug='' / display_name='(unassigned)' — nudge those users to switch to their assigned device URL.","operationId":"top_devices","parameters":[{"name":"hours","in":"query","description":"Lookback hours (default 24)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"limit","in":"query","description":"Max rows (default 20, max 1000)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TopDeviceRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/analytics/top-dga":{"get":{"tags":["analytics"],"description":"Domains scored as algorithmically-generated by olladns's inline DGA classifier (src/dga.rs). Returns the highest- scoring distinct domains in the window — use to triage potential malware C2. Default threshold 0.7 matches /policies/threat-intel.dga.threshold; tune via `min_score` if you're chasing false positives or near-misses.","operationId":"top_dga","parameters":[{"name":"hours","in":"query","description":"Lookback hours (default 24)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"limit","in":"query","description":"Max rows (default 20, max 1000)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"min_score","in":"query","description":"Minimum DGA score [0..1] (default 0.7)","required":false,"schema":{"type":"number","format":"float"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TopDgaRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/analytics/top-domains":{"get":{"tags":["analytics"],"description":"Most-queried domains in the window with per-domain query count and unique-client count. Use to answer 'what is this tenant resolving?' Includes blocked AND allowed domains; filter with /blocked or /by-reason for blocked-only views.","operationId":"top_domains","parameters":[{"name":"hours","in":"query","description":"Lookback hours (default 24)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"limit","in":"query","description":"Max rows (capped 1000, default 20)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TopDomainRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/analytics/top-typosquat":{"get":{"tags":["analytics"],"description":"Domains the inline classifier (src/typosquat.rs) flagged as Damerau-Levenshtein ≤ 2 lookalikes of one of this tenant's /policies/protect-list entries. Use to triage phishing-precursor lookups; correlate with the source client_ip to find the user who fell for the typoed link.","operationId":"top_typosquat","parameters":[{"name":"hours","in":"query","description":"Lookback hours (default 24)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"limit","in":"query","description":"Max rows (default 20, max 1000)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TopTyposquatRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["analytics:read"]}],"x-required-scopes":["analytics:read"]}},"/api/v1/api-keys":{"get":{"tags":["api_keys"],"description":"List every API key for the caller's tenant. Returns label, scopes, expires_at, last_used_at — never the plaintext key value (that's only shown once at creation time). Useful for auditing which agents have access and pruning unused keys.","operationId":"list_keys","responses":{"200":{"description":"All API keys for caller's tenant","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/KeyRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["api_keys:read"]}],"x-required-scopes":["api_keys:read"]},"post":{"tags":["api_keys"],"description":"Mint a new scoped API key. Required fields: label, scopes (non-empty subset of resource:action pairs). Optional: expires_at. Plaintext key is shown ONCE in the response — store it immediately. Humans only — excluded from MCP so an agent cannot mint other agents.","operationId":"create_key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateKeyReq"}}},"required":true},"responses":{"200":{"description":"New key, plaintext value shown ONCE","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateKeyResp"}}}},"400":{"description":"Empty label or unknown scope"}},"security":[{"bearer_jwt":[]},{"api_key":["api_keys:write"]}],"x-required-scopes":["api_keys:write"]}},"/api/v1/api-keys/scopes":{"get":{"tags":["api_keys"],"description":"List every scope a key may hold, split into resource/action and flagged for blast radius. This is the authoritative set accepted by POST /api-keys — clients should render from it rather than hardcoding a list, which silently goes stale as new resources are added. `legacy:full` is not included: it exists only on pre-v0.11 keys and is never mintable.","operationId":"list_scopes","responses":{"200":{"description":"All mintable scopes","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ScopeInfo"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["api_keys:read"]}],"x-required-scopes":["api_keys:read"]}},"/api/v1/api-keys/{id}":{"delete":{"tags":["api_keys"],"description":"Permanently revoke a key. The key value stops working instantly. Use when an agent's token is compromised or a team member leaves. Humans only — excluded from MCP.","operationId":"delete_key","parameters":[{"name":"id","in":"path","description":"Key id to revoke","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"Key revoked"},"404":{"description":"Key not found in caller's tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["api_keys:write"]}],"x-required-scopes":["api_keys:write"]}},"/api/v1/api-keys/{id}/rotate":{"post":{"tags":["api_keys"],"summary":"Rotate: mint a fresh key value for an existing row, preserving id,\nlabel, scopes, expiry. The old key value stops working immediately.","description":"Mint a fresh plaintext key for an existing row — keeps id, label, scopes, expires_at. The old value stops working instantly. Use for periodic rotation hygiene or after a suspected leak. Humans only — excluded from MCP.","operationId":"rotate_key","parameters":[{"name":"id","in":"path","description":"Key id to rotate","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"New plaintext key value; metadata preserved","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateKeyResp"}}}},"404":{"description":"Key not found in caller's tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["api_keys:write"]}],"x-required-scopes":["api_keys:write"]}},"/api/v1/audit-logs":{"get":{"tags":["audit"],"description":"Tamper-evident action log for the caller's tenant. Every config change records who did it (actor_type=user with user_id, or actor_type=api_key with api_key_id), what action, and a metadata JSON snapshot. Each row carries a SHA-256 row_hash chained to the previous row's hash (per tenant) so any later edit is detectable via /audit-logs/verify. Filter by actor_type to separate human vs agent activity; filter by api_key_id to audit a single agent. Newest-first, capped at 1000 rows.","operationId":"list_audit_logs","parameters":[{"name":"from","in":"query","description":"RFC3339 lower bound (default: 30d ago)","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","description":"RFC3339 upper bound (default: now)","required":false,"schema":{"type":"string"}},{"name":"action","in":"query","description":"Exact action match, e.g. 'policy.filtering.update'","required":false,"schema":{"type":"string"}},{"name":"actor_type","in":"query","description":"'user' | 'api_key' | 'system'","required":false,"schema":{"type":"string"}},{"name":"api_key_id","in":"query","description":"Filter to a single agent token","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"limit","in":"query","description":"Max rows, capped at 1000 (default 100)","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"offset","in":"query","description":"Pagination offset","required":false,"schema":{"type":"integer","format":"int32","minimum":0}}],"responses":{"200":{"description":"Audit rows newest-first","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AuditRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["audit:read"]}],"x-required-scopes":["audit:read"]}},"/api/v1/audit-logs/rechain":{"post":{"tags":["audit"],"summary":"Rebuild the hash chain over every row, in id order, per tenant chain.","description":"Rebuild the audit hash chain over every row. PLATFORM ADMIN ONLY. Required once after migration 052 changed the canonical form to the immutable actor snapshots; every hash written before that was computed over columns the database is allowed to rewrite. Idempotent.","operationId":"rechain_audit_logs","responses":{"200":{"description":"Rows rechained"},"403":{"description":"Not the platform admin"}},"security":[{"bearer_jwt":[]},{"api_key":["audit:write"]}],"x-required-scopes":["audit:write"]}},"/api/v1/audit-logs/verify":{"get":{"tags":["audit"],"description":"Walk this tenant's audit-log hash chain and report whether every row's stored row_hash matches its recomputed value AND links cleanly to the prior row's row_hash. Rows from before the chain rollout (row_hash IS NULL) are skipped — the chain begins at the first non-null row. Auditors call this to prove the log has not been tampered with since acquisition.","operationId":"verify_audit_chain","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChainVerifyResp"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["audit:read"]}],"x-required-scopes":["audit:read"]}},"/api/v1/auth/change-password":{"post":{"tags":["auth"],"summary":"Self-service password change. Only callable with a JWT (humans);\nAPI keys do not have an associated password and the endpoint\nrejects them. The current password is verified before the rotation\nso a stolen JWT alone cannot lock the user out.","description":"Self-service password rotation. JWT sessions only — API keys are rejected because they have no associated password. Verifies the current password first so a stolen JWT alone cannot lock the user out. \n                   \n                   REVOKES EVERY OTHER SESSION for this user, which is the \n                   point of rotating a password you think someone else has. \n                   Your own token is revoked with them, so the response \n                   carries a replacement in `token` - use it for subsequent \n                   calls or you will get 401. Excluded from MCP.","operationId":"change_password","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangePasswordReq"}}},"required":true},"responses":{"200":{"description":"Password rotated; the body carries a replacement `token` because every session, this one included, was revoked"},"400":{"description":"New password fails the password policy (min 12 chars, not a known-weak password)"},"401":{"description":"Current password incorrect"},"403":{"description":"Called with X-API-Key (humans only)"}},"security":[{"bearer_jwt":[]}]}},"/api/v1/auth/discover":{"post":{"tags":["auth"],"description":"Does this email address's domain use SSO? Called by the login page before it decides whether to show a password field or redirect to an identity provider. \n\nUnauthenticated, and deliberately uninformative: an unknown domain and a known domain without SSO return the IDENTICAL response, because distinguishing them would confirm which companies are customers. Only domains an operator has verified are considered — claiming a domain routes that company's logins into your tenant, so it is not self-serve.","operationId":"discover","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiscoverReq"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiscoverResp"}}}}}}},"/api/v1/auth/forgot-password":{"post":{"tags":["auth"],"description":"Request a password-reset link. Always returns 200 with the same body whether or not the address exists — this endpoint is public, and a distinguishable response would make it an account-enumeration oracle. If SMTP is not configured the link is written to the server log instead. Excluded from MCP.","operationId":"forgot_password","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForgotPasswordReq"}}},"required":true},"responses":{"200":{"description":"Request accepted (always)"}}}},"/api/v1/auth/login":{"post":{"tags":["auth"],"description":"Exchange a user email + password for a 24-hour JWT. The JWT carries tenant + role and bypasses scope checks. Agents do NOT call this — they authenticate via X-API-Key tokens that humans mint for them. Excluded from MCP.","operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginReq"}}},"required":true},"responses":{"200":{"description":"JWT session token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResp"}}}},"401":{"description":"Bad credentials"}}}},"/api/v1/auth/logout":{"post":{"tags":["auth"],"summary":"End this session, server-side.","description":"Revoke the session token used to make this call. The token stops being accepted immediately and stays revoked until it would have expired. Idempotent. JWT sessions only — API keys are revoked with DELETE /api-keys/{id}, and calling this with one returns 400 rather than pretending to have done something.","operationId":"logout","responses":{"200":{"description":"Session revoked"},"400":{"description":"Called with an API key, or a token with no jti (minted before v0.68)"},"500":{"description":"Revocation store unavailable — the session was NOT revoked"}},"security":[{"bearer_jwt":[]}]}},"/api/v1/auth/reset-password":{"post":{"tags":["auth"],"description":"Redeem a reset token and set a new password. Tokens are single-use and expire after one hour. Note that existing JWTs are NOT revoked — they are stateless and stay valid until they expire. Excluded from MCP.","operationId":"reset_password","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResetPasswordReq"}}},"required":true},"responses":{"200":{"description":"Password changed"},"400":{"description":"Weak password"},"401":{"description":"Token invalid, already used, or expired"}}}},"/api/v1/auth/signup":{"post":{"tags":["auth"],"description":"Create a new tenant and its first admin user, and return a session token. Public and unauthenticated — this is the self-serve entry point. The tenant is provisioned immediately: AGH client, default blocklist subscriptions and DoH/DoT endpoints are live on return. Rate-limited per source IP. Excluded from MCP.","operationId":"signup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupReq"}}},"required":true},"responses":{"200":{"description":"Tenant created; token is an immediately usable session","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupResp"}}}},"400":{"description":"Invalid email, weak password, or bad org name"},"409":{"description":"Email already registered"},"429":{"description":"Too many signups from this IP"}}}},"/api/v1/blocked":{"get":{"tags":["branding"],"description":"Public block-page renderer (HTML). Caddy reverse-proxies both http://66.163.115.233/ and https://block.olladns.com/ to this endpoint. Tenant identification cascade: ?t= query param, then olladns_tenant cookie, then ClickHouse IP-based lookup (only if exactly one dominant tenant), else generic olladns template. AGH-triggered cleartext blocks carry no tenant signal in the request itself — known limitation until per-tenant blocking IP pool or ctrld roaming client.","operationId":"blocked_page","parameters":[{"name":"t","in":"query","description":"Tenant id (canonical link form)","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"host","in":"query","description":"Blocked host (fallback for direct testing)","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Rendered HTML block page","content":{"text/html":{}}}}}},"/api/v1/blocklists":{"get":{"tags":["blocklists"],"description":"Browse the catalog of curated blocklists, grouped by category (ads, tracking, threat, phishing, adult, gambling, social, piracy, fakenews, telemetry, tlds, bypass, general). Returns id, name, url, description, category and licence. Each row also carries the result of the most recent liveness check. `health` is `ok` when the upstream answered and this compiler could emit rules from it; `unusable` when it served filter rules in a format we cannot compile — wildcard, regex or modifier-bearing — which means the upstream is HEALTHY and only our compiler is the limit; `dead` when it failed, or served nothing rule-shaped at all; `unchecked` when it has not been probed. `upstream_title` is the list's own declared title and `title_mismatch` flags — heuristically, as an advisory — that it disagrees with the name we give it. Neither a dead nor an unusable list is silently substituted: both simply contribute nothing when compiled. Subscribing to a list here does NOT change what is blocked. The unit of per-tenant enforcement is a CATEGORY, not a list: use GET/PUT /profiles/me/categories (category enforcement is live in the production category_cohorts feed mode). In that mode a per-list subscription does not change what is blocked: GET /blocklists/subscriptions returns each list's recorded preference, or its default where the tenant set none, and the PUT that set them is retired. (The unused per_tenant_feeds mode is the exception — there subscriptions would drive per-tenant compiled feeds.) See invariant 8.","operationId":"list_catalog","responses":{"200":{"description":"The blocklist catalog","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CatalogRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["blocklists:read"]}],"x-required-scopes":["blocklists:read"]}},"/api/v1/blocklists/feed-token/rotate":{"post":{"tags":["blocklists"],"summary":"Replace a feed's bearer token.","description":"Rotate a compiled feed's bearer token. The feed URL is a                    capability and is logged in full by the TLS terminator, so                    assume disclosure. The new token is not returned. Reconcile                    must run before the new URL is registered — until then the                    old token still answers. Platform-admin only.","operationId":"rotate_feed_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateFeedTokenReq"}}},"required":true},"responses":{"200":{"description":"Rotated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateFeedTokenResp"}}}},"403":{"description":"Caller is not platform admin"},"404":{"description":"No such feed"}},"security":[{"bearer_jwt":[]},{"api_key":["blocklists:write"]}],"x-required-scopes":["blocklists:write"]}},"/api/v1/blocklists/mirrors":{"get":{"tags":["blocklists"],"summary":"List every mirrored blocklist and the state of its snapshot store.","operationId":"list_mirrors","responses":{"200":{"description":"Mirror state","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/MirrorStatus"}}}}},"403":{"description":"Caller is not platform admin"}},"security":[{"bearer_jwt":[]},{"api_key":["blocklists:read"]}],"x-required-scopes":["blocklists:read"]}},"/api/v1/blocklists/mirrors/{slug}/refresh":{"post":{"tags":["blocklists"],"summary":"Force a refresh now, instead of waiting for the cron.","operationId":"refresh_mirror","parameters":[{"name":"slug","in":"path","description":"Mirror slug, e.g. hagezi-pro","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Refresh result","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshReport"}}}},"403":{"description":"Caller is not platform admin"},"404":{"description":"No such mirror"}},"security":[{"bearer_jwt":[]},{"api_key":["blocklists:write"]}],"x-required-scopes":["blocklists:write"]}},"/api/v1/blocklists/mirrors/{slug}/rollback":{"post":{"tags":["blocklists"],"summary":"Re-promote a retained snapshot.","description":"The body is already on disk and already passed the guards when it was first\naccepted, so this does not re-validate it — it moves the pointer.","operationId":"rollback_mirror","parameters":[{"name":"slug","in":"path","description":"Mirror slug, e.g. hagezi-pro","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RollbackReq"}}},"required":true},"responses":{"200":{"description":"Rolled back","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshReport"}}}},"400":{"description":"That snapshot is not retained or its body is missing"},"403":{"description":"Caller is not platform admin"},"404":{"description":"No such mirror"}},"security":[{"bearer_jwt":[]},{"api_key":["blocklists:write"]}],"x-required-scopes":["blocklists:write"]}},"/api/v1/blocklists/reconcile":{"post":{"tags":["blocklists"],"description":"Bring AGH's global filter list in line with the union of every tenant's current subscriptions. Adds missing filter URLs, removes orphaned ones, leaves matching ones alone. Platform-admin only (tenant 1) because AGH filters are shared globally. Also runs automatically every 5 minutes in the background — this endpoint is the 'I want it RIGHT NOW' escape hatch.","operationId":"reconcile","responses":{"200":{"description":"Lists added/removed/left alone","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReconcileResp"}}}},"403":{"description":"Caller is not platform admin"}},"security":[{"bearer_jwt":[]},{"api_key":["blocklists:write"]}],"x-required-scopes":["blocklists:write"]}},"/api/v1/blocklists/subscriptions":{"get":{"tags":["blocklists"],"description":"Return this tenant's recorded blocklist preferences — for every list in the catalog, whether the tenant is subscribed and when that was set. These preferences do NOT control filtering. AdGuard Home applies one global filter set to every tenant, so being unsubscribed from a list does not unblock it. To change what this tenant actually blocks, use GET/PUT /profiles/me/categories — per-category enforcement is live. The `note` field in the response says the same and lists every per-tenant lever. Read this before PUT if you want to add to existing preferences rather than replace them.","operationId":"get_subscriptions","responses":{"200":{"description":"Recorded per-tenant preferences (not enforcement state)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubsResp"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["blocklists:read"]}],"x-required-scopes":["blocklists:read"]},"put":{"tags":["blocklists"],"description":"RETIRED as of v0.60 — this endpoint now returns 400. It recorded a preference that never changed what was blocked: AdGuard Home applies one global filter set to every tenant, so subscribing added no blocking and unsubscribing removed none. Accepting configuration and silently discarding it is worse than refusing it, so it now refuses. Use PUT /profiles/me/categories instead — per-category enforcement is live and genuinely per-tenant. GET /profiles/me/categories lists what is installed and selectable. Also per-tenant: /policies/custom-rules, /policies/rewrites, /policies/threat-intel, /policies/default-deny and /profiles/me. GET /blocklists/subscriptions still works and still returns the preferences already recorded.","operationId":"set_subscriptions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetSubsReq"}}},"required":true},"responses":{"200":{"description":"No longer returned","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubsResp"}}}},"400":{"description":"Always. Use PUT /profiles/me/categories."}},"security":[{"bearer_jwt":[]},{"api_key":["blocklists:write"]}],"x-required-scopes":["blocklists:write"]}},"/api/v1/blocklists/{id}":{"get":{"tags":["blocklists"],"description":"One catalog entry by id. Same row shape as GET /blocklists, for callers holding an id from a subscription or a policy response and wanting its name, category, licence and current health without pulling the whole catalog. As with the list: subscribing to a blocklist does NOT currently change what is blocked.","operationId":"get_blocklist","parameters":[{"name":"id","in":"path","description":"blocklist_catalog id","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"The catalog entry","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CatalogRow"}}}},"404":{"description":"No blocklist with that id"}},"security":[{"bearer_jwt":[]},{"api_key":["blocklists:read"]}],"x-required-scopes":["blocklists:read"]}},"/api/v1/clients":{"get":{"tags":["clients"],"description":"List the downloadable endpoint clients with their sizes and SHA-256 hashes. Unauthenticated: these files are identical for every tenant and contain no secrets — what identifies a device is the DoH URL you put in the config, never the binary. Verify the hash before running; the builds are not yet code-signed.","operationId":"list_clients","responses":{"200":{"description":"Available client artifacts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ClientArtifact"}}}}}}}},"/api/v1/clients/{filename}":{"get":{"tags":["clients"],"description":"Download one endpoint client. Unauthenticated by design — see GET /clients. Only the filenames listed by that endpoint are served; anything else is 404.","operationId":"download_client","parameters":[{"name":"filename","in":"path","description":"Artifact filename from GET /clients","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The file"},"404":{"description":"Unknown or missing artifact"}}}},"/api/v1/devices":{"get":{"tags":["devices"],"description":"List every registered device on the caller's tenant. Each device has its own DoH URL suffix; per-device query counts surface via /analytics/top-devices. Updated `last_seen_at` shows the freshest activity from each device.","operationId":"list_devices","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DeviceRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["devices:read"]}],"x-required-scopes":["devices:read"]},"post":{"tags":["devices"],"description":"Register a new device. Pick a slug ([a-z0-9-]{1,64}) and a display_name. The response includes the DoH URL to point the device at. We also push `<tenant_uuid>/<slug>` into the tenant's AGH client config so policies apply per device on that URL — without this step the device-suffixed URL would bypass per-tenant filtering.","operationId":"create_device","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDeviceReq"}}},"required":true},"responses":{"200":{"description":"New device with DoH URL","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceRow"}}}},"400":{"description":"Invalid slug or display name"},"409":{"description":"Slug already used in this tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["devices:write"]}],"x-required-scopes":["devices:write"]}},"/api/v1/devices/enrol":{"post":{"tags":["devices"],"description":"Enrol a machine using a join code. Creates a PENDING device that cannot resolve DNS until an administrator approves it. Unauthenticated by design - the join code is the credential, and it can do nothing else. Returns a one-time secret for polling this enrollment's own status.","operationId":"enrol_device","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnrolReq"}}},"required":true},"responses":{"200":{"description":"Pending device created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnrolResp"}}}},"400":{"description":"Unusable join code, or an unusable hostname"},"429":{"description":"Too many enrollment attempts for this code"}}}},"/api/v1/devices/enrol/{id}":{"get":{"tags":["devices"],"description":"Poll one enrollment's own status, authenticated by the enrollment secret returned at enrol time (X-Enrol-Secret header). Grants no other access and cannot see another device. Returns the DoH URL once approved.","operationId":"enrol_status","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnrolStatusResp"}}}},"401":{"description":"Missing or wrong enrollment secret"}}}},"/api/v1/devices/join-codes":{"get":{"tags":["devices"],"description":"List this tenant's join codes. Never returns the plaintext - it exists only in the create response.","operationId":"list_join_codes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/JoinCodeRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["devices:read"]}],"x-required-scopes":["devices:read"]},"post":{"tags":["devices"],"description":"Create an enrollment join code for this tenant. The code can ONLY create a pending device - it cannot read analytics, change policy, approve a device, or reach any device's DoH URL. Plaintext is returned once and never recoverable. Set max_uses to the size of the rollout: a code sized to its job stops working when the job is done, without anyone having to remember to revoke it. Omitting it means unlimited uses until expires_at, which defaults to a week.","operationId":"create_join_code","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateJoinCodeReq"}}},"required":true},"responses":{"200":{"description":"Code created; plaintext shown once","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateJoinCodeResp"}}}},"400":{"description":"max_uses out of range, or an empty/oversized label"},"403":{"description":"Caller is not an admin for this tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["devices:write"]}],"x-required-scopes":["devices:write"]}},"/api/v1/devices/join-codes/{id}":{"delete":{"tags":["devices"],"description":"Revoke a join code immediately. Machines already enrolled with it are unaffected - they have their own credentials.","operationId":"revoke_join_code","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":""},"404":{"description":""}},"security":[{"bearer_jwt":[]},{"api_key":["devices:write"]}],"x-required-scopes":["devices:write"]}},"/api/v1/devices/{id}":{"get":{"tags":["devices"],"description":"Fetch one device by id. Tenant-scoped — 404 if not in caller's tenant.","operationId":"get_device","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceRow"}}}},"404":{"description":""}},"security":[{"bearer_jwt":[]},{"api_key":["devices:read"]}],"x-required-scopes":["devices:read"]},"delete":{"tags":["devices"],"description":"Remove a device. The old DoH URL keeps ANSWERING — AGH no longer recognises the suffix, so it is treated as an unknown client and served the GLOBAL filter set. Measured 2026-08-14, not inferred: a deleted-slug path still resolves, still blocks globally-listed domains, and no longer applies any of this tenant's own rules, categories or default-deny. For a tenant on a strict profile that means adult and social stop being blocked on that URL — a revoked device becomes LESS filtered, not more. Deleting a device is therefore not a revocation of DNS access; to cut a lost machine off entirely, rotate the tenant DoH id. Audit log retains the historical attribution.","operationId":"delete_device","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":""},"404":{"description":""}},"security":[{"bearer_jwt":[]},{"api_key":["devices:write"]}],"x-required-scopes":["devices:write"]},"patch":{"tags":["devices"],"description":"Rename a device or update its description. Slug cannot be changed — delete and re-create instead (since the slug is baked into the customer-configured DoH URL).","operationId":"update_device","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDeviceReq"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceRow"}}}},"404":{"description":""}},"security":[{"bearer_jwt":[]},{"api_key":["devices:write"]}],"x-required-scopes":["devices:write"]}},"/api/v1/devices/{id}/approve":{"post":{"tags":["devices"],"description":"Approve a pending machine. Sets it active and admits it to the resolver. Reports whether admission was actually confirmed rather than assuming it - an unconfirmed approval returns admitted:false with the reconcile outcome.","operationId":"approve_device","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"Approved"},"404":{"description":"No such pending device on this tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["devices:write"]}],"x-required-scopes":["devices:write"]}},"/api/v1/devices/{id}/dns-stamp":{"get":{"tags":["devices"],"description":"Return an sdns:// DNS Stamp for this device. The stamp is a single-string config primitive consumed by dnscrypt-proxy, AdGuard mobile/desktop apps, NextDNS apps, and most modern DoH-capable resolvers. It encodes the device's DoH URL plus the DNSSEC informal-property bit. No-logs / no-filter bits are NOT set — we ingest query logs for per-tenant analytics and filtering is the product. Pin the stamp anywhere a sdns:// link is accepted instead of re-entering the URL.","operationId":"get_device_dns_stamp","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DnsStampResp"}}}},"404":{"description":"Device not found in this tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["devices:read"]}],"x-required-scopes":["devices:read"]}},"/api/v1/oidc/{tenant_id}/callback":{"get":{"tags":["oidc"],"description":"OIDC redirect URI. The IdP returns here with ?code=...&state=... after the user authenticates. We exchange the code for tokens, verify the ID token signature + nonce, JIT-provision the user if first-login, mint our existing JWT, and redirect the browser to the dashboard with the token in the URL fragment. Excluded from MCP — interactive only.","operationId":"callback","parameters":[{"name":"tenant_id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"302":{"description":"Redirect to dashboard with #token=..."}}}},"/api/v1/oidc/{tenant_id}/callback/post":{"post":{"tags":["oidc"],"summary":"Some IdPs use `response_mode=form_post` which sends code+state via\nPOST instead of GET. Accept the same params in either shape.","description":"OIDC redirect target for IdPs configured with                    `response_mode=form_post`, which deliver `code` and `state`                    as a form body instead of query parameters. Identical                    behaviour to the GET callback — same PKCE, state and nonce                    checks — and it delegates to it. Unauthenticated by                    necessity: the browser arrives here holding nothing but the                    IdP's response.","operationId":"callback_post","parameters":[{"name":"tenant_id","in":"path","description":"Tenant whose OIDC config to use","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/CallbackQuery"}}},"required":true},"responses":{"303":{"description":"Signed in — redirect to the dashboard with the session token"},"400":{"description":"Missing, expired or forged state; failed PKCE or nonce check"}}}},"/api/v1/oidc/{tenant_id}/login":{"get":{"tags":["oidc"],"description":"Initiate SP-initiated OIDC SSO for this tenant. Generates a CSRF state, nonce, and PKCE challenge, persists them in oidc_login_states for ~10 minutes, and HTTP-redirects the browser to the IdP's authorization endpoint. The IdP redirects back to /oidc/{tenant_id}/callback after the user authenticates. Excluded from MCP — interactive only.","operationId":"login_redirect","parameters":[{"name":"tenant_id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"return_to","in":"query","description":"Optional post-login redirect target","required":false,"schema":{"type":"string"}}],"responses":{"302":{"description":"Redirect to IdP authorize URL"}}}},"/api/v1/policies":{"get":{"tags":["policies"],"description":"Return the raw AGH client config for this tenant. Use when you need to inspect every knob at once — filtering switch, safe-search state, parental flag, blocked-services list with schedule, etc. For checking just one area, prefer the specific endpoints (e.g. GET /policies/rewrites).","operationId":"get_policy","responses":{"200":{"description":"Full per-tenant AGH client config","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["policies:read"]}],"x-required-scopes":["policies:read"]}},"/api/v1/policies/blocked-services":{"get":{"tags":["policies"],"summary":"NEW-098. GET reader for a PUT-only resource (see get_safe_search). Returns\nthe current `blocked_services` object; `null` when never configured.","description":"Read the tenant's current blocked-services list + schedule.","operationId":"get_blocked_services","responses":{"200":{"description":"Current blocked-services settings","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["policies:read"]}],"x-required-scopes":["policies:read"]},"put":{"tags":["policies"],"description":"Block named services (TikTok, Facebook, Discord, Twitch, ~130 others) by their AGH service id. Optional schedule restricts blocking to specific day windows — useful for 'block social media weekdays 9-17 except lunch.' `ids` REPLACES the current list (not delta) — read /policies first if you need to preserve existing entries.","operationId":"set_blocked_services","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlockedServicesReq"}}},"required":true},"responses":{"200":{"description":"AGH-curated service block list + optional schedule applied","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["policies:write"]}],"x-required-scopes":["policies:write"]}},"/api/v1/policies/custom-rules":{"get":{"tags":["policies"],"description":"Return this tenant's custom block + allow lists. Rewrites (managed via /policies/rewrites) and TLD-blocks (via /policies/threat-intel) are filtered out of this view to keep the surface clean. `raw_rules` carries the underlying AGH rule lines for debugging.","operationId":"get_custom_rules","responses":{"200":{"description":"Per-tenant allow + block lists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomRulesResp"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["policies:read"]}],"x-required-scopes":["policies:read"]},"put":{"tags":["policies"],"description":"Replace this tenant's block + allow lists with the supplied arrays. Allows override blocks (standard AGH semantics). Subdomains are covered automatically: a rule for 'example.com' renders as '||example.com^', which AdGuard already matches against every subdomain, so 'www.example.com' is blocked too. Do NOT send a wildcard — '*' is rejected with 400 (allowed characters are letters, digits, '.' and '-'). This field previously read 'Wildcards supported (e.g. *.example.com)', which was never true; it is what made the MCP block_domain(wildcard=true) tool fail on every call. Rewrites and TLD-blocks are NOT touched — they live in their own endpoints and coexist independently. NOT IMMEDIATE: a 200 means the rule is stored and handed to the resolver, not that it is already being enforced. The resolver rebuilds its whole match engine on every rule change, so enforcement lags the response by seconds and the wait grows with the number of filter lists loaded — measured at 4s with 875k rules and 6s with 1.42M. Queries sent inside that window resolve normally and are logged unblocked. Poll by re-querying the domain rather than sleeping a fixed time.","operationId":"set_custom_rules","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomRulesReq"}}},"required":true},"responses":{"200":{"description":"Updated allow + block lists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomRulesResp"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["policies:write"]}],"x-required-scopes":["policies:write"]}},"/api/v1/policies/default-deny":{"get":{"tags":["policies"],"description":"Read whether the tenant is in allowlist-only (default-deny) mode. When enabled, every query is blocked unless an `@@||domain^` allow rule explicitly permits it. Read allow_count too — enabled=true + allow_count=0 means nothing resolves at all.","operationId":"get_default_deny","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DefaultDenyResp"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["policies:read"]}],"x-required-scopes":["policies:read"]},"put":{"tags":["policies"],"description":"Toggle allowlist-only mode. When enabling, the response includes the current allow_count — if it's 0 you've just blocked ALL queries for this tenant. The catch-all rule ||*^ coexists with all other rule types (block/allow, rewrites, TLD blocks) and is managed only by this endpoint.","operationId":"set_default_deny","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DefaultDenyReq"}}},"required":true},"responses":{"200":{"description":"Post-update state with safety counter","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DefaultDenyResp"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["policies:write"]}],"x-required-scopes":["policies:write"]}},"/api/v1/policies/filtering":{"put":{"tags":["policies"],"description":"Master ON/OFF switch for ALL filtering on this tenant. When false, every blocklist, custom rule, rewrite, parental, safe-search, and threat-intel check is bypassed and queries resolve as if olladns were a passthrough. Use sparingly — most ops want to toggle a specific feature, not the master.","operationId":"set_filtering","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FilteringReq"}}},"required":true},"responses":{"200":{"description":"Master filtering switch updated"}},"security":[{"bearer_jwt":[]},{"api_key":["policies:write"]}],"x-required-scopes":["policies:write"]}},"/api/v1/policies/parental":{"put":{"tags":["policies"],"description":"Toggle the AdGuard-curated parental-control (adult content) and safe-browsing (malware/phishing) filters. These are AGH-provided category filters, separate from custom blocklist subscriptions. Quick way to harden a tenant without picking specific lists.","operationId":"set_parental","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ParentalReq"}}},"required":true},"responses":{"200":{"description":"Parental + safe-browsing toggles applied","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["policies:write"]}],"x-required-scopes":["policies:write"]}},"/api/v1/policies/protect-list":{"get":{"tags":["policies"],"description":"Return the tenant's typosquat protect-list. The poller scores every query against these domains (Damerau-Levenshtein ≤ 2, with homoglyph normalization) and tags matches in dns_queries.typosquat_of. Empty list = typosquat detection is effectively off for this tenant (zero per-query cost).","operationId":"get_protect_list","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProtectListResp"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["policies:read"]}],"x-required-scopes":["policies:read"]},"put":{"tags":["policies"],"description":"Replace the tenant's typosquat protect-list with the supplied domains. Typical entries: your own brand domains (acme.com, acme.io), frequently-impersonated SaaS your team uses (slack.com, github.com), and executive alias domains. Recommended size: 5-50 entries. Larger lists work but cost more per query at the classifier.","operationId":"set_protect_list","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetProtectListReq"}}},"required":true},"responses":{"200":{"description":"Updated protect-list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProtectListResp"}}}},"400":{"description":"Invalid domain format"}},"security":[{"bearer_jwt":[]},{"api_key":["policies:write"]}],"x-required-scopes":["policies:write"]}},"/api/v1/policies/rewrites":{"get":{"tags":["policies"],"description":"List this tenant's DNS rewrites — domain-to-answer overrides for A / AAAA / CNAME / NXDOMAIN / REFUSED. Use this before PUT-ing a new list so you don't accidentally drop existing entries (PUT is full-replace, not delta).","operationId":"get_rewrites","responses":{"200":{"description":"Per-tenant DNS rewrites","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RewritesResp"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["policies:read"]}],"x-required-scopes":["policies:read"]},"put":{"tags":["policies"],"description":"Replace this tenant's DNS rewrites with the supplied list. Each rewrite has: domain, kind (A | AAAA | CNAME | NXDOMAIN | REFUSED), value (IP for A/AAAA, hostname for CNAME, omit for NXDOMAIN/REFUSED). Use for: point internal hosts at corporate IPs, sinkhole specific apps to a captive portal, block exactly one domain without subscribing to a whole list.","operationId":"set_rewrites","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RewritesReq"}}},"required":true},"responses":{"200":{"description":"Rewrites reconciled to the given list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RewritesResp"}}}},"400":{"description":"Invalid rewrite kind or missing value"}},"security":[{"bearer_jwt":[]},{"api_key":["policies:write"]}],"x-required-scopes":["policies:write"]}},"/api/v1/policies/safe-search":{"get":{"tags":["policies"],"summary":"NEW-097. GET reader for a PUT-only resource — a bare GET used to 405, with\nno way to read current state without replaying the write. Returns the same\n`safe_search` object the PUT echoes; `null` when never configured.","description":"Read the tenant's current SafeSearch settings.","operationId":"get_safe_search","responses":{"200":{"description":"Current safe-search settings","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["policies:read"]}],"x-required-scopes":["policies:read"]},"put":{"tags":["policies"],"description":"Force safe-search on supported engines (Google, Bing, DuckDuckGo, YouTube, Yandex, Ecosia, Pixabay) by rewriting their DNS responses to the safe-mode hostname. `enabled` is the master toggle; per-engine fields opt in/out individually. Use when a tenant wants kid-safe search regardless of browser settings.","operationId":"set_safe_search","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SafeSearchReq"}}},"required":true},"responses":{"200":{"description":"Safe-search settings applied","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["policies:write"]}],"x-required-scopes":["policies:write"]}},"/api/v1/policies/threat-intel":{"get":{"tags":["policies"],"description":"Read the high-level threat-intel toggle state for this tenant: newly_registered_domains (NRD blocking), tld_blocking (with the list of TLDs), and block_evasion (DoH/VPN/proxy bypass blocklist). newly_registered_domains currently returns NULL, which means UNAVAILABLE and not off: the upstream NRD list was withdrawn and no replacement is configured, so PUT refuses to enable it rather than reporting protection that is not delivered. tld_blocking and block_evasion are unaffected. parked_domains, dga and typosquatting also return null — reserved for the classifier work landing later.","operationId":"get_threat_intel","responses":{"200":{"description":"Current threat-intel toggle state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ThreatIntelResp"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["policies:read"]}],"x-required-scopes":["policies:read"]},"put":{"tags":["policies"],"description":"Intent-level threat-intel toggles. Sugar over existing primitives — newly_registered_domains subscribes the Hagezi NRD list; tld_blocking adds wildcard block rules like ||*.zip^; block_evasion subscribes the Hagezi DoH/VPN/proxy bypass list. NOTE: block_evasion is UNAVAILABLE wherever per-tenant blocklist feeds are off — the resolver loads one global filter set, so a subscription is recorded and never enforced. It reads back as null there and enabling it returns 400 rather than confirming protection that is not delivered. Browser-managed DoH additionally needs browser policy or a network rule; no DNS toggle can reach it. Pass only the fields you want to change. tld_blocking.enabled=false clears the TLD list entirely. Each field reconciles independently.","operationId":"set_threat_intel","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ThreatIntelReq"}}},"required":true},"responses":{"200":{"description":"Threat-intel state after applying changes","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ThreatIntelResp"}}}},"400":{"description":"Invalid TLD format, or an attempt to enable a capability that is unavailable"}},"security":[{"bearer_jwt":[]},{"api_key":["policies:write"]}],"x-required-scopes":["policies:write"]}},"/api/v1/profiles":{"get":{"tags":["profiles"],"description":"List the protection profiles available to this tenant. A profile is a named protection level; assign one with PUT /profiles/me. `additional_blocklists` counts what a tier adds on top of the default — the default itself adds nothing, because it is the protection every tenant already receives.","operationId":"list_profiles","responses":{"200":{"description":"Available protection profiles","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProfileRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["profiles:read"]}],"x-required-scopes":["profiles:read"]}},"/api/v1/profiles/drift":{"get":{"tags":["profiles"],"description":"Compare every tenant's assigned protection profile against                    the client tags the resolver actually holds. Read-only.                    Platform-admin only. `missing_client` means the tenant has no                    resolver client at all, which tag repair cannot fix.","operationId":"profile_drift","responses":{"200":{"description":"Per-tenant expected vs actual tags","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DriftRow"}}}}},"403":{"description":"Caller is not platform admin"}},"security":[{"bearer_jwt":[]},{"api_key":["profiles:read"]}],"x-required-scopes":["profiles:read"]}},"/api/v1/profiles/me":{"get":{"tags":["profiles"],"description":"Return this tenant's assigned protection profile and whether it is fully in force at the resolver. `in_sync` false means the profile's contents changed after this tenant was last reconciled — the tenant is still protected, but by the previous revision.","operationId":"get_my_profile","responses":{"200":{"description":"This tenant's protection profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MyProfileResp"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["profiles:read"]}],"x-required-scopes":["profiles:read"]},"put":{"tags":["profiles"],"description":"Move this tenant to a different protection profile. The target must be listed as `available` by GET /profiles: a tier whose filtering is not installed is refused rather than recorded, because recording it would promise protection that is not being delivered. Idempotent — re-assigning the current profile is safe and rewrites nothing.","operationId":"assign_my_profile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignProfileReq"}}},"required":true},"responses":{"200":{"description":"Assigned","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignProfileResp"}}}},"400":{"description":"Profile is not available for assignment"},"404":{"description":"No such profile"}},"security":[{"bearer_jwt":[]},{"api_key":["profiles:write"]}],"x-required-scopes":["profiles:write"]}},"/api/v1/profiles/me/categories":{"get":{"tags":["profiles"],"description":"List the protection categories and whether this tenant receives each. Categories are ADDITIVE on top of the baseline every tenant already gets — switching one on adds filtering, switching it off returns to the baseline. It cannot reduce protection below the baseline. `lists` is how many catalog lists the category compiles and `lists_not_contributing` how many of those currently add nothing — the upstream is gone, or it serves a format this compiler cannot emit. Either way it is skipped rather than substituted, so a non-zero value means the category is thinner than `lists` suggests.","operationId":"list_my_categories","responses":{"200":{"description":"Per-category state","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CategoryRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["profiles:read"]}],"x-required-scopes":["profiles:read"]},"put":{"tags":["profiles"],"description":"Replace the set of protection categories this tenant receives. ABSOLUTE, not a delta — anything omitted is switched off. Every category must be listed as `available`; one that is not installed at the resolver is refused rather than recorded. Setting this detaches the tenant from its profile's implied categories. NOT IMMEDIATE: the resolver applies the change seconds after this returns — see PUT /policies/custom-rules.","operationId":"set_my_categories","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetCategoriesReq"}}},"required":true},"responses":{"200":{"description":"Updated per-category state","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CategoryRow"}}}}},"400":{"description":"Unknown or unavailable category"}},"security":[{"bearer_jwt":[]},{"api_key":["profiles:write"]}],"x-required-scopes":["profiles:write"]}},"/api/v1/profiles/reconcile-tags":{"post":{"tags":["profiles"],"description":"Rewrite resolver client tags so they match each tenant's                    assigned protection profile. Idempotent. Tags this product                    did not allocate are preserved. Tenants with no resolver                    client are reported and skipped rather than silently counted                    as repaired. Platform-admin only.","operationId":"reconcile_profile_tags","responses":{"200":{"description":"How many clients were rewritten","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReconcileTagsResp"}}}},"403":{"description":"Caller is not platform admin"}},"security":[{"bearer_jwt":[]},{"api_key":["profiles:write"]}],"x-required-scopes":["profiles:write"]}},"/api/v1/tenants":{"get":{"tags":["tenants"],"description":"Cross-tenant list — PLATFORM ADMIN ONLY (tenant 1). Every other caller gets 403 and should use GET /tenants/me, which returns just their own tenant.","operationId":"list_tenants","responses":{"200":{"description":"All tenants (platform-admin view)","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TenantRow"}}}}},"403":{"description":"Caller is not the platform admin — use /tenants/me"}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:read"]}],"x-required-scopes":["tenants:read"]},"post":{"tags":["tenants"],"description":"Provision a fresh tenant + its first admin user. Also seeds default blocklist subscriptions and registers an AdGuard Home client for the tenant's DoH path routing. Platform-admin only. Excluded from MCP — provisioning is a human gate.","operationId":"create_tenant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTenantReq"}}},"required":true},"responses":{"200":{"description":"Newly created tenant + endpoints + admin user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTenantResp"}}}},"403":{"description":"Caller is not the platform admin (tenant 1)"}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:write"]}],"x-required-scopes":["tenants:write"]}},"/api/v1/tenants/me":{"get":{"tags":["tenants"],"summary":"Return the caller's own tenant — agents and humans both use this when\nthey hold no other tenant identifier than their token.","description":"Returns the caller's own tenant — name, contact email, AGH client id, and the three public DNS endpoints (DoH URL, DoT host:port, plain Do53 host:port). Agents call this when they need the DoH URL to give an end-user, or to confirm which tenant a key belongs to.","operationId":"get_my_tenant","responses":{"200":{"description":"Caller's tenant including DoH/DoT/Do53 endpoints","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantRow"}}}},"404":{"description":"Tenant not found"}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:read"]}],"x-required-scopes":["tenants:read"]}},"/api/v1/tenants/me/dns-check":{"get":{"tags":["tenants"],"description":"Self-service wiring check. Returns the DoH/DoT/Do53 endpoints this tenant should be using, the exact client_id each one should produce, and the most recent query the backend actually attributed to this tenant. Use it to confirm a newly configured resolver is landing on the right tenant and device: resolve any name through the DoH URL, then poll this endpoint until `last_seen` shows it. Ingest runs on a ~5s poll from AdGuard, so an empty or stale `last_seen` immediately after a query means 'not yet', not 'broken' — retry for ~15s before concluding anything.","operationId":"dns_check","responses":{"200":{"description":"Expected endpoints + what the backend last saw","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DnsCheckResp"}}}},"404":{"description":"Tenant not found"}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:read"]}],"x-required-scopes":["tenants:read"]}},"/api/v1/tenants/me/dns-stamp":{"get":{"tags":["tenants"],"description":"Return an sdns:// DNS Stamp for the caller's tenant-level DoH URL (no device suffix). Hand this to any DoH client that accepts sdns:// stamps — dnscrypt-proxy, AdGuard apps, NextDNS apps. DNSSEC informal-property bit is set; no-logs / no-filter bits are not (filtering + per-tenant logging are the product). For per-device stamps see /devices/{id}/dns-stamp.","operationId":"get_tenant_dns_stamp","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DnsStampResp"}}}},"404":{"description":""}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:read"]}],"x-required-scopes":["tenants:read"]}},"/api/v1/tenants/{id}":{"delete":{"tags":["tenants"],"description":"Permanently delete a tenant. Cascades to users, api_keys, blocklist subscriptions, and audit_logs. Removes the AGH client. Cannot delete tenant 1 (the default). IRREVERSIBLE — excluded from MCP.","operationId":"delete_tenant","parameters":[{"name":"id","in":"path","description":"Tenant id to delete","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"Tenant deleted (cascades to users/keys/audit)"},"400":{"description":"Cannot delete tenant 1"},"403":{"description":"Caller not authorized for this tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:write"]}],"x-required-scopes":["tenants:write"]},"patch":{"tags":["tenants"],"description":"Update tenant name and/or contact_email. Pass only the fields you want to change. Setting contact_email to an empty string clears it. Tenant admins can edit only their own tenant; platform admin (tenant 1) can edit any.","operationId":"patch_tenant","parameters":[{"name":"id","in":"path","description":"Tenant id","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchTenantReq"}}},"required":true},"responses":{"200":{"description":"Updated tenant row","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantRow"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:write"]}],"x-required-scopes":["tenants:write"]}},"/api/v1/tenants/{id}/branding":{"get":{"tags":["branding"],"description":"Read this tenant's block-page branding (org name, appeal contact, custom message, color, logo metadata). Logo bytes are NOT in this response — fetch them separately at /tenants/{id}/branding/logo.","operationId":"get_branding","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BrandingRow"}}}},"404":{"description":"No branding row for this tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:read"]}],"x-required-scopes":["tenants:read"]},"put":{"tags":["branding"],"description":"Create or replace branding text fields (NOT the logo — that uses PUT /branding/logo). Idempotent ON CONFLICT. Pass null on any optional field to clear it. enabled=false makes the block page fall back to the generic olladns template for this tenant.","operationId":"put_branding","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutBrandingReq"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BrandingRow"}}}},"400":{"description":"Validation failure"}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:write"]}],"x-required-scopes":["tenants:write"]},"delete":{"tags":["branding"],"description":"Remove the entire branding row (text fields AND logo). Block page falls back to the generic olladns template. IRREVERSIBLE — excluded from MCP.","operationId":"delete_branding","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":""},"404":{"description":""}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:write"]}],"x-required-scopes":["tenants:write"]}},"/api/v1/tenants/{id}/branding/logo":{"get":{"tags":["branding"],"description":"Public — serves the raw logo bytes. Used by the block-page renderer's <img src=...>. No auth required (logos are public branding by design). Returns 404 if tenant has no logo. ETag based on updated_at for CDN/browser cache.","operationId":"get_branding_logo","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"Raw image bytes with correct Content-Type"},"404":{"description":"No logo for this tenant"}}},"put":{"tags":["branding"],"description":"Upload (or replace) this tenant's logo. Send raw image bytes as the request body with the correct Content-Type. Allowed types: image/png, image/jpeg, image/webp. SVG is NOT accepted - it can carry script and the bytes are re-served to browsers. Max size: 100 KB. Creates the branding row if it does not already exist (text fields stay NULL).","operationId":"put_branding_logo","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"description":"Raw image bytes","content":{"image/png":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"Logo stored","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BrandingRow"}}}},"400":{"description":"Bad mime / too large / empty body"}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:write"]}],"x-required-scopes":["tenants:write"]},"delete":{"tags":["branding"],"description":"Remove the logo bytes; keeps text branding fields. IRREVERSIBLE.","operationId":"delete_branding_logo","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":""},"404":{"description":""}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:write"]}],"x-required-scopes":["tenants:write"]}},"/api/v1/tenants/{id}/oidc":{"get":{"tags":["oidc"],"description":"Fetch this tenant's OIDC SSO config. client_secret is NEVER returned (write-only). Includes derived `redirect_uri` and `login_url` for IdP-side registration.","operationId":"get_oidc_config","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OidcConfigRow"}}}},"404":{"description":"OIDC not configured for this tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:read"]}],"x-required-scopes":["tenants:read"]},"put":{"tags":["oidc"],"description":"Create or replace the OIDC SSO config for this tenant. The admin sets issuer_url + client_id + client_secret from the IdP-side application registration. Optional role_claim + role_map drive automatic role assignment for JIT-provisioned users (e.g. an Azure AD group named 'olladns-admins' maps to our 'admin' role). email_domain_lock restricts JIT to a specific email domain. Response includes the redirect_uri the customer must register in their IdP application.","operationId":"put_oidc_config","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutOidcConfigReq"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OidcConfigRow"}}}},"400":{"description":"Invalid issuer_url / role"}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:write"]}],"x-required-scopes":["tenants:write"]},"delete":{"tags":["oidc"],"description":"Remove the OIDC SSO config for this tenant. Existing users stay (they keep password-based auth); new SSO sign-ins fail. IRREVERSIBLE — excluded from MCP.","operationId":"delete_oidc_config","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":""},"404":{"description":""}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:write"]}],"x-required-scopes":["tenants:write"]}},"/api/v1/tenants/{id}/rotate-dns-id":{"post":{"tags":["tenants"],"description":"Rotate this tenant's DoH client id — the `<uuid>` in /dns-query/<uuid>. That path component is the only thing identifying a tenant on the wire, so it behaves like a bearer credential: use this if it has leaked. DESTRUCTIVE to configuration — the previous URL stops resolving as this tenant at once, and every per-device URL changes with it, so all configured clients must be updated. Analytics history is preserved: retired ids stay queryable for reporting. Excluded from MCP — an agent must not silently re-key a customer's resolvers.","operationId":"rotate_dns_id","parameters":[{"name":"id","in":"path","description":"Tenant id","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"New DoH identity + endpoints","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateDnsIdResp"}}}},"403":{"description":"Caller is not admin of this tenant"},"404":{"description":"Tenant not found"}},"security":[{"bearer_jwt":[]},{"api_key":["tenants:write"]}],"x-required-scopes":["tenants:write"]}},"/api/v1/users":{"get":{"tags":["users"],"description":"List every human user (JWT-capable) in the caller's tenant. Returns id, email, role, created_at. Does NOT list API keys — those are separate principals (see GET /api-keys).","operationId":"list_users","responses":{"200":{"description":"All users in the caller's tenant","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["users:read"]}],"x-required-scopes":["users:read"]},"post":{"tags":["users"],"description":"Create a new human user in the caller's tenant. Role defaults to 'viewer' (read-only via JWT); set 'admin' to grant tenant-admin powers. Password is bcrypted server-side. Admins typically create one user per team member and let them change_password on first login.","operationId":"create_user","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserReq"}}},"required":true},"responses":{"200":{"description":"New user in caller's tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserRow"}}}},"409":{"description":"Email already in use"}},"security":[{"bearer_jwt":[]},{"api_key":["users:write"]}],"x-required-scopes":["users:write"]}},"/api/v1/users/{id}":{"get":{"tags":["users"],"description":"Fetch one user by id. Strictly tenant-scoped — looking up a user in another tenant returns 404, never 403, so an attacker can't probe for valid ids across tenants.","operationId":"get_user","parameters":[{"name":"id","in":"path","description":"User id (must be in caller's tenant)","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserRow"}}}},"404":{"description":"User not in caller's tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["users:read"]}],"x-required-scopes":["users:read"]},"put":{"tags":["users"],"description":"Update role and/or password for a user. Both fields are optional; pass only what's changing. Use to promote a viewer to admin, demote an admin, or reset a forgotten password (admin-driven; the user can self-serve via POST /auth/change-password instead).","operationId":"update_user","parameters":[{"name":"id","in":"path","description":"User id to update","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserReq"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserRow"}}}},"404":{"description":"User not in caller's tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["users:write"]}],"x-required-scopes":["users:write"]},"delete":{"tags":["users"],"description":"Permanently delete a user from the caller's tenant. Cannot delete self (prevents accidental lockout). Use when a team member leaves. The user's API keys are NOT cascaded — revoke those separately via DELETE /api-keys/{id}. IRREVERSIBLE — excluded from MCP.","operationId":"delete_user","parameters":[{"name":"id","in":"path","description":"User id to delete","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"User deleted"},"400":{"description":"Cannot delete self"},"404":{"description":"User not in caller's tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["users:write"]}],"x-required-scopes":["users:write"]}},"/api/v1/version":{"get":{"tags":["health"],"summary":"Build provenance — git SHA + build time. Lets ops verify what is\nactually running matches a known commit. Both env vars are baked\nat compile time by the Dockerfile (falls back to \"unknown\" in\nlocal dev builds).","description":"Version and build provenance of the running API: semantic version, git SHA and build timestamp. Unauthenticated. Call this first from a client to record exactly what it is talking to — it matches the `info.version` of this spec. Also served at `/version` for container healthchecks.","operationId":"version","responses":{"200":{"description":"version, git_sha, built_at"}}}},"/api/v1/webhooks":{"get":{"tags":["webhooks"],"description":"List every webhook registered on the caller's tenant. Returns url, events filter, enabled flag, last delivery status, and running delivery/failure counters. Never returns the signing secret — that's only shown once at creation.","operationId":"list_webhooks","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WebhookRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["webhooks:read"]}],"x-required-scopes":["webhooks:read"]},"post":{"tags":["webhooks"],"description":"Register a new outbound webhook. URL must be HTTPS; loopback, RFC1918, link-local and ULA targets are refused, and the host is re-resolved at delivery time because DNS can be repointed after registration. Events is a list of dotted action names; trailing `*` matches a prefix (e.g. `audit.*` matches every audit event). The signing secret is generated server- side and returned plaintext ONCE — every subsequent delivery carries `X-Olladns-Signature: sha256=HEX` computed as HMAC-SHA256(secret, raw-body).","operationId":"create_webhook","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookReq"}}},"required":true},"responses":{"200":{"description":"New webhook with secret shown once","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookResp"}}}},"400":{"description":"Invalid URL or events list"}},"security":[{"bearer_jwt":[]},{"api_key":["webhooks:write"]}],"x-required-scopes":["webhooks:write"]}},"/api/v1/webhooks/{id}":{"get":{"tags":["webhooks"],"summary":"NEW-053. GET reader for a single webhook — a bare `GET /webhooks/{id}` used\nto 405 (only DELETE/rotate existed on the path). Tenant-scoped: an id that\nbelongs to another tenant reads as 404, not 403, so it is not an existence\noracle. The signing secret is never returned, same as the list.","description":"Read one webhook by id (tenant-scoped). Never returns the secret.","operationId":"get_webhook","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookRow"}}}},"404":{"description":"No such webhook on this tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["webhooks:read"]}],"x-required-scopes":["webhooks:read"]},"delete":{"tags":["webhooks"],"description":"Remove a webhook. In-flight deliveries that already grabbed the row continue; no new deliveries fire after this returns.","operationId":"delete_webhook","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":""},"404":{"description":""}},"security":[{"bearer_jwt":[]},{"api_key":["webhooks:write"]}],"x-required-scopes":["webhooks:write"]}},"/api/v1/webhooks/{id}/rotate":{"post":{"tags":["webhooks"],"summary":"Mint a new signing secret for an existing webhook.","description":"Replace this webhook's HMAC signing secret, keeping its id, URL and event subscriptions. Use after a secret is exposed. The new secret is returned ONCE and takes effect on the next delivery — update your verifier before the next event fires, or signatures will not validate.","operationId":"rotate_webhook_secret","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"New secret, shown once","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookResp"}}}},"404":{"description":"No such webhook on this tenant"}},"security":[{"bearer_jwt":[]},{"api_key":["webhooks:write"]}],"x-required-scopes":["webhooks:write"]}},"/api/v1/whoami":{"get":{"tags":["auth"],"summary":"Identity introspection for the calling principal. Used primarily by the\nMCP sidecar to decide which tools to expose to a given agent token.\nCheap: no scope check, no audit write.","description":"Identity introspection. Returns the caller's tenant id, key label (if an API key), the scopes the principal holds, and when the key expires. Agents should call this once at session start to orient themselves — the scope list tells them which other tools will succeed. Cheap, no audit write.","operationId":"whoami","responses":{"200":{"description":"Caller identity, type, and scope grant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WhoamiResp"}}}}},"security":[{"bearer_jwt":[]},{"api_key":[]}]}}},"components":{"schemas":{"AiCandidateRow":{"type":"object","required":["query_name","queries","devices","lexical","co_occurrence","score","first_seen","last_seen"],"properties":{"co_occurrence":{"type":"number","format":"float","description":"How much more this domain appears on devices that use a KNOWN AI tool\nthan the fleet baseline. **Zero when it cannot discriminate** — one\ndevice, or every device using AI. Not a constant: a signal that says\nthe same thing about every row must contribute nothing."},"devices":{"type":"integer","format":"int64","minimum":0},"first_seen":{"type":"string"},"last_seen":{"type":"string"},"lexical":{"type":"integer","format":"int32","description":"Strength of the AI-ish tokens in the name. This is the only signal with\nreal precision on a small fleet, so it is also the gate.","minimum":0},"queries":{"type":"integer","format":"int64","minimum":0},"query_name":{"type":"string"},"score":{"type":"number","format":"float"}}},"AssignProfileReq":{"type":"object","required":["stable_key"],"properties":{"stable_key":{"type":"string","description":"`stable_key` of the target profile, from `GET /profiles`. Machine key,\nnot the display `name` — the latter is marketing text and may change."}}},"AssignProfileResp":{"type":"object","required":["tenant_id","stable_key","name","revision","resolver_unchanged"],"properties":{"name":{"type":"string"},"previous_stable_key":{"type":["string","null"],"description":"What the tenant was on before. `None` if it had no assignment at all."},"resolver_unchanged":{"type":"boolean","description":"True when the resolver already agreed and no write was needed — i.e.\nre-assigning the same tier, or assigning the untagged default. False\nmeans the tags were rewritten successfully. It never means \"the write\nfailed\": that returns an error status, because a status code cannot be\nignored and a response field can."},"revision":{"type":"integer","format":"int32","description":"The profile revision this assignment was made against."},"stable_key":{"type":"string"},"tenant_id":{"type":"integer","format":"int32"}}},"AuditQuery":{"type":"object","properties":{"action":{"type":["string","null"]},"actor_type":{"type":["string","null"],"description":"Filter by actor type: \"user\", \"api_key\", or \"system\". Empty/absent = no filter."},"api_key_id":{"type":["integer","null"],"format":"int32","description":"Filter by specific API key id (only meaningful with actor_type=api_key)."},"from":{"type":["string","null"],"format":"date-time"},"limit":{"type":["integer","null"],"format":"int32","minimum":0},"offset":{"type":["integer","null"],"format":"int32","minimum":0},"to":{"type":["string","null"],"format":"date-time"}}},"AuditRow":{"type":"object","required":["id","actor_type","action","created_at"],"properties":{"action":{"type":"string"},"actor_type":{"type":"string"},"api_key_id":{"type":["integer","null"],"format":"int32"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"integer","format":"int64"},"metadata":{},"prev_hash":{"type":["string","null"],"description":"row_hash of the prior row in this tenant's chain. NULL for the\nfirst chained row per tenant (genesis) or pre-rollout rows."},"row_hash":{"type":["string","null"],"description":"SHA-256 hex of this row's canonicalized fields plus prev_hash.\nTamper-evident. NULL on rows written before the v0.39 chain rollout."},"tenant_id":{"type":["integer","null"],"format":"int32"},"user_id":{"type":["integer","null"],"format":"int64"}}},"BlockRateRow":{"type":"object","required":["total","blocked","rate_percent"],"properties":{"blocked":{"type":"integer","format":"int64","minimum":0},"rate_percent":{"type":"number","format":"double"},"total":{"type":"integer","format":"int64","minimum":0}}},"BlockedRow":{"type":"object","required":["domain","count","reason"],"properties":{"count":{"type":"integer","format":"int64","minimum":0},"domain":{"type":"string"},"reason":{"type":"string"}}},"BlockedServicesReq":{"type":"object","required":["ids"],"properties":{"ids":{"type":"array","items":{"type":"string"}},"schedule":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/ScheduleReq"}]}},"additionalProperties":false},"BrandingRow":{"type":"object","required":["tenant_id","enabled"],"properties":{"appeal_email":{"type":["string","null"]},"appeal_url":{"type":["string","null"]},"custom_message":{"type":["string","null"]},"enabled":{"type":"boolean"},"logo_mime":{"type":["string","null"]},"logo_url":{"type":["string","null"],"description":"Public URL where the logo (if any) is served. None if no logo\nuploaded. Always under /api/v1/tenants/{id}/branding/logo so it\ncan be inlined into the block page's <img src=...>."},"org_name":{"type":["string","null"]},"primary_color":{"type":["string","null"]},"tenant_id":{"type":"integer","format":"int32"}}},"ByReasonRow":{"type":"object","required":["block_reason","count","unique_domains"],"properties":{"block_reason":{"type":"string"},"count":{"type":"integer","format":"int64","minimum":0},"unique_domains":{"type":"integer","format":"int64","minimum":0}}},"CallbackQuery":{"type":"object","properties":{"code":{"type":["string","null"]},"error":{"type":["string","null"]},"error_description":{"type":["string","null"]},"state":{"type":["string","null"]}}},"CatalogRow":{"type":"object","required":["id","name","url","category","default_enabled","created_at","health","title_mismatch"],"properties":{"agh_filter_id":{"type":["integer","null"],"format":"int32"},"category":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"default_enabled":{"type":"boolean"},"description":{"type":["string","null"]},"health":{"type":"string","description":"What the last fetch PROVED: `ok`, `dead`, or `unchecked`. A dead list is\nskipped by the compiler rather than injected as rules, so this is not a\ncorrectness risk — it is the difference between advertising a list and\ndelivering one."},"id":{"type":"integer","format":"int32"},"last_checked_at":{"type":["string","null"],"format":"date-time"},"last_error":{"type":["string","null"]},"last_http_status":{"type":["integer","null"],"format":"int32"},"last_rule_count":{"type":["integer","null"],"format":"int32"},"license":{"type":["string","null"]},"name":{"type":"string"},"title_mismatch":{"type":"boolean","description":"Advisory only, and heuristic — see `title_mismatch`. Deliberately not a\n`health` state, so that measured fact stays separable from inference."},"upstream_title":{"type":["string","null"],"description":"The upstream's own `! Title:` header, verbatim, so a mismatch can be\njudged without refetching."},"url":{"type":"string","description":"Where the list comes FROM, for a human browsing the catalog — not\nnecessarily where AGH fetches it.\n\nFor a mirrored list these differ: `blocklist_catalog.url` is the\naddress AGH is pointed at, which since v0.63 is the internal mirror\nlistener (`http://172.18.0.202:8081/...`, never published, no Caddy\nvhost). Projecting that column straight into this response put an\nRFC1918 address and an internal port in front of every tenant in the\ndashboard's blocklist table — an implementation detail that is both a\nsmall infrastructure disclosure and useless to the reader, who wants\nto know the list is Hagezi's.\n\nSo this is `COALESCE(mirror_source_url, url)`. Unmirrored rows are\nunaffected. The AGH-facing column is deliberately untouched: reconcile\ncomputes `desired` from its own query, and cutover is done by changing\n`url`, never by editing AGH."}}},"CategoryRow":{"type":"object","required":["category","lists","lists_not_contributing","enabled","available","source"],"properties":{"available":{"type":"boolean","description":"False until the category's compiled file is installed at the resolver.\nAn unavailable category cannot be switched on, because doing so would\npromise filtering that is not there."},"category":{"type":"string","description":"Machine key. Matches `blocklist_catalog.category`."},"enabled":{"type":"boolean","description":"Whether this tenant currently receives it."},"lists":{"type":"integer","format":"int64","description":"How many catalog lists this category compiles."},"lists_not_contributing":{"type":"integer","format":"int64","description":"How many of those `lists` contribute nothing to the compiled feed —\neither the upstream is gone (`dead`) or it serves a format this compiler\ncannot emit (`unusable`: wildcard, regex or modifier-bearing rules).\nBoth are skipped rather than injected, so the category still works; it\njust delivers less than `lists` implies. Reporting the count is the\ndifference between a category quietly thinner than advertised and one\nthat says so.\n\nNamed for the effect, not the cause: an earlier `lists_dead` conflated\nthe two, and calling a healthy-but-uncompilable upstream \"dead\" is what\nmade this checker libel a live global filter."},"source":{"type":"string","description":"`profile` when this comes from the assigned preset, `explicit` when the\ntenant has chosen for itself. Once anything is chosen explicitly, the\npreset stops contributing — otherwise switching a category OFF would be\nindistinguishable from never having set it."}}},"ChainVerifyResp":{"type":"object","required":["intact","rows_checked"],"properties":{"broken_at_id":{"type":["integer","null"],"format":"int64","description":"id of the first row whose stored hash does not match the\nrecomputed hash, or whose prev_hash does not match the previous\nrow's row_hash. None when intact."},"broken_reason":{"type":["string","null"],"description":"Human-readable reason when broken."},"chain_end_id":{"type":["integer","null"],"format":"int64","description":"Most recent chained id for this tenant."},"chain_start_id":{"type":["integer","null"],"format":"int64","description":"First chained id (genesis after rollout) for this tenant."},"intact":{"type":"boolean","description":"True iff every chained row recomputes to its stored row_hash and\nlinks cleanly to the prior row's row_hash. Rows written before the\nv0.39 chain rollout (row_hash IS NULL) are skipped — the chain\nstarts at the first non-null row."},"rows_checked":{"type":"integer","format":"int64","description":"Total chained rows inspected."}}},"ChangePasswordReq":{"type":"object","required":["current_password","new_password"],"properties":{"current_password":{"type":"string"},"new_password":{"type":"string"}},"additionalProperties":false},"ClientArtifact":{"type":"object","required":["filename","title","description","os","size_bytes","sha256","url","available"],"properties":{"available":{"type":"boolean","description":"False when the file is not present on this deployment; the dashboard\ngreys it out rather than offering a download that 404s."},"description":{"type":"string"},"filename":{"type":"string"},"os":{"type":"string","description":"\"windows\" or \"linux\"."},"sha256":{"type":"string","description":"Lowercase hex SHA-256. Verify before running — these builds are not\ncode-signed, so this is the only integrity check available."},"size_bytes":{"type":"integer","format":"int64","minimum":0},"title":{"type":"string"},"url":{"type":"string","description":"Path to fetch it from, relative to the API root."}}},"CreateDeviceReq":{"type":"object","required":["slug","display_name"],"properties":{"description":{"type":["string","null"]},"display_name":{"type":"string"},"slug":{"type":"string","description":"URL-safe path component. Lowercase, digits, hyphens; 1..64 chars."}},"additionalProperties":false},"CreateJoinCodeReq":{"type":"object","required":["label"],"properties":{"expires_in_hours":{"type":["integer","null"],"format":"int64","description":"Hours until it stops working. Omitted means 168 (a week); a code that\nnever expires is a credential nobody remembers to withdraw."},"label":{"type":"string","description":"What this code is for — \"sales laptops\", \"October rollout\". Shown in the\nlist so an admin can tell which one to revoke."},"max_uses":{"type":["integer","null"],"format":"int32","description":"How many machines this code may enrol. Omitted means unlimited.\n\nSet it to the size of the rollout. A code sized to its job stops being\na credential the moment the job is done, without anyone remembering to\nrevoke it — and a code that keeps working after twenty laptops is one\nthat will still work when it resurfaces in a chat search next year."}},"additionalProperties":false},"CreateJoinCodeResp":{"type":"object","required":["id","label","code","warning"],"properties":{"code":{"type":"string","description":"Plaintext, shown ONCE. Only the SHA-256 is stored."},"expires_at":{"type":["string","null"],"format":"date-time"},"id":{"type":"integer","format":"int32"},"label":{"type":"string"},"warning":{"type":"string"}}},"CreateKeyReq":{"type":"object","required":["label","scopes"],"properties":{"expires_at":{"type":["string","null"],"format":"date-time","description":"Optional absolute expiry. After this time the key fails auth."},"label":{"type":"string"},"scopes":{"type":"array","items":{"type":"string"},"description":"Resource:action scopes this key may use. Required for new keys.\nMust be a non-empty subset of `KNOWN_SCOPES`."}},"additionalProperties":false},"CreateKeyResp":{"type":"object","required":["id","tenant_id","label","key","scopes","created_at","warning"],"properties":{"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"},"id":{"type":"integer","format":"int32"},"key":{"type":"string"},"label":{"type":"string"},"scopes":{"type":"array","items":{"type":"string"}},"tenant_id":{"type":"integer","format":"int32"},"warning":{"type":"string"}}},"CreateTenantReq":{"type":"object","required":["name","admin_email","admin_password"],"properties":{"admin_email":{"type":"string"},"admin_password":{"type":"string"},"name":{"type":"string"}},"additionalProperties":false},"CreateTenantResp":{"type":"object","required":["tenant_id","name","agh_client_id","admin_email","doh_url"],"properties":{"admin_email":{"type":"string"},"agh_client_id":{"type":"string"},"doh_url":{"type":"string"},"dot_host":{"type":["string","null"],"description":"null: DoT reaches AGH with no ClientID, so admission control refuses it."},"name":{"type":"string"},"plain_dns":{"type":["string","null"],"description":"null: public plain DNS was closed in v0.57."},"tenant_id":{"type":"integer","format":"int32"}}},"CreateUserReq":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"role":{"type":"string"}},"additionalProperties":false},"CreateWebhookReq":{"type":"object","required":["url","events"],"properties":{"description":{"type":["string","null"]},"events":{"type":"array","items":{"type":"string"},"description":"Dotted event names, validated against the set this API actually\npublishes — an unknown name is rejected with 400 rather than stored as a\nsubscription that never fires. `*` matches everything and `prefix.*`\nmatches a resource, but a prefix that matches no real event is also\nrefused.\n\nNote the tense: events are `device.create`, **not** `device.created`.\nThey are the audit action names verbatim.\n\nExamples: `[\"policy.filtering.update\"]`, `[\"device.*\"]`, `[\"*\"]`.\nFull list: see `KNOWN_EVENTS` — the 400 response enumerates it."},"url":{"type":"string"}},"additionalProperties":false},"CreateWebhookResp":{"type":"object","required":["id","tenant_id","url","events","secret","created_at","warning"],"properties":{"created_at":{"type":"string","format":"date-time"},"description":{"type":["string","null"]},"events":{"type":"array","items":{"type":"string"}},"id":{"type":"integer","format":"int32"},"secret":{"type":"string","description":"Plaintext HMAC signing secret. Shown ONCE — store immediately."},"tenant_id":{"type":"integer","format":"int32"},"url":{"type":"string"},"warning":{"type":"string"}}},"CustomRulesReq":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"},"description":"Domains to allow (override block), e.g. [\"trusted.example.com\"]"},"block":{"type":"array","items":{"type":"string"},"description":"Domains to block, e.g. [\"evil.example.com\", \"ads.example.com\"]"}},"additionalProperties":false},"CustomRulesResp":{"type":"object","required":["agh_client_id","block","allow","raw_rules"],"properties":{"agh_client_id":{"type":"string"},"allow":{"type":"array","items":{"type":"string"}},"block":{"type":"array","items":{"type":"string"}},"raw_rules":{"type":"array","items":{"type":"string"}}}},"DashboardBundle":{"type":"object","required":["summary","top_domains","blocked","top_ai_tools","block_rate"],"properties":{"block_rate":{"$ref":"#/components/schemas/BlockRateRow"},"blocked":{"type":"array","items":{"$ref":"#/components/schemas/BlockedRow"}},"summary":{"$ref":"#/components/schemas/SummaryRow"},"top_ai_tools":{"type":"array","items":{"$ref":"#/components/schemas/TopAiRow"}},"top_domains":{"type":"array","items":{"$ref":"#/components/schemas/TopDomainRow"}}}},"DayWindow":{"type":"object","required":["start","end"],"properties":{"end":{"type":"string"},"start":{"type":"string"}}},"DefaultDenyReq":{"type":"object","required":["enabled"],"properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"DefaultDenyResp":{"type":"object","required":["enabled","allow_count"],"properties":{"allow_count":{"type":"integer","description":"Count of `@@||...^` allow rules that would act as the whitelist.\nWhen `enabled=true` and `allow_count=0`, ALL queries are blocked.","minimum":0},"enabled":{"type":"boolean","description":"True when allowlist-only mode is active."}}},"DeviceRow":{"type":"object","required":["id","tenant_id","slug","display_name","doh_url","created_at","status"],"properties":{"created_at":{"type":"string","format":"date-time"},"description":{"type":["string","null"]},"display_name":{"type":"string"},"doh_url":{"type":"string"},"enrolled_at":{"type":["string","null"],"format":"date-time"},"enrolled_hostname":{"type":["string","null"],"description":"The hostname the machine reported when it enrolled. Display only, and\nshown so an admin approving a device can see what they are approving\nrather than clicking blind. Null for devices created via POST /devices."},"id":{"type":"integer","format":"int32"},"last_seen_at":{"type":["string","null"],"format":"date-time"},"slug":{"type":"string"},"status":{"type":"string","description":"`pending` until an administrator approves an enrolled machine, then\n`active`. A pending device is excluded from AGH admission, so the\n`doh_url` above is real but is REFUSED until approval."},"tenant_id":{"type":"integer","format":"int32"}}},"DgaStatus":{"type":"object","required":["enabled","threshold"],"properties":{"enabled":{"type":"boolean","description":"Always true — classifier ships always-on (it's free, runs inline)."},"threshold":{"type":"number","format":"float","description":"Default 0.7. Queries scoring above this surface as alerts.\nPer-tenant tuning is a future feature."}}},"DiscoverReq":{"type":"object","required":["email"],"properties":{"email":{"type":"string"}},"additionalProperties":false},"DiscoverResp":{"type":"object","required":["sso"],"properties":{"login_url":{"type":["string","null"],"description":"Where to send the browser. Present only when `sso` is true."},"sso":{"type":"boolean"}}},"DnsCheckDevice":{"type":"object","required":["slug","display_name","doh_url","expected_client_id"],"properties":{"display_name":{"type":"string"},"doh_url":{"type":"string"},"expected_client_id":{"type":"string","description":"The exact `client_id` this device's queries should appear under."},"slug":{"type":"string"}}},"DnsCheckResp":{"type":"object","required":["tenant_id","doh_url","expected_client_id","devices","hint"],"properties":{"devices":{"type":"array","items":{"$ref":"#/components/schemas/DnsCheckDevice"}},"doh_url":{"type":"string"},"dot_host":{"type":["string","null"],"description":"null: DoT reaches AGH with no ClientID, so admission control refuses it."},"expected_client_id":{"type":"string","description":"The `client_id` a tenant-level (non-device) query should appear under."},"hint":{"type":"string"},"last_seen":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/LastSeenQuery","description":"The most recent query the backend actually attributed to this tenant,\nor null if none has arrived yet."}]},"plain_dns":{"type":["string","null"],"description":"null: public plain DNS was closed in v0.57."},"tenant_id":{"type":"integer","format":"int32"}}},"DnsQueryRow":{"type":"object","required":["ts","client_ip","query_name","qtype","rcode","latency_us","ai_tool","blocked","block_reason","filter_list_id"],"properties":{"ai_tool":{"type":"string"},"block_reason":{"type":"string"},"blocked":{"type":"integer","format":"int32","minimum":0},"client_ip":{"type":"string"},"filter_list_id":{"type":"integer","format":"int32"},"latency_us":{"type":"integer","format":"int32","minimum":0},"qtype":{"type":"string"},"query_name":{"type":"string"},"rcode":{"type":"string"},"ts":{"type":"string","description":"ISO-8601 / RFC3339 timestamp string. Field is named `ts` to avoid\na SELECT alias collision with the underlying DateTime column in\nClickHouse ORDER BY resolution.\n\nField order here IS the CSV column order — see EXPORT_CSV_HEADER."}}},"DnsStampResp":{"type":"object","required":["stamp","doh_url","hostname","path","dnssec","no_logs","no_filter"],"properties":{"dnssec":{"type":"boolean","description":"Whether the DNSSEC informal property bit is set. We set it because\nAGH validates DNSSEC upstream when `enable_dnssec: true`."},"doh_url":{"type":"string","description":"The DoH URL this stamp resolves to (informational; clients can use\neither the stamp or this URL)."},"hostname":{"type":"string","description":"Hostname encoded in the stamp."},"no_filter":{"type":"boolean","description":"Whether the no-filter informal property bit is set. ALWAYS false —\nfiltering is the product."},"no_logs":{"type":"boolean","description":"Whether the no-logs informal property bit is set. ALWAYS false for\nus — we ingest query logs into ClickHouse for per-tenant analytics.\nCustomers who need a no-logs resolver should use a different provider."},"path":{"type":"string","description":"Path encoded in the stamp."},"stamp":{"type":"string","description":"The sdns:// URI to paste into dnscrypt-proxy, AdGuard, NextDNS app etc."}}},"DriftRow":{"type":"object","required":["tenant_id","profile","expected_tags","drifted","missing_client"],"properties":{"actual_tags":{"type":["array","null"],"items":{"type":"string"},"description":"`null` when AdGuard has no persistent client for this tenant at all."},"drifted":{"type":"boolean"},"expected_tags":{"type":"array","items":{"type":"string"}},"missing_client":{"type":"boolean","description":"True when the tenant has no AdGuard client. A provisioning failure, not\ntag drift — repairing tags cannot fix it, so it is reported apart."},"profile":{"type":"string"},"tenant_id":{"type":"integer","format":"int32"}}},"EnrolReq":{"type":"object","required":["join_code","hostname"],"properties":{"hostname":{"type":"string","description":"The machine's own hostname. Display metadata and the basis for a slug —\nnever part of any credential."},"join_code":{"type":"string"}},"additionalProperties":false},"EnrolResp":{"type":"object","required":["device_id","status","enrollment_secret","message"],"properties":{"device_id":{"type":"integer","format":"int32"},"enrollment_secret":{"type":"string","description":"One-time secret for polling THIS enrollment's status. Not a DNS\ncredential and not an API key."},"message":{"type":"string"},"status":{"type":"string"}}},"EnrolStatusResp":{"type":"object","required":["device_id","status"],"properties":{"device_id":{"type":"integer","format":"int32"},"doh_url":{"type":["string","null"],"description":"Present only once approved."},"status":{"type":"string"}}},"FilteringReq":{"type":"object","required":["enabled"],"properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"ForgotPasswordReq":{"type":"object","required":["email"],"properties":{"email":{"type":"string"}},"additionalProperties":false},"JoinCodeRow":{"type":"object","required":["id","label","created_at","uses","usable"],"properties":{"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"},"id":{"type":"integer","format":"int32"},"label":{"type":"string"},"max_uses":{"type":["integer","null"],"format":"int32","description":"Machines this code may enrol in total. Null means unlimited."},"revoked_at":{"type":["string","null"],"format":"date-time"},"usable":{"type":"boolean","description":"True when the code can still enrol a machine right now — not revoked,\nnot expired, and not exhausted."},"uses":{"type":"integer","format":"int32","description":"Machines it has enrolled so far.\n\nReported because `usable` alone cannot tell an admin that a rollout code\nis about to run out, and because a count climbing on a code nobody is\nusing is the visible symptom of a leak."}}},"KeyRow":{"type":"object","required":["id","tenant_id","label","scopes","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"},"id":{"type":"integer","format":"int32"},"label":{"type":"string"},"last_used_at":{"type":["string","null"],"format":"date-time"},"scopes":{"type":"array","items":{"type":"string"}},"tenant_id":{"type":"integer","format":"int32"}}},"LastSeenQuery":{"type":"object","description":"The most recent query the backend actually attributed to this tenant.\n\nExposed for `GET /tenants/me/dns-check`, which exists so a client can prove\nits own wiring. It deliberately reuses `tenant_filter` + `where_client`\nrather than writing its own predicate: a diagnostic that disagrees with the\nanalytics endpoints would be worse than none at all.","required":["device_id","query_name","seen_at","blocked"],"properties":{"blocked":{"type":"integer","format":"int32","minimum":0},"device_id":{"type":"integer","format":"int32","description":"Internal `devices.id`, 0 for traffic not attributed to a device.\n\nThis replaces `client_id`, which was the DoH bearer credential echoed\nstraight back in a diagnostic response. It was the last such echo in the\ncodebase. A caller proving its own wiring needs to know WHICH DEVICE the\nbackend attributed the query to — it does not need to be told its own\nsecret back.","minimum":0},"query_name":{"type":"string"},"seen_at":{"type":"string","description":"RFC3339. Aliased away from the source column name — projecting a\ntransform of a column that also appears in ORDER BY breaks ClickHouse\n(see invariant 7)."}}},"LatencyRow":{"type":"object","required":["p50","p95","p99","count"],"properties":{"count":{"type":"integer","format":"int64","minimum":0},"p50":{"type":"number","format":"double"},"p95":{"type":"number","format":"double"},"p99":{"type":"number","format":"double"}}},"LoginReq":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"}},"additionalProperties":false},"LoginResp":{"type":"object","required":["token","tenant_id","role"],"properties":{"role":{"type":"string"},"tenant_id":{"type":"integer","format":"int32"},"token":{"type":"string"}}},"MirrorStatus":{"type":"object","required":["slug","name","source_url","status","consecutive_failures","snapshots_retained"],"properties":{"bytes":{"type":["integer","null"],"format":"int64"},"consecutive_failures":{"type":"integer","format":"int32"},"current_sha256":{"type":["string","null"]},"last_attempt_at":{"type":["string","null"],"format":"date-time"},"last_success_at":{"type":["string","null"],"format":"date-time"},"name":{"type":"string"},"reject_reason":{"type":["string","null"]},"rule_count":{"type":["integer","null"],"format":"int32"},"slug":{"type":"string"},"snapshots_retained":{"type":"integer","format":"int64"},"source_url":{"type":"string"},"status":{"type":"string"}}},"MyProfileResp":{"type":"object","required":["tenant_id","in_sync"],"properties":{"assigned_revision":{"type":["integer","null"],"format":"int32","description":"The profile revision this tenant was last reconciled against. If it\ntrails the profile's current revision, the tier's contents changed and\nthis tenant has not yet been re-reconciled."},"current_revision":{"type":["integer","null"],"format":"int32"},"in_sync":{"type":"boolean","description":"True when the assignment is fully in force at the resolver."},"name":{"type":["string","null"]},"stable_key":{"type":["string","null"],"description":"`None` only for a tenant created before it was assigned a profile; such a\ntenant receives the default protection."},"tenant_id":{"type":"integer","format":"int32"}}},"OidcConfigRow":{"type":"object","required":["tenant_id","issuer_url","client_id","default_role","enabled","redirect_uri","login_url"],"properties":{"client_id":{"type":"string"},"default_role":{"type":"string","description":"Default role assigned to new JIT-provisioned users when role_claim\nis absent or unmapped. One of admin / viewer / security_analyst /\nauditor / billing."},"email_domain_lock":{"type":["string","null"],"description":"Optional email-domain lock — only emails ending in @<this> may\nJIT-provision. Existing users may still log in regardless."},"enabled":{"type":"boolean"},"issuer_url":{"type":"string","description":"IdP issuer URL — the base used for OIDC discovery. The crate fetches\n<issuer_url>/.well-known/openid-configuration on every login. Example:\n\"https://login.microsoftonline.com/<your-tenant-uuid>/v2.0\" for\nAzure AD / Microsoft Entra ID; \"https://accounts.google.com\" for\nGoogle Workspace; \"https://<your-keycloak>/realms/<your-realm>\"\nfor Keycloak."},"login_url":{"type":"string"},"redirect_uri":{"type":"string","description":"URLs the customer's IdP admin needs:\n- redirect_uri: register this in the IdP application as the callback\n- login_url: the URL end-users visit to start SSO\nThese are derived from API_PUBLIC_HOST, not stored."},"role_claim":{"type":["string","null"],"description":"Name of the ID-token claim that holds the user's IdP-side group\nor role (e.g. \"groups\", \"roles\", \"olladns_role\"). Optional."},"role_map":{"description":"JSON object mapping IdP-side values to our roles. Example:\n{\"olladns-admins\":\"admin\",\"olladns-analysts\":\"security_analyst\"}."},"tenant_id":{"type":"integer","format":"int32"}},"additionalProperties":false},"ParentalReq":{"type":"object","properties":{"parental_enabled":{"type":["boolean","null"]},"safebrowsing_enabled":{"type":["boolean","null"]}},"additionalProperties":false},"PatchTenantReq":{"type":"object","properties":{"contact_email":{"type":["string","null"],"description":"Notification address for billing / incident response. Set to \"\" to clear."},"ecs_forward":{"type":["boolean","null"],"description":"Opt-in EDNS Client Subnet forward. Default false. AGH currently\napplies ECS globally — this field stores the per-tenant intent\nand will activate per-client once AGH supports it."},"log_anonymize_clients":{"type":["boolean","null"],"description":"Toggle whether the AGH poller drops client IPs at ingest. Privacy-\nfirst deployments set this to true."},"log_retention_days":{"type":["integer","null"],"format":"int32","description":"Per-tenant retention window in days (1..365). Capped by global TTL."},"name":{"type":["string","null"],"description":"Rename the tenant. Must remain unique."}},"additionalProperties":false},"ProfileRow":{"type":"object","required":["stable_key","name","revision","additional_blocklists","available"],"properties":{"additional_blocklists":{"type":"integer","format":"int64","description":"How many additional blocklists this tier layers on top of the default.\nZero for the default tier itself, which is the lists every tenant\nalready receives."},"available":{"type":"boolean","description":"False while the tier's runtime backend has not been compiled and\nregistered yet. A tier that is not `available` must not be offered for\nassignment, because assigning it would promise filtering that is not\ninstalled."},"description":{"type":["string","null"]},"name":{"type":"string"},"revision":{"type":"integer","format":"int32"},"stable_key":{"type":"string","description":"Stable machine key — reference this, not `name`, which is display text."}}},"ProtectEntry":{"type":"object","required":["id","domain"],"properties":{"description":{"type":["string","null"]},"domain":{"type":"string"},"id":{"type":"integer","format":"int32"}}},"ProtectEntryInput":{"type":"object","required":["domain"],"properties":{"description":{"type":["string","null"]},"domain":{"type":"string"}}},"ProtectListResp":{"type":"object","required":["entries"],"properties":{"entries":{"type":"array","items":{"$ref":"#/components/schemas/ProtectEntry"}}}},"PutBrandingReq":{"type":"object","properties":{"appeal_email":{"type":["string","null"],"description":"Contact email for appeals. Rendered as mailto: link. Max 254 chars."},"appeal_url":{"type":["string","null"],"description":"Optional URL to the customer's internal appeal / AUP-exception\nportal. Rendered as a link. Max 512 chars. Must start https://."},"custom_message":{"type":["string","null"],"description":"Short paragraph rendered above the appeal section. HTML-escaped\nbefore output — links inside are NOT rendered. Max 1024 chars."},"enabled":{"type":"boolean"},"org_name":{"type":["string","null"],"description":"\"Acme Corporation\" — appears as the blocker on the block page.\nMax 128 chars."},"primary_color":{"type":["string","null"],"description":"Hex accent color like \"#0066cc\". 7 chars exactly (incl. leading #)."}},"additionalProperties":false},"PutOidcConfigReq":{"type":"object","required":["issuer_url","client_id","client_secret"],"properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"default_role":{"type":"string"},"email_domain_lock":{"type":["string","null"]},"enabled":{"type":"boolean"},"issuer_url":{"type":"string"},"role_claim":{"type":["string","null"]},"role_map":{}},"additionalProperties":false},"ReconcileResp":{"type":"object","required":["added","removed","unchanged"],"properties":{"added":{"type":"array","items":{"type":"string"}},"desired_count":{"type":"integer","minimum":0},"failed":{"type":"array","items":{"type":"string"},"description":"Adds that FAILED, with the reason. Previously these were only\n`tracing::warn!`ed and the response reported bare counts, so a reconcile\nin which every single add failed came back as\n    {\"added\":[],\"removed\":[...],\"unchanged\":0}\n— indistinguishable from \"there was nothing to add\" while it had just\nremoved every list the tenants were relying on. The reason has to travel\nwith the response: container logs do not survive a `--force-recreate`,\nwhich is exactly what an operator does while fixing the resulting mess."},"lock_acquired":{"type":"boolean","description":"False when another reconcile already held the lock, in which case this\nrun did nothing at all — distinct from a run that did nothing because\nthere was nothing to do."},"observed_after_count":{"type":"integer","minimum":0},"observed_before_count":{"type":"integer","minimum":0},"removals_skipped":{"type":"boolean","description":"True when removals were deliberately abandoned for safety."},"removed":{"type":"array","items":{"type":"string"}},"skip_reason":{"type":["string","null"]},"success":{"type":"boolean","description":"False if anything failed or removals were skipped. A caller must not have\nto infer this from empty vectors, which is how \"every add failed\" once\nlooked identical to \"there was nothing to do\"."},"unchanged":{"type":"integer","minimum":0},"unverified":{"type":"array","items":{"type":"string"},"description":"Desired filters that could not be confirmed. Distinct from `failed`:\nnothing errored, we simply cannot prove the filter is installed — and\nthat is equally disqualifying for removals."},"verified":{"type":"array","items":{"type":"string"},"description":"Desired filters confirmed present AND enabled in AGH after the add phase."}}},"ReconcileTagsResp":{"type":"object","required":["repaired"],"properties":{"repaired":{"type":"integer","minimum":0}}},"RefreshReport":{"type":"object","description":"What one refresh attempt did. Returned by the admin endpoint and logged.","required":["slug","outcome"],"properties":{"bytes":{"type":["integer","null"],"format":"int64"},"delta":{"type":["integer","null"],"format":"int32","description":"Rule delta against the snapshot that was current before this run."},"outcome":{"type":"string","description":"`promoted` | `unchanged` | `stale` | `quarantined`"},"reason":{"type":["string","null"]},"rule_count":{"type":["integer","null"],"format":"int32"},"sha256":{"type":["string","null"]},"slug":{"type":"string"}}},"ResetPasswordReq":{"type":"object","required":["token","new_password"],"properties":{"new_password":{"type":"string"},"token":{"type":"string"}},"additionalProperties":false},"RewriteRule":{"type":"object","required":["domain","kind"],"properties":{"domain":{"type":"string","description":"Domain to rewrite, e.g. \"chatgpt.com\" or \"*.tiktok.com\""},"kind":{"type":"string","description":"One of: \"A\", \"AAAA\", \"CNAME\", \"NXDOMAIN\", \"REFUSED\""},"value":{"type":["string","null"],"description":"Required for A / AAAA / CNAME. Ignored for NXDOMAIN / REFUSED.\nExamples: \"192.168.1.100\" (A), \"::1\" (AAAA), \"warned.corp.local\" (CNAME)"}}},"RewritesReq":{"type":"object","required":["rewrites"],"properties":{"rewrites":{"type":"array","items":{"$ref":"#/components/schemas/RewriteRule"}}},"additionalProperties":false},"RewritesResp":{"type":"object","required":["agh_client_id","rewrites","raw_rules"],"properties":{"agh_client_id":{"type":"string"},"raw_rules":{"type":"array","items":{"type":"string"}},"rewrites":{"type":"array","items":{"$ref":"#/components/schemas/RewriteRule"}}}},"RollbackReq":{"type":"object","required":["sha256"],"properties":{"sha256":{"type":"string","description":"sha256 of a retained snapshot to make current again."}}},"RotateDnsIdResp":{"type":"object","required":["tenant_id","previous_agh_client_id","agh_client_id","doh_url","devices_affected","warning"],"properties":{"agh_client_id":{"type":"string"},"devices_affected":{"type":"integer","format":"int64","description":"Device DoH URLs are derived from the tenant id, so every one of these\nchanged too and must be redeployed."},"doh_url":{"type":"string"},"dot_host":{"type":["string","null"],"description":"null: DoT reaches AGH with no ClientID, so admission control refuses it."},"plain_dns":{"type":["string","null"],"description":"null: public plain DNS was closed in v0.57."},"previous_agh_client_id":{"type":"string","description":"The retired id. Stops resolving as this tenant immediately."},"tenant_id":{"type":"integer","format":"int32"},"warning":{"type":"string"}}},"RotateFeedTokenReq":{"type":"object","description":"Serve one protection profile's INCREMENT: the lists that tier adds on top of\nthe untagged default, every rule carrying the tier's `$ctag=`.\n\nThis is the whole scaling argument. The output is per PROFILE, not per\ntenant, so a thousand tenants on Business Strict cost one compiled file\nbetween them. Moving a tenant between tiers changes a client tag and nothing\nhere; only editing a tier's contents regenerates a file.","required":["kind","key"],"properties":{"key":{"type":"string","description":"For `tier`, the profile's `stable_key`. For `category`, the category name."},"kind":{"type":"string","description":"`tier` or `category`."}}},"RotateFeedTokenResp":{"type":"object","required":["kind","key","rotated","pending_reconcile"],"properties":{"key":{"type":"string"},"kind":{"type":"string"},"pending_reconcile":{"type":"boolean","description":"Reconcile must run before the new URL is registered and the old one stops\nbeing served. Until then the feed answers on the OLD token."},"rotated":{"type":"boolean","description":"Always true on success. The new token is deliberately NOT returned: it is\nonly ever needed inside the URL reconcile hands to the resolver, and an\noperator who does not hold it cannot leak it again."}}},"SafeSearchReq":{"type":"object","required":["enabled"],"properties":{"bing":{"type":["boolean","null"]},"duckduckgo":{"type":["boolean","null"]},"ecosia":{"type":["boolean","null"]},"enabled":{"type":"boolean"},"google":{"type":["boolean","null"]},"pixabay":{"type":["boolean","null"]},"yandex":{"type":["boolean","null"]},"youtube":{"type":["boolean","null"]}},"additionalProperties":false},"ScheduleReq":{"type":"object","properties":{"time_zone":{"type":["string","null"]}},"additionalProperties":{"$ref":"#/components/schemas/DayWindow"}},"ScopeInfo":{"type":"object","required":["scope","resource","action","destructive"],"properties":{"action":{"type":"string","description":"Action half — \"read\" or \"write\"."},"destructive":{"type":"boolean","description":"True for scopes that can destroy data or credentials. See\n`DESTRUCTIVE_SCOPES`."},"resource":{"type":"string","description":"Resource half, e.g. \"policies\". Lets a UI group without re-parsing."},"scope":{"type":"string","description":"The scope string to send in `POST /api-keys`, e.g. \"policies:write\"."}}},"SetCategoriesReq":{"type":"object","required":["categories"],"properties":{"categories":{"type":"array","items":{"type":"string"},"description":"The complete set of categories this tenant should receive. Absolute, not\na delta: anything omitted is switched off."}}},"SetProtectListReq":{"type":"object","required":["domains"],"properties":{"domains":{"type":"array","items":{"$ref":"#/components/schemas/ProtectEntryInput"},"description":"Full list of domains to protect. Replaces existing entries —\nGET first if you want to append rather than replace."}},"additionalProperties":false},"SetSubsReq":{"type":"object","required":["list_ids"],"properties":{"list_ids":{"type":"array","items":{"type":"integer","format":"int32"},"description":"blocklist_catalog.id values to subscribe to. Anything not in this list\nwill be marked unsubscribed for this tenant."}},"additionalProperties":false},"SignupReq":{"type":"object","required":["email","password","org_name"],"properties":{"email":{"type":"string"},"org_name":{"type":"string","description":"Organisation name; becomes the tenant name and the AGH client name."},"password":{"type":"string"}},"additionalProperties":false},"SignupResp":{"type":"object","required":["token","tenant_id","role","org_name","doh_url"],"properties":{"doh_url":{"type":"string"},"dot_host":{"type":["string","null"],"description":"null: DoT reaches AGH with no ClientID, so admission control (v0.66)\nrefuses it. See tenants::build_endpoints."},"org_name":{"type":"string"},"role":{"type":"string"},"tenant_id":{"type":"integer","format":"int32"},"token":{"type":"string"}}},"SubRow":{"type":"object","required":["blocklist_id","name","category","subscribed","subscribed_at"],"properties":{"blocklist_id":{"type":"integer","format":"int32"},"category":{"type":"string"},"name":{"type":"string"},"subscribed":{"type":"boolean"},"subscribed_at":{"type":"string","format":"date-time"}}},"SubsResp":{"type":"object","required":["tenant_id","subscriptions","note"],"properties":{"note":{"type":"string"},"subscriptions":{"type":"array","items":{"$ref":"#/components/schemas/SubRow"}},"tenant_id":{"type":"integer","format":"int32"}}},"SummaryRow":{"type":"object","required":["total_queries","unique_clients","unique_domains","ai_queries","blocked_queries","nxdomain"],"properties":{"ai_queries":{"type":"integer","format":"int64","minimum":0},"blocked_queries":{"type":"integer","format":"int64","minimum":0},"nxdomain":{"type":"integer","format":"int64","minimum":0},"total_queries":{"type":"integer","format":"int64","minimum":0},"unique_clients":{"type":"integer","format":"int64","minimum":0},"unique_domains":{"type":"integer","format":"int64","minimum":0}}},"TenantRow":{"type":"object","required":["id","name","agh_client_id","doh_url","log_anonymize_clients","log_retention_days","ecs_forward"],"properties":{"agh_client_id":{"type":"string"},"contact_email":{"type":["string","null"]},"doh_url":{"type":"string"},"dot_host":{"type":["string","null"],"description":"null: DoT reaches AGH with no ClientID, so admission control refuses it."},"ecs_forward":{"type":"boolean","description":"Opt-in to EDNS Client Subnet forwarding to authoritative resolvers.\nDefault false (privacy-by-default for India DPDP compliance). AGH\ntoday applies ECS as a global flag — this is the per-tenant intent\nwe will honor once AGH supports per-persistent-client ECS settings."},"id":{"type":"integer","format":"int32"},"log_anonymize_clients":{"type":"boolean","description":"When true, AGH poller stores client_ip as empty string for this\ntenant's queries — privacy-first analytics with no source IPs."},"log_retention_days":{"type":"integer","format":"int32","description":"How many days of query log rows are visible via /analytics + /export.\nCapped by the global ClickHouse TTL (currently 30 days). 1..365."},"name":{"type":"string"},"plain_dns":{"type":["string","null"],"description":"null: public plain DNS was closed in v0.57."}}},"ThreatIntelReq":{"type":"object","properties":{"block_evasion":{"type":["boolean","null"]},"newly_registered_domains":{"type":["boolean","null"]},"tld_blocking":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/TldBlocking"}]}},"additionalProperties":false},"ThreatIntelResp":{"type":"object","required":["tld_blocking","dga","typosquatting"],"properties":{"block_evasion":{"type":["boolean","null"],"description":"`Some(true|false)` only where a subscription can actually reach the\nresolver. **`None` means UNAVAILABLE, not off** — same convention as\n`newly_registered_domains` above, for a different reason.\n\nNRD is unavailable because its feed was withdrawn. This is unavailable\nbecause **subscribing to a list does not filter anything** (invariant 8):\nAGH loads one global `filters[]` shared by every tenant, so\n`tenant_blocklist_subs` records an intention the resolver never sees.\n\nIt previously reported the raw subscription as a plain `bool`, so a\ntenant could enable \"DoH/VPN escape blocking\", get a 200, read\n`block_evasion: true` back, and be running with precisely the protection\nthey had before. Reported honestly since v0.20260820, after an external\nreport enabled it, measured no change, and was right about that much."},"dga":{"$ref":"#/components/schemas/DgaStatus","description":"Transparent feature-based DGA classifier (src/dga.rs). Always on\nfor every tenant — there is no per-tenant config. Score is\nsurfaced via /analytics/top-dga."},"newly_registered_domains":{"type":["boolean","null"],"description":"`Some(true|false)` when an NRD feed is installed and the toggle is\nmeaningful. **`None` means the capability is UNAVAILABLE, not off.**\n\nThe upstream list this toggle subscribed to was withdrawn — the catalog\nentry carrying `agh_filter_id = 160` pointed at a URL that now 404s, and\nfive candidate replacements were probed and all 404 as well. Rather than\nleave a switch a tenant could turn on and see reported as on while\nreceiving nothing, the capability reports itself absent.\n\nThis mirrors `parked_domains` below, which has always used `None` to mean\n\"we cannot deliver this yet\" — the established shape in this struct for a\ncapability that does not exist, as distinct from one that is switched off."},"parked_domains":{"type":["boolean","null"],"description":"Parked-domain detection — DEFERRED, needs paid passive-DNS feed."},"tld_blocking":{"$ref":"#/components/schemas/TldBlocking"},"typosquatting":{"$ref":"#/components/schemas/TyposquatStatus","description":"Typosquat detection — active iff the tenant's protect-list is\nnon-empty (manage via /policies/protect-list)."}}},"TldBlocking":{"type":"object","required":["enabled"],"properties":{"enabled":{"type":"boolean"},"tlds":{"type":"array","items":{"type":"string"},"description":"TLDs with or without leading dot, e.g. [\"xyz\", \".zip\", \"top\"].\nNormalized on write to bare TLD without dot."}}},"TopAiRow":{"type":"object","required":["tool","count","unique_clients"],"properties":{"count":{"type":"integer","format":"int64","minimum":0},"tool":{"type":"string"},"unique_clients":{"type":"integer","format":"int64","minimum":0}}},"TopClientRow":{"type":"object","required":["client_ip","count","unique_domains"],"properties":{"client_ip":{"type":"string"},"count":{"type":"integer","format":"int64","minimum":0},"unique_domains":{"type":"integer","format":"int64","minimum":0}}},"TopDeviceRow":{"type":"object","required":["device_id","device_slug","display_name","count","unique_domains"],"properties":{"count":{"type":"integer","format":"int64","minimum":0},"device_id":{"type":"integer","format":"int32","description":"Immutable `devices.id`. THIS is the join key — the slug is user-editable\ntext and was previously doing double duty as an identifier, so renaming a\ndevice silently zeroed its activity count.","minimum":0},"device_slug":{"type":"string","description":"Informational. Empty for a device that has since been deleted, whose\ntraffic is still inside the 30-day TTL."},"display_name":{"type":"string"},"unique_domains":{"type":"integer","format":"int64","minimum":0}}},"TopDgaRow":{"type":"object","required":["query_name","max_score","count","unique_clients"],"properties":{"count":{"type":"integer","format":"int64","minimum":0},"max_score":{"type":"number","format":"float"},"query_name":{"type":"string"},"unique_clients":{"type":"integer","format":"int64","minimum":0}}},"TopDomainRow":{"type":"object","required":["domain","count","clients"],"properties":{"clients":{"type":"integer","format":"int64","minimum":0},"count":{"type":"integer","format":"int64","minimum":0},"domain":{"type":"string"}}},"TopTyposquatRow":{"type":"object","required":["query_name","victim","count","unique_clients"],"properties":{"count":{"type":"integer","format":"int64","minimum":0},"query_name":{"type":"string"},"unique_clients":{"type":"integer","format":"int64","minimum":0},"victim":{"type":"string","description":"The protect-list entry this query is a typosquat of. Field name\nmust differ from the underlying `typosquat_of` column to avoid\na ClickHouse aggregate-vs-column alias collision in WHERE."}}},"TyposquatStatus":{"type":"object","required":["enabled","protect_count"],"properties":{"enabled":{"type":"boolean","description":"True iff this tenant has at least one entry in /policies/protect-list."},"protect_count":{"type":"integer","format":"int64","description":"Number of protect-list entries the classifier is scoring against."}}},"UpdateDeviceReq":{"type":"object","properties":{"description":{"type":["string","null"]},"display_name":{"type":["string","null"]}},"additionalProperties":false},"UpdateUserReq":{"type":"object","properties":{"password":{"type":["string","null"]},"role":{"type":["string","null"]},"timezone":{"type":["string","null"],"description":"IANA timezone (e.g. \"Asia/Kolkata\", \"UTC\"). Used for dashboard\nrendering + CSV / PDF export timestamps. Validated lightly —\nany non-empty string with at most 64 chars is accepted server-side;\nthe dashboard maps unknown zones to UTC."}},"additionalProperties":false},"UserRow":{"type":"object","required":["id","tenant_id","email","role","created_at","timezone"],"properties":{"created_at":{"type":"string","format":"date-time"},"email":{"type":"string"},"id":{"type":"integer","format":"int64"},"role":{"type":"string"},"tenant_id":{"type":"integer","format":"int32"},"timezone":{"type":"string","description":"IANA timezone name used to render timestamps in the dashboard\nand on CSV / PDF exports. Default Asia/Kolkata (India SMB\ntarget market); per-user override via PUT /users/{id}."}}},"WebhookRow":{"type":"object","required":["id","tenant_id","url","events","enabled","created_at","delivery_count","failure_count"],"properties":{"created_at":{"type":"string","format":"date-time"},"delivery_count":{"type":"integer","format":"int64"},"description":{"type":["string","null"]},"enabled":{"type":"boolean"},"events":{"type":"array","items":{"type":"string"}},"failure_count":{"type":"integer","format":"int64"},"id":{"type":"integer","format":"int32"},"last_error":{"type":["string","null"]},"last_fired_at":{"type":["string","null"],"format":"date-time"},"last_status":{"type":["integer","null"],"format":"int32"},"tenant_id":{"type":"integer","format":"int32"},"url":{"type":"string"}}},"WhoamiResp":{"type":"object","required":["tenant_id","actor_type","role","scopes"],"properties":{"actor_type":{"type":"string"},"api_key_id":{"type":["integer","null"],"format":"int32","description":"API key id if this is an X-API-Key session; null for JWT."},"email":{"type":["string","null"],"description":"Email of the signed-in user. Null for API-key sessions, which have no\nhuman behind them."},"expires_at":{"type":["string","null"],"format":"date-time","description":"When the API key expires. Null for JWT sessions and non-expiring keys."},"label":{"type":["string","null"]},"role":{"type":"string","description":"Role of the calling principal (`admin` / `viewer`).\n\nAdded in v0.62 for the SSO callback: the browser arrives holding only a\nJWT and needs an AUTHORITATIVE identity to render with. The alternative\n— decoding the token client-side — means trusting a value that arrived\nin a URL, and the JWT carries no email at all."},"scopes":{"type":"array","items":{"type":"string"}},"tenant_id":{"type":"integer","format":"int32"},"user_id":{"type":["integer","null"],"format":"int64","description":"User id if this is a JWT session; null for API-key sessions."}}}},"securitySchemes":{"api_key":{"type":"apiKey","in":"header","name":"X-API-Key","description":"Agent token minted by POST /api-keys. Carries scopes; every request is checked against the scope list."},"bearer_jwt":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Human session token issued by POST /auth/login. Carries tenant + role; bypasses scope checks."}}},"tags":[{"name":"auth","description":"Session login + password change"},{"name":"tenants","description":"Tenant metadata + lifecycle"},{"name":"api_keys","description":"Scoped agent tokens (X-API-Key)"},{"name":"health","description":"Liveness and build provenance"},{"name":"clients","description":"Downloadable endpoint clients"},{"name":"policies","description":"Filtering, safe search, parental, blocked services, custom rules"},{"name":"analytics","description":"Per-tenant query log aggregations"},{"name":"webhooks","description":"Outbound HMAC-signed event callbacks (push to Slack/PagerDuty/SIEM)"},{"name":"devices","description":"Per-tenant named DoH endpoints — point a specific machine at its own URL for per-device attribution"},{"name":"profiles","description":"Protection profiles — named protection levels a tenant can be assigned"}]}