{"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.13.0"},"servers":[{"url":"https://api.olladns.com","description":"Production"},{"url":"http://localhost:8080","description":"Local dev"}],"paths":{"/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"]}]}},"/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"]}]}},"/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"]}]}},"/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"]}]}},"/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"]}]}},"/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"]}]}},"/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 (ChatGPT, Claude, Gemini, Copilot, Cursor, Replit, Perplexity, etc) and tags each query with `ai_tool`. Critical metric for CISO/CTO 'what AI is my company using?' reporting. Per-AI vendor, 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}}],"responses":{"200":{"description":"Per-AI-tool query counts (ChatGPT, Claude, Gemini, etc)","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TopAiRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["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"]}]}},"/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"]}]}},"/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"]}]},"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"]}]}},"/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"]}]}},"/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"]}]}},"/api/v1/audit-logs":{"get":{"tags":["audit"],"description":"Tamper-attributed 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. 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","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"]}]}},"/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. Excluded from MCP.","operationId":"change_password","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangePasswordReq"}}},"required":true},"responses":{"200":{"description":"Password rotated"},"400":{"description":"New password too short (<10)"},"401":{"description":"Current password incorrect"},"403":{"description":"Called with X-API-Key (humans only)"}},"security":[{"bearer_jwt":[]}]}},"/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/blocklists":{"get":{"tags":["blocklists"],"description":"Browse the catalog of blocklists available across all tenants — currently 30 curated lists across categories (ads, tracking, threat, phishing, adult, gambling, social, ai, fakenews, telemetry, nrd, tlds, bypass, general). Returns id, name, url, description, category, license. Use these ids when calling PUT /blocklists/subscriptions.","operationId":"list_catalog","responses":{"200":{"description":"All blocklists available to subscribe to","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CatalogRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["blocklists:read"]}]}},"/api/v1/blocklists/subscriptions":{"get":{"tags":["blocklists"],"description":"Return this tenant's subscription state — for every list in the catalog, whether the tenant is subscribed and when the subscription was set. Read this before calling PUT /blocklists/subscriptions if you want to add to existing subscriptions rather than replace them.","operationId":"get_subscriptions","responses":{"200":{"description":"Per-tenant subscription state for the catalog","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubsResp"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["blocklists:read"]}]},"put":{"tags":["blocklists"],"description":"Replace this tenant's blocklist subscriptions with the given list_ids. Anything not in list_ids is marked unsubscribed. To add a single list without losing existing subscriptions: GET first, append, then PUT the union. For NRD specifically, prefer PUT /policies/threat-intel which is a friendlier one-toggle wrapper.","operationId":"set_subscriptions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetSubsReq"}}},"required":true},"responses":{"200":{"description":"Subscriptions reconciled to the given id list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubsResp"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["blocklists:write"]}]}},"/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"]}]}},"/api/v1/policies/blocked-services":{"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"]}]}},"/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"]}]},"put":{"tags":["policies"],"description":"Replace this tenant's block + allow lists with the supplied arrays. Allows override blocks (standard AGH semantics). Wildcards supported (e.g. '*.example.com'). Rewrites and TLD-blocks are NOT touched — they live in their own endpoints and coexist independently.","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"]}]}},"/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"]}]}},"/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"]}]}},"/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"]}]},"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"]}]}},"/api/v1/policies/safe-search":{"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"]}]}},"/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). Future fields (parked_domains, dga, typosquatting) currently 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"]}]},"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 (defeats end-users escaping the filter via commercial DoH or VPN). 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"}},"security":[{"bearer_jwt":[]},{"api_key":["policies:write"]}]}},"/api/v1/tenants":{"get":{"tags":["tenants"],"description":"Cross-tenant list — only useful to the platform admin (tenant 1). Regular agents almost always want GET /tenants/me instead, which returns just the caller's own tenant.","operationId":"list_tenants","responses":{"200":{"description":"All tenants (platform-admin view)","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TenantRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["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"}}}}},"security":[{"bearer_jwt":[]},{"api_key":["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"]}]}},"/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"]}]},"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"]}]}},"/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"]}]},"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"}}}},"400":{"description":"Email already in use"}},"security":[{"bearer_jwt":[]},{"api_key":["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"]}]},"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"]}]},"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"]}]}},"/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","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WebhookRow"}}}}}},"security":[{"bearer_jwt":[]},{"api_key":["webhooks:read"]}]},"post":{"tags":["webhooks"],"description":"Register a new outbound webhook. URL must be HTTPS (or http://localhost for dev). 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","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"]}]}},"/api/v1/webhooks/{id}":{"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","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":""},"404":{"description":""}},"security":[{"bearer_jwt":[]},{"api_key":["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":{"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":{},"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"}]}}},"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}}},"CatalogRow":{"type":"object","required":["id","name","url","category","default_enabled","created_at"],"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"]},"id":{"type":"integer","format":"int32"},"license":{"type":["string","null"]},"name":{"type":"string"},"url":{"type":"string"}}},"ChangePasswordReq":{"type":"object","required":["current_password","new_password"],"properties":{"current_password":{"type":"string"},"new_password":{"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`."}}},"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"}}},"CreateTenantResp":{"type":"object","required":["tenant_id","name","agh_client_id","admin_email","doh_url","dot_host","plain_dns"],"properties":{"admin_email":{"type":"string"},"agh_client_id":{"type":"string"},"doh_url":{"type":"string"},"dot_host":{"type":"string"},"name":{"type":"string"},"plain_dns":{"type":"string"},"tenant_id":{"type":"integer","format":"int32"}}},"CreateUserReq":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"role":{"type":"string"}}},"CreateWebhookReq":{"type":"object","required":["url","events"],"properties":{"description":{"type":["string","null"]},"events":{"type":"array","items":{"type":"string"},"description":"Dotted event names. Supports trailing `*` for prefix wildcards.\nExamples: `[\"policy.filtering.update\"]`, `[\"audit.*\"]`, `[\"*\"]`."},"url":{"type":"string"}}},"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\"]"}}},"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"}}}},"DayWindow":{"type":"object","required":["start","end"],"properties":{"end":{"type":"string"},"start":{"type":"string"}}},"DnsQueryRow":{"type":"object","required":["ts","client_ip","client_id","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_id":{"type":"string"},"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."}}},"FilteringReq":{"type":"object","required":["enabled"],"properties":{"enabled":{"type":"boolean"}}},"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"}}},"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"}}},"LoginResp":{"type":"object","required":["token","tenant_id","role"],"properties":{"role":{"type":"string"},"tenant_id":{"type":"integer","format":"int32"},"token":{"type":"string"}}},"ParentalReq":{"type":"object","properties":{"parental_enabled":{"type":["boolean","null"]},"safebrowsing_enabled":{"type":["boolean","null"]}}},"PatchTenantReq":{"type":"object","properties":{"contact_email":{"type":["string","null"],"description":"Notification address for billing / incident response. Set to \"\" to clear."},"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."}}},"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"}}}},"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"}}}},"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"]}}},"ScheduleReq":{"type":"object","properties":{"time_zone":{"type":["string","null"]}},"additionalProperties":{"$ref":"#/components/schemas/DayWindow"}},"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."}}},"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","dot_host","plain_dns","log_anonymize_clients","log_retention_days"],"properties":{"agh_client_id":{"type":"string"},"contact_email":{"type":["string","null"]},"doh_url":{"type":"string"},"dot_host":{"type":"string"},"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"}}},"ThreatIntelReq":{"type":"object","properties":{"block_evasion":{"type":["boolean","null"]},"newly_registered_domains":{"type":["boolean","null"]},"tld_blocking":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/TldBlocking"}]}}},"ThreatIntelResp":{"type":"object","required":["newly_registered_domains","tld_blocking","block_evasion"],"properties":{"block_evasion":{"type":"boolean","description":"True iff this tenant is subscribed to the Hagezi DoH/VPN/proxy\nbypass blocklist — blocks known endpoints end-users use to escape\nDNS filtering (commercial DoH bootstraps, VPN providers, Tor)."},"dga":{"type":["boolean","null"]},"newly_registered_domains":{"type":"boolean","description":"True iff this tenant is subscribed to the NRD blocklist."},"parked_domains":{"type":["boolean","null"],"description":"v1 returns null. Reserved for the classifier work landing later."},"tld_blocking":{"$ref":"#/components/schemas/TldBlocking"},"typosquatting":{"type":["boolean","null"]}}},"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}}},"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"}}},"UpdateUserReq":{"type":"object","properties":{"password":{"type":["string","null"]},"role":{"type":["string","null"]}}},"UserRow":{"type":"object","required":["id","tenant_id","email","role","created_at"],"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"}}},"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","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."},"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"]},"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":"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)"}]}