diff --git a/CHANGELOG.md b/CHANGELOG.md index 13dab3d..f4404dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,32 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added: audit log + +- **`audit` key in `10xgraph.json`** (`enabled`, `sinks`, `include_reads`; off by default, unknown + keys refuse to boot). Records who accessed or changed what, when, and whether it was allowed, as + metadata only: ids and results, never prompts, answers, message text or file contents. Event + types: `access` (`allowed`, `denied`, `not_found`), `auth` (`failed` with a reason) and `change` + (`success`, with a `count` where known). `include_reads: false` drops allowed access events; + denials, auth failures and changes are always kept. +- **Sinks.** `"log"` writes one JSON line per event on stdout (logger `tenxgraph_api.audit`), + whatever `LOG_FORMAT`. `"publisher"` also sends each event through the app's events publisher + (Kafka, Redis, RabbitMQ, custom); it is best effort and events still queued at SIGTERM may be + lost, so the log sink is the record. Observability publishers (OTEL, Logfire, Phoenix) are + skipped, and a `"publisher"` entry with no events publisher logs a warning at boot and is + skipped. `"module:Class"` loads your own sink once at boot (a `BaseAuditSink` subclass, an + instance or a zero-argument factory); anything else refuses to boot. +- **`BaseAuditSink` and `AuditEvent`** are exported from `tenxgraph_api`. A sink that raises is + logged at ERROR and the request continues; with metrics on it also counts into + `tenxgraph_audit_sink_errors_total` (label `sink`, the sink class name). +- **`LOG_FORMAT=json`** writes every log line as one JSON object, with request id, transport and + user id when known; secrets are still masked. +- **`10xgraph config`** has an Audit log section. +- **`10xgraph init` production template** turns the audit log on, and **`10xgraph build`** sets + `LOG_FORMAT=json` in the Dockerfile, Kubernetes manifest and Docker Compose file. +- **`10xgraph audit`** has an Audit log check that warns when `MODE=production` and + `audit.enabled` is not true. + ### Breaking changes: auth, RBAC and reach Needs the `10xgraph` release with `Identity` and `Reach` (`tenxgraph.core.authz`). The unreleased diff --git a/CLAUDE.md b/CLAUDE.md index ffe764f..a8d0fcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ Importable package: `tenxgraph_api/`. Two halves: | `tenxgraph_api/src/app/` | The FastAPI app. `main.py` + `loader.py` (build app from `10xgraph.json`), `routers/` (graph, checkpointer, store, media, ping, ag_ui), `core/auth/`, `core/config/`, `core/middleware/` (rate_limit, security_headers, request_limits), `tasks/`, `utils/`, `worker.py` | Public exports from the package root (`from tenxgraph_api import ...`): `BaseAuth`, -`SnowFlakeIdGenerator`, `ThreadNameGenerator`. +`SnowFlakeIdGenerator`, `ThreadNameGenerator`, `BaseAuditSink`, `AuditEvent`. ## CLI commands (verified against `cli/main.py`) @@ -99,6 +99,7 @@ Parsed by `tenxgraph_api/src/app/core/config/graph_config.py`. Supported keys: | `websocket` | `enabled` (`false` leaves out `/v1/graph/ws` and `/v1/graph/live`; `true` without `websockets`/`wsproto` refuses to boot; unset keeps them and warns if no library; `routers/setup_router.py::_graph_router`), `max_connections` (1000), `max_connections_per_user` (10), `realtime_models` (models a `/v1/graph/live` client may pick), `realtime_client_overrides` (init options a client may set; default `voice`, `modalities`, `vad`; add `system_prompt` only for trusted callers) | | `runs` | `RunsConfig`: `thread_ids` (`server` default = only existing ids may be named on REST invoke/stream, `/v1/graph/ws` and checkpointer writes, a new thread is started by omitting the id; enforced by `require_thread` with auth on or off. AG-UI and `/v1/graph/live` follow the same rule (a blank or `new` id gets a server id). `client` = any request may name a new thread, explicit opt-in), `config_keys` / `state_keys` (allow-lists for request config and `initial_state`/PUT state; 422 on other keys; `thread_id`, `run_id` always allowed), `max_concurrent_per_user` (HTTP invoke/stream/AG-UI runs in flight, default 10, 429 over it; `routers/graph/run_limits.py`), `timeout` (seconds; invoke 504, streams end with an error chunk), `max_parallel_tool_calls` (positive int, default unset = core's default 10 or env `TENXGRAPH_MAX_PARALLEL_TOOL_CALLS`; set into every run's config in `_prepare_input`, reserved from clients) | | `cache_store.token_revocation` | `true`, `false`, `null` or `{"enabled", "token_lifetime", "user_ttl"}` (`graph_config.TokenRevocationConfig`; a string is a config error). Read through `GraphConfig.token_revocation`. Revocations live in the cache store (`core/auth/revocation.py`: `revoke_token`, `revoke_user`, `is_revoked`), checked by `RequirePermission` (401), before every `/v1/graph/ws` run, and before and every 30 s during a `/v1/graph/live` session (`REALTIME_RECHECK_INTERVAL`, close 1008); cache store errors fail closed (503), and an unreachable cache store refuses the boot | +| `audit` | `core/audit/config.py::AuditConfig` (unknown keys refuse to boot): `enabled` (false), `sinks` (`["log"]`: JSON lines on stdout via logger `tenxgraph_api.audit`, own handler, never propagates; `"publisher"` sends each event through the app's events publisher, best effort, skipping AI tracing publishers (OTEL, Logfire, Phoenix), and is skipped with a boot warning when no events publisher is bound (`core/audit/sinks.py`); `"module:Class"` loads a `BaseAuditSink` instance, subclass or zero-arg factory once at boot), `include_reads` (true; false drops allowed access events). One emit function `core/audit::audit_event`, configured once in the lifespan (`configure_audit`). Hooks: `permissions.py` (`_authenticate`/`_identity` auth failures, `_authorize` access allowed/denied for `RequirePermission` and `authorize_resource`), `core/threads.py::require_thread` and the `ThreadNotFoundError` 404 handler (not_found, deduped by marking the exception), `routers/graph/router.py::_ws_credential_error` (WS re-checks); `change`/`success` events after successful writes and deletes in the checkpointer, store, media, graph (stop, fix) and tasks routers, and in `core/auth/revocation.py` (`tokens:revoke`, `users:revoke`, also from `10xgraph revoke`). Request id, transport and client ip come from `core/request_context.py`, bound by the request-id middleware | | `media` | `delete_with_thread` (default true): delete an uploaded file when the last message or thread that references it is deleted. Links are recorded in the file's `threads` metadata (`MediaService.link_threads` / `unlink_threads`); files without a `threads` key (uploaded before link tracking) and stores without `update_metadata` (core older than the release that added it) are never auto-deleted. `false` still records links. Parsed by `graph_config.MediaConfig` | `cache_store.rate_limit` object (read through `GraphConfig.rate_limit`, None when absent or diff --git a/README.md b/README.md index d5d434f..3fc4ba3 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [![PyPI](https://img.shields.io/pypi/v/10xgraph-api?color=blue)](https://pypi.org/project/10xgraph-api/) [![Python](https://img.shields.io/pypi/pyversions/10xgraph-api)](https://pypi.org/project/10xgraph-api/) [![License](https://img.shields.io/github/license/10xGraph/10xgraph-api)](https://github.com/10xGraph/10xgraph-api/blob/main/LICENSE) +[![Verified on FreeAPI](https://findfreeapi.com/api/badge/10xgraph-api)](https://findfreeapi.com/en/service/10xgraph-api) `10xgraph-api` generates the production server around a [10xGraph](https://github.com/10xGraph/10xGraph) agent. Point `10xgraph.json` at your compiled graph and it serves REST, NDJSON streaming, WebSocket and realtime-audio endpoints with JWT or custom auth, scoped authorization on every endpoint, owner-only threads and rate limiting, then writes your Docker Compose and Kubernetes files. MIT licensed and self-hosted. diff --git a/config-editor-ui/src/audit.jsx b/config-editor-ui/src/audit.jsx new file mode 100644 index 0000000..34505ce --- /dev/null +++ b/config-editor-ui/src/audit.jsx @@ -0,0 +1,82 @@ +import { commit, isObject, issueAt, store } from "./store.js"; +import { Chips, FieldIssue, Toggle } from "./ui.jsx"; + +const note = "mt-2 max-w-prose text-[13px] leading-5 text-muted"; +const audit = () => (isObject(store.config.audit) ? store.config.audit : {}); +const isOn = () => audit().enabled === true; +const sinks = () => (Array.isArray(audit().sinks) ? audit().sinks : ["log"]); +const custom = () => sinks().filter((s) => s !== "log" && s !== "publisher"); + +const edit = (fn) => commit((s) => { + const a = isObject(s.config.audit) ? s.config.audit : {}; + fn(a); + s.config.audit = a; +}); + +function setOn(on) { + if (!on) { commit((s) => { delete s.config.audit; }); return; } + edit((a) => { + a.enabled = true; + a.sinks = ["log"]; + a.include_reads = true; + }); +} + +// "log" is always kept, so the file never ends up with an audit log that goes nowhere. +function setSinks({ publisher = sinks().includes("publisher"), extra = custom() }) { + edit((a) => { a.sinks = ["log", ...(publisher ? ["publisher"] : []), ...extra]; }); +} + +export function AuditWidget() { + const on = isOn(); + const reads = audit().include_reads !== false; + const publisher = sinks().includes("publisher"); + return ( +
+
+ + +
+

{on ? "Events are written as JSON lines on stdout." : "No audit events are recorded."}

+ + {on ? ( +
+
+

Sinks

+

+ log is always on: one JSON line per event on + stdout, for your log pipeline to collect. +

+
+ setSinks({ publisher: v })} /> + +
+

Also send events through the configured publisher. Best effort.

+ +
+ setSinks({ extra: next })} + /> +
+

A module:Class that extends BaseAuditSink.

+ +
+
+
+ edit((a) => { a.include_reads = v; })} /> + +
+

Off keeps denials, auth failures and changes, and drops allowed reads.

+ +
+
+ ) : null} +
+ ); +} + +export function auditSummary(offText) { + if (!isOn()) return { text: offText("audit") }; + return { text: audit().include_reads === false ? "On, denials and changes only." : "On, all access and changes." }; +} diff --git a/config-editor-ui/src/icons.js b/config-editor-ui/src/icons.js index 10fdc07..6331679 100644 --- a/config-editor-ui/src/icons.js +++ b/config-editor-ui/src/icons.js @@ -41,7 +41,7 @@ export const ICONS = { export const SECTION_ICONS = { "get-running": "flag", environment: "terminal", core: "flag", auth: "lock", cache_store: "gauge", - observability: "activity", metrics: "gauge", runs: "play", websocket: "bolt", ag_ui: "external", remote_tools: "wrench", + observability: "activity", metrics: "gauge", audit: "file", runs: "play", websocket: "bolt", ag_ui: "external", remote_tools: "wrench", media: "file", checkpointer: "layers", id_generator: "key", thread_name_generator: "file", store: "box", injectq: "arrows", test: "flask", evaluation: "target", }; diff --git a/config-editor-ui/src/pages.jsx b/config-editor-ui/src/pages.jsx index 85ae1b2..e1b6688 100644 --- a/config-editor-ui/src/pages.jsx +++ b/config-editor-ui/src/pages.jsx @@ -8,6 +8,7 @@ import { Callout, Disclosure, Icon, IssueCallout, Segmented } from "./ui.jsx"; import { AuthWidget, CacheStoreWidget, ChoiceWidget, RemoteToolsWidget, } from "./widgets.jsx"; +import { AuditWidget, auditSummary } from "./audit.jsx"; import { MetricsWidget, TracingWidget, metricsSummary, traceSummary } from "./telemetry.jsx"; const Heading = ({ children, code }) => ( @@ -72,7 +73,7 @@ function GetRunning() { const core = section("core"); const ready = isAgentReady(); const general = core ? issuesFor("core").filter((i) => !core.fields.some((f) => f.path.join(".") === i.path)) : []; - const rows = [["auth", authSummary()], ["cache_store", cacheSummary()], ["observability", traceSummary(offText)], ["metrics", metricsSummary(offText)]]; + const rows = [["auth", authSummary()], ["cache_store", cacheSummary()], ["observability", traceSummary(offText)], ["metrics", metricsSummary(offText)], ["audit", auditSummary(offText)]]; const all = store.nav.flatMap((g) => g.items).filter((id) => id !== "get-running"); return ( <> @@ -142,6 +143,7 @@ function SectionWidget({ sec }) { if (sec.widget === "remote_tools") return ; if (sec.widget === "observability") return ; if (sec.widget === "metrics") return ; + if (sec.widget === "audit") return ; if (sec.widget === "cache_store") return ; if (sec.widget === "id_generator" || sec.widget === "thread_name_generator") return ; return null; diff --git a/config-editor-ui/src/store.js b/config-editor-ui/src/store.js index c8f52e3..81b4555 100644 --- a/config-editor-ui/src/store.js +++ b/config-editor-ui/src/store.js @@ -7,7 +7,7 @@ // are pages of their own rather than schema sections. export const NAV = [ { label: null, items: ["get-running"] }, - { label: "Before production", items: ["auth", "cache_store", "observability", "metrics"] }, + { label: "Before production", items: ["auth", "cache_store", "observability", "metrics", "audit"] }, { label: "Runs and streaming", items: ["runs", "websocket", "ag_ui", "remote_tools", "media"] }, { label: "Project wiring", items: ["checkpointer", "id_generator", "thread_name_generator", "store", "injectq"] }, { label: "Developer tools", items: ["test", "evaluation"] }, @@ -156,6 +156,7 @@ export const sectionChanged = (sec) => { if (sec.id === "core") return false; if (sec.id === "auth") return store.config.auth != null; if (sec.id === "metrics") return store.config.metrics?.enabled === true; + if (sec.id === "audit") return store.config.audit?.enabled === true; return sec.optional ? isOn(sec) : sec.fields.some(fieldChanged); }; // Highest-severity issue of a section, or null. diff --git a/tenxgraph_api/__init__.py b/tenxgraph_api/__init__.py index db90613..1dd08fa 100644 --- a/tenxgraph_api/__init__.py +++ b/tenxgraph_api/__init__.py @@ -10,6 +10,7 @@ # Lets expose few things the user suppose to use from tenxgraph.core.authz import Identity +from .src.app.core.audit import AuditEvent, BaseAuditSink from .src.app.core.auth.base_auth import BaseAuth from .src.app.core.auth.permissions import RequireIdentity, RequirePermission from .src.app.core.cache_store import ( @@ -25,6 +26,8 @@ __all__ = [ + "AuditEvent", + "BaseAuditSink", "BaseAuth", "BaseCacheStore", "Identity", diff --git a/tenxgraph_api/cli/commands/audit.py b/tenxgraph_api/cli/commands/audit.py index 173038c..1d642f2 100644 --- a/tenxgraph_api/cli/commands/audit.py +++ b/tenxgraph_api/cli/commands/audit.py @@ -24,6 +24,7 @@ import importlib import json +import os import socket import sys from collections.abc import Callable @@ -57,7 +58,7 @@ class Diagnostic: class AuditCommand(BaseCommand): """Audit the local CLI, core package, project config, and default port. - Runs eight checks in a fixed order and reports them twice: live, through the + Runs nine checks in a fixed order and reports them twice: live, through the step timeline, and again as a summary table once every check has finished. ============ ========================================================== @@ -78,6 +79,8 @@ class AuditCommand(BaseCommand): security The settings in ``10xgraph.json`` that matter before production (auth, authorization, rate limiting, token revocation), as warnings only: they are the developer's choices, never failures. + audit log Warns when MODE=production and ``audit.enabled`` is not true + in ``10xgraph.json``. port The default API port is free to bind. ============ ========================================================== """ @@ -108,6 +111,7 @@ def execute(self, *, config: str = DEFAULT_CONFIG_FILE, **kwargs: Any) -> int: ("config", "Project configuration", lambda: self._config_check(config)), ("auth", "Auth configuration", lambda: self._auth_check(config)), ("security", "Security posture", lambda: self._security_check(config)), + ("audit_log", "Audit log", lambda: self._audit_log_check(config)), ("port", f"Port {DEFAULT_PORT}", lambda: self._port_check(DEFAULT_PORT)), ) @@ -253,6 +257,21 @@ def _security_check(config: str = DEFAULT_CONFIG_FILE) -> Diagnostic: "Security", "warn", "; ".join(f"{i['path']}: {i['message']}" for i in issues) ) + @staticmethod + def _audit_log_check(config: str = DEFAULT_CONFIG_FILE) -> Diagnostic: + """Warn when production runs without the audit log (reads the JSON and MODE only).""" + if os.environ.get("MODE", "").lower() != "production": + return Diagnostic("Audit log", "pass", "not checked outside MODE=production") + config_path = resolve_default_config(config).resolve() + try: + data = json.loads(config_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return Diagnostic("Audit log", "pass", "no readable config to check") + section = data.get("audit") if isinstance(data, dict) else None + if isinstance(section, dict) and section.get("enabled") is True: + return Diagnostic("Audit log", "pass", "on") + return Diagnostic("Audit log", "warn", "audit log off in production") + @staticmethod def _port_check(port: int) -> Diagnostic: """Warn when ``port`` on loopback already has a listener. diff --git a/tenxgraph_api/cli/commands/init.py b/tenxgraph_api/cli/commands/init.py index b7e1bec..959170d 100644 --- a/tenxgraph_api/cli/commands/init.py +++ b/tenxgraph_api/cli/commands/init.py @@ -291,6 +291,7 @@ def _print_summary(self, context: dict) -> None: store = context.get("cache_store") or "memory" rows.append(("Cache store", "Redis" if store == "redis" else "Memory")) + rows.append(("Audit log", "On")) rows.append(("ID generator", str(context.get("id_generator", "uuid")))) if context.get("rate_limit"): rl_by = {"global": "Global", "user": "Per user"}.get( diff --git a/tenxgraph_api/cli/commands/init_context.py b/tenxgraph_api/cli/commands/init_context.py index cf1744d..8da8c2d 100644 --- a/tenxgraph_api/cli/commands/init_context.py +++ b/tenxgraph_api/cli/commands/init_context.py @@ -267,6 +267,9 @@ def build_config(context: dict, is_prod: bool) -> dict: cache_store["token_revocation"] = True config["cache_store"] = cache_store + # Who accessed or changed which data, as JSON lines on stdout for the log pipeline. + config["audit"] = {"enabled": True, "sinks": ["log"], "include_reads": True} + worker = WORKERS.get(context.get("worker", "none")) if worker: config["worker"] = { diff --git a/tenxgraph_api/cli/commands/revoke.py b/tenxgraph_api/cli/commands/revoke.py index 30438b7..527d8d4 100644 --- a/tenxgraph_api/cli/commands/revoke.py +++ b/tenxgraph_api/cli/commands/revoke.py @@ -21,6 +21,19 @@ class RevokeCommand(BaseCommand): """Revoke one token (``--jti``) or all of a user's older tokens (``--user``).""" + def _configure_audit(self, graph_config: Any) -> None: + """Set up the audit sinks; on failure warn and use the log sink, never block.""" + from tenxgraph_api.src.app.core.audit import AuditConfig, configure_audit + + try: + configure_audit(graph_config.audit, auth_enabled=True) + except Exception as e: + self.output.warning( + f"Audit sinks could not be set up ({type(e).__name__}: {e}); " + "the revocation is audited to the log only." + ) + configure_audit(AuditConfig(enabled=True, sinks=["log"]), auth_enabled=True) + def _cache_store(self, redis_url: str | None, config: str) -> tuple[Any, float | None]: """The cache store to write to and the default ``user_ttl`` from the project config.""" from tenxgraph_api.src.app.core.cache_store import InMemoryCacheStore, RedisCacheStore @@ -32,6 +45,12 @@ def _cache_store(self, redis_url: str | None, config: str) -> tuple[Any, float | path = resolve_default_config(config) graph_config = GraphConfig(str(path)) if path.exists() else None + if graph_config is not None: + # Custom cache stores and audit sinks import from the project, like on the server. + root = str(path.resolve().parent) + if root not in sys.path: + sys.path.insert(0, root) + self._configure_audit(graph_config) user_ttl: float | None = DEFAULT_USER_REVOCATION_TTL if graph_config is not None: settings = graph_config.token_revocation # raises for an invalid value @@ -40,11 +59,6 @@ def _cache_store(self, redis_url: str | None, config: str) -> tuple[Any, float | if redis_url: return RedisCacheStore.from_url(redis_url), user_ttl - if graph_config is not None: - # A custom cache_store.path imports from the project, like the server does. - root = str(path.resolve().parent) - if root not in sys.path: - sys.path.insert(0, root) # GraphConfig has loaded the project's env file, so cache_store.url expands after it. store, source = build_cache_store(graph_config) if isinstance(store, InMemoryCacheStore): diff --git a/tenxgraph_api/cli/config_editor/schema.py b/tenxgraph_api/cli/config_editor/schema.py index e62b906..055c268 100644 --- a/tenxgraph_api/cli/config_editor/schema.py +++ b/tenxgraph_api/cli/config_editor/schema.py @@ -17,7 +17,7 @@ "default": {...}, # value written when the toggle is switched on "widget": None, # custom renderer: "auth", "remote_tools", # "cache_store", "id_generator", "thread_name_generator", - # "observability", "metrics" + # "observability", "metrics", "audit" "choices": [["value", "Label", "One line of help"]], # widget sections with a dropdown "fields": [...], # generic fields, paths are relative to the root "notes": ["..."], # extra hints (secrets that belong in .env, ...) @@ -90,6 +90,7 @@ "media", "observability", "metrics", + "audit", "injectq", "store", "store_allow_tenant_mismatch", @@ -890,6 +891,49 @@ }, ], }, + { + "id": "audit", + "title": "Audit log", + "description": ( + "Who accessed or changed which data, when, and whether it was allowed. Ids and " + "results only, never prompts, answers or file contents." + ), + "key": "audit", + "optional": True, + "off_summary": "No audit events are recorded.", + "default": {"enabled": True, "sinks": ["log"], "include_reads": True}, + "self_managed": True, + "widget": "audit", + "fields": [ + { + "path": ["audit", "enabled"], + "managed": True, + "label": "Record an audit log", + "type": "bool", + }, + { + "path": ["audit", "sinks"], + "managed": True, + "label": "Sinks", + "type": "list", + "placeholder": "my_pkg.audit:MySink", + "help": 'Where events go: "log", "publisher", or a "module:Class" sink.', + }, + { + "path": ["audit", "include_reads"], + "managed": True, + "label": "Include allowed reads", + "type": "bool", + "help": "Off keeps denials, auth failures and changes only.", + }, + ], + "env": [ + { + "name": "LOG_FORMAT", + "purpose": ("json writes every log line as one JSON object; use in production."), + }, + ], + }, { "id": "checkpointer", "title": "Checkpointer", diff --git a/tenxgraph_api/cli/config_editor/static/app.js b/tenxgraph_api/cli/config_editor/static/app.js index eda825d..9ac87b2 100644 --- a/tenxgraph_api/cli/config_editor/static/app.js +++ b/tenxgraph_api/cli/config_editor/static/app.js @@ -1,3 +1,3 @@ -var Ie,v,Lt,eo,J,At,Nt,Dt,et,Se,ue,Ht,ot,tt,nt,to,Ee={},Pe=[],no=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,Ae=Array.isArray;function F(e,t){for(var o in t)e[o]=t[o];return e}function rt(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function oo(e,t,o){var r,i,s,a={};for(s in t)s=="key"?r=t[s]:s=="ref"?i=t[s]:a[s]=t[s];if(arguments.length>2&&(a.children=arguments.length>3?Ie.call(arguments,2):o),typeof e=="function"&&e.defaultProps!=null)for(s in e.defaultProps)a[s]===void 0&&(a[s]=e.defaultProps[s]);return Te(e,a,r,i,null)}function Te(e,t,o,r,i){var s={type:e,props:t,key:o,ref:r,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:i??++Lt,__i:-1,__u:0};return i==null&&v.vnode!=null&&v.vnode(s),s}function x(e){return e.children}function $e(e,t){this.props=e,this.context=t}function Z(e,t){if(t==null)return e.__?Z(e.__,e.__i+1):null;for(var o;tt&&J.sort(Dt),e=J.shift(),t=J.length,ro(e)}finally{J.length=Oe.__r=0}}function Ft(e,t,o,r,i,s,a,u,m,l,p){var h,c,f,y,T,w,E=r&&r.__k||Pe,k=t.length;for(m=so(o,t,E,m,k),h=0;h0?a=e.__k[s]=Te(a.type,a.props,a.key,a.ref?a.ref:null,a.__v):e.__k[s]=a,m=s+c,a.__=e,a.__b=e.__b+1,u=null,(l=a.__i=io(a,o,m,h))!=-1&&(h--,(u=o[l])&&(u.__u|=2)),u==null||u.__v==null?(l==-1&&(i>p?c--:im?c--:c++,a.__u|=4))):e.__k[s]=null;if(h)for(s=0;s(p?1:0)){for(i=o-1,s=o+1;i>=0||s=0?i--:s++])!=null&&(2&l.__u)==0&&u==l.key&&m==l.type)return a}return-1}function jt(e,t,o){t[0]=="-"?e.setProperty(t,o??""):e[t]=o==null?"":typeof o!="number"||no.test(t)?o:o+"px"}function Ce(e,t,o,r,i){var s,a;e:if(t=="style")if(typeof o=="string")e.style.cssText=o;else{if(typeof r=="string"&&(e.style.cssText=r=""),r)for(t in r)o&&t in o||jt(e.style,t,"");if(o)for(t in o)r&&o[t]==r[t]||jt(e.style,t,o[t])}else if(t[0]=="o"&&t[1]=="n")s=t!=(t=t.replace(Ht,"$1")),a=t.toLowerCase(),t=a in e||t=="onFocusOut"||t=="onFocusIn"?a.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+s]=o,o?r?o[ue]=r[ue]:(o[ue]=ot,e.addEventListener(t,s?nt:tt,s)):e.removeEventListener(t,s?nt:tt,s);else{if(i=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=o??"";break e}catch{}typeof o=="function"||(o==null||o===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&o==1?"":o))}}function Mt(e){return function(t){if(this.l){var o=this.l[t.type+e];if(t[Se]==null)t[Se]=ot++;else if(t[Se]0?e:Ae(e)?e.map(Gt):e.constructor!==void 0?null:F({},e)}function ao(e,t,o,r,i,s,a,u,m){var l,p,h,c,f,y,T,w=o.props||Ee,E=t.props,k=t.type;if(k=="svg"?i="http://www.w3.org/2000/svg":k=="math"?i="http://www.w3.org/1998/Math/MathML":i||(i="http://www.w3.org/1999/xhtml"),s!=null){for(l=0;l=o.__.length&&o.__.push({}),o.__[e]}function K(e){return je=1,dt(rn,e)}function dt(e,t,o){var r=ct(pe++,2);if(r.t=e,!r.__c&&(r.__=[o?o(t):rn(void 0,t),function(u){var m=r.__N?r.__N[0]:r.__[0],l=r.t(m,u);m!==l&&(r.__N=[l,r.__[1]],r.__c.setState({}))}],r.__c=C,!C.__f)){var i=function(u,m,l){if(!r.__c.__H)return!0;var p=!1,h=r.__c.props!==u;if(r.__c.__H.__.some(function(f){if(f.__N){p=!0;var y=f.__[0];f.__=f.__N,f.__N=void 0,y!==f.__[0]&&(h=!0)}}),s){var c=s.call(this,u,m,l);return p?c||h:c}return!p||h};C.__f=!0;var s=C.shouldComponentUpdate,a=C.componentWillUpdate;C.componentWillUpdate=function(u,m,l){if(this.__e){var p=s;s=void 0,i(u,m,l),s=p}a&&a.call(this,u,m,l)},C.shouldComponentUpdate=i}return r.__N||r.__}function U(e,t){var o=ct(pe++,3);!$.__s&&on(o.__H,t)&&(o.__=e,o.u=t,C.__H.__h.push(o))}function Me(e){return je=5,co(function(){return{current:e}},[])}function co(e,t){var o=ct(pe++,7);return on(o.__H,t)&&(o.__=e(),o.__H=t,o.__h=e),o.__}function uo(){for(var e;e=nn.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(Re),t.__h.some(lt),t.__h=[]}catch(o){t.__h=[],$.__e(o,e.__v)}}}$.__b=function(e){C=null,Kt&&Kt(e)},$.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),en&&en(e,t)},$.__r=function(e){Yt&&Yt(e),pe=0;var t=(C=e.__c).__H;t&&(at===C?(t.__h=[],C.__h=[],t.__.some(function(o){o.__N&&(o.__=o.__N),o.u=o.__N=void 0})):(t.__h.some(Re),t.__h.some(lt),t.__h=[],pe=0)),at=C},$.diffed=function(e){Xt&&Xt(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(nn.push(t)!==1&&Jt===$.requestAnimationFrame||((Jt=$.requestAnimationFrame)||po)(uo)),t.__H.__.some(function(o){o.u&&(o.__H=o.u,o.u=void 0)})),at=C=null},$.__c=function(e,t){t.some(function(o){try{o.__h.some(Re),o.__h=o.__h.filter(function(r){return!r.__||lt(r)})}catch(r){t.some(function(i){i.__h&&(i.__h=[])}),t=[],$.__e(r,o.__v)}}),Qt&&Qt(e,t)},$.unmount=function(e){Zt&&Zt(e);var t,o=e.__c;o&&o.__H&&(o.__H.__.some(function(r){try{Re(r)}catch(i){t=i}}),o.__H=void 0,t&&$.__e(t,o.__v))};var tn=typeof requestAnimationFrame=="function";function po(e){var t,o=function(){clearTimeout(r),tn&&cancelAnimationFrame(t),setTimeout(e)},r=setTimeout(o,35);tn&&(t=requestAnimationFrame(o))}function Re(e){var t=C,o=e.__c;typeof o=="function"&&(e.__c=void 0,o()),C=t}function lt(e){var t=C;e.__c=e.__(),C=t}function on(e,t){return!e||e.length!==t.length||t.some(function(o,r){return o!==e[r]})}function rn(e,t){return typeof t=="function"?t(e):t}var Le=[{label:null,items:["get-running"]},{label:"Before production",items:["auth","cache_store","observability","metrics"]},{label:"Runs and streaming",items:["runs","websocket","ag_ui","remote_tools","media"]},{label:"Project wiring",items:["checkpointer","id_generator","thread_name_generator","store","injectq"]},{label:"Developer tools",items:["test","evaluation"]},{label:"Reference",items:["environment"]}],q={"get-running":{title:"Get running",icon:"flag",description:"Only the agent graph is required. Everything else is optional."},environment:{title:"Server environment (.env)",icon:"terminal",description:"Variables the server reads from .env. The editor never reads that file, so values are not shown."}},ut={"auth.path":"auth-path"},sn="tg-config-token",an=!1;function mo(){let e=new URLSearchParams(location.hash.slice(1)).get("token")||"";try{e&&sessionStorage.setItem(sn,e);let t=sessionStorage.getItem(sn)||"";return an=t!==""&&t===(e||t),e||t}catch{return e}}var me=mo(),pt=e=>an||!me?`#${e}`:`#token=${encodeURIComponent(me)}&page=${e}`;function ln(){let e=location.hash.slice(1);if(e.includes("=")){let t=new URLSearchParams(e);return t.get("page")||t.get("section")||""}try{return decodeURIComponent(e)}catch{return e}}var d={schema:null,sections:[],nav:[],fields:new Map,config:{},version:null,exists:!1,path:"",parseError:null,saved:"",validated:null,issues:[],page:"get-running",query:"",sideOpen:!0,sideRender:!0,sideShown:!0,drawerOpen:!1,tab:"json",copied:!1,busy:null,toolParams:{},toasts:[],fatal:null},cn=()=>{},dn=e=>{cn=e};function b(e){e&&e(d),cn()}var un=e=>JSON.parse(JSON.stringify(e)),L=()=>JSON.stringify(d.config),g=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),N=(e,t)=>t.reduce((o,r)=>g(o)?o[r]:void 0,e),fe=(e,t)=>`${e} ${t}${e===1?"":"s"}`;function oe(e,t,o){let r=e;for(let i of t.slice(0,-1))g(r[i])||(r[i]={}),r=r[i];r[t[t.length-1]]=o}var fo=new Set(["cache_store.rate_limit","cache_store.token_revocation"]);function he(e,t){let o=[],r=e;for(let i of t.slice(0,-1)){if(!g(r))return;o.push([r,i]),r=r[i]}g(r)&&delete r[t[t.length-1]];for(let i=o.length-1;i>=1;i--){let[s,a]=o[i];if(fo.has(t.slice(0,i+1).join(".")))break;if(g(s[a])&&Object.keys(s[a]).length===0)delete s[a];else break}}function mt(e){let t=N(d.config,e);return t!==void 0?t:d.fields.get(e.join("."))?.default}var ho=(e,t)=>JSON.stringify(e)===JSON.stringify(t),O=e=>d.sections.find(t=>t.id===e),W=()=>L()!==d.saved,Ne=e=>!e.optional||e.key in d.config&&d.config[e.key]!==null,re=e=>d.issues.filter(t=>t.section===e),S=e=>d.issues.find(t=>t.path===e);function _e(e=d.issues){let t=e.filter(o=>o.level==="error").length;return{errors:t,warnings:e.length-t}}var De=e=>ut[e]||`f-${e.replaceAll(".","-")}`,ft=e=>{let t=N(d.config,e.path);return t!==void 0&&!ho(t,e.default)},pn=e=>e.id==="core"?!1:e.id==="auth"?d.config.auth!=null:e.id==="metrics"?d.config.metrics?.enabled===!0:e.optional?Ne(e):e.fields.some(ft);function mn(e){let t=re(e);return t.find(o=>o.level==="error")||t[0]||null}var fn=e=>!!e.show_if&&mt(e.show_if.path)!==e.show_if.equals;function hn(e){let o=d.fields.get(e.show_if.path.join("."))?.label||e.show_if.path.join("."),r=e.show_if.equals;return`Used when ${o} is ${r===!0?"on":r===!1?"off":String(r)}.`}var ge=e=>{let t=O(e);return q[e]?{id:e,...q[e]}:t?{id:e,title:t.title,subtitle:t.subtitle,description:t.description,icon:null}:null},_n=()=>/^[\w.]+:[\w.]+$/.test(String(d.config.agent||""));async function _t(e,t,o){let r={"X-10xGraph-Token":me};o!==void 0&&(r["Content-Type"]="application/json");let i=await fetch(t,{method:e,headers:r,body:o===void 0?void 0:JSON.stringify(o)}),s={};try{s=await i.json()}catch{}return{ok:i.ok,status:i.status,data:s}}var ht=(e,t)=>b(o=>{o.fatal={title:e,text:t}});async function xn(){if(!me)return ht("Open the link printed by 10xgraph config","This page needs the session token included in that link.");let e;try{e=await _t("GET","/api/state")}catch{return ht("Cannot reach the config server","Check that 10xgraph config is still running in your terminal, then reload.")}if(!e.ok)return ht("This link has expired",e.data.error||"Restart 10xgraph config and open the new link it prints.");let t=e.data;b(o=>{o.schema=t.schema,o.sections=t.schema.sections;let r=new Set(Le.flatMap(a=>a.items)),i=o.sections.map(a=>a.id).filter(a=>a!=="core"&&!r.has(a));o.nav=Le.map((a,u)=>({label:a.label,items:uq[m]||O(m)):[...i,...a.items]}));for(let a of o.sections)for(let u of a.fields)o.fields.set(u.path.join("."),u);o.config=t.config,o.version=t.version,o.exists=t.exists,o.path=t.path,o.parseError=t.parse_error,o.issues=t.issues;let s=t.exists&&!t.parse_error;o.saved=s?L():"",o.validated=s?L():null}),ze({focus:!1}),history.replaceState(null,"",pt(d.page))}var gn=null;async function vn(){let e=L();if(!(!d.schema||d.validated===e||gn===e)&&!Object.values(d.toolParams).some(t=>t.error)){gn=e;try{let t=await _t("POST","/api/validate",{config:JSON.parse(e)});if(!t.ok)return;b(o=>{o.issues=t.data.issues,o.validated=e})}catch{}}}async function He(){if(!(d.busy||!d.schema)){if(d.exists&&!W()&&!d.parseError)return G("success","No changes to save.");if(_o()){b(e=>{e.busy="save"});try{let e=await _t("POST","/api/save",{config:d.config,version:d.version});if(e.data.issues&&(d.issues=e.data.issues,d.validated=L()),e.ok){d.version=e.data.version,d.config=e.data.config,d.toolParams={},d.validated=L(),d.saved=L(),d.exists=!0,d.parseError=null;let t=e.data.path.split(/[\\/]/).pop();G("success",e.data.backup?`Saved ${t}. The previous version is in ${t}.bak.`:`Created ${t}.`)}else if(e.status===409)G("error",e.data.error,{label:"Reload",run:()=>location.reload()});else if(e.status===422){let{errors:t}=_e();G("error",`Not saved: ${fe(t,"error")} to fix first.`,{label:"Show",run:go})}else G("error",e.data.error||"Save failed.")}catch{G("error","Cannot reach the config server.")}finally{b(e=>{e.busy=null})}}}}function _o(){let e=Object.values(d.toolParams).some(t=>t.error);return e&&(D("remote_tools"),G("error","A remote tool has invalid parameters JSON.")),!e}function yn(e,t){b(o=>{t?o.config[e.key]=un(e.default):delete o.config[e.key],e.key==="remote_tools"&&(o.toolParams={})})}function go(){let e=d.issues.find(t=>t.level==="error")||d.issues[0];e&&D(e.section,De(e.path))}var bo=()=>window.matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth";function xo(e){return e==="core"?"get-running":q[e]||O(e)?e:"get-running"}function ze({focus:e=!0}={}){b(t=>{t.page=xo(ln()),t.query="",t.drawerOpen=!1}),e&&requestAnimationFrame(()=>{window.scrollTo(0,0),document.getElementById("page-title")?.focus({preventScroll:!0})})}function D(e,t){let o=pt(e);location.hash!==o&&history.pushState(null,"",o),ze(),t&&requestAnimationFrame(()=>requestAnimationFrame(()=>{let r=document.getElementById(t);r&&(r.scrollIntoView({block:"center",behavior:bo()}),r.focus({preventScroll:!0}))}))}function Fe(e){b(t=>{t.query=e})}var vo=()=>window.matchMedia("(prefers-reduced-motion: reduce)").matches,bn;function se(e,{instant:t=!1}={}){try{localStorage.setItem("tg-sidebar",e?"shown":"hidden")}catch{}clearTimeout(bn);let o=t||vo();b(r=>{r.sideOpen=e,e?(r.sideRender=!0,r.sideShown=o):r.sideShown=!1,!e&&o&&(r.sideRender=!1)}),e&&!o&&requestAnimationFrame(()=>requestAnimationFrame(()=>b(r=>{r.sideShown=!0}))),!e&&!o&&(bn=setTimeout(()=>b(r=>{r.sideOpen||(r.sideRender=!1)}),200))}function gt(){d.sideOpen||se(!0,{instant:!0}),requestAnimationFrame(()=>{let e=document.getElementById("q");e?.focus(),e?.select()})}function kn(e){b(t=>{e&&(t.tab=e),t.drawerOpen=!0})}var Ue=()=>b(e=>{e.drawerOpen=!1});async function wn(){let e="10xgraph dev";try{await navigator.clipboard.writeText(e)}catch{try{let t=document.createElement("textarea");t.value=e,document.body.append(t),t.select(),document.execCommand("copy"),t.remove()}catch{}}b(t=>{t.copied=!0}),setTimeout(()=>b(t=>{t.copied=!1}),2e3)}async function Cn(){try{await navigator.clipboard.writeText(JSON.stringify(d.config,null,2)+` -`),G("success","Copied 10xgraph.json to the clipboard.")}catch{G("error","The clipboard is not available here.")}}var yo=0;function G(e,t,o){let r=++yo;b(i=>{i.toasts=[...i.toasts,{id:r,kind:e,message:t,action:o}]}),setTimeout(()=>qe(r),o?8e3:4e3)}function qe(e){b(t=>{t.toasts=t.toasts.filter(o=>o.id!==e)})}var Sn={box:'',key:'',shield:'',gauge:'',arrows:'',activity:'',layers:'',wrench:'',flask:'',target:'',checkCircle:'',xCircle:'',alert:'',info:'',file:'',copy:'',x:'',plus:'',trash:'',panel:'',chevronDown:'',expand:'',collapse:'',power:'',spinner:'',book:'',flag:'',lock:'',play:'',bolt:'',terminal:'',search:'',chevronRight:'',code:'',circle:'',external:''},ee={"get-running":"flag",environment:"terminal",core:"flag",auth:"lock",cache_store:"gauge",observability:"activity",metrics:"gauge",runs:"play",websocket:"bolt",ag_ui:"external",remote_tools:"wrench",media:"file",checkpointer:"layers",id_generator:"key",thread_name_generator:"file",store:"box",injectq:"arrows",test:"flask",evaluation:"target"};var ko=0;function n(e,t,o,r,i,s){t||(t={});var a,u,m=t;if("ref"in m)for(u in m={},t)u=="ref"?a=t[u]:m[u]=t[u];var l={type:e,props:m,key:o,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--ko,__i:-1,__u:0,__source:i,__self:s};if(typeof e=="function"&&(a=e.defaultProps))for(u in a)m[u]===void 0&&(m[u]=a[u]);return v.vnode&&v.vnode(l),l}function _({name:e,class:t="size-4"}){return/(^|\s)size-/.test(t)||(t=`size-4 ${t}`),n("span",{class:`inline-flex shrink-0 ${t}`,"aria-hidden":"true",children:n("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"1.75","stroke-linecap":"round","stroke-linejoin":"round",class:"size-full",dangerouslySetInnerHTML:{__html:Sn[e]}})})}function ie({class:e,children:t}){return n("span",{class:`inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[11px] font-semibold ${e}`,children:t})}function be({id:e,on:t,label:o,onChange:r}){return n("button",{id:e,type:"button",role:"switch","aria-checked":t?"true":"false","aria-label":o,class:`focus-ring relative inline-flex h-6 w-10 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 before:absolute before:-inset-2.5 before:content-[''] ${t?"bg-brand-solid":"bg-line-strong"}`,onClick:()=>r(!t),children:n("span",{class:`pointer-events-none inline-block size-5 rounded-full bg-white shadow-sm ring-1 ring-black/5 transition-transform duration-200 ease-out ${t?"translate-x-[18px]":"translate-x-0.5"}`})})}function te({id:e,label:t,options:o,value:r,onPick:i}){return n("div",{id:e,role:"radiogroup","aria-label":t,class:"inline-flex max-w-full flex-wrap gap-0.5 rounded-[10px] border border-line-strong bg-raised p-0.5",children:o.map(([s,a])=>n("button",{type:"button",role:"radio","aria-checked":s===r?"true":"false",class:`focus-ring min-h-10 cursor-pointer rounded-lg px-3.5 text-sm font-medium transition-colors duration-150 ${s===r?"bg-surface text-ink shadow-card":"text-muted hover:text-ink"}`,onClick:()=>{s!==r&&i(s)},children:a},s))})}function A({id:e,options:t,value:o,invalid:r,onChange:i,plain:s}){return n("div",{class:"relative",children:[n("select",{id:e,value:o,class:`input cursor-pointer appearance-none pr-9 ${s?"":"font-mono"} ${r?"input-invalid":""}`,onChange:a=>i(a.currentTarget.value),children:t.map(([a,u])=>n("option",{value:a,children:u},a))}),n(_,{name:"chevronDown",class:"pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-faint"})]})}function H({id:e,values:t,placeholder:o,suggestions:r,onChange:i}){let s=Me(null),a=m=>{let l=m.trim();s.current&&(s.current.value=""),l&&!t.includes(l)&&i([...t,l])},u=r?`${e}-options`:void 0;return n("div",{class:"flex min-h-11 cursor-text flex-wrap items-center gap-1.5 rounded-lg border border-line-strong bg-sunken px-1.5 py-1.5 transition-[border-color,box-shadow] duration-150 focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-ring sm:py-1",onClick:m=>{m.target===m.currentTarget&&s.current?.focus()},children:[t.map((m,l)=>n("span",{class:"inline-flex h-7 items-center gap-0.5 rounded-md border border-line bg-raised pr-0.5 pl-2 font-mono text-xs text-body sm:h-6",children:[m,n("button",{type:"button","aria-label":`Remove ${m}`,class:"inline-flex size-6 cursor-pointer items-center justify-center rounded text-faint transition-colors hover:bg-hover hover:text-ink sm:size-5",onClick:()=>i(t.filter((p,h)=>h!==l)),children:n(_,{name:"x",class:"size-3"})})]},m)),n("input",{ref:s,id:e,type:"text",list:u,placeholder:t.length?"Add...":o,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:"h-7 min-w-[7rem] flex-1 bg-transparent px-1 font-mono text-base text-ink outline-none placeholder:text-faint sm:text-sm",onKeyDown:m=>{let l=m.currentTarget;(m.key==="Enter"||m.key===",")&&l.value.trim()?(m.preventDefault(),a(l.value)):m.key==="Backspace"&&!l.value&&t.length&&i(t.slice(0,-1))},onChange:m=>{r?.includes(m.currentTarget.value.trim())&&a(m.currentTarget.value)},onBlur:m=>a(m.currentTarget.value)}),r?n("datalist",{id:u,children:r.filter(m=>!t.includes(m)).map(m=>n("option",{value:m},m))}):null]})}function xe({label:e,path:t,help:o,id:r,required:i}){return n("div",{class:"min-w-0",children:[n("label",{for:r,class:"block text-sm font-medium text-ink",children:[e,i?n("span",{class:"text-danger",children:" *"}):null]}),n("div",{class:"mt-0.5 font-mono text-xs text-faint",children:t}),o?n("p",{class:"mt-1.5 text-[13px] leading-5 text-muted",children:o}):null]})}function P({issue:e}){if(!e)return null;let t=e.level==="error";return n("p",{class:`mt-2 flex gap-1.5 text-[13px] leading-5 ${t?"text-danger":"text-warn"}`,role:t?"alert":void 0,children:[n(_,{name:t?"xCircle":"alert",class:"mt-0.5 size-3.5"}),n("span",{children:e.message})]})}function bt({children:e}){return n("div",{class:"grid gap-3 px-5 py-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] sm:gap-8",children:e})}function Y({id:e,label:t,path:o,value:r,placeholder:i,onInput:s,issue:a,help:u}){return n(bt,{children:[n(xe,{label:t,path:o,help:u,id:e}),n("div",{class:"min-w-0 sm:pt-0.5",children:[n("input",{id:e,type:"text",value:r??"",placeholder:i,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:`input font-mono ${a?.level==="error"?"input-invalid":""}`,onInput:m=>s(m.currentTarget.value)}),n(P,{issue:a})]})]})}function xt({name:e,label:t,options:o,value:r,onPick:i}){return n("div",{role:"radiogroup","aria-label":t,class:"grid gap-3 sm:grid-cols-2",children:o.map(([s,a,u])=>{let m=s===r;return n("button",{type:"button",role:"radio","aria-checked":m?"true":"false",id:`${e}-${s}`,class:`focus-ring flex cursor-pointer items-start gap-3 rounded-xl border p-4 text-left transition-colors duration-150 ${m?"border-brand bg-brand-soft":"border-line bg-surface shadow-card hover:border-line-strong"}`,onClick:()=>{m||i(s)},children:[n("span",{class:`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border ${m?"border-brand-solid bg-brand-solid":"border-line-strong"}`,children:m?n("span",{class:"size-1.5 rounded-full bg-white"}):null}),n("span",{class:"min-w-0",children:[n("span",{class:"block text-sm font-medium text-ink",children:a}),n("span",{class:"mt-0.5 block text-[13px] leading-5 text-muted",children:u})]})]},s)})})}function R({title:e,children:t}){return n("section",{children:[e?n("h4",{class:"eyebrow mb-2.5",children:e}):null,t]})}var wo={info:["info","border-brand/20 bg-brand-soft","text-brand"],warn:["alert","border-warn/25 bg-warn-soft","text-warn"],error:["xCircle","border-danger/25 bg-danger-soft","text-danger"]};function ve({kind:e,title:t,lines:o}){let[r,i,s]=wo[e];return n("div",{class:`flex gap-3 rounded-xl border px-4 py-3 ${i}`,role:e==="error"?"alert":void 0,children:[n(_,{name:r,class:`mt-0.5 size-4 ${s}`}),n("div",{class:"min-w-0 space-y-1 text-[13px] leading-5 text-body",children:[t?n("p",{class:"font-medium text-ink",children:t}):null,o.map((a,u)=>n("p",{children:a},u))]})]})}function vt({issue:e}){return n(ve,{kind:e.level==="error"?"error":"warn",lines:[n("span",{children:[e.path?n("code",{class:"mr-1.5 font-mono text-xs text-muted",children:e.path}):null,e.message]})]})}function Tn({icon:e,tint:t,title:o,text:r,children:i}){return n("div",{class:"px-6 py-14 text-center",children:[n("div",{class:`mx-auto flex size-11 items-center justify-center rounded-full bg-raised ${t}`,children:n(_,{name:e,class:"size-5"})}),n("p",{class:"mt-4 text-sm font-semibold text-ink",children:o}),n("p",{class:"mx-auto mt-1 max-w-xs text-sm leading-6 text-muted",children:r}),i]})}function We({title:e,open:t,class:o="",children:r}){return n("details",{class:`group ${o}`,open:t,children:[n("summary",{class:"focus-ring flex min-h-11 cursor-pointer list-none items-center gap-2 font-medium text-muted [&::-webkit-details-marker]:hidden",children:[n(_,{name:"chevronRight",class:"size-4 transition-transform duration-150 group-open:rotate-90"}),e]}),r]})}function $n(){let e=Me(null);U(()=>{let a=e.current;a&&(d.drawerOpen&&!a.open?a.showModal():!d.drawerOpen&&a.open&&a.close())});let t=d.issues.length>0,o=t&&d.tab==="problems"?"problems":"json",{errors:r,warnings:i}=_e(),s=(a,u,m)=>n("button",{type:"button",role:"tab",id:`tab-${a}`,"aria-selected":o===a?"true":"false",class:`focus-ring inline-flex h-10 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors duration-150 ${o===a?"bg-raised text-ink":"text-muted hover:text-ink"}`,onClick:()=>b(l=>{l.tab=a}),children:[u,m]});return n("dialog",{ref:e,"aria-label":"10xgraph.json preview",class:"m-0 ml-auto h-dvh max-h-none w-[min(540px,100vw)] border-0 border-l border-line-strong bg-surface p-0 text-body shadow-pop backdrop:bg-black/45",onClose:()=>{d.drawerOpen&&Ue()},onClick:a=>{a.target===a.currentTarget&&Ue()},children:n("div",{class:"flex h-full flex-col",children:[n("div",{class:"flex h-14 shrink-0 items-center gap-1 border-b border-line px-3",children:[n("div",{role:"tablist",class:"flex flex-1 gap-1",children:[s("json","10xgraph.json",null),t?s("problems","Problems",n("span",{class:`inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[11px] font-semibold ${r?"bg-danger-soft text-danger":"bg-warn-soft text-warn"}`,children:r+i})):null]}),n("button",{type:"button",class:"icon-btn","aria-label":"Close preview",onClick:Ue,children:n(_,{name:"x",class:"size-5"})})]}),n("div",{class:"min-h-0 flex-1 overflow-y-auto overscroll-contain",children:o==="problems"?n(Co,{}):n(To,{})})]})})}function Co(){let{errors:e,warnings:t}=_e(),o=d.sections.map(r=>[r,re(r.id)]).filter(([,r])=>r.length);return n("div",{children:[n("div",{class:"border-b border-line px-4 py-3",children:n("p",{class:"text-sm text-muted",children:[n("span",{class:e?"font-medium text-danger":"",children:fe(e,"error")})," \xB7 ",n("span",{class:t?"font-medium text-warn":"",children:fe(t,"warning")})]})}),n("div",{class:"space-y-5 px-3 py-4",children:o.map(([r,i])=>n("div",{children:[n("p",{class:"eyebrow mb-1.5 flex items-center gap-2 px-2",children:[n(_,{name:ee[r.id]||"box",class:"size-3.5"}),r.title]}),n("ul",{class:"space-y-0.5",children:i.map((s,a)=>{let u=s.level==="error";return n("li",{children:n("button",{type:"button",onClick:()=>D(r.id,De(s.path)),class:"focus-ring flex min-h-11 w-full cursor-pointer gap-2.5 rounded-lg px-2 py-2 text-left transition-colors duration-150 hover:bg-raised",children:[n(_,{name:u?"xCircle":"alert",class:`mt-0.5 size-4 ${u?"text-danger":"text-warn"}`}),n("span",{class:"min-w-0",children:[n("span",{class:"block text-[13px] leading-5 text-body",children:s.message}),s.path?n("span",{class:"mt-0.5 block truncate font-mono text-xs text-faint",children:s.path}):null]})]})},a)})})]},r.id))})]})}function So(e){return e.replace(/&/g,"&").replace(//g,">").replace(/("(\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,o=>{let r="var(--af-json-number)";return o.startsWith('"')?r=o.endsWith(":")?"var(--af-json-key)":"var(--af-json-string)":(o==="true"||o==="false"||o==="null")&&(r="var(--af-json-literal)"),`${o}`})}function To(){let e=JSON.stringify(d.config,null,2).split(` -`);return n("div",{children:[n("div",{class:"sticky top-0 flex items-center justify-between gap-3 border-b border-line bg-surface px-4 py-2.5",children:[n("p",{class:"text-xs text-faint",children:W()||!d.exists?"Preview of what Save will write":"Matches the file on disk"}),n("button",{type:"button",class:"btn btn-ghost h-8 px-2.5 text-xs",onClick:Cn,children:[n(_,{name:"copy",class:"size-3.5"}),"Copy"]})]}),n("div",{class:"py-3 pr-4 font-mono text-[12.5px] leading-5",children:e.map((t,o)=>n("div",{class:"flex",children:[n("span",{class:"w-10 shrink-0 pr-3 text-right text-faint/70 select-none",children:o+1}),n("span",{class:"min-w-0 flex-1 break-all whitespace-pre-wrap",dangerouslySetInnerHTML:{__html:So(t)}})]},o))})]})}function kt(e){let t=[];for(let o of e){let r=o.group||"",i=t.find(s=>s.name===r);i||t.push(i={name:r,fields:[]}),i.fields.push(o)}return t}var B=(e,t)=>b(o=>{if(t===void 0){he(o.config,e);return}let r=e.length>1?o.sections.find(i=>i.optional&&i.key===e[0]):null;r&&(o.config[r.key]===void 0||o.config[r.key]===null)&&(o.config[r.key]=JSON.parse(JSON.stringify(r.default))),oe(o.config,e,t)}),$o=e=>`f-${e.path.join("-")}`;function Eo({id:e,value:t,field:o,invalid:r,disabled:i}){let[s,a]=K(String(t??""));return U(()=>{(s===""?t!==void 0:Number(s)!==t)&&a(String(t??""))},[t]),n("input",{id:e,type:"number",inputmode:"decimal",value:s,disabled:i,min:o.min,step:o.step??"any",placeholder:o.placeholder??o.default??"",class:`input max-w-40 font-mono tabular-nums ${r?"input-invalid":""}`,onInput:u=>{let m=u.currentTarget.value;a(m),m===""?B(o.path,void 0):Number.isNaN(Number(m))||B(o.path,Number(m))}})}function yt({field:e,value:t}){return e.exclusive_min===void 0||typeof t!="number"||t>e.exclusive_min?null:n("p",{class:"mt-1.5 text-[13px] text-warn",children:["Must be greater than ",e.exclusive_min,"."]})}function Po({id:e,field:t,value:o,invalid:r}){let i=t.unlimited_value!==void 0&&o===t.unlimited_value,s=n(Eo,{id:e,value:i?void 0:o,field:t,invalid:r,disabled:i});if(t.null_label!==void 0){let a=o===null?"n":o===void 0?"d":"s",u=typeof t.default=="number"?t.default:300;return n("div",{children:[n(te,{id:e,label:t.label,value:a,options:[["d","Default"],["s","Seconds"],["n",t.null_label]],onPick:m=>B(t.path,m==="d"?void 0:m==="n"?null:u)}),a==="s"?n("div",{class:"mt-2.5",children:[s,n(yt,{field:t,value:o})]}):null]})}return t.unlimited_value!==void 0?n("div",{children:[n("div",{class:"flex flex-wrap items-center gap-x-4",children:[s,n("label",{class:"inline-flex min-h-11 cursor-pointer items-center gap-2.5 text-sm text-body",children:[n("input",{type:"checkbox",class:"size-[18px] accent-brand-solid",checked:i,onChange:a=>B(t.path,a.currentTarget.checked?t.unlimited_value:void 0)}),"Unlimited"]})]}),n(yt,{field:t,value:o})]}):n("div",{children:[s,n(yt,{field:t,value:o})]})}function Oo({id:e,field:t,value:o}){if(t.unset_label!==void 0){let r=[["d","Default"]];return t.allow_true!==!1&&r.push(["t","On"]),r.push(["f","Off"]),n("div",{children:[n(te,{id:e,label:t.label,value:o===void 0?"d":o?"t":"f",options:r,onPick:i=>B(t.path,i==="d"?void 0:i==="t")}),n("p",{class:"mt-1.5 text-xs text-faint",children:["Default: ",t.unset_label]})]})}return n("label",{class:"inline-flex min-h-11 cursor-pointer items-center gap-2.5 text-sm font-semibold text-ink",children:[n("input",{id:e,type:"checkbox",class:"size-[18px] accent-brand-solid",checked:(o??t.default??!1)===!0,onChange:r=>B(t.path,r.currentTarget.checked)}),t.label]})}function Io({id:e,field:t,value:o,invalid:r}){if(t.type==="bool")return n(Oo,{id:e,field:t,value:o});if(t.type==="number")return n(Po,{id:e,field:t,value:o,invalid:r});if(t.type==="list")return n(H,{id:e,values:Array.isArray(o)?o:[],placeholder:t.placeholder,onChange:i=>B(t.path,i.length?i:void 0)});if(t.type==="select"){let i=t.options.map(s=>Array.isArray(s)?s:[s,s]);return t.unset_label!==void 0&&i.unshift(["",t.unset_label]),n(A,{id:e,options:i,invalid:r,value:o??(t.unset_label!==void 0?"":t.default??(Array.isArray(t.options[0])?t.options[0][0]:t.options[0])),onChange:s=>B(t.path,s===""?void 0:s)})}return n("input",{id:e,type:"text",value:o??"",placeholder:t.placeholder,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:`input font-mono ${r?"input-invalid":""}`,onInput:i=>B(t.path,i.currentTarget.value===""?void 0:i.currentTarget.value)})}function I({field:e}){let t=e.path.join("."),o=$o(e),r=N(d.config,e.path),i=S(t),s=ft(e),a=fn(e),u=e.type==="bool"&&e.unset_label===void 0,m=e.default!==void 0&&!s,l=Array.isArray(e.default)?e.default.join(", "):String(e.default??"");return n("div",{id:`row-${o}`,class:`relative pt-5 pb-1 ${s?"before:absolute before:top-6 before:-left-2.5 before:bottom-2 before:w-[3px] before:rounded before:bg-brand before:content-[''] sm:before:-left-3.5":""}`,children:[n("div",{class:"flex min-h-6 items-center gap-2.5",children:[u?null:n("label",{for:o,class:`text-sm font-semibold text-ink ${a?"opacity-50":""}`,children:[e.label,e.required?n("span",{class:"text-danger",children:" *"}):null]}),u?null:n("code",{class:"hidden font-mono text-[11px] text-faint sm:inline",children:t}),m&&!u?n("span",{class:"rounded border border-line px-1.5 text-[11px] leading-4 font-medium text-faint",children:"default"}):null,n("span",{class:"flex-1"}),s?n("button",{type:"button",class:"focus-ring min-h-8 cursor-pointer rounded px-1 text-[13px] font-medium text-brand hover:underline",title:`Reset to default${e.default!==void 0?` (${l})`:""}`,"aria-label":`Reset ${e.label} to default`,onClick:()=>b(p=>he(p.config,e.path)),children:"Reset"}):null]}),e.help?n("p",{class:`mt-0.5 max-w-prose text-[13px] leading-5 text-muted ${a?"opacity-50":""}`,children:e.help}):null,n("fieldset",{disabled:a,class:`m-0 mt-2.5 min-w-0 max-w-[520px] border-0 p-0 ${a?"opacity-50":""}`,children:n(Io,{id:o,field:e,value:r,invalid:i?.level==="error"})}),a?n("p",{class:"mt-1.5 text-xs text-faint",children:hn(e)}):null,n(P,{issue:i})]})}var Ao=[["","Choose reach"],["own","own"],["tenant","tenant"],["all","all"]],On=e=>Array.isArray(e)?e.length>0:g(e)&&Object.keys(e).length>0;function In(){let e=d.config.auth;return e==="jwt"?{method:"jwt"}:g(e)?e:{method:"none"}}function z(e){b(t=>{let o={...In()};e(o),t.config.auth=o.method==="none"?null:o})}function Ve(e,t,o){o==null||o===""||typeof o!="string"&&!On(o)?delete e[t]:e[t]=o}function Ro(e){return e.roles_claim!==void 0||On(e.roles)?"roles":e.permissions_claim!==void 0?"permissions":"default"}var jo=e=>d.issues.filter(t=>t.path===e||t.path.startsWith(`${e}.`));function wt({prefix:e}){return jo(e).map((t,o)=>n(P,{issue:t},o))}function An({id:e,label:t,name:o,onRename:r}){let[i,s]=K(o);return U(()=>s(o),[o]),n("div",{children:[n("label",{for:e,class:"sr-only",children:t}),n("input",{id:e,type:"text",value:i,class:"input font-mono",autocomplete:"off",spellcheck:!1,onInput:a=>s(a.currentTarget.value),onChange:a=>{r(a.currentTarget.value.trim())||s(o)}})]})}var Be=({children:e})=>n("span",{class:"mb-1.5 block text-xs font-medium text-faint",children:e});function Rn(e,t,o){return!o||o!==t&&o in e?null:Object.fromEntries(Object.entries(e).map(([r,i])=>[r===t?o:r,i]))}function jn({sec:e}){let t=In(),o=t.method??"none",r=Ro(t);return n("div",{class:"space-y-6",children:[n(R,{title:"Method",children:n(xt,{name:"auth-method",label:"Authentication method",value:o,options:[["none","None","Every request is accepted. For local development."],["jwt","JWT","Verify bearer tokens signed with JWT_SECRET_KEY."],["custom","Custom","Your own BaseAuth subclass decides who is calling."]],onPick:i=>z(s=>{s.method=i,i==="custom"?s.path=s.path??"":delete s.path})})}),o==="custom"?n(R,{title:"Class",children:n("div",{class:"panel",children:n(Y,{id:"auth-path",label:"Auth class",path:"auth.path",value:t.path,placeholder:"auth.custom:MyAuth",help:"Import path of a BaseAuth subclass, as module:attribute.",issue:S("auth.path"),onInput:i=>z(s=>{s.path=i})})})}):null,o!=="none"?n(x,{children:[n(R,{title:"Permission source",children:n(xt,{name:"auth-source",label:"Permission source",value:r,options:[["default","Default","Every signed-in user, own data only."],["roles","Roles from a claim","Map the roles in the token to permissions."],["permissions","Permissions from a claim","The token already lists the permissions."]],onPick:i=>z(s=>{delete s.roles_claim,delete s.permissions_claim,delete s.roles,delete s.default_role,i==="roles"&&(s.roles_claim="roles",s.roles={member:{permissions:[],reach:"own"}}),i==="permissions"&&(s.permissions_claim="permissions")})})}),r==="roles"?n(Lo,{auth:t,registry:e.registry}):null,r==="permissions"?n(R,{title:"Claim",children:n("div",{class:"panel",children:n(Y,{id:"auth-permissions-claim",label:"Permissions claim",path:"auth.permissions_claim",value:t.permissions_claim,placeholder:"permissions",issue:S("auth.permissions_claim"),help:"Token claim holding a list of permissions, such as graph:invoke.",onInput:i=>z(s=>{s.permissions_claim=i})})})}):null,n(R,{title:"Tenants",children:n("div",{class:"panel divide-y divide-line",children:[n("div",{class:"px-5 py-4",children:[n(xe,{label:"Tenant claims",path:"auth.tenant_claims",id:"auth-tenant-claims",help:"Token claims that name the tenant, such as org_id. Rows are separated by them."}),n("div",{class:"mt-3",children:n(H,{id:"auth-tenant-claims",values:t.tenant_claims??[],placeholder:"org_id",onChange:i=>z(s=>Ve(s,"tenant_claims",i))})}),n(wt,{prefix:"auth.tenant_claims"})]}),r==="roles"?n(Mo,{auth:t}):null]})}),n(No,{auth:t})]}):null]})}function Mo({auth:e}){let t=Object.keys(g(e.roles)?e.roles:{});return n(bt,{children:[n(xe,{label:"Default role",path:"auth.default_role",id:"auth-default-role",help:"Given to a signed-in user whose token carries no role."}),n("div",{class:"min-w-0 sm:pt-0.5",children:[n(A,{id:"auth-default-role",value:e.default_role??"",options:[["","None"],...t.map(o=>[o,o])],onChange:o=>z(r=>Ve(r,"default_role",o))}),n(P,{issue:S("auth.default_role")})]})]})}function Lo({auth:e,registry:t}){let o=g(e.roles)?e.roles:{},r=Object.entries(o),i=Object.keys(o),s=["*",...Object.entries(t||{}).flatMap(([p,h])=>[`${p}:*`,...h.map(c=>`${p}:${c}`)])],a=p=>z(h=>{h.roles=p}),u=(p,h)=>a({...o,[p]:{...o[p],...h}}),m=()=>{let p=r.length+1;for(;`role${p}`in o;)p++;a({...o,[`role${p}`]:{permissions:[],reach:"own"}})},l=(p,h)=>{let c=Rn(o,p,h);return c?(z(f=>{f.roles=Object.fromEntries(Object.entries(c).map(([y,T])=>[y,T.inherits?{...T,inherits:T.inherits.map(w=>w===p?h:w)}:T])),f.default_role===p&&(f.default_role=h),g(f.system_identities)&&(f.system_identities=Object.fromEntries(Object.entries(f.system_identities).map(([y,T])=>[y,{...T,roles:(T.roles??[]).map(w=>w===p?h:w)}])))}),!0):!1};return n(R,{title:"Roles",children:n("div",{class:"panel",children:[n("div",{class:"border-b border-line",children:n(Y,{id:"auth-roles-claim",label:"Roles claim",path:"auth.roles_claim",value:e.roles_claim,placeholder:"roles",issue:S("auth.roles_claim"),help:"Token claim holding the user's list of role names.",onInput:p=>z(h=>{h.roles_claim=p})})}),r.length?n("div",{class:"divide-y divide-line",children:r.map(([p,h],c)=>n("div",{class:"space-y-3 px-5 py-4",children:[n("div",{class:"grid grid-cols-[minmax(0,1fr)_8rem_auto] items-start gap-3",children:[n(An,{id:`role-name-${c}`,label:"Role name",name:p,onRename:f=>l(p,f)}),n(A,{id:`role-reach-${c}`,value:h.reach??"",options:Ao,invalid:S(`auth.roles.${p}.reach`)?.level==="error",onChange:f=>u(p,{reach:f})}),n("button",{type:"button",class:"icon-btn hover:text-danger","aria-label":`Remove role ${p}`,onClick:()=>a(Object.fromEntries(r.filter(([f])=>f!==p))),children:n(_,{name:"trash"})})]}),n("div",{children:[n(Be,{children:"Permissions"}),n(H,{id:`role-permissions-${c}`,values:h.permissions??[],placeholder:"graph:invoke, threads:*, *",suggestions:s,onChange:f=>u(p,{permissions:f})})]}),n("div",{children:[n(Be,{children:"Inherits"}),n(H,{id:`role-inherits-${c}`,values:h.inherits??[],placeholder:"another role",suggestions:i.filter(f=>f!==p),onChange:f=>{let y={...h};Ve(y,"inherits",f),a({...o,[p]:y})}})]}),n(wt,{prefix:`auth.roles.${p}`})]},c))}):n("p",{class:"px-5 py-6 text-center text-sm text-muted",children:"No roles yet. Add one to use roles."}),n("div",{class:"flex items-center justify-between gap-4 border-t border-line px-5 py-3",children:[n("p",{class:"text-[13px] text-muted",children:["Reach: ",n("code",{class:"font-mono",children:"own"})," the user's rows, ",n("code",{class:"font-mono",children:"tenant"})," every row of their tenant, ",n("code",{class:"font-mono",children:"all"})," every row."]}),n("button",{type:"button",class:"btn btn-ghost",onClick:m,children:[n(_,{name:"plus"}),"Add role"]})]})]})})}function No({auth:e}){let t=g(e.system_identities)?e.system_identities:{},o=Object.entries(t),r=Object.keys(g(e.roles)?e.roles:{}),i=l=>z(p=>Ve(p,"system_identities",l)),s=(l,p)=>i({...t,[l]:{...t[l],...p}}),a=()=>{let l=o.length+1;for(;`job${l}`in t;)l++;i({...t,[`job${l}`]:{roles:[],tenant:{}}})},u=l=>Object.entries(l??{}).map(([p,h])=>`${p}=${h}`),m=l=>Object.fromEntries(l.filter(p=>p.includes("=")).map(p=>[p.slice(0,p.indexOf("=")).trim(),p.slice(p.indexOf("=")+1).trim()]));return n(R,{title:"System identities",children:n("div",{class:"panel",children:[o.length?n("div",{class:"divide-y divide-line",children:o.map(([l,p],h)=>n("div",{class:"space-y-3 px-5 py-4",children:[n("div",{class:"grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3",children:[n(An,{id:`system-name-${h}`,label:"Identity name",name:l,onRename:c=>{let f=Rn(t,l,c);return f&&i(f),!!f}}),n("button",{type:"button",class:"icon-btn hover:text-danger","aria-label":`Remove ${l}`,onClick:()=>i(Object.fromEntries(o.filter(([c])=>c!==l))),children:n(_,{name:"trash"})})]}),n("div",{children:[n(Be,{children:"Roles"}),n(H,{id:`system-roles-${h}`,values:p.roles??[],placeholder:"member",suggestions:r,onChange:c=>s(l,{roles:c})})]}),n("div",{children:[n(Be,{children:"Tenant, as claim=value"}),n(H,{id:`system-tenant-${h}`,values:u(p.tenant),placeholder:"org_id=acme",onChange:c=>s(l,{tenant:m(c)})})]}),n(wt,{prefix:`auth.system_identities.${l}`})]},h))}):n("p",{class:"px-5 py-6 text-center text-sm text-muted",children:"None. Scheduled jobs and other non-user callers can run as one."}),n("div",{class:"flex justify-end border-t border-line px-5 py-3",children:n("button",{type:"button",class:"btn btn-ghost",onClick:a,children:[n(_,{name:"plus"}),"Add identity"]})})]})})}var Ge=[["memory","Memory","This process only"],["redis","Redis","Shared. Redis, Valkey, Dragonfly, KeyDB"],["custom","Custom","Your BaseCacheStore class"]],Do={requests:100,window:60,by:"ip"},Je=()=>g(d.config.cache_store)?d.config.cache_store:{};function Ct(e){b(t=>{let o=g(t.config.cache_store)?t.config.cache_store:{};e(o),Object.keys(o).length?t.config.cache_store=o:delete t.config.cache_store})}var Ho=()=>{let e=Je().rate_limit;return g(e)&&e.enabled!==!1},zo=()=>{let e=Je().token_revocation;return e===!0||g(e)&&e.enabled!==!1};function Fo(e){Ct(t=>{delete t.url,delete t.path,e==="memory"?delete t.type:t.type=e})}function Uo(e){Ct(t=>{e?g(t.rate_limit)?delete t.rate_limit.enabled:t.rate_limit={...Do}:delete t.rate_limit})}function qo(e){Ct(t=>{e?g(t.token_revocation)?delete t.token_revocation.enabled:t.token_revocation=!0:delete t.token_revocation})}var En=(e,t)=>b(o=>{oe(o.config,e,t)});function Wo({value:e,onPick:t}){let o=(r,i)=>{let s=r.key==="ArrowRight"||r.key==="ArrowDown"?1:r.key==="ArrowLeft"||r.key==="ArrowUp"?-1:0;if(!s)return;r.preventDefault();let a=Ge[(i+s+Ge.length)%Ge.length][0];t(a),requestAnimationFrame(()=>document.getElementById(`cache-type-${a}`)?.focus())};return n("div",{role:"radiogroup","aria-label":"Cache store type",class:"grid gap-3 sm:grid-cols-3",children:Ge.map(([r,i,s],a)=>{let u=r===e;return n("button",{type:"button",role:"radio",id:`cache-type-${r}`,"aria-checked":u?"true":"false",tabIndex:u?0:-1,onKeyDown:m=>o(m,a),class:`focus-ring flex cursor-pointer items-start gap-3 rounded-xl border p-4 text-left transition-colors duration-150 motion-reduce:transition-none ${u?"border-brand bg-brand-soft":"border-line bg-surface shadow-card hover:border-line-strong"}`,onClick:()=>{u||t(r)},children:[n("span",{class:`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border ${u?"border-brand-solid bg-brand-solid":"border-line-strong"}`,children:u?n("span",{class:"size-1.5 rounded-full bg-white"}):null}),n("span",{class:"min-w-0",children:[n("span",{class:"block text-sm font-medium text-ink",children:i}),n("span",{class:"mt-0.5 block text-[13px] leading-5 text-muted",children:s})]})]},r)})})}function ye({warn:e,children:t}){return n("p",{class:`mt-3 flex items-start gap-1.5 text-[13px] leading-5 ${e?"text-warn":"text-muted"}`,children:[e?n(_,{name:"alert",class:"mt-0.5 size-3.5"}):null,n("span",{children:t})]})}function Pn({id:e,title:t,text:o,on:r,onChange:i}){return n("div",{class:"flex items-center gap-4",children:[n("div",{class:"min-w-0 flex-1",children:[n("h3",{class:"text-base font-semibold text-ink",children:t}),n("p",{class:"mt-0.5 text-[13px] leading-5 text-muted",children:o})]}),n(be,{id:e,on:r,label:t,onChange:i})]})}var Go=[["ip","IP"],["user","User"],["global","Global"]];function Bo({field:e,memory:t}){let[o,r]=K(!1),i=Je().rate_limit,s=g(i)&&i.by||"ip",a=!(g(i)&&i.fail_open===!1),u=["trusted_proxy_headers","trusted_proxy_hops","trusted_proxies","exclude_paths"];return n(x,{children:[t?n(ye,{warn:!0,children:"Counts per worker with the Memory store."}):null,n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(I,{field:e("cache_store.rate_limit.requests")}),n(I,{field:e("cache_store.rate_limit.window")}),n("div",{class:"pt-5",children:[n("span",{id:"cache-by-label",class:"block text-sm font-semibold text-ink",children:"Limit by"}),n("div",{class:"mt-2.5",children:n(te,{id:"f-cache_store-rate_limit-by",label:"Limit by",options:Go,value:s,onPick:m=>En(["cache_store","rate_limit","by"],m)})}),n(P,{issue:S("cache_store.rate_limit.by")})]}),n("div",{class:"pt-5",children:[n("span",{id:"cache-fail-open-label",class:"block text-sm font-semibold text-ink",children:"Fail open"}),n("div",{class:"mt-2.5 flex items-center gap-3",children:[n(be,{id:"f-cache_store-rate_limit-fail_open",on:a,label:"Fail open",onChange:m=>En(["cache_store","rate_limit","fail_open"],m)}),n("p",{class:"m-0 text-[13px] leading-5 text-muted",children:a?"Allow requests if the store errors.":"Deny with 503 if the store errors."})]})]})]}),n("div",{class:"mt-5 border-t border-line pt-1",children:[n("button",{type:"button","aria-expanded":o?"true":"false","aria-controls":"cache-advanced",class:"focus-ring flex min-h-11 cursor-pointer items-center gap-2 rounded font-medium text-muted hover:text-ink",onClick:()=>r(!o),children:[n(_,{name:"chevronRight",class:`size-4 transition-transform duration-150 motion-reduce:transition-none ${o?"rotate-90":""}`}),"Advanced"]}),o?n("div",{id:"cache-advanced",children:u.map(m=>n(I,{field:e(`cache_store.rate_limit.${m}`)},m))}):null]})]})}var ae=[["rate","Rate limiting"],["revoke","Token revocation"]];function Mn({sec:e}){let[t,o]=K("rate"),r=Je().type||"memory",i=l=>e.fields.find(p=>p.path.join(".")===l),s={rate:Ho(),revoke:zo()},a=r==="memory",u={rate:!!S("cache_store.rate_limit"),revoke:!!S("cache_store.token_revocation")},m=(l,p)=>{let h=l.key==="ArrowRight"?1:l.key==="ArrowLeft"?-1:0,c=l.key==="Home"?0:l.key==="End"?ae.length-1:h?(p+h+ae.length)%ae.length:-1;c<0||(l.preventDefault(),o(ae[c][0]),document.getElementById(`cache-tab-${ae[c][0]}`)?.focus())};return n("div",{children:[n(R,{title:"Store type",children:[n(Wo,{value:r,onPick:Fo}),r==="redis"?n(I,{field:i("cache_store.url")}):null,r==="custom"?n(I,{field:i("cache_store.path")}):null,a?n("p",{class:"mt-3 mb-0 text-[13px] leading-5 text-muted",children:"Not shared between workers: each worker keeps its own locks, limits and revocations."}):null]}),n("div",{role:"tablist","aria-label":"Cache store features",class:"mt-8 flex gap-1 border-b border-line",children:ae.map(([l,p],h)=>{let c=t===l;return n("button",{type:"button",role:"tab",id:`cache-tab-${l}`,"aria-selected":c?"true":"false","aria-controls":`cache-panel-${l}`,tabIndex:c?0:-1,onKeyDown:f=>m(f,h),class:`focus-ring -mb-px flex min-h-11 cursor-pointer items-center gap-2 border-b-2 px-3 text-sm font-medium transition-colors duration-150 motion-reduce:transition-none ${c?"border-brand-solid text-ink":"border-transparent text-muted hover:text-ink"}`,onClick:()=>o(l),children:[p,n(ie,{class:s[l]?"bg-brand-soft text-brand":"bg-raised text-muted",children:s[l]?"On":"Off"})]},l)})}),n("div",{role:"tabpanel",id:`cache-panel-${t}`,"aria-labelledby":`cache-tab-${t}`,class:"pt-5",children:t==="rate"?n(x,{children:[n(Pn,{id:"cache-rate-limit",title:"Rate limiting",on:s.rate,onChange:Uo,text:"Cap how many requests one caller can make."}),s.rate?n(Bo,{field:i,memory:a}):u.rate?n(ye,{warn:!0,children:"Off: one caller can exhaust the service."}):null]}):n(x,{children:[n(Pn,{id:"cache-token-revocation",title:"Token revocation",on:s.revoke,onChange:qo,text:"Reject revoked tokens before they expire (10xgraph revoke)."}),s.revoke?n(x,{children:[a?n(ye,{warn:!0,children:"10xgraph revoke cannot reach an in-memory store; pick Redis or Custom."}):null,n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(I,{field:i("cache_store.token_revocation.token_lifetime")}),n(I,{field:i("cache_store.token_revocation.user_ttl")})]}),n(ye,{children:"Fails closed: while the cache store is unreachable, authenticated requests get 503 and the server will not start."})]}):u.revoke?n(ye,{warn:!0,children:"Off: a leaked token works until it expires."}):null]})})]})}function Ln({sec:e}){let t=e.fields[0].path[0],o=e.choices.map(p=>p[0]),r=d.config[t],i=t==="thread_name_generator"?"random":"",s=r===void 0?i:typeof r=="string"&&o.includes(r)?r:"custom",a=[...t==="id_generator"?[["","Not set (the graph's own generator)"]]:[],...e.choices.map(([p,h])=>[p,t==="thread_name_generator"&&p==="random"?`${h} (default)`:h]),["custom","Custom (module:attr)"]],u=s===""?"The graph keeps the generator it was compiled with.":s==="custom"?"Your own class, as module:attribute.":e.choices.find(p=>p[0]===s)?.[2],m=p=>b(h=>{p===i?delete h.config[t]:h.config[t]=p==="custom"?"":p}),l=`f-${t}`;return n("div",{children:[n(R,{title:"Choice",children:n("div",{class:"max-w-[520px]",children:[n("label",{for:`${l}-select`,class:"sr-only",children:e.title}),n(A,{id:`${l}-select`,options:a,value:s,onChange:m}),n("p",{class:"mt-1.5 text-[13px] text-muted",children:u})]})}),s==="custom"?n(R,{title:"Class",children:n("div",{class:"panel",children:n(Y,{id:l,label:e.fields[0].label,path:t,value:typeof r=="string"?r:"",placeholder:e.fields[0].placeholder,help:e.fields[0].help,issue:S(t),onInput:p=>b(h=>{h.config[t]=p})})})}):null]})}function Nn(){let e=Array.isArray(d.config.remote_tools)?d.config.remote_tools:[],t=()=>{b(o=>{e.push({node:"",name:"",description:"",parameters:{type:"object",properties:{},required:[]}}),o.config.remote_tools=e}),requestAnimationFrame(()=>document.getElementById(`tool-${e.length-1}-name`)?.focus())};return e.length?n("div",{class:"space-y-4",children:[e.map((o,r)=>n(Vo,{tools:e,tool:o,index:r},r)),n("button",{type:"button",onClick:t,class:"focus-ring flex h-12 w-full cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed border-line-strong text-sm font-medium text-muted transition-colors duration-150 hover:border-brand hover:text-brand",children:[n(_,{name:"plus"}),"Add remote tool"]})]}):n("div",{class:"rounded-xl border border-dashed border-line-strong bg-surface px-6 py-10 text-center",children:[n("div",{class:"mx-auto flex size-11 items-center justify-center rounded-full bg-raised text-faint",children:n(_,{name:"wrench",class:"size-5"})}),n("p",{class:"mt-4 text-sm font-semibold text-ink",children:"No remote tools yet"}),n("p",{class:"mx-auto mt-1 max-w-sm text-sm leading-6 text-muted",children:"Declare a tool here and the client that calls the API runs it, for example to read the user's location."}),n("button",{type:"button",class:"btn btn-outline mt-5",onClick:t,children:[n(_,{name:"plus"}),"Add remote tool"]})]})}function Vo({tools:e,tool:t,index:o}){let r=d.toolParams[o]??{text:JSON.stringify(t.parameters??{},null,2),error:null},i=a=>b(()=>{e[o]={...e[o],...a}}),s=a=>`remote_tools[${o}].${a}`;return n("section",{class:"panel",children:[n("div",{class:"flex items-center gap-3 border-b border-line px-5 py-3",children:[n(_,{name:"wrench",class:"size-4 text-faint"}),n("span",{class:"min-w-0 flex-1 truncate font-mono text-sm font-medium text-ink",children:t.name||`tool_${o+1}`}),t.node?n("span",{class:"rounded-md border border-line bg-raised px-1.5 py-0.5 font-mono text-xs text-muted",children:t.node}):null,n("button",{type:"button",class:"icon-btn hover:text-danger","aria-label":`Remove ${t.name||"tool"}`,onClick:()=>b(a=>{e.splice(o,1),a.toolParams={}}),children:n(_,{name:"trash"})})]}),n("div",{class:"divide-y divide-line",children:[n(Y,{id:`tool-${o}-name`,label:"Tool name",path:s("name"),value:t.name,placeholder:"get_location",onInput:a=>i({name:a})}),n(Y,{id:`tool-${o}-node`,label:"Graph node",path:s("node"),value:t.node,placeholder:"MAIN",help:"The node whose model may call this tool.",onInput:a=>i({node:a})}),n(Y,{id:`tool-${o}-description`,label:"Description",path:s("description"),value:t.description,placeholder:"Returns the user's current city",help:"What the model reads when deciding to call it.",onInput:a=>i({description:a})}),n("div",{class:"px-5 py-4",children:[n(xe,{label:"Parameters",path:s("parameters"),help:"JSON schema of the arguments. type must be object.",id:`tool-${o}-params`}),n("textarea",{id:`tool-${o}-params`,rows:7,spellcheck:!1,value:r.text,class:`input mt-3 h-auto py-2.5 font-mono text-[13px] leading-5 ${r.error?"input-invalid":""}`,onInput:a=>{let u=a.currentTarget.value;b(m=>{try{e[o]={...e[o],parameters:JSON.parse(u)},m.toolParams[o]={text:u,error:null}}catch(l){m.toolParams[o]={text:u,error:l.message}}})}}),n(P,{issue:r.error?{level:"error",message:r.error}:null})]})]})]})}var $t="focus-ring min-h-8 cursor-pointer rounded px-1.5 text-[13px] font-medium text-brand transition-colors duration-150 hover:underline",X="mt-2 max-w-prose text-[13px] leading-5 text-muted",V=(e,t)=>b(o=>{t===void 0||t===""?he(o.config,e):oe(o.config,e,t)});function Et({id:e,label:t,path:o,help:r,children:i}){return n("div",{class:"pt-4",children:[n("div",{class:"flex items-baseline gap-2.5",children:[n("label",{for:e,class:"text-sm font-semibold text-ink",children:t}),o?n("code",{class:"hidden font-mono text-[11px] text-faint sm:inline",children:o}):null]}),n("div",{class:"mt-2 max-w-[520px]",children:i}),r?n("p",{class:X,children:r}):null,n(P,{issue:S(o)})]})}function le({path:e,label:t,help:o,placeholder:r}){let i=e.join("."),s=`f-${e.join("-")}`,a=N(d.config,e);return n(Et,{id:s,label:t,path:i,help:o,children:n("input",{id:s,type:"text",value:a??"",placeholder:r,autocomplete:"off",spellcheck:!1,class:`input font-mono ${S(i)?.level==="error"?"input-invalid":""}`,onInput:u=>V(e,u.currentTarget.value)})})}function Jo({path:e,label:t,help:o}){let r=e.join("."),i=`f-${e.join("-")}`,s=N(d.config,e);return n(Et,{id:i,label:t,path:r,help:o,children:n("input",{id:i,type:"number",inputmode:"numeric",min:"1",step:"1",value:s??"",class:`input max-w-40 font-mono tabular-nums ${S(r)?.level==="error"?"input-invalid":""}`,onInput:a=>{let u=a.currentTarget.value;V(e,u===""?void 0:Number(u))}})})}function St({path:e,label:t,help:o,options:r,value:i,onChange:s}){let a=`f-${e.join("-")}`;return n(Et,{id:a,label:t,path:e.join("."),help:o,children:n(A,{id:a,plain:!0,options:r,value:i,onChange:s})})}function Ko({path:e,label:t,checked:o}){let r=`f-${e.join("-")}`;return n("label",{class:"mt-3 inline-flex min-h-11 cursor-pointer items-center gap-2.5 text-sm font-semibold text-ink",children:[n("input",{id:r,type:"checkbox",class:"size-[18px] accent-brand-solid",checked:o,onChange:i=>V(e,i.currentTarget.checked)}),t]})}var Ye=[{key:"logfire",name:"Logfire",label:"Logfire",text:"Pydantic Logfire.",secret:n(x,{children:["Token: set ",n("code",{class:"font-mono text-xs",children:"LOGFIRE_TOKEN"})," in .env."]})},{key:"langsmith",name:"LangSmith",label:"LangSmith",text:"LangChain's tracing service.",secret:n(x,{children:["API key: set ",n("code",{class:"font-mono text-xs",children:"LANGSMITH_API_KEY"})," in .env."]})},{key:"phoenix",name:"Phoenix",label:"Phoenix",text:"Arize Phoenix, local or hosted.",secret:n(x,{children:["API key: set ",n("code",{class:"font-mono text-xs",children:"PHOENIX_API_KEY"})," in .env. A local Phoenix does not need one."]})},{key:"otlp",name:"OTLP endpoint",label:"OTLP endpoint",text:"Any OpenTelemetry endpoint: a collector, Tempo, Jaeger.",secret:n(x,{children:["Auth headers: set ",n("code",{class:"font-mono text-xs",children:"OTEL_EXPORTER_OTLP_HEADERS"})," in .env."]})}],Yo=[["standard","Standard"],["spans","Spans only"],["full","Full"]],Xo={standard:"Adds model name, token counts and request parameters.",spans:"Timing and structure only. No inputs or outputs.",full:"Records prompts and tool I/O. May contain PII."},Qo=[["raw","Raw"],["hash","Hashed"],["mask","Masked"]],Zo={raw:"The user id is sent as is.",hash:"A SHA-256 of the user id, keyed when TENXGRAPH_TRACE_USER_ID_KEY is set.",mask:"The middle of the user id is hidden."},Tt=()=>g(d.config.observability)?d.config.observability:{},Xe=e=>g(Tt()[e])&&Tt()[e].enabled===!0;function Dn(e){b(t=>{g(t.config.observability)||(t.config.observability={level:"standard",user_id_mode:"raw"}),t.config.observability[e]={enabled:!0}})}function er(e){b(t=>{delete t.config.observability[e],Ye.some(o=>Xe(o.key))||delete t.config.observability})}function tr(){let e=d.config.metrics,t=g(e)&&g(e.otlp)?e.otlp.endpoint:null;return!g(e)||e.enabled!==!0||e.export!=="otlp"||!t?null:t.replace(/\/+$/,"").replace(/\/v1\/metrics$/,"")}function nr(){let e=["observability","otlp","use_metrics_endpoint"],t=N(d.config,e)===!0,o=tr(),r="f-observability-otlp-use_metrics_endpoint";return n(x,{children:[o?n("div",{class:"pt-4",children:[n("div",{class:"flex items-center gap-3",children:[n(be,{id:r,on:t,label:"Use the metrics endpoint",onChange:i=>V(e,i?!0:void 0)}),n("label",{for:r,class:"cursor-pointer text-sm font-semibold text-ink",children:"Use the metrics endpoint"})]}),n("p",{class:X,children:t?n(x,{children:["Traces go to ",n("code",{class:"font-mono text-xs text-body",children:[o,"/v1/traces"]})]}):"Send traces to the same endpoint as the metrics."}),n(P,{issue:S(e.join("."))})]}):null,t&&o?null:n(le,{path:["observability","otlp","endpoint"],label:"Endpoint",placeholder:"http://collector:4318",help:"Base URL of an OTLP HTTP receiver. /v1/traces is appended."}),o?null:n("p",{class:X,children:["Sharing the metrics endpoint needs Metrics set to OTLP push with an endpoint.",t?n(x,{children:[" It is on in the file. ",n("button",{type:"button",class:$t,onClick:()=>V(e,void 0),children:"Stop sharing"})]}):null]}),n(P,{issue:t&&!o?S(e.join(".")):null}),n("p",{class:X,children:"Prometheus stores metrics only. To send traces and metrics to one place, use a collector (OTel Collector, Grafana Alloy, Grafana Cloud)."})]})}function or({dest:e}){let t=r=>["observability",e.key,r];if(e.key==="logfire"){let r=t("send_to_logfire"),i=t("console");return n(x,{children:[n(le,{path:t("service_name"),label:"Service name",placeholder:"my-agent"}),n(Ko,{path:r,label:"Send to Logfire",checked:N(d.config,r)!==!1}),n(St,{path:i,label:"Print spans to console",help:"Logfire also takes console options that JSON cannot express, so only its own behavior or Off is offered.",options:[["","Logfire's own behavior"],["off","Off"]],value:N(d.config,i)===!1?"off":"",onChange:s=>V(i,s==="off"?!1:void 0)})]})}if(e.key==="otlp")return n(nr,{});let o=e.key==="phoenix";return n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(le,{path:t("project"),label:"Project",placeholder:"my-agent"}),n(le,{path:t("endpoint"),label:"Endpoint",placeholder:o?"http://localhost:6006":"https://api.smith.langchain.com/otel",help:o?"Base collector URL. /v1/traces is appended.":void 0})]})}function rr({dest:e}){return n("div",{id:`dest-${e.key}`,class:"mt-4 rounded-xl border border-line bg-surface p-5",children:[n("div",{class:"flex items-start gap-3",children:[n("div",{class:"min-w-0 flex-1",children:[n("h3",{class:"text-sm font-semibold text-ink",children:e.name}),n("p",{class:"mt-0.5 text-[13px] text-muted",children:e.text})]}),n("button",{type:"button",class:$t,"aria-label":`Remove ${e.name}`,onClick:()=>er(e.key),children:"Remove"})]}),n(or,{dest:e}),n("p",{class:"mt-5 border-t border-line pt-3 text-[13px] text-muted",children:e.secret})]})}function Hn(){return n("p",{class:X,children:"These are observability publishers. They get graph, node, LLM and tool spans, AI errors included. Event publishers (Kafka, Redis, RabbitMQ) are set in code and also get task events. API and worker errors go to Metrics and logs."})}function zn(){let[e,t]=K(!1),o=Ye.filter(s=>Xe(s.key)),r=Ye.filter(s=>!Xe(s.key)),i=Tt();return n("div",{children:o.length===0?n("div",{class:"max-w-[520px]",children:[n("label",{for:"trace-dest",class:"mb-2 block text-sm font-semibold text-ink",children:"Send AI traces to"}),n(A,{id:"trace-dest",value:"",plain:!0,options:[["","None"],...r.map(s=>[s.key,s.label])],onChange:s=>{s&&Dn(s)}}),n("p",{class:X,children:"Graph runs are not traced."}),n(Hn,{})]}):n(x,{children:[n("h2",{class:"eyebrow",children:"Send AI traces to"}),o.map(s=>n(rr,{dest:s},s.key)),r.length?e?n("div",{class:"mt-4 max-w-[320px]",children:[n("label",{for:"trace-add",class:"sr-only",children:"Add another destination"}),n(A,{id:"trace-add",value:"",plain:!0,options:[["","Choose a destination"],...r.map(s=>[s.key,s.label])],onChange:s=>{t(!1),s&&Dn(s)}})]}):n("button",{type:"button",class:`${$t} mt-3 inline-flex min-h-10 items-center gap-1.5`,onClick:()=>t(!0),children:[n(_,{name:"plus",class:"size-4"}),"Add another destination"]}):null,n("div",{class:"max-w-[520px]",children:n(Hn,{})}),n("h2",{class:"eyebrow mt-8",children:"Trace settings"}),n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(St,{path:["observability","level"],label:"Level",options:Yo,value:i.level??"standard",onChange:s=>V(["observability","level"],s),help:Xo[i.level??"standard"]}),n(St,{path:["observability","user_id_mode"],label:"User id in traces",options:Qo,value:i.user_id_mode??"raw",onChange:s=>V(["observability","user_id_mode"],s),help:Zo[i.user_id_mode??"raw"]})]})]})})}var sr=[["","None"],["otlp","OTLP push"],["prometheus","Prometheus scrape"]],ir={"":"Metrics are not exported.",otlp:"Recommended for production. Works with several worker processes.",prometheus:"One process only. The API serves the scrape path, the worker its own port."},ar=[50,100,250,500,1e3,2500,5e3,1e4,3e4,6e4,12e4],Ke=()=>g(d.config.metrics)?d.config.metrics:{},Fn=()=>Ke().enabled===!0&&["otlp","prometheus"].includes(Ke().export)?Ke().export:"";function lr(e){b(t=>{if(!e){delete t.config.metrics;return}let o=g(t.config.metrics)?t.config.metrics:{};o.enabled=!0,o.export=e,e==="otlp"&&!g(o.otlp)&&(o.otlp={endpoint:null}),e==="prometheus"&&!g(o.prometheus)&&(o.prometheus={path:"/metrics",worker_port:9464}),t.config.metrics=o})}function Un(){let e=Fn(),t=Ke().latency_buckets_ms,o=S("metrics.latency_buckets_ms");return n("div",{children:[n("div",{class:"max-w-[520px]",children:[n("label",{for:"f-metrics-export",class:"mb-2 block text-sm font-semibold text-ink",children:"Export metrics"}),n(A,{id:"f-metrics-export",plain:!0,options:sr,value:e,invalid:S("metrics.export")?.level==="error",onChange:lr}),n("p",{class:X,children:ir[e]}),n(P,{issue:S("metrics.export")})]}),e===""?null:n("div",{class:"mt-5 rounded-xl border border-line bg-surface px-5 pt-1 pb-5",children:[e==="otlp"?n(le,{path:["metrics","otlp","endpoint"],label:"Endpoint",placeholder:"http://collector:4318",help:n(x,{children:["/v1/metrics is appended.",n("br",{}),"Prometheus: ",n("code",{class:"font-mono text-xs",children:"http://prometheus:9090/api/v1/otlp"})," (start it with ",n("code",{class:"font-mono text-xs",children:"--web.enable-otlp-receiver"}),")."]})}):n(x,{children:n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(le,{path:["metrics","prometheus","path"],label:"Scrape path",placeholder:"/metrics",help:"Route on the API server that Prometheus scrapes."}),n(Jo,{path:["metrics","prometheus","worker_port"],label:"Worker port",help:"Each worker serves its own metrics on this port."})]})}),e==="otlp"?n("p",{class:"mt-5 border-t border-line pt-3 text-[13px] text-muted",children:["Auth headers: set ",n("code",{class:"font-mono text-xs",children:"OTEL_EXPORTER_OTLP_HEADERS"})," in .env."]}):null]}),e!==""?n(We,{title:"Advanced",class:"mt-4",open:!!o||t!==void 0,children:n("div",{class:"pt-4",children:[n("label",{for:"f-metrics-latency_buckets_ms",class:"text-sm font-semibold text-ink",children:"Latency buckets (ms)"}),n("div",{class:"mt-2 max-w-[520px]",children:n(H,{id:"f-metrics-latency_buckets_ms",values:(t??ar).map(String),placeholder:"250",onChange:r=>V(["metrics","latency_buckets_ms"],r.length?r.map(Number).filter(Number.isFinite):void 0)})}),n("p",{class:X,children:"Upper bounds of the latency histogram in milliseconds, ascending."}),n(P,{issue:o})]})}):null]})}var cr=e=>e.length>1?`${e.slice(0,-1).join(", ")} and ${e[e.length-1]}`:e[0];function qn(e){let t=Ye.filter(o=>Xe(o.key)).map(o=>o.name);return t.length?{text:`Level ${mt(["observability","level"])}, sending to ${cr(t)}.`}:{text:e("observability")}}function Wn(e){let t={otlp:"OTLP push",prometheus:"Prometheus scrape"}[Fn()];return t?{text:`${t}.`}:{text:e("metrics")}}var ke=({children:e,code:t})=>n("h1",{id:"page-title",tabindex:-1,class:"flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-semibold tracking-tight text-ink focus:outline-none",children:[e,t?n("code",{class:"rounded border border-line bg-raised px-1.5 font-mono text-xs font-normal text-faint",children:t}):null]}),Ot=({children:e})=>n("p",{class:"mt-1.5 max-w-[62ch] text-[15px] leading-6 text-muted",children:e}),Gn=({children:e})=>n("h2",{class:"mt-10 mb-2 eyebrow",children:e});function Bn({id:e,text:t,warn:o,compact:r}){let i=ge(e);return i?n("a",{href:`#${e}`,onClick:s=>{s.preventDefault(),D(e)},class:`focus-ring group flex items-center gap-3.5 border-t border-line py-2 last:border-b ${r?"min-h-12":"min-h-14"}`,children:[n(_,{name:ee[e]||"box",class:"size-[18px] text-faint"}),n("span",{class:`min-w-0 flex-1 ${r?"flex flex-wrap items-baseline gap-x-4":""}`,children:[n("span",{class:`block text-sm text-ink group-hover:text-brand ${r?"min-w-40 font-medium":"font-semibold"}`,children:i.title}),n("span",{class:`block text-[13px] leading-5 ${o?"text-warn":"text-muted"}`,children:t??i.description})]}),n(_,{name:"chevronRight",class:"size-4 text-faint"})]}):null}function Pt(e){return`Not set. ${O(e)?.off_summary||""}`.trim()}function dr(){let e=d.config.auth,t=e==="jwt"?"jwt":g(e)?e.method??"none":"none";if(t==="none")return{text:Pt("auth")};let o=g(e)&&g(e.roles)?Object.keys(e.roles).length:0;return{text:`${t==="jwt"?"JWT bearer tokens":`Custom backend${e.path?`: ${e.path}`:""}`}${o?`, ${o} ${o===1?"role":"roles"}`:""}.`}}function ur(){let e=g(d.config.cache_store)?d.config.cache_store:{},t=[`${e.type||"memory"} store`],o=e.rate_limit;if(g(o)&&o.enabled!==!1){let i={ip:"per IP",user:"per user",global:"across all clients"}[o.by??"ip"]||o.by;t.push(`${o.requests??100} requests per ${o.window??60} s, ${i}`)}let r=e.token_revocation;return(r===!0||g(r)&&r.enabled!==!1)&&t.push("token revocation on"),{text:`${t.join("; ")}.`}}function Vn(){let e=O("core"),t=_n(),o=e?re("core").filter(s=>!e.fields.some(a=>a.path.join(".")===s.path)):[],r=[["auth",dr()],["cache_store",ur()],["observability",qn(Pt)],["metrics",Wn(Pt)]],i=d.nav.flatMap(s=>s.items).filter(s=>s!=="get-running");return n(x,{children:[n(ke,{children:t?"You're ready to run":"One step to run your agent"}),n(Ot,{children:q["get-running"].description}),n("div",{class:`mt-4 flex items-center gap-2 font-medium ${t?"text-ok":"text-muted"}`,children:[n(_,{name:t?"checkCircle":"circle",class:"size-[18px]"}),"Required: ",t?"1":"0"," of 1 done"]}),o.length?n("div",{class:"mt-4 space-y-2",children:o.map((s,a)=>n(vt,{issue:s},a))}):null,n("div",{class:"mt-2",children:e?kt(e.fields).flatMap(s=>s.fields).map(s=>n(I,{field:s},s.path.join("."))):null}),t?n("div",{class:"mt-7 border-t border-line pt-5",children:[n("div",{class:"flex items-center gap-2.5 font-medium text-ok",children:[n(_,{name:"checkCircle",class:"size-[18px]"}),"Ready for 10xgraph dev"]}),n("div",{class:"mt-3 flex max-w-[420px] items-center gap-2 rounded-lg border border-line-strong bg-sunken py-0 pr-1 pl-3.5",children:[n("code",{class:"flex-1 font-mono text-[13px] font-medium text-ink",children:"10xgraph dev"}),d.copied?n("span",{class:"text-xs text-ok",children:"Copied"}):null,n("button",{type:"button",class:"icon-btn",onClick:wn,"aria-label":d.copied?"Copied 10xgraph dev":"Copy command 10xgraph dev",children:n(_,{name:"copy",class:"size-[18px]"})})]}),n("p",{class:"mt-3 text-muted",children:"Everything else already has working defaults."})]}):n("p",{class:"mt-4 text-xs text-faint",children:"Add the agent graph above, then run 10xgraph dev."}),n(Gn,{children:"Before production (optional)"}),n("div",{children:r.map(([s,a])=>n(Bn,{id:s,text:a.text,warn:a.warn},s))}),n(Gn,{children:"What you can configure"}),n("div",{children:i.map(s=>n(Bn,{id:s,compact:!0},s))})]})}function Qe({vars:e}){return n("dl",{class:"mb-3 grid gap-x-4 gap-y-1 sm:grid-cols-[minmax(0,260px)_minmax(0,1fr)]",children:e.map(t=>[n("dt",{class:"py-0.5 font-mono text-xs font-medium break-all text-ink",children:t.name},`${t.name}-n`),n("dd",{class:"m-0 pb-2 text-[13px] text-muted sm:py-0.5",children:t.purpose},`${t.name}-p`)])})}function pr({vars:e,open:t}){return e?.length?n(We,{title:"Set in .env",open:t,class:"mt-9 border-t border-line pt-1",children:[n("p",{class:"mb-2 text-xs text-faint",children:"Read only. The editor never reads .env, so only names are shown."}),n(Qe,{vars:e})]}):null}function Jn({sec:e}){return e.widget==="auth"?n(jn,{sec:e}):e.widget==="remote_tools"?n(Nn,{}):e.widget==="observability"?n(zn,{}):e.widget==="metrics"?n(Un,{}):e.widget==="cache_store"?n(Mn,{sec:e}):e.widget==="id_generator"||e.widget==="thread_name_generator"?n(Ln,{sec:e}):null}function mr(e){let t=new Set([...e.fields.map(i=>i.path.join(".")),...Object.keys(ut)]),o=new Set(["cache_store.rate_limit","cache_store.token_revocation"]),r=i=>i.startsWith("auth.roles.")||i.startsWith("auth.system_identities.");return re(e.id).filter(i=>!t.has(i.path)&&!(e.id==="cache_store"&&o.has(i.path))&&!(e.id==="auth"&&r(i.path)))}function fr({sec:e}){let t=Ne(e),o=e.widget&&(t||!e.optional||e.self_managed);return n(x,{children:[o?n("div",{class:"mt-6",children:n(Jn,{sec:e})}):null,n("div",{class:e.optional&&!t?"opacity-80":"",children:kt(e.fields.filter(r=>!r.managed)).map((r,i)=>n("div",{children:[r.name?n("h3",{class:`eyebrow ${i===0?"mt-6":"mt-8 border-t border-line pt-4"}`,children:r.name}):null,r.fields.map(s=>n(I,{field:s},s.path.join(".")))]},r.name))})]})}function hr({sec:e}){let t=Ne(e),o=mr(e);return n(x,{children:[n(ke,{code:e.key,children:e.title}),n(Ot,{children:e.description}),o.length?n("div",{class:"mt-4 space-y-2",children:o.map((r,i)=>n(vt,{issue:r},i))}):null,e.optional&&!e.self_managed?n("div",{class:"mt-5 flex flex-wrap items-center gap-4",children:[n(te,{label:`Settings for ${e.title}`,value:t?"c":"d",options:[["d","Use defaults"],["c","Customize"]],onPick:r=>yn(e,r==="c")}),n("p",{class:"m-0 min-w-56 flex-1 text-[13px] text-muted",children:t?`Saved in 10xgraph.json under ${e.key}.`:e.off_summary})]}):null,e.notes?.length?n("div",{class:"mt-5",children:n(ve,{kind:"info",lines:e.notes})}):null,n(fr,{sec:e}),n(pr,{vars:e.env})]})}function _r(){let e=d.schema.environment||[];return n(x,{children:[n(ke,{children:q.environment.title}),n(Ot,{children:"These variables are set in .env or your deployment environment. The editor never reads that file, so only names and purposes are shown."}),e.map(t=>n("section",{children:[n("h2",{class:"mt-8 mb-2 text-sm font-semibold text-ink",children:t.title}),t.note?n("p",{class:"mb-2 text-xs text-faint",children:t.note}):null,n(Qe,{vars:t.vars})]},t.title))]})}var Kn=e=>String(e??"").toLowerCase();function gr(e){let t=i=>e.every(s=>Kn(i).includes(s)),o=[];for(let i of d.sections){let s=t(`${i.title} ${i.description} ${i.key||""}`),a=p=>s||t(`${p.label} ${p.help||""} ${p.path.join(".")}`),u=i.fields.filter(p=>!p.managed&&a(p)),m=(i.env||[]).filter(p=>t(`${p.name} ${p.purpose}`)),l=!!i.widget&&(s||i.fields.some(p=>p.managed&&a(p)));(u.length||m.length||l)&&o.push({id:i.id,sec:i,fields:u,env:m,widget:l})}let r=(d.schema.environment||[]).map(i=>({title:i.title,vars:i.vars.filter(s=>t(`${s.name} ${s.purpose} ${i.title}`))})).filter(i=>i.vars.length);return{results:o,envGroups:r}}function br(){let e=Kn(d.query).split(/\s+/).filter(Boolean),{results:t,envGroups:o}=gr(e),r=t.reduce((i,s)=>i+s.fields.length+s.env.length+(s.widget?1:0),0)+o.reduce((i,s)=>i+s.vars.length,0);return r?n(x,{children:[n(ke,{code:d.query,children:[r," ",r===1?"setting matches":"settings match"]}),t.map(({id:i,sec:s,fields:a,env:u,widget:m})=>n("section",{class:"mt-8",children:[n("h2",{class:"eyebrow m-0 flex items-center gap-2",children:[n(_,{name:ee[i]||"box",class:"size-4"}),s.title,n("a",{href:`#${i}`,onClick:l=>{l.preventDefault(),D(i)},class:"focus-ring ml-auto text-xs font-medium tracking-normal text-brand normal-case hover:underline",children:"Open page"})]}),m?n("div",{class:"mt-4",children:n(Jn,{sec:s})}):null,a.map(l=>n(I,{field:l},l.path.join("."))),u.length?n("details",{class:"group mt-5",open:!0,children:[n("summary",{class:"focus-ring flex min-h-11 cursor-pointer list-none items-center gap-2 font-medium text-muted [&::-webkit-details-marker]:hidden",children:[n(_,{name:"chevronRight",class:"size-4 transition-transform group-open:rotate-90"}),"Set in .env"]}),n(Qe,{vars:u})]}):null]},i)),o.length?n("section",{class:"mt-8",children:[n("h2",{class:"eyebrow m-0 flex items-center gap-2",children:[n(_,{name:"terminal",class:"size-4"}),q.environment.title,n("a",{href:"#environment",onClick:i=>{i.preventDefault(),D("environment")},class:"focus-ring ml-auto text-xs font-medium tracking-normal text-brand normal-case hover:underline",children:"Open page"})]}),o.map(i=>n("div",{children:[n("h3",{class:"mt-4 mb-1.5 text-sm font-semibold text-ink",children:i.title}),i.note?n("p",{class:"mb-1.5 text-xs text-faint",children:i.note}):null,n(Qe,{vars:i.vars})]},i.title))]}):null]}):n(x,{children:[n(ke,{children:"No matches"}),n("p",{class:"mt-8 text-muted",children:['Nothing matches "',d.query,'". Try a shorter word, or press Esc to go back.']})]})}function Yn(){if(d.query.trim())return n(br,{});if(d.page==="get-running")return n(Vn,{});if(d.page==="environment")return n(_r,{});let e=O(d.page);return e?n(hr,{sec:e}):n(Vn,{})}function xr(){let e=d.path.split(/[\\/]/).pop()||"10xgraph.json",t=d.path.slice(0,d.path.length-e.length),o=null;return d.schema?d.exists?W()?o=n(ie,{class:"bg-warn-soft text-warn",children:"Unsaved"}):o=n(ie,{class:"bg-ok-soft text-ok",children:"Saved"}):o=n(ie,{class:"bg-brand-soft text-brand",children:"New"}):o=null,n("div",{class:"flex min-w-0 flex-1 items-center gap-2",title:d.path||void 0,children:[n(_,{name:"file",class:"size-4 text-faint"}),n("span",{class:"truncate font-mono text-[13px] font-medium text-ink",children:e}),o,t?n("span",{class:"hidden min-w-0 truncate font-mono text-xs text-faint lg:block",dir:"rtl",children:n("bdi",{children:t})}):null]})}function Xn(){let e=!!d.schema&&!d.fatal,t=d.sideOpen?"Hide sidebar":"Show sidebar";return n("header",{class:"sticky top-0 z-40 flex h-14 items-center gap-2 border-b border-line bg-surface px-3 sm:gap-3 sm:px-4",children:[n("button",{type:"button",class:"icon-btn","aria-controls":"side","aria-expanded":d.sideOpen?"true":"false","aria-label":t,title:`${t} (Ctrl/Cmd+B)`,onClick:()=>se(!d.sideOpen),children:n(_,{name:"panel",class:"size-[18px]"})}),n("div",{class:"flex shrink-0 items-center gap-2.5",children:[n("svg",{viewBox:"0 0 48 48",class:"size-7 shrink-0 text-ink","aria-hidden":"true",children:[n("rect",{x:"4",y:"4",width:"40",height:"40",rx:"11",fill:"none",stroke:"currentColor","stroke-width":"4.4"}),n("path",{d:"M13 33L21 23L34 15L34 31L21 23",fill:"none",stroke:"currentColor","stroke-width":"3","stroke-linecap":"round","stroke-linejoin":"round"}),n("g",{fill:"currentColor",children:[n("circle",{cx:"21",cy:"23",r:"4"}),n("circle",{cx:"34",cy:"15",r:"4"}),n("circle",{cx:"34",cy:"31",r:"4"})]}),n("circle",{cx:"13",cy:"33",r:"4.6",class:"fill-brand-amber"})]}),n("div",{class:"hidden items-baseline gap-1.5 sm:flex",children:[n("span",{class:"text-[15px] font-semibold tracking-tight text-ink",children:"10xGraph"}),n("span",{class:"text-[15px] text-faint",children:"Config"})]})]}),n("span",{class:"hidden h-6 w-px shrink-0 bg-line md:block","aria-hidden":"true"}),n(xr,{}),n("button",{type:"button",class:"btn btn-ghost","aria-label":"View 10xgraph.json","aria-haspopup":"dialog",disabled:!e,onClick:()=>kn(),children:[n(_,{name:"code",class:"size-[18px]"}),n("span",{class:"hidden md:inline",children:"View 10xgraph.json"})]}),n("button",{type:"button",class:"btn btn-primary",onClick:He,disabled:!e||!!d.busy||d.exists&&!W()&&!d.parseError,children:[d.busy==="save"?n(_,{name:"spinner",class:"size-4 animate-spin"}):null,d.exists?"Save":"Create file",n("span",{class:"kbd hidden lg:inline-flex",children:"Ctrl S"})]})]})}function Qn(){return d.fatal?n("div",{class:"panel mt-10",children:n(Tn,{icon:"xCircle",tint:"text-danger",title:d.fatal.title,text:d.fatal.text})}):d.schema?n(x,{children:[d.parseError?n("div",{class:"mb-6",children:n(ve,{kind:"warn",title:"The existing file is not valid JSON",lines:[`${d.parseError}. The form starts from defaults; saving replaces the file and keeps the old one as 10xgraph.json.bak.`]})}):null,n(Yn,{})]}):n("div",{class:"animate-pulse space-y-4 pt-2","aria-label":"Loading",children:[n("div",{class:"h-7 w-48 rounded-md bg-raised"}),n("div",{class:"h-4 w-80 max-w-full rounded-md bg-raised"}),[0,1,2].map(e=>n("div",{class:"h-16 rounded-xl bg-raised"},e))]})}function vr({id:e}){let t=!!O(e)&&pn(O(e)),o=mn(e);return!t&&!o?null:n("span",{class:"flex shrink-0 gap-1",children:[t?n("span",{class:"size-2 rounded-full bg-brand",title:O(e).self_managed?"Configured":"Changed from default"}):null,o?n("span",{class:`size-2 rounded-full ${o.level==="error"?"bg-danger":"bg-warn"}`,title:o.level==="error"?"Needs attention":"Has a warning"}):null]})}function yr({id:e}){let t=ge(e);if(!t)return null;let o=!d.query&&d.page===e;return n("a",{href:`#${e}`,"aria-current":o?"page":void 0,onClick:r=>{r.preventDefault(),D(e)},ref:r=>{r&&o&&window.innerWidth<1024&&r.scrollIntoView({block:"nearest",inline:"nearest"})},class:`focus-ring flex min-h-10 items-center gap-2.5 rounded-lg px-3 py-1.5 font-medium transition-colors duration-150 max-lg:min-h-11 max-lg:shrink-0 max-lg:whitespace-nowrap ${o?"bg-brand-soft text-ink":"text-muted hover:bg-raised hover:text-ink"}`,children:[n(_,{name:ee[e]||"box",class:`size-[18px] ${o?"text-brand":""}`}),n("span",{class:"min-w-0 flex-1",children:[n("span",{class:"block truncate",children:t.title}),t.subtitle?n("span",{class:"block truncate text-xs font-normal text-faint max-lg:hidden",children:t.subtitle}):null]}),n(vr,{id:e})]})}function Zn(){return d.sideRender?n("aside",{id:"side","aria-label":"Settings navigation",class:`contents lg:sticky lg:top-14 lg:block lg:h-[calc(100dvh-3.5rem)] lg:overflow-y-auto lg:border-r lg:border-line lg:bg-surface lg:px-3 lg:pt-4 lg:pb-16 lg:transition-[transform,opacity] lg:duration-200 lg:ease-out ${d.sideShown?"lg:translate-x-0 lg:opacity-100":"lg:-translate-x-6 lg:opacity-0"}`,inert:!d.sideOpen,children:[n("div",{role:"search",class:"relative mx-1 mb-3 max-lg:mx-4 max-lg:mt-3",children:[n(_,{name:"search",class:"pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-faint"}),n("label",{for:"q",class:"sr-only",children:"Search settings"}),n("input",{id:"q",type:"search",value:d.query,autocomplete:"off",spellcheck:!1,placeholder:"Search settings ( / )",class:"input pl-10",onInput:e=>Fe(e.currentTarget.value)})]}),n("nav",{"aria-label":"Pages",class:"max-lg:sticky max-lg:top-14 max-lg:z-20 max-lg:mt-3 max-lg:flex max-lg:gap-0.5 max-lg:overflow-x-auto max-lg:border-b max-lg:border-line max-lg:bg-surface max-lg:px-3 max-lg:py-1.5",children:d.nav.map(e=>n("div",{class:"max-lg:contents",children:[e.label?n("div",{class:"mt-4 mb-1 px-3 text-[11px] font-semibold tracking-wider text-faint uppercase max-lg:hidden",children:e.label}):null,e.items.map(t=>n(yr,{id:t},t))]},e.label||"top"))})]}):null}var kr={success:["checkCircle","text-ok"],warning:["alert","text-warn"],error:["xCircle","text-danger"]};function wr(){return n("div",{class:"pointer-events-none fixed right-4 bottom-4 left-4 z-50 flex flex-col items-end gap-2 sm:left-auto","aria-live":"polite",children:d.toasts.map(e=>{let[t,o]=kr[e.kind];return n("div",{role:e.kind==="error"?"alert":"status",class:"pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-xl border border-line bg-surface px-4 py-3 text-sm shadow-pop",children:[n(_,{name:t,class:`mt-0.5 size-4 ${o}`}),n("p",{class:"min-w-0 flex-1 leading-5 text-body",children:e.message}),e.action?n("button",{type:"button",class:"shrink-0 font-medium text-brand hover:underline",onClick:()=>{qe(e.id),e.action.run()},children:e.action.label}):null,n("button",{type:"button",class:"-mr-1 shrink-0 cursor-pointer rounded text-faint hover:text-ink","aria-label":"Dismiss",onClick:()=>qe(e.id),children:n(_,{name:"x"})})]},e.id)})})}var Cr=()=>/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName)||document.activeElement?.isContentEditable;function Sr(){let[,e]=dt(o=>o+1,0);U(()=>{dn(e);try{localStorage.getItem("tg-sidebar")==="hidden"&&se(!1,{instant:!0})}catch{}let o=s=>{let a=s.ctrlKey||s.metaKey,u=s.key.toLowerCase();a&&u==="s"?(s.preventDefault(),He()):a&&u==="b"?(s.preventDefault(),se(!d.sideOpen)):a&&u==="k"?(s.preventDefault(),gt()):s.key==="/"&&!a&&!s.altKey&&!Cr()&&!d.drawerOpen?(s.preventDefault(),gt()):s.key==="Escape"&&d.query&&!d.drawerOpen&&Fe("")},r=s=>{d.schema&&W()&&s.preventDefault()},i=()=>ze();return document.addEventListener("keydown",o),window.addEventListener("beforeunload",r),window.addEventListener("hashchange",i),xn(),()=>{document.removeEventListener("keydown",o),window.removeEventListener("beforeunload",r),window.removeEventListener("hashchange",i)}},[]),U(()=>{if(!d.schema||d.validated===L())return;let o=setTimeout(vn,400);return()=>clearTimeout(o)});let t=d.sideRender;return n(x,{children:[n("a",{href:"#main",class:"sr-only z-50 rounded-md bg-brand-solid px-3 py-2 text-white focus:not-sr-only focus:fixed focus:top-3 focus:left-3",children:"Skip to content"}),n(Xn,{}),n("div",{class:t?"lg:grid lg:grid-cols-[260px_minmax(0,1fr)] lg:items-start":"",children:[n(Zn,{}),n("main",{id:"main",tabindex:-1,class:"min-w-0 px-4 pt-6 pb-24 focus:outline-none sm:px-8 sm:pt-8",children:n("div",{class:"mx-auto w-full max-w-[760px]",children:n(Qn,{})})})]}),n($n,{}),n("div",{class:"sr-only",role:"status","aria-live":"polite",children:d.copied?"Copied":""}),n(wr,{})]})}Vt(n(Sr,{}),document.getElementById("app")); +var Re,v,zt,co,K,Nt,Ft,Ut,ot,$e,pe,qt,it,rt,st,uo,Ae={},Pe=[],po=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,je=Array.isArray;function F(e,t){for(var o in t)e[o]=t[o];return e}function at(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function mo(e,t,o){var r,i,s,a={};for(s in t)s=="key"?r=t[s]:s=="ref"?i=t[s]:a[s]=t[s];if(arguments.length>2&&(a.children=arguments.length>3?Re.call(arguments,2):o),typeof e=="function"&&e.defaultProps!=null)for(s in e.defaultProps)a[s]===void 0&&(a[s]=e.defaultProps[s]);return Oe(e,a,r,i,null)}function Oe(e,t,o,r,i){var s={type:e,props:t,key:o,ref:r,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:i??++zt,__i:-1,__u:0};return i==null&&v.vnode!=null&&v.vnode(s),s}function x(e){return e.children}function Ee(e,t){this.props=e,this.context=t}function ee(e,t){if(t==null)return e.__?ee(e.__,e.__i+1):null;for(var o;tt&&K.sort(Ut),e=K.shift(),t=K.length,fo(e)}finally{K.length=Ie.__r=0}}function Gt(e,t,o,r,i,s,a,u,m,l,p){var h,c,f,y,T,C,O=r&&r.__k||Pe,k=t.length;for(m=ho(o,t,O,m,k),h=0;h0?a=e.__k[s]=Oe(a.type,a.props,a.key,a.ref?a.ref:null,a.__v):e.__k[s]=a,m=s+c,a.__=e,a.__b=e.__b+1,u=null,(l=a.__i=_o(a,o,m,h))!=-1&&(h--,(u=o[l])&&(u.__u|=2)),u==null||u.__v==null?(l==-1&&(i>p?c--:im?c--:c++,a.__u|=4))):e.__k[s]=null;if(h)for(s=0;s(p?1:0)){for(i=o-1,s=o+1;i>=0||s=0?i--:s++])!=null&&(2&l.__u)==0&&u==l.key&&m==l.type)return a}return-1}function Dt(e,t,o){t[0]=="-"?e.setProperty(t,o??""):e[t]=o==null?"":typeof o!="number"||po.test(t)?o:o+"px"}function Te(e,t,o,r,i){var s,a;e:if(t=="style")if(typeof o=="string")e.style.cssText=o;else{if(typeof r=="string"&&(e.style.cssText=r=""),r)for(t in r)o&&t in o||Dt(e.style,t,"");if(o)for(t in o)r&&o[t]==r[t]||Dt(e.style,t,o[t])}else if(t[0]=="o"&&t[1]=="n")s=t!=(t=t.replace(qt,"$1")),a=t.toLowerCase(),t=a in e||t=="onFocusOut"||t=="onFocusIn"?a.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+s]=o,o?r?o[pe]=r[pe]:(o[pe]=it,e.addEventListener(t,s?st:rt,s)):e.removeEventListener(t,s?st:rt,s);else{if(i=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=o??"";break e}catch{}typeof o=="function"||(o==null||o===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&o==1?"":o))}}function Ht(e){return function(t){if(this.l){var o=this.l[t.type+e];if(t[$e]==null)t[$e]=it++;else if(t[$e]0?e:je(e)?e.map(Kt):e.constructor!==void 0?null:F({},e)}function bo(e,t,o,r,i,s,a,u,m){var l,p,h,c,f,y,T,C=o.props||Ae,O=t.props,k=t.type;if(k=="svg"?i="http://www.w3.org/2000/svg":k=="math"?i="http://www.w3.org/1998/Math/MathML":i||(i="http://www.w3.org/1999/xhtml"),s!=null){for(l=0;l=o.__.length&&o.__.push({}),o.__[e]}function Y(e){return Ne=1,mt(cn,e)}function mt(e,t,o){var r=pt(me++,2);if(r.t=e,!r.__c&&(r.__=[o?o(t):cn(void 0,t),function(u){var m=r.__N?r.__N[0]:r.__[0],l=r.t(m,u);m!==l&&(r.__N=[l,r.__[1]],r.__c.setState({}))}],r.__c=S,!S.__f)){var i=function(u,m,l){if(!r.__c.__H)return!0;var p=!1,h=r.__c.props!==u;if(r.__c.__H.__.some(function(f){if(f.__N){p=!0;var y=f.__[0];f.__=f.__N,f.__N=void 0,y!==f.__[0]&&(h=!0)}}),s){var c=s.call(this,u,m,l);return p?c||h:c}return!p||h};S.__f=!0;var s=S.shouldComponentUpdate,a=S.componentWillUpdate;S.componentWillUpdate=function(u,m,l){if(this.__e){var p=s;s=void 0,i(u,m,l),s=p}a&&a.call(this,u,m,l)},S.shouldComponentUpdate=i}return r.__N||r.__}function U(e,t){var o=pt(me++,3);!$.__s&&ln(o.__H,t)&&(o.__=e,o.u=t,S.__H.__h.push(o))}function Le(e){return Ne=5,xo(function(){return{current:e}},[])}function xo(e,t){var o=pt(me++,7);return ln(o.__H,t)&&(o.__=e(),o.__H=t,o.__h=e),o.__}function vo(){for(var e;e=an.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(Me),t.__h.some(ut),t.__h=[]}catch(o){t.__h=[],$.__e(o,e.__v)}}}$.__b=function(e){S=null,Zt&&Zt(e)},$.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),rn&&rn(e,t)},$.__r=function(e){en&&en(e),me=0;var t=(S=e.__c).__H;t&&(dt===S?(t.__h=[],S.__h=[],t.__.some(function(o){o.__N&&(o.__=o.__N),o.u=o.__N=void 0})):(t.__h.some(Me),t.__h.some(ut),t.__h=[],me=0)),dt=S},$.diffed=function(e){tn&&tn(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(an.push(t)!==1&&Qt===$.requestAnimationFrame||((Qt=$.requestAnimationFrame)||yo)(vo)),t.__H.__.some(function(o){o.u&&(o.__H=o.u,o.u=void 0)})),dt=S=null},$.__c=function(e,t){t.some(function(o){try{o.__h.some(Me),o.__h=o.__h.filter(function(r){return!r.__||ut(r)})}catch(r){t.some(function(i){i.__h&&(i.__h=[])}),t=[],$.__e(r,o.__v)}}),nn&&nn(e,t)},$.unmount=function(e){on&&on(e);var t,o=e.__c;o&&o.__H&&(o.__H.__.some(function(r){try{Me(r)}catch(i){t=i}}),o.__H=void 0,t&&$.__e(t,o.__v))};var sn=typeof requestAnimationFrame=="function";function yo(e){var t,o=function(){clearTimeout(r),sn&&cancelAnimationFrame(t),setTimeout(e)},r=setTimeout(o,35);sn&&(t=requestAnimationFrame(o))}function Me(e){var t=S,o=e.__c;typeof o=="function"&&(e.__c=void 0,o()),S=t}function ut(e){var t=S;e.__c=e.__(),S=t}function ln(e,t){return!e||e.length!==t.length||t.some(function(o,r){return o!==e[r]})}function cn(e,t){return typeof t=="function"?t(e):t}var De=[{label:null,items:["get-running"]},{label:"Before production",items:["auth","cache_store","observability","metrics","audit"]},{label:"Runs and streaming",items:["runs","websocket","ag_ui","remote_tools","media"]},{label:"Project wiring",items:["checkpointer","id_generator","thread_name_generator","store","injectq"]},{label:"Developer tools",items:["test","evaluation"]},{label:"Reference",items:["environment"]}],q={"get-running":{title:"Get running",icon:"flag",description:"Only the agent graph is required. Everything else is optional."},environment:{title:"Server environment (.env)",icon:"terminal",description:"Variables the server reads from .env. The editor never reads that file, so values are not shown."}},ft={"auth.path":"auth-path"},dn="tg-config-token",un=!1;function ko(){let e=new URLSearchParams(location.hash.slice(1)).get("token")||"";try{e&&sessionStorage.setItem(dn,e);let t=sessionStorage.getItem(dn)||"";return un=t!==""&&t===(e||t),e||t}catch{return e}}var fe=ko(),ht=e=>un||!fe?`#${e}`:`#token=${encodeURIComponent(fe)}&page=${e}`;function pn(){let e=location.hash.slice(1);if(e.includes("=")){let t=new URLSearchParams(e);return t.get("page")||t.get("section")||""}try{return decodeURIComponent(e)}catch{return e}}var d={schema:null,sections:[],nav:[],fields:new Map,config:{},version:null,exists:!1,path:"",parseError:null,saved:"",validated:null,issues:[],page:"get-running",query:"",sideOpen:!0,sideRender:!0,sideShown:!0,drawerOpen:!1,tab:"json",copied:!1,busy:null,toolParams:{},toasts:[],fatal:null},mn=()=>{},fn=e=>{mn=e};function g(e){e&&e(d),mn()}var hn=e=>JSON.parse(JSON.stringify(e)),L=()=>JSON.stringify(d.config),b=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),D=(e,t)=>t.reduce((o,r)=>b(o)?o[r]:void 0,e),he=(e,t)=>`${e} ${t}${e===1?"":"s"}`;function re(e,t,o){let r=e;for(let i of t.slice(0,-1))b(r[i])||(r[i]={}),r=r[i];r[t[t.length-1]]=o}var wo=new Set(["cache_store.rate_limit","cache_store.token_revocation"]);function _e(e,t){let o=[],r=e;for(let i of t.slice(0,-1)){if(!b(r))return;o.push([r,i]),r=r[i]}b(r)&&delete r[t[t.length-1]];for(let i=o.length-1;i>=1;i--){let[s,a]=o[i];if(wo.has(t.slice(0,i+1).join(".")))break;if(b(s[a])&&Object.keys(s[a]).length===0)delete s[a];else break}}function _t(e){let t=D(d.config,e);return t!==void 0?t:d.fields.get(e.join("."))?.default}var Co=(e,t)=>JSON.stringify(e)===JSON.stringify(t),A=e=>d.sections.find(t=>t.id===e),W=()=>L()!==d.saved,He=e=>!e.optional||e.key in d.config&&d.config[e.key]!==null,se=e=>d.issues.filter(t=>t.section===e),w=e=>d.issues.find(t=>t.path===e);function be(e=d.issues){let t=e.filter(o=>o.level==="error").length;return{errors:t,warnings:e.length-t}}var ze=e=>ft[e]||`f-${e.replaceAll(".","-")}`,bt=e=>{let t=D(d.config,e.path);return t!==void 0&&!Co(t,e.default)},_n=e=>e.id==="core"?!1:e.id==="auth"?d.config.auth!=null:e.id==="metrics"?d.config.metrics?.enabled===!0:e.id==="audit"?d.config.audit?.enabled===!0:e.optional?He(e):e.fields.some(bt);function bn(e){let t=se(e);return t.find(o=>o.level==="error")||t[0]||null}var gn=e=>!!e.show_if&&_t(e.show_if.path)!==e.show_if.equals;function xn(e){let o=d.fields.get(e.show_if.path.join("."))?.label||e.show_if.path.join("."),r=e.show_if.equals;return`Used when ${o} is ${r===!0?"on":r===!1?"off":String(r)}.`}var ge=e=>{let t=A(e);return q[e]?{id:e,...q[e]}:t?{id:e,title:t.title,subtitle:t.subtitle,description:t.description,icon:null}:null},vn=()=>/^[\w.]+:[\w.]+$/.test(String(d.config.agent||""));async function xt(e,t,o){let r={"X-10xGraph-Token":fe};o!==void 0&&(r["Content-Type"]="application/json");let i=await fetch(t,{method:e,headers:r,body:o===void 0?void 0:JSON.stringify(o)}),s={};try{s=await i.json()}catch{}return{ok:i.ok,status:i.status,data:s}}var gt=(e,t)=>g(o=>{o.fatal={title:e,text:t}});async function wn(){if(!fe)return gt("Open the link printed by 10xgraph config","This page needs the session token included in that link.");let e;try{e=await xt("GET","/api/state")}catch{return gt("Cannot reach the config server","Check that 10xgraph config is still running in your terminal, then reload.")}if(!e.ok)return gt("This link has expired",e.data.error||"Restart 10xgraph config and open the new link it prints.");let t=e.data;g(o=>{o.schema=t.schema,o.sections=t.schema.sections;let r=new Set(De.flatMap(a=>a.items)),i=o.sections.map(a=>a.id).filter(a=>a!=="core"&&!r.has(a));o.nav=De.map((a,u)=>({label:a.label,items:uq[m]||A(m)):[...i,...a.items]}));for(let a of o.sections)for(let u of a.fields)o.fields.set(u.path.join("."),u);o.config=t.config,o.version=t.version,o.exists=t.exists,o.path=t.path,o.parseError=t.parse_error,o.issues=t.issues;let s=t.exists&&!t.parse_error;o.saved=s?L():"",o.validated=s?L():null}),Ue({focus:!1}),history.replaceState(null,"",ht(d.page))}var yn=null;async function Cn(){let e=L();if(!(!d.schema||d.validated===e||yn===e)&&!Object.values(d.toolParams).some(t=>t.error)){yn=e;try{let t=await xt("POST","/api/validate",{config:JSON.parse(e)});if(!t.ok)return;g(o=>{o.issues=t.data.issues,o.validated=e})}catch{}}}async function Fe(){if(!(d.busy||!d.schema)){if(d.exists&&!W()&&!d.parseError)return G("success","No changes to save.");if(So()){g(e=>{e.busy="save"});try{let e=await xt("POST","/api/save",{config:d.config,version:d.version});if(e.data.issues&&(d.issues=e.data.issues,d.validated=L()),e.ok){d.version=e.data.version,d.config=e.data.config,d.toolParams={},d.validated=L(),d.saved=L(),d.exists=!0,d.parseError=null;let t=e.data.path.split(/[\\/]/).pop();G("success",e.data.backup?`Saved ${t}. The previous version is in ${t}.bak.`:`Created ${t}.`)}else if(e.status===409)G("error",e.data.error,{label:"Reload",run:()=>location.reload()});else if(e.status===422){let{errors:t}=be();G("error",`Not saved: ${he(t,"error")} to fix first.`,{label:"Show",run:To})}else G("error",e.data.error||"Save failed.")}catch{G("error","Cannot reach the config server.")}finally{g(e=>{e.busy=null})}}}}function So(){let e=Object.values(d.toolParams).some(t=>t.error);return e&&(H("remote_tools"),G("error","A remote tool has invalid parameters JSON.")),!e}function Sn(e,t){g(o=>{t?o.config[e.key]=hn(e.default):delete o.config[e.key],e.key==="remote_tools"&&(o.toolParams={})})}function To(){let e=d.issues.find(t=>t.level==="error")||d.issues[0];e&&H(e.section,ze(e.path))}var $o=()=>window.matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth";function Oo(e){return e==="core"?"get-running":q[e]||A(e)?e:"get-running"}function Ue({focus:e=!0}={}){g(t=>{t.page=Oo(pn()),t.query="",t.drawerOpen=!1}),e&&requestAnimationFrame(()=>{window.scrollTo(0,0),document.getElementById("page-title")?.focus({preventScroll:!0})})}function H(e,t){let o=ht(e);location.hash!==o&&history.pushState(null,"",o),Ue(),t&&requestAnimationFrame(()=>requestAnimationFrame(()=>{let r=document.getElementById(t);r&&(r.scrollIntoView({block:"center",behavior:$o()}),r.focus({preventScroll:!0}))}))}function qe(e){g(t=>{t.query=e})}var Eo=()=>window.matchMedia("(prefers-reduced-motion: reduce)").matches,kn;function ie(e,{instant:t=!1}={}){try{localStorage.setItem("tg-sidebar",e?"shown":"hidden")}catch{}clearTimeout(kn);let o=t||Eo();g(r=>{r.sideOpen=e,e?(r.sideRender=!0,r.sideShown=o):r.sideShown=!1,!e&&o&&(r.sideRender=!1)}),e&&!o&&requestAnimationFrame(()=>requestAnimationFrame(()=>g(r=>{r.sideShown=!0}))),!e&&!o&&(kn=setTimeout(()=>g(r=>{r.sideOpen||(r.sideRender=!1)}),200))}function vt(){d.sideOpen||ie(!0,{instant:!0}),requestAnimationFrame(()=>{let e=document.getElementById("q");e?.focus(),e?.select()})}function Tn(e){g(t=>{e&&(t.tab=e),t.drawerOpen=!0})}var We=()=>g(e=>{e.drawerOpen=!1});async function $n(){let e="10xgraph dev";try{await navigator.clipboard.writeText(e)}catch{try{let t=document.createElement("textarea");t.value=e,document.body.append(t),t.select(),document.execCommand("copy"),t.remove()}catch{}}g(t=>{t.copied=!0}),setTimeout(()=>g(t=>{t.copied=!1}),2e3)}async function On(){try{await navigator.clipboard.writeText(JSON.stringify(d.config,null,2)+` +`),G("success","Copied 10xgraph.json to the clipboard.")}catch{G("error","The clipboard is not available here.")}}var Ao=0;function G(e,t,o){let r=++Ao;g(i=>{i.toasts=[...i.toasts,{id:r,kind:e,message:t,action:o}]}),setTimeout(()=>Ge(r),o?8e3:4e3)}function Ge(e){g(t=>{t.toasts=t.toasts.filter(o=>o.id!==e)})}var En={box:'',key:'',shield:'',gauge:'',arrows:'',activity:'',layers:'',wrench:'',flask:'',target:'',checkCircle:'',xCircle:'',alert:'',info:'',file:'',copy:'',x:'',plus:'',trash:'',panel:'',chevronDown:'',expand:'',collapse:'',power:'',spinner:'',book:'',flag:'',lock:'',play:'',bolt:'',terminal:'',search:'',chevronRight:'',code:'',circle:'',external:''},te={"get-running":"flag",environment:"terminal",core:"flag",auth:"lock",cache_store:"gauge",observability:"activity",metrics:"gauge",audit:"file",runs:"play",websocket:"bolt",ag_ui:"external",remote_tools:"wrench",media:"file",checkpointer:"layers",id_generator:"key",thread_name_generator:"file",store:"box",injectq:"arrows",test:"flask",evaluation:"target"};var Po=0;function n(e,t,o,r,i,s){t||(t={});var a,u,m=t;if("ref"in m)for(u in m={},t)u=="ref"?a=t[u]:m[u]=t[u];var l={type:e,props:m,key:o,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--Po,__i:-1,__u:0,__source:i,__self:s};if(typeof e=="function"&&(a=e.defaultProps))for(u in a)m[u]===void 0&&(m[u]=a[u]);return v.vnode&&v.vnode(l),l}function _({name:e,class:t="size-4"}){return/(^|\s)size-/.test(t)||(t=`size-4 ${t}`),n("span",{class:`inline-flex shrink-0 ${t}`,"aria-hidden":"true",children:n("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"1.75","stroke-linecap":"round","stroke-linejoin":"round",class:"size-full",dangerouslySetInnerHTML:{__html:En[e]}})})}function ae({class:e,children:t}){return n("span",{class:`inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[11px] font-semibold ${e}`,children:t})}function B({id:e,on:t,label:o,onChange:r}){return n("button",{id:e,type:"button",role:"switch","aria-checked":t?"true":"false","aria-label":o,class:`focus-ring relative inline-flex h-6 w-10 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 before:absolute before:-inset-2.5 before:content-[''] ${t?"bg-brand-solid":"bg-line-strong"}`,onClick:()=>r(!t),children:n("span",{class:`pointer-events-none inline-block size-5 rounded-full bg-white shadow-sm ring-1 ring-black/5 transition-transform duration-200 ease-out ${t?"translate-x-[18px]":"translate-x-0.5"}`})})}function ne({id:e,label:t,options:o,value:r,onPick:i}){return n("div",{id:e,role:"radiogroup","aria-label":t,class:"inline-flex max-w-full flex-wrap gap-0.5 rounded-[10px] border border-line-strong bg-raised p-0.5",children:o.map(([s,a])=>n("button",{type:"button",role:"radio","aria-checked":s===r?"true":"false",class:`focus-ring min-h-10 cursor-pointer rounded-lg px-3.5 text-sm font-medium transition-colors duration-150 ${s===r?"bg-surface text-ink shadow-card":"text-muted hover:text-ink"}`,onClick:()=>{s!==r&&i(s)},children:a},s))})}function R({id:e,options:t,value:o,invalid:r,onChange:i,plain:s}){return n("div",{class:"relative",children:[n("select",{id:e,value:o,class:`input cursor-pointer appearance-none pr-9 ${s?"":"font-mono"} ${r?"input-invalid":""}`,onChange:a=>i(a.currentTarget.value),children:t.map(([a,u])=>n("option",{value:a,children:u},a))}),n(_,{name:"chevronDown",class:"pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-faint"})]})}function P({id:e,values:t,placeholder:o,suggestions:r,onChange:i}){let s=Le(null),a=m=>{let l=m.trim();s.current&&(s.current.value=""),l&&!t.includes(l)&&i([...t,l])},u=r?`${e}-options`:void 0;return n("div",{class:"flex min-h-11 cursor-text flex-wrap items-center gap-1.5 rounded-lg border border-line-strong bg-sunken px-1.5 py-1.5 transition-[border-color,box-shadow] duration-150 focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-ring sm:py-1",onClick:m=>{m.target===m.currentTarget&&s.current?.focus()},children:[t.map((m,l)=>n("span",{class:"inline-flex h-7 items-center gap-0.5 rounded-md border border-line bg-raised pr-0.5 pl-2 font-mono text-xs text-body sm:h-6",children:[m,n("button",{type:"button","aria-label":`Remove ${m}`,class:"inline-flex size-6 cursor-pointer items-center justify-center rounded text-faint transition-colors hover:bg-hover hover:text-ink sm:size-5",onClick:()=>i(t.filter((p,h)=>h!==l)),children:n(_,{name:"x",class:"size-3"})})]},m)),n("input",{ref:s,id:e,type:"text",list:u,placeholder:t.length?"Add...":o,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:"h-7 min-w-[7rem] flex-1 bg-transparent px-1 font-mono text-base text-ink outline-none placeholder:text-faint sm:text-sm",onKeyDown:m=>{let l=m.currentTarget;(m.key==="Enter"||m.key===",")&&l.value.trim()?(m.preventDefault(),a(l.value)):m.key==="Backspace"&&!l.value&&t.length&&i(t.slice(0,-1))},onChange:m=>{r?.includes(m.currentTarget.value.trim())&&a(m.currentTarget.value)},onBlur:m=>a(m.currentTarget.value)}),r?n("datalist",{id:u,children:r.filter(m=>!t.includes(m)).map(m=>n("option",{value:m},m))}):null]})}function xe({label:e,path:t,help:o,id:r,required:i}){return n("div",{class:"min-w-0",children:[n("label",{for:r,class:"block text-sm font-medium text-ink",children:[e,i?n("span",{class:"text-danger",children:" *"}):null]}),n("div",{class:"mt-0.5 font-mono text-xs text-faint",children:t}),o?n("p",{class:"mt-1.5 text-[13px] leading-5 text-muted",children:o}):null]})}function E({issue:e}){if(!e)return null;let t=e.level==="error";return n("p",{class:`mt-2 flex gap-1.5 text-[13px] leading-5 ${t?"text-danger":"text-warn"}`,role:t?"alert":void 0,children:[n(_,{name:t?"xCircle":"alert",class:"mt-0.5 size-3.5"}),n("span",{children:e.message})]})}function yt({children:e}){return n("div",{class:"grid gap-3 px-5 py-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] sm:gap-8",children:e})}function X({id:e,label:t,path:o,value:r,placeholder:i,onInput:s,issue:a,help:u}){return n(yt,{children:[n(xe,{label:t,path:o,help:u,id:e}),n("div",{class:"min-w-0 sm:pt-0.5",children:[n("input",{id:e,type:"text",value:r??"",placeholder:i,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:`input font-mono ${a?.level==="error"?"input-invalid":""}`,onInput:m=>s(m.currentTarget.value)}),n(E,{issue:a})]})]})}function kt({name:e,label:t,options:o,value:r,onPick:i}){return n("div",{role:"radiogroup","aria-label":t,class:"grid gap-3 sm:grid-cols-2",children:o.map(([s,a,u])=>{let m=s===r;return n("button",{type:"button",role:"radio","aria-checked":m?"true":"false",id:`${e}-${s}`,class:`focus-ring flex cursor-pointer items-start gap-3 rounded-xl border p-4 text-left transition-colors duration-150 ${m?"border-brand bg-brand-soft":"border-line bg-surface shadow-card hover:border-line-strong"}`,onClick:()=>{m||i(s)},children:[n("span",{class:`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border ${m?"border-brand-solid bg-brand-solid":"border-line-strong"}`,children:m?n("span",{class:"size-1.5 rounded-full bg-white"}):null}),n("span",{class:"min-w-0",children:[n("span",{class:"block text-sm font-medium text-ink",children:a}),n("span",{class:"mt-0.5 block text-[13px] leading-5 text-muted",children:u})]})]},s)})})}function j({title:e,children:t}){return n("section",{children:[e?n("h4",{class:"eyebrow mb-2.5",children:e}):null,t]})}var Io={info:["info","border-brand/20 bg-brand-soft","text-brand"],warn:["alert","border-warn/25 bg-warn-soft","text-warn"],error:["xCircle","border-danger/25 bg-danger-soft","text-danger"]};function ve({kind:e,title:t,lines:o}){let[r,i,s]=Io[e];return n("div",{class:`flex gap-3 rounded-xl border px-4 py-3 ${i}`,role:e==="error"?"alert":void 0,children:[n(_,{name:r,class:`mt-0.5 size-4 ${s}`}),n("div",{class:"min-w-0 space-y-1 text-[13px] leading-5 text-body",children:[t?n("p",{class:"font-medium text-ink",children:t}):null,o.map((a,u)=>n("p",{children:a},u))]})]})}function wt({issue:e}){return n(ve,{kind:e.level==="error"?"error":"warn",lines:[n("span",{children:[e.path?n("code",{class:"mr-1.5 font-mono text-xs text-muted",children:e.path}):null,e.message]})]})}function An({icon:e,tint:t,title:o,text:r,children:i}){return n("div",{class:"px-6 py-14 text-center",children:[n("div",{class:`mx-auto flex size-11 items-center justify-center rounded-full bg-raised ${t}`,children:n(_,{name:e,class:"size-5"})}),n("p",{class:"mt-4 text-sm font-semibold text-ink",children:o}),n("p",{class:"mx-auto mt-1 max-w-xs text-sm leading-6 text-muted",children:r}),i]})}function Be({title:e,open:t,class:o="",children:r}){return n("details",{class:`group ${o}`,open:t,children:[n("summary",{class:"focus-ring flex min-h-11 cursor-pointer list-none items-center gap-2 font-medium text-muted [&::-webkit-details-marker]:hidden",children:[n(_,{name:"chevronRight",class:"size-4 transition-transform duration-150 group-open:rotate-90"}),e]}),r]})}function Pn(){let e=Le(null);U(()=>{let a=e.current;a&&(d.drawerOpen&&!a.open?a.showModal():!d.drawerOpen&&a.open&&a.close())});let t=d.issues.length>0,o=t&&d.tab==="problems"?"problems":"json",{errors:r,warnings:i}=be(),s=(a,u,m)=>n("button",{type:"button",role:"tab",id:`tab-${a}`,"aria-selected":o===a?"true":"false",class:`focus-ring inline-flex h-10 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors duration-150 ${o===a?"bg-raised text-ink":"text-muted hover:text-ink"}`,onClick:()=>g(l=>{l.tab=a}),children:[u,m]});return n("dialog",{ref:e,"aria-label":"10xgraph.json preview",class:"m-0 ml-auto h-dvh max-h-none w-[min(540px,100vw)] border-0 border-l border-line-strong bg-surface p-0 text-body shadow-pop backdrop:bg-black/45",onClose:()=>{d.drawerOpen&&We()},onClick:a=>{a.target===a.currentTarget&&We()},children:n("div",{class:"flex h-full flex-col",children:[n("div",{class:"flex h-14 shrink-0 items-center gap-1 border-b border-line px-3",children:[n("div",{role:"tablist",class:"flex flex-1 gap-1",children:[s("json","10xgraph.json",null),t?s("problems","Problems",n("span",{class:`inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[11px] font-semibold ${r?"bg-danger-soft text-danger":"bg-warn-soft text-warn"}`,children:r+i})):null]}),n("button",{type:"button",class:"icon-btn","aria-label":"Close preview",onClick:We,children:n(_,{name:"x",class:"size-5"})})]}),n("div",{class:"min-h-0 flex-1 overflow-y-auto overscroll-contain",children:o==="problems"?n(Ro,{}):n(Mo,{})})]})})}function Ro(){let{errors:e,warnings:t}=be(),o=d.sections.map(r=>[r,se(r.id)]).filter(([,r])=>r.length);return n("div",{children:[n("div",{class:"border-b border-line px-4 py-3",children:n("p",{class:"text-sm text-muted",children:[n("span",{class:e?"font-medium text-danger":"",children:he(e,"error")})," \xB7 ",n("span",{class:t?"font-medium text-warn":"",children:he(t,"warning")})]})}),n("div",{class:"space-y-5 px-3 py-4",children:o.map(([r,i])=>n("div",{children:[n("p",{class:"eyebrow mb-1.5 flex items-center gap-2 px-2",children:[n(_,{name:te[r.id]||"box",class:"size-3.5"}),r.title]}),n("ul",{class:"space-y-0.5",children:i.map((s,a)=>{let u=s.level==="error";return n("li",{children:n("button",{type:"button",onClick:()=>H(r.id,ze(s.path)),class:"focus-ring flex min-h-11 w-full cursor-pointer gap-2.5 rounded-lg px-2 py-2 text-left transition-colors duration-150 hover:bg-raised",children:[n(_,{name:u?"xCircle":"alert",class:`mt-0.5 size-4 ${u?"text-danger":"text-warn"}`}),n("span",{class:"min-w-0",children:[n("span",{class:"block text-[13px] leading-5 text-body",children:s.message}),s.path?n("span",{class:"mt-0.5 block truncate font-mono text-xs text-faint",children:s.path}):null]})]})},a)})})]},r.id))})]})}function jo(e){return e.replace(/&/g,"&").replace(//g,">").replace(/("(\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,o=>{let r="var(--af-json-number)";return o.startsWith('"')?r=o.endsWith(":")?"var(--af-json-key)":"var(--af-json-string)":(o==="true"||o==="false"||o==="null")&&(r="var(--af-json-literal)"),`${o}`})}function Mo(){let e=JSON.stringify(d.config,null,2).split(` +`);return n("div",{children:[n("div",{class:"sticky top-0 flex items-center justify-between gap-3 border-b border-line bg-surface px-4 py-2.5",children:[n("p",{class:"text-xs text-faint",children:W()||!d.exists?"Preview of what Save will write":"Matches the file on disk"}),n("button",{type:"button",class:"btn btn-ghost h-8 px-2.5 text-xs",onClick:On,children:[n(_,{name:"copy",class:"size-3.5"}),"Copy"]})]}),n("div",{class:"py-3 pr-4 font-mono text-[12.5px] leading-5",children:e.map((t,o)=>n("div",{class:"flex",children:[n("span",{class:"w-10 shrink-0 pr-3 text-right text-faint/70 select-none",children:o+1}),n("span",{class:"min-w-0 flex-1 break-all whitespace-pre-wrap",dangerouslySetInnerHTML:{__html:jo(t)}})]},o))})]})}function St(e){let t=[];for(let o of e){let r=o.group||"",i=t.find(s=>s.name===r);i||t.push(i={name:r,fields:[]}),i.fields.push(o)}return t}var V=(e,t)=>g(o=>{if(t===void 0){_e(o.config,e);return}let r=e.length>1?o.sections.find(i=>i.optional&&i.key===e[0]):null;r&&(o.config[r.key]===void 0||o.config[r.key]===null)&&(o.config[r.key]=JSON.parse(JSON.stringify(r.default))),re(o.config,e,t)}),No=e=>`f-${e.path.join("-")}`;function Lo({id:e,value:t,field:o,invalid:r,disabled:i}){let[s,a]=Y(String(t??""));return U(()=>{(s===""?t!==void 0:Number(s)!==t)&&a(String(t??""))},[t]),n("input",{id:e,type:"number",inputmode:"decimal",value:s,disabled:i,min:o.min,step:o.step??"any",placeholder:o.placeholder??o.default??"",class:`input max-w-40 font-mono tabular-nums ${r?"input-invalid":""}`,onInput:u=>{let m=u.currentTarget.value;a(m),m===""?V(o.path,void 0):Number.isNaN(Number(m))||V(o.path,Number(m))}})}function Ct({field:e,value:t}){return e.exclusive_min===void 0||typeof t!="number"||t>e.exclusive_min?null:n("p",{class:"mt-1.5 text-[13px] text-warn",children:["Must be greater than ",e.exclusive_min,"."]})}function Do({id:e,field:t,value:o,invalid:r}){let i=t.unlimited_value!==void 0&&o===t.unlimited_value,s=n(Lo,{id:e,value:i?void 0:o,field:t,invalid:r,disabled:i});if(t.null_label!==void 0){let a=o===null?"n":o===void 0?"d":"s",u=typeof t.default=="number"?t.default:300;return n("div",{children:[n(ne,{id:e,label:t.label,value:a,options:[["d","Default"],["s","Seconds"],["n",t.null_label]],onPick:m=>V(t.path,m==="d"?void 0:m==="n"?null:u)}),a==="s"?n("div",{class:"mt-2.5",children:[s,n(Ct,{field:t,value:o})]}):null]})}return t.unlimited_value!==void 0?n("div",{children:[n("div",{class:"flex flex-wrap items-center gap-x-4",children:[s,n("label",{class:"inline-flex min-h-11 cursor-pointer items-center gap-2.5 text-sm text-body",children:[n("input",{type:"checkbox",class:"size-[18px] accent-brand-solid",checked:i,onChange:a=>V(t.path,a.currentTarget.checked?t.unlimited_value:void 0)}),"Unlimited"]})]}),n(Ct,{field:t,value:o})]}):n("div",{children:[s,n(Ct,{field:t,value:o})]})}function Ho({id:e,field:t,value:o}){if(t.unset_label!==void 0){let r=[["d","Default"]];return t.allow_true!==!1&&r.push(["t","On"]),r.push(["f","Off"]),n("div",{children:[n(ne,{id:e,label:t.label,value:o===void 0?"d":o?"t":"f",options:r,onPick:i=>V(t.path,i==="d"?void 0:i==="t")}),n("p",{class:"mt-1.5 text-xs text-faint",children:["Default: ",t.unset_label]})]})}return n("label",{class:"inline-flex min-h-11 cursor-pointer items-center gap-2.5 text-sm font-semibold text-ink",children:[n("input",{id:e,type:"checkbox",class:"size-[18px] accent-brand-solid",checked:(o??t.default??!1)===!0,onChange:r=>V(t.path,r.currentTarget.checked)}),t.label]})}function zo({id:e,field:t,value:o,invalid:r}){if(t.type==="bool")return n(Ho,{id:e,field:t,value:o});if(t.type==="number")return n(Do,{id:e,field:t,value:o,invalid:r});if(t.type==="list")return n(P,{id:e,values:Array.isArray(o)?o:[],placeholder:t.placeholder,onChange:i=>V(t.path,i.length?i:void 0)});if(t.type==="select"){let i=t.options.map(s=>Array.isArray(s)?s:[s,s]);return t.unset_label!==void 0&&i.unshift(["",t.unset_label]),n(R,{id:e,options:i,invalid:r,value:o??(t.unset_label!==void 0?"":t.default??(Array.isArray(t.options[0])?t.options[0][0]:t.options[0])),onChange:s=>V(t.path,s===""?void 0:s)})}return n("input",{id:e,type:"text",value:o??"",placeholder:t.placeholder,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:`input font-mono ${r?"input-invalid":""}`,onInput:i=>V(t.path,i.currentTarget.value===""?void 0:i.currentTarget.value)})}function I({field:e}){let t=e.path.join("."),o=No(e),r=D(d.config,e.path),i=w(t),s=bt(e),a=gn(e),u=e.type==="bool"&&e.unset_label===void 0,m=e.default!==void 0&&!s,l=Array.isArray(e.default)?e.default.join(", "):String(e.default??"");return n("div",{id:`row-${o}`,class:`relative pt-5 pb-1 ${s?"before:absolute before:top-6 before:-left-2.5 before:bottom-2 before:w-[3px] before:rounded before:bg-brand before:content-[''] sm:before:-left-3.5":""}`,children:[n("div",{class:"flex min-h-6 items-center gap-2.5",children:[u?null:n("label",{for:o,class:`text-sm font-semibold text-ink ${a?"opacity-50":""}`,children:[e.label,e.required?n("span",{class:"text-danger",children:" *"}):null]}),u?null:n("code",{class:"hidden font-mono text-[11px] text-faint sm:inline",children:t}),m&&!u?n("span",{class:"rounded border border-line px-1.5 text-[11px] leading-4 font-medium text-faint",children:"default"}):null,n("span",{class:"flex-1"}),s?n("button",{type:"button",class:"focus-ring min-h-8 cursor-pointer rounded px-1 text-[13px] font-medium text-brand hover:underline",title:`Reset to default${e.default!==void 0?` (${l})`:""}`,"aria-label":`Reset ${e.label} to default`,onClick:()=>g(p=>_e(p.config,e.path)),children:"Reset"}):null]}),e.help?n("p",{class:`mt-0.5 max-w-prose text-[13px] leading-5 text-muted ${a?"opacity-50":""}`,children:e.help}):null,n("fieldset",{disabled:a,class:`m-0 mt-2.5 min-w-0 max-w-[520px] border-0 p-0 ${a?"opacity-50":""}`,children:n(zo,{id:o,field:e,value:r,invalid:i?.level==="error"})}),a?n("p",{class:"mt-1.5 text-xs text-faint",children:xn(e)}):null,n(E,{issue:i})]})}var Fo=[["","Choose reach"],["own","own"],["tenant","tenant"],["all","all"]],jn=e=>Array.isArray(e)?e.length>0:b(e)&&Object.keys(e).length>0;function Mn(){let e=d.config.auth;return e==="jwt"?{method:"jwt"}:b(e)?e:{method:"none"}}function z(e){g(t=>{let o={...Mn()};e(o),t.config.auth=o.method==="none"?null:o})}function Ke(e,t,o){o==null||o===""||typeof o!="string"&&!jn(o)?delete e[t]:e[t]=o}function Uo(e){return e.roles_claim!==void 0||jn(e.roles)?"roles":e.permissions_claim!==void 0?"permissions":"default"}var qo=e=>d.issues.filter(t=>t.path===e||t.path.startsWith(`${e}.`));function Tt({prefix:e}){return qo(e).map((t,o)=>n(E,{issue:t},o))}function Nn({id:e,label:t,name:o,onRename:r}){let[i,s]=Y(o);return U(()=>s(o),[o]),n("div",{children:[n("label",{for:e,class:"sr-only",children:t}),n("input",{id:e,type:"text",value:i,class:"input font-mono",autocomplete:"off",spellcheck:!1,onInput:a=>s(a.currentTarget.value),onChange:a=>{r(a.currentTarget.value.trim())||s(o)}})]})}var Je=({children:e})=>n("span",{class:"mb-1.5 block text-xs font-medium text-faint",children:e});function Ln(e,t,o){return!o||o!==t&&o in e?null:Object.fromEntries(Object.entries(e).map(([r,i])=>[r===t?o:r,i]))}function Dn({sec:e}){let t=Mn(),o=t.method??"none",r=Uo(t);return n("div",{class:"space-y-6",children:[n(j,{title:"Method",children:n(kt,{name:"auth-method",label:"Authentication method",value:o,options:[["none","None","Every request is accepted. For local development."],["jwt","JWT","Verify bearer tokens signed with JWT_SECRET_KEY."],["custom","Custom","Your own BaseAuth subclass decides who is calling."]],onPick:i=>z(s=>{s.method=i,i==="custom"?s.path=s.path??"":delete s.path})})}),o==="custom"?n(j,{title:"Class",children:n("div",{class:"panel",children:n(X,{id:"auth-path",label:"Auth class",path:"auth.path",value:t.path,placeholder:"auth.custom:MyAuth",help:"Import path of a BaseAuth subclass, as module:attribute.",issue:w("auth.path"),onInput:i=>z(s=>{s.path=i})})})}):null,o!=="none"?n(x,{children:[n(j,{title:"Permission source",children:n(kt,{name:"auth-source",label:"Permission source",value:r,options:[["default","Default","Every signed-in user, own data only."],["roles","Roles from a claim","Map the roles in the token to permissions."],["permissions","Permissions from a claim","The token already lists the permissions."]],onPick:i=>z(s=>{delete s.roles_claim,delete s.permissions_claim,delete s.roles,delete s.default_role,i==="roles"&&(s.roles_claim="roles",s.roles={member:{permissions:[],reach:"own"}}),i==="permissions"&&(s.permissions_claim="permissions")})})}),r==="roles"?n(Go,{auth:t,registry:e.registry}):null,r==="permissions"?n(j,{title:"Claim",children:n("div",{class:"panel",children:n(X,{id:"auth-permissions-claim",label:"Permissions claim",path:"auth.permissions_claim",value:t.permissions_claim,placeholder:"permissions",issue:w("auth.permissions_claim"),help:"Token claim holding a list of permissions, such as graph:invoke.",onInput:i=>z(s=>{s.permissions_claim=i})})})}):null,n(j,{title:"Tenants",children:n("div",{class:"panel divide-y divide-line",children:[n("div",{class:"px-5 py-4",children:[n(xe,{label:"Tenant claims",path:"auth.tenant_claims",id:"auth-tenant-claims",help:"Token claims that name the tenant, such as org_id. Rows are separated by them."}),n("div",{class:"mt-3",children:n(P,{id:"auth-tenant-claims",values:t.tenant_claims??[],placeholder:"org_id",onChange:i=>z(s=>Ke(s,"tenant_claims",i))})}),n(Tt,{prefix:"auth.tenant_claims"})]}),r==="roles"?n(Wo,{auth:t}):null]})}),n(Bo,{auth:t})]}):null]})}function Wo({auth:e}){let t=Object.keys(b(e.roles)?e.roles:{});return n(yt,{children:[n(xe,{label:"Default role",path:"auth.default_role",id:"auth-default-role",help:"Given to a signed-in user whose token carries no role."}),n("div",{class:"min-w-0 sm:pt-0.5",children:[n(R,{id:"auth-default-role",value:e.default_role??"",options:[["","None"],...t.map(o=>[o,o])],onChange:o=>z(r=>Ke(r,"default_role",o))}),n(E,{issue:w("auth.default_role")})]})]})}function Go({auth:e,registry:t}){let o=b(e.roles)?e.roles:{},r=Object.entries(o),i=Object.keys(o),s=["*",...Object.entries(t||{}).flatMap(([p,h])=>[`${p}:*`,...h.map(c=>`${p}:${c}`)])],a=p=>z(h=>{h.roles=p}),u=(p,h)=>a({...o,[p]:{...o[p],...h}}),m=()=>{let p=r.length+1;for(;`role${p}`in o;)p++;a({...o,[`role${p}`]:{permissions:[],reach:"own"}})},l=(p,h)=>{let c=Ln(o,p,h);return c?(z(f=>{f.roles=Object.fromEntries(Object.entries(c).map(([y,T])=>[y,T.inherits?{...T,inherits:T.inherits.map(C=>C===p?h:C)}:T])),f.default_role===p&&(f.default_role=h),b(f.system_identities)&&(f.system_identities=Object.fromEntries(Object.entries(f.system_identities).map(([y,T])=>[y,{...T,roles:(T.roles??[]).map(C=>C===p?h:C)}])))}),!0):!1};return n(j,{title:"Roles",children:n("div",{class:"panel",children:[n("div",{class:"border-b border-line",children:n(X,{id:"auth-roles-claim",label:"Roles claim",path:"auth.roles_claim",value:e.roles_claim,placeholder:"roles",issue:w("auth.roles_claim"),help:"Token claim holding the user's list of role names.",onInput:p=>z(h=>{h.roles_claim=p})})}),r.length?n("div",{class:"divide-y divide-line",children:r.map(([p,h],c)=>n("div",{class:"space-y-3 px-5 py-4",children:[n("div",{class:"grid grid-cols-[minmax(0,1fr)_8rem_auto] items-start gap-3",children:[n(Nn,{id:`role-name-${c}`,label:"Role name",name:p,onRename:f=>l(p,f)}),n(R,{id:`role-reach-${c}`,value:h.reach??"",options:Fo,invalid:w(`auth.roles.${p}.reach`)?.level==="error",onChange:f=>u(p,{reach:f})}),n("button",{type:"button",class:"icon-btn hover:text-danger","aria-label":`Remove role ${p}`,onClick:()=>a(Object.fromEntries(r.filter(([f])=>f!==p))),children:n(_,{name:"trash"})})]}),n("div",{children:[n(Je,{children:"Permissions"}),n(P,{id:`role-permissions-${c}`,values:h.permissions??[],placeholder:"graph:invoke, threads:*, *",suggestions:s,onChange:f=>u(p,{permissions:f})})]}),n("div",{children:[n(Je,{children:"Inherits"}),n(P,{id:`role-inherits-${c}`,values:h.inherits??[],placeholder:"another role",suggestions:i.filter(f=>f!==p),onChange:f=>{let y={...h};Ke(y,"inherits",f),a({...o,[p]:y})}})]}),n(Tt,{prefix:`auth.roles.${p}`})]},c))}):n("p",{class:"px-5 py-6 text-center text-sm text-muted",children:"No roles yet. Add one to use roles."}),n("div",{class:"flex items-center justify-between gap-4 border-t border-line px-5 py-3",children:[n("p",{class:"text-[13px] text-muted",children:["Reach: ",n("code",{class:"font-mono",children:"own"})," the user's rows, ",n("code",{class:"font-mono",children:"tenant"})," every row of their tenant, ",n("code",{class:"font-mono",children:"all"})," every row."]}),n("button",{type:"button",class:"btn btn-ghost",onClick:m,children:[n(_,{name:"plus"}),"Add role"]})]})]})})}function Bo({auth:e}){let t=b(e.system_identities)?e.system_identities:{},o=Object.entries(t),r=Object.keys(b(e.roles)?e.roles:{}),i=l=>z(p=>Ke(p,"system_identities",l)),s=(l,p)=>i({...t,[l]:{...t[l],...p}}),a=()=>{let l=o.length+1;for(;`job${l}`in t;)l++;i({...t,[`job${l}`]:{roles:[],tenant:{}}})},u=l=>Object.entries(l??{}).map(([p,h])=>`${p}=${h}`),m=l=>Object.fromEntries(l.filter(p=>p.includes("=")).map(p=>[p.slice(0,p.indexOf("=")).trim(),p.slice(p.indexOf("=")+1).trim()]));return n(j,{title:"System identities",children:n("div",{class:"panel",children:[o.length?n("div",{class:"divide-y divide-line",children:o.map(([l,p],h)=>n("div",{class:"space-y-3 px-5 py-4",children:[n("div",{class:"grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3",children:[n(Nn,{id:`system-name-${h}`,label:"Identity name",name:l,onRename:c=>{let f=Ln(t,l,c);return f&&i(f),!!f}}),n("button",{type:"button",class:"icon-btn hover:text-danger","aria-label":`Remove ${l}`,onClick:()=>i(Object.fromEntries(o.filter(([c])=>c!==l))),children:n(_,{name:"trash"})})]}),n("div",{children:[n(Je,{children:"Roles"}),n(P,{id:`system-roles-${h}`,values:p.roles??[],placeholder:"member",suggestions:r,onChange:c=>s(l,{roles:c})})]}),n("div",{children:[n(Je,{children:"Tenant, as claim=value"}),n(P,{id:`system-tenant-${h}`,values:u(p.tenant),placeholder:"org_id=acme",onChange:c=>s(l,{tenant:m(c)})})]}),n(Tt,{prefix:`auth.system_identities.${l}`})]},h))}):n("p",{class:"px-5 py-6 text-center text-sm text-muted",children:"None. Scheduled jobs and other non-user callers can run as one."}),n("div",{class:"flex justify-end border-t border-line px-5 py-3",children:n("button",{type:"button",class:"btn btn-ghost",onClick:a,children:[n(_,{name:"plus"}),"Add identity"]})})]})})}var Ve=[["memory","Memory","This process only"],["redis","Redis","Shared. Redis, Valkey, Dragonfly, KeyDB"],["custom","Custom","Your BaseCacheStore class"]],Vo={requests:100,window:60,by:"ip"},Ye=()=>b(d.config.cache_store)?d.config.cache_store:{};function $t(e){g(t=>{let o=b(t.config.cache_store)?t.config.cache_store:{};e(o),Object.keys(o).length?t.config.cache_store=o:delete t.config.cache_store})}var Jo=()=>{let e=Ye().rate_limit;return b(e)&&e.enabled!==!1},Ko=()=>{let e=Ye().token_revocation;return e===!0||b(e)&&e.enabled!==!1};function Yo(e){$t(t=>{delete t.url,delete t.path,e==="memory"?delete t.type:t.type=e})}function Xo(e){$t(t=>{e?b(t.rate_limit)?delete t.rate_limit.enabled:t.rate_limit={...Vo}:delete t.rate_limit})}function Qo(e){$t(t=>{e?b(t.token_revocation)?delete t.token_revocation.enabled:t.token_revocation=!0:delete t.token_revocation})}var In=(e,t)=>g(o=>{re(o.config,e,t)});function Zo({value:e,onPick:t}){let o=(r,i)=>{let s=r.key==="ArrowRight"||r.key==="ArrowDown"?1:r.key==="ArrowLeft"||r.key==="ArrowUp"?-1:0;if(!s)return;r.preventDefault();let a=Ve[(i+s+Ve.length)%Ve.length][0];t(a),requestAnimationFrame(()=>document.getElementById(`cache-type-${a}`)?.focus())};return n("div",{role:"radiogroup","aria-label":"Cache store type",class:"grid gap-3 sm:grid-cols-3",children:Ve.map(([r,i,s],a)=>{let u=r===e;return n("button",{type:"button",role:"radio",id:`cache-type-${r}`,"aria-checked":u?"true":"false",tabIndex:u?0:-1,onKeyDown:m=>o(m,a),class:`focus-ring flex cursor-pointer items-start gap-3 rounded-xl border p-4 text-left transition-colors duration-150 motion-reduce:transition-none ${u?"border-brand bg-brand-soft":"border-line bg-surface shadow-card hover:border-line-strong"}`,onClick:()=>{u||t(r)},children:[n("span",{class:`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border ${u?"border-brand-solid bg-brand-solid":"border-line-strong"}`,children:u?n("span",{class:"size-1.5 rounded-full bg-white"}):null}),n("span",{class:"min-w-0",children:[n("span",{class:"block text-sm font-medium text-ink",children:i}),n("span",{class:"mt-0.5 block text-[13px] leading-5 text-muted",children:s})]})]},r)})})}function ye({warn:e,children:t}){return n("p",{class:`mt-3 flex items-start gap-1.5 text-[13px] leading-5 ${e?"text-warn":"text-muted"}`,children:[e?n(_,{name:"alert",class:"mt-0.5 size-3.5"}):null,n("span",{children:t})]})}function Rn({id:e,title:t,text:o,on:r,onChange:i}){return n("div",{class:"flex items-center gap-4",children:[n("div",{class:"min-w-0 flex-1",children:[n("h3",{class:"text-base font-semibold text-ink",children:t}),n("p",{class:"mt-0.5 text-[13px] leading-5 text-muted",children:o})]}),n(B,{id:e,on:r,label:t,onChange:i})]})}var er=[["ip","IP"],["user","User"],["global","Global"]];function tr({field:e,memory:t}){let[o,r]=Y(!1),i=Ye().rate_limit,s=b(i)&&i.by||"ip",a=!(b(i)&&i.fail_open===!1),u=["trusted_proxy_headers","trusted_proxy_hops","trusted_proxies","exclude_paths"];return n(x,{children:[t?n(ye,{warn:!0,children:"Counts per worker with the Memory store."}):null,n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(I,{field:e("cache_store.rate_limit.requests")}),n(I,{field:e("cache_store.rate_limit.window")}),n("div",{class:"pt-5",children:[n("span",{id:"cache-by-label",class:"block text-sm font-semibold text-ink",children:"Limit by"}),n("div",{class:"mt-2.5",children:n(ne,{id:"f-cache_store-rate_limit-by",label:"Limit by",options:er,value:s,onPick:m=>In(["cache_store","rate_limit","by"],m)})}),n(E,{issue:w("cache_store.rate_limit.by")})]}),n("div",{class:"pt-5",children:[n("span",{id:"cache-fail-open-label",class:"block text-sm font-semibold text-ink",children:"Fail open"}),n("div",{class:"mt-2.5 flex items-center gap-3",children:[n(B,{id:"f-cache_store-rate_limit-fail_open",on:a,label:"Fail open",onChange:m=>In(["cache_store","rate_limit","fail_open"],m)}),n("p",{class:"m-0 text-[13px] leading-5 text-muted",children:a?"Allow requests if the store errors.":"Deny with 503 if the store errors."})]})]})]}),n("div",{class:"mt-5 border-t border-line pt-1",children:[n("button",{type:"button","aria-expanded":o?"true":"false","aria-controls":"cache-advanced",class:"focus-ring flex min-h-11 cursor-pointer items-center gap-2 rounded font-medium text-muted hover:text-ink",onClick:()=>r(!o),children:[n(_,{name:"chevronRight",class:`size-4 transition-transform duration-150 motion-reduce:transition-none ${o?"rotate-90":""}`}),"Advanced"]}),o?n("div",{id:"cache-advanced",children:u.map(m=>n(I,{field:e(`cache_store.rate_limit.${m}`)},m))}):null]})]})}var le=[["rate","Rate limiting"],["revoke","Token revocation"]];function Hn({sec:e}){let[t,o]=Y("rate"),r=Ye().type||"memory",i=l=>e.fields.find(p=>p.path.join(".")===l),s={rate:Jo(),revoke:Ko()},a=r==="memory",u={rate:!!w("cache_store.rate_limit"),revoke:!!w("cache_store.token_revocation")},m=(l,p)=>{let h=l.key==="ArrowRight"?1:l.key==="ArrowLeft"?-1:0,c=l.key==="Home"?0:l.key==="End"?le.length-1:h?(p+h+le.length)%le.length:-1;c<0||(l.preventDefault(),o(le[c][0]),document.getElementById(`cache-tab-${le[c][0]}`)?.focus())};return n("div",{children:[n(j,{title:"Store type",children:[n(Zo,{value:r,onPick:Yo}),r==="redis"?n(I,{field:i("cache_store.url")}):null,r==="custom"?n(I,{field:i("cache_store.path")}):null,a?n("p",{class:"mt-3 mb-0 text-[13px] leading-5 text-muted",children:"Not shared between workers: each worker keeps its own locks, limits and revocations."}):null]}),n("div",{role:"tablist","aria-label":"Cache store features",class:"mt-8 flex gap-1 border-b border-line",children:le.map(([l,p],h)=>{let c=t===l;return n("button",{type:"button",role:"tab",id:`cache-tab-${l}`,"aria-selected":c?"true":"false","aria-controls":`cache-panel-${l}`,tabIndex:c?0:-1,onKeyDown:f=>m(f,h),class:`focus-ring -mb-px flex min-h-11 cursor-pointer items-center gap-2 border-b-2 px-3 text-sm font-medium transition-colors duration-150 motion-reduce:transition-none ${c?"border-brand-solid text-ink":"border-transparent text-muted hover:text-ink"}`,onClick:()=>o(l),children:[p,n(ae,{class:s[l]?"bg-brand-soft text-brand":"bg-raised text-muted",children:s[l]?"On":"Off"})]},l)})}),n("div",{role:"tabpanel",id:`cache-panel-${t}`,"aria-labelledby":`cache-tab-${t}`,class:"pt-5",children:t==="rate"?n(x,{children:[n(Rn,{id:"cache-rate-limit",title:"Rate limiting",on:s.rate,onChange:Xo,text:"Cap how many requests one caller can make."}),s.rate?n(tr,{field:i,memory:a}):u.rate?n(ye,{warn:!0,children:"Off: one caller can exhaust the service."}):null]}):n(x,{children:[n(Rn,{id:"cache-token-revocation",title:"Token revocation",on:s.revoke,onChange:Qo,text:"Reject revoked tokens before they expire (10xgraph revoke)."}),s.revoke?n(x,{children:[a?n(ye,{warn:!0,children:"10xgraph revoke cannot reach an in-memory store; pick Redis or Custom."}):null,n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(I,{field:i("cache_store.token_revocation.token_lifetime")}),n(I,{field:i("cache_store.token_revocation.user_ttl")})]}),n(ye,{children:"Fails closed: while the cache store is unreachable, authenticated requests get 503 and the server will not start."})]}):u.revoke?n(ye,{warn:!0,children:"Off: a leaked token works until it expires."}):null]})})]})}function zn({sec:e}){let t=e.fields[0].path[0],o=e.choices.map(p=>p[0]),r=d.config[t],i=t==="thread_name_generator"?"random":"",s=r===void 0?i:typeof r=="string"&&o.includes(r)?r:"custom",a=[...t==="id_generator"?[["","Not set (the graph's own generator)"]]:[],...e.choices.map(([p,h])=>[p,t==="thread_name_generator"&&p==="random"?`${h} (default)`:h]),["custom","Custom (module:attr)"]],u=s===""?"The graph keeps the generator it was compiled with.":s==="custom"?"Your own class, as module:attribute.":e.choices.find(p=>p[0]===s)?.[2],m=p=>g(h=>{p===i?delete h.config[t]:h.config[t]=p==="custom"?"":p}),l=`f-${t}`;return n("div",{children:[n(j,{title:"Choice",children:n("div",{class:"max-w-[520px]",children:[n("label",{for:`${l}-select`,class:"sr-only",children:e.title}),n(R,{id:`${l}-select`,options:a,value:s,onChange:m}),n("p",{class:"mt-1.5 text-[13px] text-muted",children:u})]})}),s==="custom"?n(j,{title:"Class",children:n("div",{class:"panel",children:n(X,{id:l,label:e.fields[0].label,path:t,value:typeof r=="string"?r:"",placeholder:e.fields[0].placeholder,help:e.fields[0].help,issue:w(t),onInput:p=>g(h=>{h.config[t]=p})})})}):null]})}function Fn(){let e=Array.isArray(d.config.remote_tools)?d.config.remote_tools:[],t=()=>{g(o=>{e.push({node:"",name:"",description:"",parameters:{type:"object",properties:{},required:[]}}),o.config.remote_tools=e}),requestAnimationFrame(()=>document.getElementById(`tool-${e.length-1}-name`)?.focus())};return e.length?n("div",{class:"space-y-4",children:[e.map((o,r)=>n(nr,{tools:e,tool:o,index:r},r)),n("button",{type:"button",onClick:t,class:"focus-ring flex h-12 w-full cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed border-line-strong text-sm font-medium text-muted transition-colors duration-150 hover:border-brand hover:text-brand",children:[n(_,{name:"plus"}),"Add remote tool"]})]}):n("div",{class:"rounded-xl border border-dashed border-line-strong bg-surface px-6 py-10 text-center",children:[n("div",{class:"mx-auto flex size-11 items-center justify-center rounded-full bg-raised text-faint",children:n(_,{name:"wrench",class:"size-5"})}),n("p",{class:"mt-4 text-sm font-semibold text-ink",children:"No remote tools yet"}),n("p",{class:"mx-auto mt-1 max-w-sm text-sm leading-6 text-muted",children:"Declare a tool here and the client that calls the API runs it, for example to read the user's location."}),n("button",{type:"button",class:"btn btn-outline mt-5",onClick:t,children:[n(_,{name:"plus"}),"Add remote tool"]})]})}function nr({tools:e,tool:t,index:o}){let r=d.toolParams[o]??{text:JSON.stringify(t.parameters??{},null,2),error:null},i=a=>g(()=>{e[o]={...e[o],...a}}),s=a=>`remote_tools[${o}].${a}`;return n("section",{class:"panel",children:[n("div",{class:"flex items-center gap-3 border-b border-line px-5 py-3",children:[n(_,{name:"wrench",class:"size-4 text-faint"}),n("span",{class:"min-w-0 flex-1 truncate font-mono text-sm font-medium text-ink",children:t.name||`tool_${o+1}`}),t.node?n("span",{class:"rounded-md border border-line bg-raised px-1.5 py-0.5 font-mono text-xs text-muted",children:t.node}):null,n("button",{type:"button",class:"icon-btn hover:text-danger","aria-label":`Remove ${t.name||"tool"}`,onClick:()=>g(a=>{e.splice(o,1),a.toolParams={}}),children:n(_,{name:"trash"})})]}),n("div",{class:"divide-y divide-line",children:[n(X,{id:`tool-${o}-name`,label:"Tool name",path:s("name"),value:t.name,placeholder:"get_location",onInput:a=>i({name:a})}),n(X,{id:`tool-${o}-node`,label:"Graph node",path:s("node"),value:t.node,placeholder:"MAIN",help:"The node whose model may call this tool.",onInput:a=>i({node:a})}),n(X,{id:`tool-${o}-description`,label:"Description",path:s("description"),value:t.description,placeholder:"Returns the user's current city",help:"What the model reads when deciding to call it.",onInput:a=>i({description:a})}),n("div",{class:"px-5 py-4",children:[n(xe,{label:"Parameters",path:s("parameters"),help:"JSON schema of the arguments. type must be object.",id:`tool-${o}-params`}),n("textarea",{id:`tool-${o}-params`,rows:7,spellcheck:!1,value:r.text,class:`input mt-3 h-auto py-2.5 font-mono text-[13px] leading-5 ${r.error?"input-invalid":""}`,onInput:a=>{let u=a.currentTarget.value;g(m=>{try{e[o]={...e[o],parameters:JSON.parse(u)},m.toolParams[o]={text:u,error:null}}catch(l){m.toolParams[o]={text:u,error:l.message}}})}}),n(E,{issue:r.error?{level:"error",message:r.error}:null})]})]})]})}var ke="mt-2 max-w-prose text-[13px] leading-5 text-muted",we=()=>b(d.config.audit)?d.config.audit:{},qn=()=>we().enabled===!0,Ot=()=>Array.isArray(we().sinks)?we().sinks:["log"],Wn=()=>Ot().filter(e=>e!=="log"&&e!=="publisher"),Et=e=>g(t=>{let o=b(t.config.audit)?t.config.audit:{};e(o),t.config.audit=o});function or(e){if(!e){g(t=>{delete t.config.audit});return}Et(t=>{t.enabled=!0,t.sinks=["log"],t.include_reads=!0})}function Un({publisher:e=Ot().includes("publisher"),extra:t=Wn()}){Et(o=>{o.sinks=["log",...e?["publisher"]:[],...t]})}function Gn(){let e=qn(),t=we().include_reads!==!1,o=Ot().includes("publisher");return n("div",{children:[n("div",{class:"flex items-center gap-3",children:[n(B,{id:"f-audit-enabled",on:e,label:"Record an audit log",onChange:or}),n("label",{for:"f-audit-enabled",class:"cursor-pointer text-sm font-semibold text-ink",children:"Record an audit log"})]}),n("p",{class:ke,children:e?"Events are written as JSON lines on stdout.":"No audit events are recorded."}),n(E,{issue:w("audit.enabled")}),e?n("div",{class:"mt-5 rounded-xl border border-line bg-surface px-5 pt-1 pb-5",children:[n("div",{class:"pt-4",children:[n("h3",{class:"text-sm font-semibold text-ink",children:"Sinks"}),n("p",{class:ke,children:[n("code",{class:"font-mono text-xs text-body",children:"log"})," is always on: one JSON line per event on stdout, for your log pipeline to collect."]}),n("div",{class:"mt-3 flex items-center gap-3",children:[n(B,{id:"f-audit-publisher",on:o,label:"Publisher",onChange:r=>Un({publisher:r})}),n("label",{for:"f-audit-publisher",class:"cursor-pointer text-sm font-semibold text-ink",children:"Publisher"})]}),n("p",{class:ke,children:"Also send events through the configured publisher. Best effort."}),n("label",{for:"f-audit-sinks",class:"mt-4 block text-sm font-semibold text-ink",children:"Custom sinks"}),n("div",{class:"mt-2 max-w-[520px]",children:n(P,{id:"f-audit-sinks",values:Wn(),placeholder:"my_pkg.audit:MySink",onChange:r=>Un({extra:r})})}),n("p",{class:ke,children:["A ",n("code",{class:"font-mono text-xs text-body",children:"module:Class"})," that extends BaseAuditSink."]}),n(E,{issue:w("audit.sinks")})]}),n("div",{class:"mt-5 border-t border-line pt-4",children:[n("div",{class:"flex items-center gap-3",children:[n(B,{id:"f-audit-include_reads",on:t,label:"Include allowed reads",onChange:r=>Et(i=>{i.include_reads=r})}),n("label",{for:"f-audit-include_reads",class:"cursor-pointer text-sm font-semibold text-ink",children:"Include allowed reads"})]}),n("p",{class:ke,children:"Off keeps denials, auth failures and changes, and drops allowed reads."}),n(E,{issue:w("audit.include_reads")})]})]}):null]})}function Bn(e){return qn()?{text:we().include_reads===!1?"On, denials and changes only.":"On, all access and changes."}:{text:e("audit")}}var It="focus-ring min-h-8 cursor-pointer rounded px-1.5 text-[13px] font-medium text-brand transition-colors duration-150 hover:underline",Q="mt-2 max-w-prose text-[13px] leading-5 text-muted",J=(e,t)=>g(o=>{t===void 0||t===""?_e(o.config,e):re(o.config,e,t)});function Rt({id:e,label:t,path:o,help:r,children:i}){return n("div",{class:"pt-4",children:[n("div",{class:"flex items-baseline gap-2.5",children:[n("label",{for:e,class:"text-sm font-semibold text-ink",children:t}),o?n("code",{class:"hidden font-mono text-[11px] text-faint sm:inline",children:o}):null]}),n("div",{class:"mt-2 max-w-[520px]",children:i}),r?n("p",{class:Q,children:r}):null,n(E,{issue:w(o)})]})}function ce({path:e,label:t,help:o,placeholder:r}){let i=e.join("."),s=`f-${e.join("-")}`,a=D(d.config,e);return n(Rt,{id:s,label:t,path:i,help:o,children:n("input",{id:s,type:"text",value:a??"",placeholder:r,autocomplete:"off",spellcheck:!1,class:`input font-mono ${w(i)?.level==="error"?"input-invalid":""}`,onInput:u=>J(e,u.currentTarget.value)})})}function rr({path:e,label:t,help:o}){let r=e.join("."),i=`f-${e.join("-")}`,s=D(d.config,e);return n(Rt,{id:i,label:t,path:r,help:o,children:n("input",{id:i,type:"number",inputmode:"numeric",min:"1",step:"1",value:s??"",class:`input max-w-40 font-mono tabular-nums ${w(r)?.level==="error"?"input-invalid":""}`,onInput:a=>{let u=a.currentTarget.value;J(e,u===""?void 0:Number(u))}})})}function At({path:e,label:t,help:o,options:r,value:i,onChange:s}){let a=`f-${e.join("-")}`;return n(Rt,{id:a,label:t,path:e.join("."),help:o,children:n(R,{id:a,plain:!0,options:r,value:i,onChange:s})})}function sr({path:e,label:t,checked:o}){let r=`f-${e.join("-")}`;return n("label",{class:"mt-3 inline-flex min-h-11 cursor-pointer items-center gap-2.5 text-sm font-semibold text-ink",children:[n("input",{id:r,type:"checkbox",class:"size-[18px] accent-brand-solid",checked:o,onChange:i=>J(e,i.currentTarget.checked)}),t]})}var Qe=[{key:"logfire",name:"Logfire",label:"Logfire",text:"Pydantic Logfire.",secret:n(x,{children:["Token: set ",n("code",{class:"font-mono text-xs",children:"LOGFIRE_TOKEN"})," in .env."]})},{key:"langsmith",name:"LangSmith",label:"LangSmith",text:"LangChain's tracing service.",secret:n(x,{children:["API key: set ",n("code",{class:"font-mono text-xs",children:"LANGSMITH_API_KEY"})," in .env."]})},{key:"phoenix",name:"Phoenix",label:"Phoenix",text:"Arize Phoenix, local or hosted.",secret:n(x,{children:["API key: set ",n("code",{class:"font-mono text-xs",children:"PHOENIX_API_KEY"})," in .env. A local Phoenix does not need one."]})},{key:"otlp",name:"OTLP endpoint",label:"OTLP endpoint",text:"Any OpenTelemetry endpoint: a collector, Tempo, Jaeger.",secret:n(x,{children:["Auth headers: set ",n("code",{class:"font-mono text-xs",children:"OTEL_EXPORTER_OTLP_HEADERS"})," in .env."]})}],ir=[["standard","Standard"],["spans","Spans only"],["full","Full"]],ar={standard:"Adds model name, token counts and request parameters.",spans:"Timing and structure only. No inputs or outputs.",full:"Records prompts and tool I/O. May contain PII."},lr=[["raw","Raw"],["hash","Hashed"],["mask","Masked"]],cr={raw:"The user id is sent as is.",hash:"A SHA-256 of the user id, keyed when TENXGRAPH_TRACE_USER_ID_KEY is set.",mask:"The middle of the user id is hidden."},Pt=()=>b(d.config.observability)?d.config.observability:{},Ze=e=>b(Pt()[e])&&Pt()[e].enabled===!0;function Vn(e){g(t=>{b(t.config.observability)||(t.config.observability={level:"standard",user_id_mode:"raw"}),t.config.observability[e]={enabled:!0}})}function dr(e){g(t=>{delete t.config.observability[e],Qe.some(o=>Ze(o.key))||delete t.config.observability})}function ur(){let e=d.config.metrics,t=b(e)&&b(e.otlp)?e.otlp.endpoint:null;return!b(e)||e.enabled!==!0||e.export!=="otlp"||!t?null:t.replace(/\/+$/,"").replace(/\/v1\/metrics$/,"")}function pr(){let e=["observability","otlp","use_metrics_endpoint"],t=D(d.config,e)===!0,o=ur(),r="f-observability-otlp-use_metrics_endpoint";return n(x,{children:[o?n("div",{class:"pt-4",children:[n("div",{class:"flex items-center gap-3",children:[n(B,{id:r,on:t,label:"Use the metrics endpoint",onChange:i=>J(e,i?!0:void 0)}),n("label",{for:r,class:"cursor-pointer text-sm font-semibold text-ink",children:"Use the metrics endpoint"})]}),n("p",{class:Q,children:t?n(x,{children:["Traces go to ",n("code",{class:"font-mono text-xs text-body",children:[o,"/v1/traces"]})]}):"Send traces to the same endpoint as the metrics."}),n(E,{issue:w(e.join("."))})]}):null,t&&o?null:n(ce,{path:["observability","otlp","endpoint"],label:"Endpoint",placeholder:"http://collector:4318",help:"Base URL of an OTLP HTTP receiver. /v1/traces is appended."}),o?null:n("p",{class:Q,children:["Sharing the metrics endpoint needs Metrics set to OTLP push with an endpoint.",t?n(x,{children:[" It is on in the file. ",n("button",{type:"button",class:It,onClick:()=>J(e,void 0),children:"Stop sharing"})]}):null]}),n(E,{issue:t&&!o?w(e.join(".")):null}),n("p",{class:Q,children:"Prometheus stores metrics only. To send traces and metrics to one place, use a collector (OTel Collector, Grafana Alloy, Grafana Cloud)."})]})}function mr({dest:e}){let t=r=>["observability",e.key,r];if(e.key==="logfire"){let r=t("send_to_logfire"),i=t("console");return n(x,{children:[n(ce,{path:t("service_name"),label:"Service name",placeholder:"my-agent"}),n(sr,{path:r,label:"Send to Logfire",checked:D(d.config,r)!==!1}),n(At,{path:i,label:"Print spans to console",help:"Logfire also takes console options that JSON cannot express, so only its own behavior or Off is offered.",options:[["","Logfire's own behavior"],["off","Off"]],value:D(d.config,i)===!1?"off":"",onChange:s=>J(i,s==="off"?!1:void 0)})]})}if(e.key==="otlp")return n(pr,{});let o=e.key==="phoenix";return n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(ce,{path:t("project"),label:"Project",placeholder:"my-agent"}),n(ce,{path:t("endpoint"),label:"Endpoint",placeholder:o?"http://localhost:6006":"https://api.smith.langchain.com/otel",help:o?"Base collector URL. /v1/traces is appended.":void 0})]})}function fr({dest:e}){return n("div",{id:`dest-${e.key}`,class:"mt-4 rounded-xl border border-line bg-surface p-5",children:[n("div",{class:"flex items-start gap-3",children:[n("div",{class:"min-w-0 flex-1",children:[n("h3",{class:"text-sm font-semibold text-ink",children:e.name}),n("p",{class:"mt-0.5 text-[13px] text-muted",children:e.text})]}),n("button",{type:"button",class:It,"aria-label":`Remove ${e.name}`,onClick:()=>dr(e.key),children:"Remove"})]}),n(mr,{dest:e}),n("p",{class:"mt-5 border-t border-line pt-3 text-[13px] text-muted",children:e.secret})]})}function Jn(){return n("p",{class:Q,children:"These are observability publishers. They get graph, node, LLM and tool spans, AI errors included. Event publishers (Kafka, Redis, RabbitMQ) are set in code and also get task events. API and worker errors go to Metrics and logs."})}function Kn(){let[e,t]=Y(!1),o=Qe.filter(s=>Ze(s.key)),r=Qe.filter(s=>!Ze(s.key)),i=Pt();return n("div",{children:o.length===0?n("div",{class:"max-w-[520px]",children:[n("label",{for:"trace-dest",class:"mb-2 block text-sm font-semibold text-ink",children:"Send AI traces to"}),n(R,{id:"trace-dest",value:"",plain:!0,options:[["","None"],...r.map(s=>[s.key,s.label])],onChange:s=>{s&&Vn(s)}}),n("p",{class:Q,children:"Graph runs are not traced."}),n(Jn,{})]}):n(x,{children:[n("h2",{class:"eyebrow",children:"Send AI traces to"}),o.map(s=>n(fr,{dest:s},s.key)),r.length?e?n("div",{class:"mt-4 max-w-[320px]",children:[n("label",{for:"trace-add",class:"sr-only",children:"Add another destination"}),n(R,{id:"trace-add",value:"",plain:!0,options:[["","Choose a destination"],...r.map(s=>[s.key,s.label])],onChange:s=>{t(!1),s&&Vn(s)}})]}):n("button",{type:"button",class:`${It} mt-3 inline-flex min-h-10 items-center gap-1.5`,onClick:()=>t(!0),children:[n(_,{name:"plus",class:"size-4"}),"Add another destination"]}):null,n("div",{class:"max-w-[520px]",children:n(Jn,{})}),n("h2",{class:"eyebrow mt-8",children:"Trace settings"}),n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(At,{path:["observability","level"],label:"Level",options:ir,value:i.level??"standard",onChange:s=>J(["observability","level"],s),help:ar[i.level??"standard"]}),n(At,{path:["observability","user_id_mode"],label:"User id in traces",options:lr,value:i.user_id_mode??"raw",onChange:s=>J(["observability","user_id_mode"],s),help:cr[i.user_id_mode??"raw"]})]})]})})}var hr=[["","None"],["otlp","OTLP push"],["prometheus","Prometheus scrape"]],_r={"":"Metrics are not exported.",otlp:"Recommended for production. Works with several worker processes.",prometheus:"One process only. The API serves the scrape path, the worker its own port."},br=[50,100,250,500,1e3,2500,5e3,1e4,3e4,6e4,12e4],Xe=()=>b(d.config.metrics)?d.config.metrics:{},Yn=()=>Xe().enabled===!0&&["otlp","prometheus"].includes(Xe().export)?Xe().export:"";function gr(e){g(t=>{if(!e){delete t.config.metrics;return}let o=b(t.config.metrics)?t.config.metrics:{};o.enabled=!0,o.export=e,e==="otlp"&&!b(o.otlp)&&(o.otlp={endpoint:null}),e==="prometheus"&&!b(o.prometheus)&&(o.prometheus={path:"/metrics",worker_port:9464}),t.config.metrics=o})}function Xn(){let e=Yn(),t=Xe().latency_buckets_ms,o=w("metrics.latency_buckets_ms");return n("div",{children:[n("div",{class:"max-w-[520px]",children:[n("label",{for:"f-metrics-export",class:"mb-2 block text-sm font-semibold text-ink",children:"Export metrics"}),n(R,{id:"f-metrics-export",plain:!0,options:hr,value:e,invalid:w("metrics.export")?.level==="error",onChange:gr}),n("p",{class:Q,children:_r[e]}),n(E,{issue:w("metrics.export")})]}),e===""?null:n("div",{class:"mt-5 rounded-xl border border-line bg-surface px-5 pt-1 pb-5",children:[e==="otlp"?n(ce,{path:["metrics","otlp","endpoint"],label:"Endpoint",placeholder:"http://collector:4318",help:n(x,{children:["/v1/metrics is appended.",n("br",{}),"Prometheus: ",n("code",{class:"font-mono text-xs",children:"http://prometheus:9090/api/v1/otlp"})," (start it with ",n("code",{class:"font-mono text-xs",children:"--web.enable-otlp-receiver"}),")."]})}):n(x,{children:n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(ce,{path:["metrics","prometheus","path"],label:"Scrape path",placeholder:"/metrics",help:"Route on the API server that Prometheus scrapes."}),n(rr,{path:["metrics","prometheus","worker_port"],label:"Worker port",help:"Each worker serves its own metrics on this port."})]})}),e==="otlp"?n("p",{class:"mt-5 border-t border-line pt-3 text-[13px] text-muted",children:["Auth headers: set ",n("code",{class:"font-mono text-xs",children:"OTEL_EXPORTER_OTLP_HEADERS"})," in .env."]}):null]}),e!==""?n(Be,{title:"Advanced",class:"mt-4",open:!!o||t!==void 0,children:n("div",{class:"pt-4",children:[n("label",{for:"f-metrics-latency_buckets_ms",class:"text-sm font-semibold text-ink",children:"Latency buckets (ms)"}),n("div",{class:"mt-2 max-w-[520px]",children:n(P,{id:"f-metrics-latency_buckets_ms",values:(t??br).map(String),placeholder:"250",onChange:r=>J(["metrics","latency_buckets_ms"],r.length?r.map(Number).filter(Number.isFinite):void 0)})}),n("p",{class:Q,children:"Upper bounds of the latency histogram in milliseconds, ascending."}),n(E,{issue:o})]})}):null]})}var xr=e=>e.length>1?`${e.slice(0,-1).join(", ")} and ${e[e.length-1]}`:e[0];function Qn(e){let t=Qe.filter(o=>Ze(o.key)).map(o=>o.name);return t.length?{text:`Level ${_t(["observability","level"])}, sending to ${xr(t)}.`}:{text:e("observability")}}function Zn(e){let t={otlp:"OTLP push",prometheus:"Prometheus scrape"}[Yn()];return t?{text:`${t}.`}:{text:e("metrics")}}var Ce=({children:e,code:t})=>n("h1",{id:"page-title",tabindex:-1,class:"flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-semibold tracking-tight text-ink focus:outline-none",children:[e,t?n("code",{class:"rounded border border-line bg-raised px-1.5 font-mono text-xs font-normal text-faint",children:t}):null]}),jt=({children:e})=>n("p",{class:"mt-1.5 max-w-[62ch] text-[15px] leading-6 text-muted",children:e}),eo=({children:e})=>n("h2",{class:"mt-10 mb-2 eyebrow",children:e});function to({id:e,text:t,warn:o,compact:r}){let i=ge(e);return i?n("a",{href:`#${e}`,onClick:s=>{s.preventDefault(),H(e)},class:`focus-ring group flex items-center gap-3.5 border-t border-line py-2 last:border-b ${r?"min-h-12":"min-h-14"}`,children:[n(_,{name:te[e]||"box",class:"size-[18px] text-faint"}),n("span",{class:`min-w-0 flex-1 ${r?"flex flex-wrap items-baseline gap-x-4":""}`,children:[n("span",{class:`block text-sm text-ink group-hover:text-brand ${r?"min-w-40 font-medium":"font-semibold"}`,children:i.title}),n("span",{class:`block text-[13px] leading-5 ${o?"text-warn":"text-muted"}`,children:t??i.description})]}),n(_,{name:"chevronRight",class:"size-4 text-faint"})]}):null}function et(e){return`Not set. ${A(e)?.off_summary||""}`.trim()}function vr(){let e=d.config.auth,t=e==="jwt"?"jwt":b(e)?e.method??"none":"none";if(t==="none")return{text:et("auth")};let o=b(e)&&b(e.roles)?Object.keys(e.roles).length:0;return{text:`${t==="jwt"?"JWT bearer tokens":`Custom backend${e.path?`: ${e.path}`:""}`}${o?`, ${o} ${o===1?"role":"roles"}`:""}.`}}function yr(){let e=b(d.config.cache_store)?d.config.cache_store:{},t=[`${e.type||"memory"} store`],o=e.rate_limit;if(b(o)&&o.enabled!==!1){let i={ip:"per IP",user:"per user",global:"across all clients"}[o.by??"ip"]||o.by;t.push(`${o.requests??100} requests per ${o.window??60} s, ${i}`)}let r=e.token_revocation;return(r===!0||b(r)&&r.enabled!==!1)&&t.push("token revocation on"),{text:`${t.join("; ")}.`}}function no(){let e=A("core"),t=vn(),o=e?se("core").filter(s=>!e.fields.some(a=>a.path.join(".")===s.path)):[],r=[["auth",vr()],["cache_store",yr()],["observability",Qn(et)],["metrics",Zn(et)],["audit",Bn(et)]],i=d.nav.flatMap(s=>s.items).filter(s=>s!=="get-running");return n(x,{children:[n(Ce,{children:t?"You're ready to run":"One step to run your agent"}),n(jt,{children:q["get-running"].description}),n("div",{class:`mt-4 flex items-center gap-2 font-medium ${t?"text-ok":"text-muted"}`,children:[n(_,{name:t?"checkCircle":"circle",class:"size-[18px]"}),"Required: ",t?"1":"0"," of 1 done"]}),o.length?n("div",{class:"mt-4 space-y-2",children:o.map((s,a)=>n(wt,{issue:s},a))}):null,n("div",{class:"mt-2",children:e?St(e.fields).flatMap(s=>s.fields).map(s=>n(I,{field:s},s.path.join("."))):null}),t?n("div",{class:"mt-7 border-t border-line pt-5",children:[n("div",{class:"flex items-center gap-2.5 font-medium text-ok",children:[n(_,{name:"checkCircle",class:"size-[18px]"}),"Ready for 10xgraph dev"]}),n("div",{class:"mt-3 flex max-w-[420px] items-center gap-2 rounded-lg border border-line-strong bg-sunken py-0 pr-1 pl-3.5",children:[n("code",{class:"flex-1 font-mono text-[13px] font-medium text-ink",children:"10xgraph dev"}),d.copied?n("span",{class:"text-xs text-ok",children:"Copied"}):null,n("button",{type:"button",class:"icon-btn",onClick:$n,"aria-label":d.copied?"Copied 10xgraph dev":"Copy command 10xgraph dev",children:n(_,{name:"copy",class:"size-[18px]"})})]}),n("p",{class:"mt-3 text-muted",children:"Everything else already has working defaults."})]}):n("p",{class:"mt-4 text-xs text-faint",children:"Add the agent graph above, then run 10xgraph dev."}),n(eo,{children:"Before production (optional)"}),n("div",{children:r.map(([s,a])=>n(to,{id:s,text:a.text,warn:a.warn},s))}),n(eo,{children:"What you can configure"}),n("div",{children:i.map(s=>n(to,{id:s,compact:!0},s))})]})}function tt({vars:e}){return n("dl",{class:"mb-3 grid gap-x-4 gap-y-1 sm:grid-cols-[minmax(0,260px)_minmax(0,1fr)]",children:e.map(t=>[n("dt",{class:"py-0.5 font-mono text-xs font-medium break-all text-ink",children:t.name},`${t.name}-n`),n("dd",{class:"m-0 pb-2 text-[13px] text-muted sm:py-0.5",children:t.purpose},`${t.name}-p`)])})}function kr({vars:e,open:t}){return e?.length?n(Be,{title:"Set in .env",open:t,class:"mt-9 border-t border-line pt-1",children:[n("p",{class:"mb-2 text-xs text-faint",children:"Read only. The editor never reads .env, so only names are shown."}),n(tt,{vars:e})]}):null}function oo({sec:e}){return e.widget==="auth"?n(Dn,{sec:e}):e.widget==="remote_tools"?n(Fn,{}):e.widget==="observability"?n(Kn,{}):e.widget==="metrics"?n(Xn,{}):e.widget==="audit"?n(Gn,{}):e.widget==="cache_store"?n(Hn,{sec:e}):e.widget==="id_generator"||e.widget==="thread_name_generator"?n(zn,{sec:e}):null}function wr(e){let t=new Set([...e.fields.map(i=>i.path.join(".")),...Object.keys(ft)]),o=new Set(["cache_store.rate_limit","cache_store.token_revocation"]),r=i=>i.startsWith("auth.roles.")||i.startsWith("auth.system_identities.");return se(e.id).filter(i=>!t.has(i.path)&&!(e.id==="cache_store"&&o.has(i.path))&&!(e.id==="auth"&&r(i.path)))}function Cr({sec:e}){let t=He(e),o=e.widget&&(t||!e.optional||e.self_managed);return n(x,{children:[o?n("div",{class:"mt-6",children:n(oo,{sec:e})}):null,n("div",{class:e.optional&&!t?"opacity-80":"",children:St(e.fields.filter(r=>!r.managed)).map((r,i)=>n("div",{children:[r.name?n("h3",{class:`eyebrow ${i===0?"mt-6":"mt-8 border-t border-line pt-4"}`,children:r.name}):null,r.fields.map(s=>n(I,{field:s},s.path.join(".")))]},r.name))})]})}function Sr({sec:e}){let t=He(e),o=wr(e);return n(x,{children:[n(Ce,{code:e.key,children:e.title}),n(jt,{children:e.description}),o.length?n("div",{class:"mt-4 space-y-2",children:o.map((r,i)=>n(wt,{issue:r},i))}):null,e.optional&&!e.self_managed?n("div",{class:"mt-5 flex flex-wrap items-center gap-4",children:[n(ne,{label:`Settings for ${e.title}`,value:t?"c":"d",options:[["d","Use defaults"],["c","Customize"]],onPick:r=>Sn(e,r==="c")}),n("p",{class:"m-0 min-w-56 flex-1 text-[13px] text-muted",children:t?`Saved in 10xgraph.json under ${e.key}.`:e.off_summary})]}):null,e.notes?.length?n("div",{class:"mt-5",children:n(ve,{kind:"info",lines:e.notes})}):null,n(Cr,{sec:e}),n(kr,{vars:e.env})]})}function Tr(){let e=d.schema.environment||[];return n(x,{children:[n(Ce,{children:q.environment.title}),n(jt,{children:"These variables are set in .env or your deployment environment. The editor never reads that file, so only names and purposes are shown."}),e.map(t=>n("section",{children:[n("h2",{class:"mt-8 mb-2 text-sm font-semibold text-ink",children:t.title}),t.note?n("p",{class:"mb-2 text-xs text-faint",children:t.note}):null,n(tt,{vars:t.vars})]},t.title))]})}var ro=e=>String(e??"").toLowerCase();function $r(e){let t=i=>e.every(s=>ro(i).includes(s)),o=[];for(let i of d.sections){let s=t(`${i.title} ${i.description} ${i.key||""}`),a=p=>s||t(`${p.label} ${p.help||""} ${p.path.join(".")}`),u=i.fields.filter(p=>!p.managed&&a(p)),m=(i.env||[]).filter(p=>t(`${p.name} ${p.purpose}`)),l=!!i.widget&&(s||i.fields.some(p=>p.managed&&a(p)));(u.length||m.length||l)&&o.push({id:i.id,sec:i,fields:u,env:m,widget:l})}let r=(d.schema.environment||[]).map(i=>({title:i.title,vars:i.vars.filter(s=>t(`${s.name} ${s.purpose} ${i.title}`))})).filter(i=>i.vars.length);return{results:o,envGroups:r}}function Or(){let e=ro(d.query).split(/\s+/).filter(Boolean),{results:t,envGroups:o}=$r(e),r=t.reduce((i,s)=>i+s.fields.length+s.env.length+(s.widget?1:0),0)+o.reduce((i,s)=>i+s.vars.length,0);return r?n(x,{children:[n(Ce,{code:d.query,children:[r," ",r===1?"setting matches":"settings match"]}),t.map(({id:i,sec:s,fields:a,env:u,widget:m})=>n("section",{class:"mt-8",children:[n("h2",{class:"eyebrow m-0 flex items-center gap-2",children:[n(_,{name:te[i]||"box",class:"size-4"}),s.title,n("a",{href:`#${i}`,onClick:l=>{l.preventDefault(),H(i)},class:"focus-ring ml-auto text-xs font-medium tracking-normal text-brand normal-case hover:underline",children:"Open page"})]}),m?n("div",{class:"mt-4",children:n(oo,{sec:s})}):null,a.map(l=>n(I,{field:l},l.path.join("."))),u.length?n("details",{class:"group mt-5",open:!0,children:[n("summary",{class:"focus-ring flex min-h-11 cursor-pointer list-none items-center gap-2 font-medium text-muted [&::-webkit-details-marker]:hidden",children:[n(_,{name:"chevronRight",class:"size-4 transition-transform group-open:rotate-90"}),"Set in .env"]}),n(tt,{vars:u})]}):null]},i)),o.length?n("section",{class:"mt-8",children:[n("h2",{class:"eyebrow m-0 flex items-center gap-2",children:[n(_,{name:"terminal",class:"size-4"}),q.environment.title,n("a",{href:"#environment",onClick:i=>{i.preventDefault(),H("environment")},class:"focus-ring ml-auto text-xs font-medium tracking-normal text-brand normal-case hover:underline",children:"Open page"})]}),o.map(i=>n("div",{children:[n("h3",{class:"mt-4 mb-1.5 text-sm font-semibold text-ink",children:i.title}),i.note?n("p",{class:"mb-1.5 text-xs text-faint",children:i.note}):null,n(tt,{vars:i.vars})]},i.title))]}):null]}):n(x,{children:[n(Ce,{children:"No matches"}),n("p",{class:"mt-8 text-muted",children:['Nothing matches "',d.query,'". Try a shorter word, or press Esc to go back.']})]})}function so(){if(d.query.trim())return n(Or,{});if(d.page==="get-running")return n(no,{});if(d.page==="environment")return n(Tr,{});let e=A(d.page);return e?n(Sr,{sec:e}):n(no,{})}function Er(){let e=d.path.split(/[\\/]/).pop()||"10xgraph.json",t=d.path.slice(0,d.path.length-e.length),o=null;return d.schema?d.exists?W()?o=n(ae,{class:"bg-warn-soft text-warn",children:"Unsaved"}):o=n(ae,{class:"bg-ok-soft text-ok",children:"Saved"}):o=n(ae,{class:"bg-brand-soft text-brand",children:"New"}):o=null,n("div",{class:"flex min-w-0 flex-1 items-center gap-2",title:d.path||void 0,children:[n(_,{name:"file",class:"size-4 text-faint"}),n("span",{class:"truncate font-mono text-[13px] font-medium text-ink",children:e}),o,t?n("span",{class:"hidden min-w-0 truncate font-mono text-xs text-faint lg:block",dir:"rtl",children:n("bdi",{children:t})}):null]})}function io(){let e=!!d.schema&&!d.fatal,t=d.sideOpen?"Hide sidebar":"Show sidebar";return n("header",{class:"sticky top-0 z-40 flex h-14 items-center gap-2 border-b border-line bg-surface px-3 sm:gap-3 sm:px-4",children:[n("button",{type:"button",class:"icon-btn","aria-controls":"side","aria-expanded":d.sideOpen?"true":"false","aria-label":t,title:`${t} (Ctrl/Cmd+B)`,onClick:()=>ie(!d.sideOpen),children:n(_,{name:"panel",class:"size-[18px]"})}),n("div",{class:"flex shrink-0 items-center gap-2.5",children:[n("svg",{viewBox:"0 0 48 48",class:"size-7 shrink-0 text-ink","aria-hidden":"true",children:[n("rect",{x:"4",y:"4",width:"40",height:"40",rx:"11",fill:"none",stroke:"currentColor","stroke-width":"4.4"}),n("path",{d:"M13 33L21 23L34 15L34 31L21 23",fill:"none",stroke:"currentColor","stroke-width":"3","stroke-linecap":"round","stroke-linejoin":"round"}),n("g",{fill:"currentColor",children:[n("circle",{cx:"21",cy:"23",r:"4"}),n("circle",{cx:"34",cy:"15",r:"4"}),n("circle",{cx:"34",cy:"31",r:"4"})]}),n("circle",{cx:"13",cy:"33",r:"4.6",class:"fill-brand-amber"})]}),n("div",{class:"hidden items-baseline gap-1.5 sm:flex",children:[n("span",{class:"text-[15px] font-semibold tracking-tight text-ink",children:"10xGraph"}),n("span",{class:"text-[15px] text-faint",children:"Config"})]})]}),n("span",{class:"hidden h-6 w-px shrink-0 bg-line md:block","aria-hidden":"true"}),n(Er,{}),n("button",{type:"button",class:"btn btn-ghost","aria-label":"View 10xgraph.json","aria-haspopup":"dialog",disabled:!e,onClick:()=>Tn(),children:[n(_,{name:"code",class:"size-[18px]"}),n("span",{class:"hidden md:inline",children:"View 10xgraph.json"})]}),n("button",{type:"button",class:"btn btn-primary",onClick:Fe,disabled:!e||!!d.busy||d.exists&&!W()&&!d.parseError,children:[d.busy==="save"?n(_,{name:"spinner",class:"size-4 animate-spin"}):null,d.exists?"Save":"Create file",n("span",{class:"kbd hidden lg:inline-flex",children:"Ctrl S"})]})]})}function ao(){return d.fatal?n("div",{class:"panel mt-10",children:n(An,{icon:"xCircle",tint:"text-danger",title:d.fatal.title,text:d.fatal.text})}):d.schema?n(x,{children:[d.parseError?n("div",{class:"mb-6",children:n(ve,{kind:"warn",title:"The existing file is not valid JSON",lines:[`${d.parseError}. The form starts from defaults; saving replaces the file and keeps the old one as 10xgraph.json.bak.`]})}):null,n(so,{})]}):n("div",{class:"animate-pulse space-y-4 pt-2","aria-label":"Loading",children:[n("div",{class:"h-7 w-48 rounded-md bg-raised"}),n("div",{class:"h-4 w-80 max-w-full rounded-md bg-raised"}),[0,1,2].map(e=>n("div",{class:"h-16 rounded-xl bg-raised"},e))]})}function Ar({id:e}){let t=!!A(e)&&_n(A(e)),o=bn(e);return!t&&!o?null:n("span",{class:"flex shrink-0 gap-1",children:[t?n("span",{class:"size-2 rounded-full bg-brand",title:A(e).self_managed?"Configured":"Changed from default"}):null,o?n("span",{class:`size-2 rounded-full ${o.level==="error"?"bg-danger":"bg-warn"}`,title:o.level==="error"?"Needs attention":"Has a warning"}):null]})}function Pr({id:e}){let t=ge(e);if(!t)return null;let o=!d.query&&d.page===e;return n("a",{href:`#${e}`,"aria-current":o?"page":void 0,onClick:r=>{r.preventDefault(),H(e)},ref:r=>{r&&o&&window.innerWidth<1024&&r.scrollIntoView({block:"nearest",inline:"nearest"})},class:`focus-ring flex min-h-10 items-center gap-2.5 rounded-lg px-3 py-1.5 font-medium transition-colors duration-150 max-lg:min-h-11 max-lg:shrink-0 max-lg:whitespace-nowrap ${o?"bg-brand-soft text-ink":"text-muted hover:bg-raised hover:text-ink"}`,children:[n(_,{name:te[e]||"box",class:`size-[18px] ${o?"text-brand":""}`}),n("span",{class:"min-w-0 flex-1",children:[n("span",{class:"block truncate",children:t.title}),t.subtitle?n("span",{class:"block truncate text-xs font-normal text-faint max-lg:hidden",children:t.subtitle}):null]}),n(Ar,{id:e})]})}function lo(){return d.sideRender?n("aside",{id:"side","aria-label":"Settings navigation",class:`contents lg:sticky lg:top-14 lg:block lg:h-[calc(100dvh-3.5rem)] lg:overflow-y-auto lg:border-r lg:border-line lg:bg-surface lg:px-3 lg:pt-4 lg:pb-16 lg:transition-[transform,opacity] lg:duration-200 lg:ease-out ${d.sideShown?"lg:translate-x-0 lg:opacity-100":"lg:-translate-x-6 lg:opacity-0"}`,inert:!d.sideOpen,children:[n("div",{role:"search",class:"relative mx-1 mb-3 max-lg:mx-4 max-lg:mt-3",children:[n(_,{name:"search",class:"pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-faint"}),n("label",{for:"q",class:"sr-only",children:"Search settings"}),n("input",{id:"q",type:"search",value:d.query,autocomplete:"off",spellcheck:!1,placeholder:"Search settings ( / )",class:"input pl-10",onInput:e=>qe(e.currentTarget.value)})]}),n("nav",{"aria-label":"Pages",class:"max-lg:sticky max-lg:top-14 max-lg:z-20 max-lg:mt-3 max-lg:flex max-lg:gap-0.5 max-lg:overflow-x-auto max-lg:border-b max-lg:border-line max-lg:bg-surface max-lg:px-3 max-lg:py-1.5",children:d.nav.map(e=>n("div",{class:"max-lg:contents",children:[e.label?n("div",{class:"mt-4 mb-1 px-3 text-[11px] font-semibold tracking-wider text-faint uppercase max-lg:hidden",children:e.label}):null,e.items.map(t=>n(Pr,{id:t},t))]},e.label||"top"))})]}):null}var Ir={success:["checkCircle","text-ok"],warning:["alert","text-warn"],error:["xCircle","text-danger"]};function Rr(){return n("div",{class:"pointer-events-none fixed right-4 bottom-4 left-4 z-50 flex flex-col items-end gap-2 sm:left-auto","aria-live":"polite",children:d.toasts.map(e=>{let[t,o]=Ir[e.kind];return n("div",{role:e.kind==="error"?"alert":"status",class:"pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-xl border border-line bg-surface px-4 py-3 text-sm shadow-pop",children:[n(_,{name:t,class:`mt-0.5 size-4 ${o}`}),n("p",{class:"min-w-0 flex-1 leading-5 text-body",children:e.message}),e.action?n("button",{type:"button",class:"shrink-0 font-medium text-brand hover:underline",onClick:()=>{Ge(e.id),e.action.run()},children:e.action.label}):null,n("button",{type:"button",class:"-mr-1 shrink-0 cursor-pointer rounded text-faint hover:text-ink","aria-label":"Dismiss",onClick:()=>Ge(e.id),children:n(_,{name:"x"})})]},e.id)})})}var jr=()=>/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName)||document.activeElement?.isContentEditable;function Mr(){let[,e]=mt(o=>o+1,0);U(()=>{fn(e);try{localStorage.getItem("tg-sidebar")==="hidden"&&ie(!1,{instant:!0})}catch{}let o=s=>{let a=s.ctrlKey||s.metaKey,u=s.key.toLowerCase();a&&u==="s"?(s.preventDefault(),Fe()):a&&u==="b"?(s.preventDefault(),ie(!d.sideOpen)):a&&u==="k"?(s.preventDefault(),vt()):s.key==="/"&&!a&&!s.altKey&&!jr()&&!d.drawerOpen?(s.preventDefault(),vt()):s.key==="Escape"&&d.query&&!d.drawerOpen&&qe("")},r=s=>{d.schema&&W()&&s.preventDefault()},i=()=>Ue();return document.addEventListener("keydown",o),window.addEventListener("beforeunload",r),window.addEventListener("hashchange",i),wn(),()=>{document.removeEventListener("keydown",o),window.removeEventListener("beforeunload",r),window.removeEventListener("hashchange",i)}},[]),U(()=>{if(!d.schema||d.validated===L())return;let o=setTimeout(Cn,400);return()=>clearTimeout(o)});let t=d.sideRender;return n(x,{children:[n("a",{href:"#main",class:"sr-only z-50 rounded-md bg-brand-solid px-3 py-2 text-white focus:not-sr-only focus:fixed focus:top-3 focus:left-3",children:"Skip to content"}),n(io,{}),n("div",{class:t?"lg:grid lg:grid-cols-[260px_minmax(0,1fr)] lg:items-start":"",children:[n(lo,{}),n("main",{id:"main",tabindex:-1,class:"min-w-0 px-4 pt-6 pb-24 focus:outline-none sm:px-8 sm:pt-8",children:n("div",{class:"mx-auto w-full max-w-[760px]",children:n(ao,{})})})]}),n(Pn,{}),n("div",{class:"sr-only",role:"status","aria-live":"polite",children:d.copied?"Copied":""}),n(Rr,{})]})}Xt(n(Mr,{}),document.getElementById("app")); diff --git a/tenxgraph_api/cli/config_editor/validation.py b/tenxgraph_api/cli/config_editor/validation.py index d5cc286..8cefcc2 100644 --- a/tenxgraph_api/cli/config_editor/validation.py +++ b/tenxgraph_api/cli/config_editor/validation.py @@ -32,6 +32,7 @@ _USER_ID_MODES = ("raw", "hash", "mask") _TRACE_DESTINATIONS = ("logfire", "langsmith", "phoenix", "otlp") _MAX_PERCENT = 100 +_AUDIT_KEYS = ("enabled", "sinks", "include_reads") # Settings that live inside cache_store; at the top level the server refuses them. _CACHE_STORE_SETTINGS = ("redis", "rate_limit", "token_revocation") @@ -81,6 +82,7 @@ def validate_config(config: Any, base_dir: Path | None = None) -> list[Issue]: _check_media(config, out) _check_observability(config, out) _check_metrics(config, out) + _check_audit(config, out) _check_wiring(config, out, base_dir) _check_remote_tools(config, out) _check_test(config, out) @@ -390,6 +392,37 @@ def _check_metrics(config: dict, out: _Collector) -> None: out.error(named.group(1) if named else path, message) +def _check_audit(config: dict, out: _Collector) -> None: + data = config.get("audit") + if data is None: + return + if not isinstance(data, dict): + out.error("audit", "audit must be an object.") + return + for key in data: + if key not in _AUDIT_KEYS: + out.error( + f"audit.{key}", f"audit.{key} is not a setting. Use {', '.join(_AUDIT_KEYS)}." + ) + for key in ("enabled", "include_reads"): + if key in data and not isinstance(data[key], bool): + out.error(f"audit.{key}", f"audit.{key} must be true or false.") + sinks = data.get("sinks") + if sinks is None: + return + if not isinstance(sinks, list) or not sinks: + out.error("audit.sinks", "audit.sinks must be a non-empty list.") + return + for sink in sinks: + if sink not in ("log", "publisher") and not ( + isinstance(sink, str) and _IMPORT_PATH.match(sink) + ): + out.error( + "audit.sinks", + f'Unknown sink {sink!r}. Use "log", "publisher" or "module:Class".', + ) + + def _check_wiring(config: dict, out: _Collector, base_dir: Path | None) -> None: for key in ("injectq", "store", "checkpointer"): value = config.get(key) diff --git a/tenxgraph_api/cli/templates/defaults.py b/tenxgraph_api/cli/templates/defaults.py index a0e8e9f..4bf0d79 100644 --- a/tenxgraph_api/cli/templates/defaults.py +++ b/tenxgraph_api/cli/templates/defaults.py @@ -376,6 +376,8 @@ def generate_dockerfile_content( "# In production the local in-memory telemetry store stays disabled", "# (use OTEL / a publisher for observability instead).", "ENV MODE=production", + "# One JSON object per log line, for the log pipeline (includes the audit log).", + "ENV LOG_FORMAT=json", "# Gunicorn worker count. Gunicorn reads WEB_CONCURRENCY natively; a sane default", "# here beats gunicorn's built-in default of 1 worker. Tune to your CPU/memory", "# at deploy time, e.g. `docker run -e WEB_CONCURRENCY=8 ...`.", @@ -526,6 +528,8 @@ def generate_k8s_manifest_content(service_name: str, port: int) -> str: " env:", " - name: MODE", ' value: "production"', + " - name: LOG_FORMAT", + ' value: "json"', # ORIGINS and ALLOWED_HOST come from the Secret (an env entry here would # override it). Production refuses to start while either is missing or a wildcard. " lifecycle:", @@ -607,6 +611,7 @@ def generate_docker_compose_content(service_name: str, port: int) -> str: " - PYTHONDONTWRITEBYTECODE=1", # Default to production; local in-memory telemetry stays disabled. " - MODE=production", + " - LOG_FORMAT=json", " # ORIGINS and ALLOWED_HOST come from .env (a value here would override it).", " # Production refuses to start while either is missing or a wildcard.", " # Loopback only: put a TLS reverse proxy (Caddy, nginx, a cloud load", diff --git a/tenxgraph_api/src/app/core/audit/__init__.py b/tenxgraph_api/src/app/core/audit/__init__.py new file mode 100644 index 0000000..966daaa --- /dev/null +++ b/tenxgraph_api/src/app/core/audit/__init__.py @@ -0,0 +1,189 @@ +"""Audit events: who accessed or changed what, when, and whether it was allowed. + +One emit function, :func:`audit_event`, called from the few places every request passes +through (the auth dependency, the thread lookup, the 404 handler; changes from the routers). +Metadata only: never prompts, answers, message text, file contents, tokens or claims beyond +ids. Request id, transport and client address come from the request context; callers pass only +what they know. + +A sink error never fails the request: it is logged at ERROR and the request continues. With +audit off (the default) :func:`audit_event` returns at once. +""" + +import logging +from datetime import UTC, datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field +from tenxgraph.core.authz import Identity + +from tenxgraph_api.src.app.core.audit.config import AuditConfig +from tenxgraph_api.src.app.core.config.metrics import count_audit_sink_error +from tenxgraph_api.src.app.core.request_context import get_request_context + + +logger = logging.getLogger("tenxgraph_api") + +EventType = Literal["access", "auth", "change"] +Result = Literal["allowed", "denied", "not_found", "failed", "success"] + + +def _now() -> str: + return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +class AuditEvent(BaseModel): + """One audit record; ``model_dump()`` gives the JSON fields (schema version ``v``).""" + + v: Literal[1] = 1 + ts: str = Field(default_factory=_now) + event: EventType + request_id: str | None = None + transport: Literal["http", "ws"] | None = None + user_id: str | None = None + tenant: dict[str, str] | None = None + roles: list[str] | None = None + action: str | None = None + resource: str | None = None + result: Result + reason: str | None = None + client_ip: str | None = None + count: int | None = None + + +class BaseAuditSink: + """Where audit events go. Subclass and name it in ``audit.sinks`` as ``"module:Class"``. + + ``emit`` should be quick; a raised exception is logged and the request continues. + """ + + async def emit(self, event: AuditEvent) -> None: + raise NotImplementedError + + +class _Active: + """The sinks in use (set once at boot) and the ``include_reads`` switch.""" + + sinks: tuple[BaseAuditSink, ...] = () + include_reads = True + + +def set_sinks(sinks: list[BaseAuditSink], *, include_reads: bool = True) -> None: + """Replace the active sinks (empty turns audit off).""" + _Active.sinks = tuple(sinks) + _Active.include_reads = include_reads + + +def audit_enabled() -> bool: + return bool(_Active.sinks) + + +def configure_audit(config: AuditConfig, *, auth_enabled: bool) -> None: + """Build the sinks from the ``audit`` block, once at boot.""" + from tenxgraph_api.src.app.core.audit.sinks import build_sinks + + if not config.enabled: + set_sinks([]) + return + if not auth_enabled: + logger.warning("Audit log is on but auth is off: audit events carry user_id null") + set_sinks(build_sinks(config.sinks), include_reads=config.include_reads) + + +async def audit_event( + event: EventType, + result: Result, + *, + action: str | None = None, + resource: str | None = None, + reason: str | None = None, + identity: Identity | None = None, + user_id: str | None = None, + count: int | None = None, +) -> None: + """Send one event to every sink. ``identity`` (when known) gives user, tenant and roles.""" + if not _Active.sinks: + return + if not _Active.include_reads and event == "access" and result == "allowed": + return + ctx = get_request_context() + if identity is not None: + user_id = identity.user_id + elif user_id is None and ctx is not None: + user_id = ctx.user_id + record = AuditEvent( + event=event, + request_id=ctx.request_id if ctx else None, + transport=ctx.transport if ctx else None, + user_id=user_id, + tenant=dict(identity.tenant) if identity is not None else None, + roles=sorted(identity.roles) if identity is not None else None, + action=action, + resource=resource, + result=result, + reason=reason, + client_ip=ctx.client_ip if ctx else None, + count=count, + ) + for sink in _Active.sinks: + try: + await sink.emit(record) + except Exception as exc: + _sink_failed(sink, exc) + + +def _sink_failed(sink: BaseAuditSink, exc: Exception) -> None: + """A sink raised: log it and count it (``tenxgraph_audit_sink_errors_total``).""" + count_audit_sink_error(type(sink).__name__) + logger.error("Audit sink %s failed: %s: %s", type(sink).__name__, type(exc).__name__, exc) + + +async def audit_not_found(exc: Any, identity: Identity | None = None) -> None: + """One ``not_found`` access event per ``ThreadNotFoundError``. + + The thread lookup (``core/threads.py``) emits with the identity and marks the exception; + the HTTP 404 handler then skips it and covers only the ones raised elsewhere (storage). + The action is the last permission the request checked. + """ + if getattr(exc, "audited", False): + return + exc.audited = True + ctx = get_request_context() + context = getattr(exc, "context", None) or {} + thread_id = context.get("thread_id") + await audit_event( + "access", + "not_found", + action=ctx.action if ctx else None, + resource=f"thread/{thread_id}" if thread_id is not None else None, + identity=identity, + ) + if ctx is not None: + ctx.action = None # recorded: a later 404 response must not emit it again + + +async def audit_http_not_found() -> None: + """One ``not_found`` event for a 404 ``HTTPException`` after an allowed permission check. + + Covers files, memories and tasks, where the service answers 404 for a missing resource and + for one outside the caller's reach. A request with no permission check (an unknown route) + and a 404 already recorded by ``audit_not_found`` emit nothing. + """ + ctx = get_request_context() + if ctx is None or ctx.action is None: + return + action, ctx.action = ctx.action, None + await audit_event("access", "not_found", action=action, resource=ctx.resource) + + +__all__ = [ + "AuditConfig", + "AuditEvent", + "BaseAuditSink", + "audit_enabled", + "audit_http_not_found", + "audit_event", + "audit_not_found", + "configure_audit", + "set_sinks", +] diff --git a/tenxgraph_api/src/app/core/audit/config.py b/tenxgraph_api/src/app/core/audit/config.py new file mode 100644 index 0000000..790d6de --- /dev/null +++ b/tenxgraph_api/src/app/core/audit/config.py @@ -0,0 +1,36 @@ +"""The ``audit`` block of 10xgraph.json.""" + +import re + +from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator + + +# "module:Class" (a BaseAuditSink subclass, instance or zero-argument factory). +_IMPORT_PATH = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_][\w.]*$") +BUILTIN_SINKS = ("log", "publisher") + + +class AuditConfig(BaseModel): + """``audit`` in 10xgraph.json. Example:: + + "audit": {"enabled": true, "sinks": ["log"], "include_reads": true} + + ``include_reads: false`` drops allowed access events; denials, auth failures and changes + are always kept. Unknown keys refuse to boot. + """ + + model_config = ConfigDict(extra="forbid") + + enabled: StrictBool = False + sinks: list[str] = Field(default_factory=lambda: ["log"], min_length=1) + include_reads: StrictBool = True + + @field_validator("sinks") + @classmethod + def _check_sinks(cls, sinks: list[str]) -> list[str]: + for sink in sinks: + if sink not in BUILTIN_SINKS and not _IMPORT_PATH.match(sink): + raise ValueError( + f'audit.sinks: unknown sink {sink!r}; use "log", "publisher" or "module:Class"' + ) + return sinks diff --git a/tenxgraph_api/src/app/core/audit/sinks.py b/tenxgraph_api/src/app/core/audit/sinks.py new file mode 100644 index 0000000..b2de2ab --- /dev/null +++ b/tenxgraph_api/src/app/core/audit/sinks.py @@ -0,0 +1,122 @@ +"""Built-in audit sinks and the sink list built from ``audit.sinks`` at boot.""" + +import importlib +import inspect +import logging +import sys +from typing import Any + +from injectq import InjectQ +from tenxgraph.runtime.publisher.base_publisher import BasePublisher +from tenxgraph.runtime.publisher.events import ContentType, Event, EventModel, EventType +from tenxgraph.runtime.publisher.publish import _select, publish_event +from tenxgraph.utils.background_task_manager import BackgroundTaskManager + +from tenxgraph_api.src.app.core.audit import AuditEvent, BaseAuditSink + + +AUDIT_LOGGER_NAME = "tenxgraph_api.audit" + +logger = logging.getLogger("tenxgraph_api") + + +def _audit_logger() -> logging.Logger: + """``tenxgraph_api.audit``: raw JSON lines on stdout, whatever ``LOG_FORMAT`` and ``LOG_LEVEL``. + + It has its own handler and does not propagate, so the app's (sanitizing, possibly text) + console handler never writes a second, reformatted copy. Events are metadata only. + """ + audit_logger = logging.getLogger(AUDIT_LOGGER_NAME) + audit_logger.setLevel(logging.INFO) + audit_logger.propagate = False + if not audit_logger.handlers: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter("%(message)s")) + audit_logger.addHandler(handler) + return audit_logger + + +class LogAuditSink(BaseAuditSink): + """One JSON line per event on stdout (logger ``tenxgraph_api.audit``).""" + + def __init__(self) -> None: + self._logger = _audit_logger() + + async def emit(self, event: AuditEvent) -> None: + self._logger.info(event.model_dump_json()) + + +class PublisherAuditSink(BaseAuditSink): + """Also sends each event through the app's publisher (Kafka, Redis, RabbitMQ, custom). + + Best effort: the event is queued as a background task and ``emit`` returns at once, so a + slow or down broker never delays a request. The sink has its own task manager (and so its + own queue cap), so a slow broker under audit volume sheds audit events only, never graph + events or memory-tool writes. Events still in flight at SIGTERM, or shed when the queue is + full, are lost; the ``log`` sink is the record. Observability publishers (OTEL, Logfire, + Phoenix) are skipped, so audit events never reach an AI tracing backend. + """ + + def __init__(self, publisher: BasePublisher) -> None: + self._publisher = publisher + self._tasks = BackgroundTaskManager() + + async def emit(self, event: AuditEvent) -> None: + publish_event( + EventModel( + event=Event.GRAPH_EXECUTION, + event_type=EventType.UPDATE, + data=event.model_dump(), + content_type=[ContentType.DATA], + node_name="audit", + user_id=event.user_id, + metadata={"audit": True}, + ), + publisher=self._publisher, + task_manager=self._tasks, + only="events", + ) + + +def _build_publisher_sink() -> BaseAuditSink | None: + publisher = InjectQ.get_instance().try_get(BasePublisher) + if publisher is None or _select(publisher, "events") is None: + logger.warning( + 'audit.sinks lists "publisher" but no events publisher is configured; skipped' + ) + return None + return PublisherAuditSink(publisher) + + +def _load_custom_sink(path: str) -> BaseAuditSink: + """Import ``module:attr`` (an instance, a ``BaseAuditSink`` subclass or a zero-argument + factory) and return the sink. Anything else raises, which stops the boot.""" + module_name, _, attr = path.partition(":") + try: + target: Any = importlib.import_module(module_name) + for part in attr.split("."): + target = getattr(target, part) + if isinstance(target, BaseAuditSink): + return target + if inspect.isclass(target) and not issubclass(target, BaseAuditSink): + raise TypeError("class is not a BaseAuditSink subclass") + sink = target() if callable(target) else target + except Exception as exc: + raise ValueError(f"audit.sinks {path!r} cannot be loaded: {exc}") from exc + if not isinstance(sink, BaseAuditSink): + raise ValueError(f"audit.sinks {path!r} did not produce a BaseAuditSink instance") + return sink + + +def build_sinks(names: list[str]) -> list[BaseAuditSink]: + """The sinks named in ``audit.sinks``: ``"log"``, ``"publisher"`` or ``"module:Class"``.""" + sinks: list[BaseAuditSink] = [] + for name in names: + if name == "log": + sinks.append(LogAuditSink()) + elif name == "publisher": + if (sink := _build_publisher_sink()) is not None: + sinks.append(sink) + else: + sinks.append(_load_custom_sink(name)) + return sinks diff --git a/tenxgraph_api/src/app/core/auth/permissions.py b/tenxgraph_api/src/app/core/auth/permissions.py index 74d1f58..f1935a8 100644 --- a/tenxgraph_api/src/app/core/auth/permissions.py +++ b/tenxgraph_api/src/app/core/auth/permissions.py @@ -14,7 +14,7 @@ import copy import email.message -from typing import Any, NoReturn +from typing import Any, Literal, NoReturn from fastapi import HTTPException, Response, WebSocket, WebSocketException from fastapi.security import HTTPAuthorizationCredentials @@ -23,6 +23,7 @@ from tenxgraph.core.authz import Identity from tenxgraph_api.src.app.core import logger +from tenxgraph_api.src.app.core.audit import audit_enabled, audit_event from tenxgraph_api.src.app.core.auth.base_auth import BaseAuth from tenxgraph_api.src.app.core.auth.permissions_registry import ALL_PERMISSIONS from tenxgraph_api.src.app.core.auth.rbac import InvalidClaimsError, resolve @@ -36,6 +37,7 @@ token_revoked, ) from tenxgraph_api.src.app.core.exceptions.general_exception import GeneralException +from tenxgraph_api.src.app.core.request_context import get_request_context, set_request_user from tenxgraph_api.src.app.core.utils.container import current_container from tenxgraph_api.src.app.core.validation.request_config import ( ClientInputError, @@ -179,27 +181,53 @@ def _recall_claims(connection: HTTPConnection, auth: BaseAuth, token: str) -> di return copy.deepcopy(claims) -def _authenticate( +async def _auth_failed(reason: str, user_id: str | None = None) -> None: + """The ``auth`` audit event for a refused credential (no token or claim content).""" + await audit_event("auth", "failed", reason=reason, user_id=user_id) + + +def _failure_reason( + credential: HTTPAuthorizationCredentials | None, exc: Exception | None = None +) -> str: + """Short audit code for a failed ``authenticate``: missing, expired or invalid token.""" + if getattr(exc, "status_code", 401) >= 500: # noqa: PLR2004 + return "auth_error" # the auth backend broke (JWT settings missing), not the caller + if credential is None: + return "missing_token" + if getattr(exc, "error_code", None) == "EXPIRED_TOKEN": + return "expired" + return "invalid_token" + + +async def _authenticate( auth: BaseAuth, connection: HTTPConnection, response: Response | None ) -> dict[str, Any]: - """The verified claims of this request, or a 401 (1008 on a WebSocket).""" + """The verified claims of this request, or a 401 (1008 on a WebSocket). + + Claims the rate limiter already verified are reused (``_recall_claims``): the limiter + emits nothing, so each request gets at most one ``auth`` event, from here or ``_identity``. + """ credential = _extract_credential(connection) try: claims = _recall_claims(connection, auth, credential.credentials) if credential else None if claims is None: claims = auth.authenticate(connection, response, credential) except HTTPException as exc: + await _auth_failed(_failure_reason(credential, exc)) _reject(connection, exc.status_code, str(exc.detail)) except GeneralException as exc: + await _auth_failed(_failure_reason(credential, exc)) # JwtAuth raises UserAccountError (401). HTTP routes render it through its exception # handler; a WebSocket has no handler, so close it cleanly. if isinstance(connection, WebSocket): _reject(connection, exc.status_code, exc.message) raise if not claims: + await _auth_failed(_failure_reason(credential)) _reject(connection, 401, "Authentication required") if not isinstance(claims, dict): logger.error("%s.authenticate returned %s, not a dict", type(auth).__name__, type(claims)) + await _auth_failed("claims_rejected") _reject(connection, 401, "Authentication failed") return claims @@ -207,24 +235,38 @@ def _authenticate( async def _identity( connection: HTTPConnection, response: Response | None, auth: BaseAuth ) -> Identity: - """Authenticate, resolve roles and permissions, refuse reserved ids and revoked tokens.""" - claims = _authenticate(auth, connection, response) + """Authenticate, resolve roles and permissions, refuse reserved ids and revoked tokens. + + Each refusal emits one ``auth`` audit event; the WARNING lines are written only while audit + is off, so the record is not doubled. + """ + claims = await _authenticate(auth, connection, response) try: identity = resolve(claims, auth.rbac) except InvalidClaimsError as exc: - logger.warning("Rejected token claims: %s", exc) + if not audit_enabled(): + logger.warning("Rejected token claims: %s", exc) + user_id = claims.get("user_id") + await _auth_failed("claims_rejected", user_id if isinstance(user_id, str) else None) _reject(connection, 401, "Invalid token, please login again") if identity_is_reserved(identity): # Any auth class, any transport, cached or fresh: nobody acts as the shared owner. - log_reserved_rejection() + # The event leaves the reserved id out, like the log line. + if not audit_enabled(): + log_reserved_rejection() + await _auth_failed("reserved_id") _reject(connection, 401, RESERVED_USER_ID_MESSAGE) try: revoked = await token_revoked(identity) except TokenRevocationUnavailableError: + await _auth_failed("revocation_unavailable", identity.user_id) _reject(connection, 503, "Token revocation check unavailable") if revoked: - logger.warning("Rejected revoked credential for user %s", identity.user_id) + if not audit_enabled(): + logger.warning("Rejected revoked credential for user %s", identity.user_id) + await _auth_failed("revoked", identity.user_id) _reject(connection, 401, "Token has been revoked, please login again") + set_request_user(identity.user_id) return identity @@ -244,12 +286,16 @@ async def __call__( auth: BaseAuth | None = InjectAPI(BaseAuth), ) -> Identity | None: if auth is None: + if self.permission is not None: + # Auth off: everyone is allowed; the event records the call (user_id null). + await _audit_access(None, self.permission, _path_resource(connection), "allowed") return None identity = await _identity(connection, response, auth) if self.permission is not None: resource_id = await _resource_id(connection) + resource = _resource_label(connection, resource_id) try: - await _authorize(auth, identity, self.permission, resource_id) + await _authorize(auth, identity, self.permission, resource_id, resource) except HTTPException as exc: _reject(connection, exc.status_code, str(exc.detail)) return identity @@ -271,12 +317,40 @@ def __init__(self, resource: str, action: str): self.permission = permission +async def _audit_access( + identity: Identity | None, + permission: str, + resource: str | None, + result: Literal["allowed", "denied"], +) -> None: + """The ``access`` audit event; also remembers the permission for a later ``not_found``.""" + ctx = get_request_context() + if ctx is not None: + ctx.action = permission + ctx.resource = resource + await audit_event("access", result, action=permission, resource=resource, identity=identity) + + async def _authorize( - auth: BaseAuth, identity: Identity, permission: str, resource: str | None + auth: BaseAuth, + identity: Identity, + permission: str, + resource_id: str | None, + resource: str | None, ) -> None: - """``auth.authorize`` or an ``HTTPException`` (403; one ``authorize`` raised passes through).""" - if not await auth.authorize(identity, permission, resource): - logger.warning("User %s lacks permission %s", identity.user_id, permission) + """``auth.authorize`` or an ``HTTPException`` (403; one ``authorize`` raised passes through). + + ``resource_id`` goes to ``authorize``; ``resource`` (``"thread/"``) to the audit event. + """ + try: + allowed = await auth.authorize(identity, permission, resource_id) + except HTTPException: + await _audit_access(identity, permission, resource, "denied") + raise + await _audit_access(identity, permission, resource, "allowed" if allowed else "denied") + if not allowed: + if not audit_enabled(): + logger.warning("User %s lacks permission %s", identity.user_id, permission) raise HTTPException(status_code=403, detail=f"Missing required permission: {permission}") @@ -296,7 +370,33 @@ async def authorize_resource( return auth = current_container().try_get(BaseAuth) if auth is not None: - await _authorize(auth, identity, permission, resource) + label = f"thread/{resource}" if resource is not None else None + await _authorize(auth, identity, permission, resource, label) + + +# Path parameters that name the target, in priority order, with the audit resource kind. +_RESOURCE_KINDS = { + "thread_id": "thread", + "memory_id": "memory", + "namespace": "namespace", + "file_id": "file", +} + + +def _resource_label(connection: HTTPConnection, resource_id: str | None) -> str | None: + """``"/"`` for the audit event; a body id is always a thread.""" + if resource_id is None: + return None + params = connection.path_params + kind = next((k for name, k in _RESOURCE_KINDS.items() if name in params), "thread") + return f"{kind}/{resource_id}" + + +def _path_resource(connection: HTTPConnection) -> str | None: + """The audit resource from the path alone (auth off: the body is not read).""" + params = connection.path_params + resource_id = next((str(params[n]) for n in _RESOURCE_KINDS if n in params), None) + return _resource_label(connection, resource_id) async def _resource_id(connection: HTTPConnection) -> str | None: @@ -310,10 +410,7 @@ async def _resource_id(connection: HTTPConnection) -> str | None: for name in ("thread_id", "message_id", "memory_id"): if name in params: problem = resource_id_error(params[name], name) or problem - resource_id = next( - (str(params[n]) for n in ("thread_id", "memory_id", "namespace", "file_id") if n in params), - None, - ) + resource_id = next((str(params[n]) for n in _RESOURCE_KINDS if n in params), None) if resource_id is None: resource_id = await _body_thread_id(connection) problem = problem or resource_id_error(resource_id, "thread_id") diff --git a/tenxgraph_api/src/app/core/auth/revocation.py b/tenxgraph_api/src/app/core/auth/revocation.py index 4d023df..6b3eac0 100644 --- a/tenxgraph_api/src/app/core/auth/revocation.py +++ b/tenxgraph_api/src/app/core/auth/revocation.py @@ -41,6 +41,7 @@ from tenxgraph.core.authz import Identity +from tenxgraph_api.src.app.core.audit import audit_event from tenxgraph_api.src.app.core.config.graph_config import ( DEFAULT_USER_REVOCATION_TTL, TokenRevocationConfig, @@ -118,6 +119,7 @@ async def revoke_token(cache: BaseCacheStore, jti: str, expires_at: float | None if expires_at is not None: ttl = max(1, int(float(expires_at) - await cache.now())) await cache.set(jti_key(jti), b"1", ttl) + await audit_event("change", "success", action="tokens:revoke", resource=f"token/{jti}") async def revoke_user( @@ -137,6 +139,7 @@ async def revoke_user( # cutoff itself through. text = Decimal(cutoff).quantize(_MICROSECOND, rounding=ROUND_CEILING) await cache.set(user_key(user_id), f"{text:f}".encode(), ttl, mode="if_greater") + await audit_event("change", "success", action="users:revoke", resource=f"user/{user_id}") async def _none() -> None: diff --git a/tenxgraph_api/src/app/core/config/graph_config.py b/tenxgraph_api/src/app/core/config/graph_config.py index a2ff8b9..5dd365f 100644 --- a/tenxgraph_api/src/app/core/config/graph_config.py +++ b/tenxgraph_api/src/app/core/config/graph_config.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from tenxgraph_api.jobs.config import WorkerConfig + from tenxgraph_api.src.app.core.audit.config import AuditConfig from tenxgraph_api.src.app.core.auth.auth_config import AuthConfig from tenxgraph_api.src.app.core.config.metrics import MetricsConfig @@ -984,6 +985,14 @@ def metrics(self) -> "MetricsConfig": return MetricsConfig.model_validate(self.data.get("metrics") or {}) + @property + def audit(self) -> "AuditConfig": + """The ``audit`` block (access, auth and change events); off when absent. Unknown keys + are a validation error, so a typo refuses to boot.""" + from tenxgraph_api.src.app.core.audit.config import AuditConfig + + return AuditConfig.model_validate(self.data.get("audit") or {}) + @property def media(self) -> MediaConfig: """Uploaded-file lifecycle settings (``media`` key); defaults when absent.""" diff --git a/tenxgraph_api/src/app/core/config/metrics.py b/tenxgraph_api/src/app/core/config/metrics.py index 84c17b3..6c7dd7d 100644 --- a/tenxgraph_api/src/app/core/config/metrics.py +++ b/tenxgraph_api/src/app/core/config/metrics.py @@ -95,6 +95,13 @@ def scrape_path(self) -> str | None: return self.prometheus.path if self.enabled and self.export == "prometheus" else None +def count_audit_sink_error(sink: str) -> None: + """Count one failed audit sink call. A no-op until ``setup_metrics`` has run.""" + from tenxgraph.utils.metrics import counter + + counter("tenxgraph_audit_sink_errors_total").inc(attributes={"sink": sink}) + + def _otlp_metrics_url(endpoint: str) -> str: base = endpoint.rstrip("/") return base if base.endswith("/v1/metrics") else base + "/v1/metrics" diff --git a/tenxgraph_api/src/app/core/config/settings.py b/tenxgraph_api/src/app/core/config/settings.py index f372307..f459238 100644 --- a/tenxgraph_api/src/app/core/config/settings.py +++ b/tenxgraph_api/src/app/core/config/settings.py @@ -90,6 +90,8 @@ class Settings(BaseSettings): # DEBUG = 10 # NOTSET = 0 LOG_LEVEL: str = "INFO" + # "text" (one readable line) or "json" (one JSON object per line, for log pipelines). + LOG_FORMAT: str = "text" # When true, MODE=production refuses to start on the settings it otherwise only warns # about: wildcard ALLOWED_HOST, wildcard ORIGINS, and enabled API docs. Unset means on in # production and off in development; STRICT_PRODUCTION_CHECKS=false opts out. @@ -241,6 +243,15 @@ class Settings(BaseSettings): OTEL_EXPORTER_OTLP_ENDPOINT: str | None = None OTEL_LEVEL: str = "standard" # spans | standard | full + @field_validator("LOG_FORMAT", mode="before") + @classmethod + def check_log_format(cls, v: str | None) -> str: + """LOG_FORMAT is ``text`` or ``json``; reject anything else.""" + value = str(v or "text").strip().lower() + if value not in ("text", "json"): + raise ValueError(f"LOG_FORMAT must be 'text' or 'json', got {v!r}") + return value + @field_validator("MODE", mode="before") @classmethod def normalize_mode(cls, v: str | None) -> str: diff --git a/tenxgraph_api/src/app/core/config/setup_logs.py b/tenxgraph_api/src/app/core/config/setup_logs.py index 6b128f8..b5ed388 100644 --- a/tenxgraph_api/src/app/core/config/setup_logs.py +++ b/tenxgraph_api/src/app/core/config/setup_logs.py @@ -1,9 +1,12 @@ +import json import logging import sys +from datetime import UTC, datetime from fastapi.logger import logger as fastapi_logger from tenxgraph_api.src.app.core.config.settings import LOGGER_NAME +from tenxgraph_api.src.app.core.request_context import get_request_context from tenxgraph_api.src.app.core.utils.log_sanitizer import SanitizingFormatter, redact_text @@ -25,7 +28,39 @@ def filter(self, record: logging.LogRecord) -> bool: return True -def init_logger(level: int | str = logging.INFO) -> None: +class RequestContextFilter(logging.Filter): + """Copy the current request context (request id, transport, user) onto the record.""" + + def filter(self, record: logging.LogRecord) -> bool: + ctx = get_request_context() + record.request_id = ctx.request_id if ctx else None + record.transport = ctx.transport if ctx else None + record.user_id = ctx.user_id if ctx else None + return True + + +class JsonFormatter(logging.Formatter): + """One JSON object per record. Wrapped by ``SanitizingFormatter``, so secrets are masked.""" + + def format(self, record: logging.LogRecord) -> str: + entry: dict[str, object] = { + "ts": datetime.fromtimestamp(record.created, UTC).isoformat(timespec="milliseconds")[ + :-6 + ] + + "Z", + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + for key in ("request_id", "transport", "user_id"): + if getattr(record, key, None): + entry[key] = getattr(record, key) + if record.exc_info: + entry["exc_info"] = self.formatException(record.exc_info) + return json.dumps(entry, default=str) + + +def init_logger(level: int | str = logging.INFO, log_format: str = "text") -> None: """ Initializes and configures logging for the application. @@ -56,10 +91,15 @@ def init_logger(level: int | str = logging.INFO) -> None: # Create console handler and set level to DEBUG console_handler = logging.StreamHandler(sys.stdout) console_handler.setLevel(level) + console_handler.addFilter(RequestContextFilter()) # Create formatter - base_formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s" + base_formatter: logging.Formatter = ( + JsonFormatter() + if log_format == "json" + else logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s" + ) ) # Wrap with sanitizing formatter to prevent sensitive data in logs formatter = SanitizingFormatter(base_formatter) diff --git a/tenxgraph_api/src/app/core/config/setup_middleware.py b/tenxgraph_api/src/app/core/config/setup_middleware.py index 545f1d7..7973955 100644 --- a/tenxgraph_api/src/app/core/config/setup_middleware.py +++ b/tenxgraph_api/src/app/core/config/setup_middleware.py @@ -12,16 +12,18 @@ from injectq import InjectQ from starlette.datastructures import Headers, MutableHeaders from starlette.middleware.cors import CORSMiddleware -from starlette.requests import Request +from starlette.requests import HTTPConnection, Request from starlette.responses import Response from starlette.types import ASGIApp, Message, Receive, Scope, Send from tenxgraph_api.src.app.core.exceptions.server_errors import UnhandledErrorMiddleware from tenxgraph_api.src.app.core.middleware.rate_limit import RateLimitMiddleware +from tenxgraph_api.src.app.core.middleware.rate_limit.keying import _client_ip from tenxgraph_api.src.app.core.middleware.request_limits import RequestSizeLimitMiddleware from tenxgraph_api.src.app.core.middleware.security_headers import SecurityHeadersMiddleware +from tenxgraph_api.src.app.core.request_context import RequestContext, request_context_var -from .graph_config import GraphConfig +from .graph_config import GraphConfig, RateLimitConfig from .metrics import resolve_use_metrics_endpoint, setup_metrics from .sentry_config import init_sentry from .settings import get_settings, logger @@ -197,10 +199,20 @@ class RequestIDMiddleware: per-request task and never buffers a streaming body (``BaseHTTPMiddleware`` does both). """ - def __init__(self, app: ASGIApp) -> None: + def __init__(self, app: ASGIApp, rate_limit_config: RateLimitConfig | None = None) -> None: self.app = app + self.rate_limit_config = rate_limit_config async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "websocket": + # Same request context as HTTP, but no headers: the handshake send path is untouched. + scope.setdefault("state", {})["request_id"] = request_id = str(uuid.uuid4()) + token = self._bind_context(scope, request_id, "ws") + try: + await self.app(scope, receive, send) + finally: + request_context_var.reset(token) + return if scope["type"] != "http": await self.app(scope, receive, send) return @@ -219,9 +231,24 @@ async def send_with_id(message: Message) -> None: headers["X-Timestamp"] = timestamp await send(message) - await self.app(scope, receive, send_with_id) + token = self._bind_context(scope, request_id, "http") + try: + await self.app(scope, receive, send_with_id) + finally: + request_context_var.reset(token) logger.debug(f"Response: Request ID: {request_id}, Timestamp: {timestamp}") + def _bind_context(self, scope: Scope, request_id: str, transport: str) -> Any: + """Set the request context; client_ip is the address the rate limiter keys on.""" + config = self.rate_limit_config + if config is not None: + client_ip = _client_ip(HTTPConnection(scope), config) + else: + client_ip = scope["client"][0] if scope.get("client") else None + return request_context_var.set( + RequestContext(request_id=request_id, transport=transport, client_ip=client_ip) + ) + def _user_id_mode(graph_config: GraphConfig | None) -> str: """``observability.user_id_mode`` from ``10xgraph.json``: how traces carry the user id.""" @@ -681,7 +708,10 @@ def setup_middleware( csp_policy=settings.CSP_POLICY, ) - app.add_middleware(RequestIDMiddleware) + app.add_middleware( + RequestIDMiddleware, + rate_limit_config=graph_config.rate_limit if graph_config is not None else None, + ) app.add_middleware( AllowlistCORSMiddleware, diff --git a/tenxgraph_api/src/app/core/exceptions/handle_errors.py b/tenxgraph_api/src/app/core/exceptions/handle_errors.py index 6316703..3a68239 100644 --- a/tenxgraph_api/src/app/core/exceptions/handle_errors.py +++ b/tenxgraph_api/src/app/core/exceptions/handle_errors.py @@ -20,6 +20,7 @@ from tenxgraph.utils.validators import ValidationError from tenxgraph_api.src.app.core import logger +from tenxgraph_api.src.app.core.audit import audit_http_not_found, audit_not_found from tenxgraph_api.src.app.core.config.settings import get_settings from tenxgraph_api.src.app.core.exceptions.server_errors import ( RETRY_AFTER_SECONDS, @@ -142,6 +143,8 @@ def init_errors_handler(app: FastAPI): # noqa: PLR0915 @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): _log_handled(request, exc, exc.status_code, "HTTPException", "HTTP exception") + if exc.status_code == 404: # noqa: PLR2004 + await audit_http_not_found() # Get request ID for tracking request_id = getattr(request.state, "request_id", "unknown") @@ -399,6 +402,7 @@ async def thread_not_found_exception_handler(request: Request, exc: ThreadNotFou # A subclass of StorageError: Starlette picks the handler of the closest class, so this # one wins over the generic 500. No details: the context holds the thread id. _log_handled(request, exc, 404, THREAD_NOT_FOUND_CODE, "ThreadNotFoundError") + await audit_not_found(exc) # skipped when the thread lookup already recorded it return error_response( request, error_code=THREAD_NOT_FOUND_CODE, diff --git a/tenxgraph_api/src/app/core/request_context.py b/tenxgraph_api/src/app/core/request_context.py new file mode 100644 index 0000000..2ce6166 --- /dev/null +++ b/tenxgraph_api/src/app/core/request_context.py @@ -0,0 +1,34 @@ +"""Per-request context (request id, transport, client address, user) carried in a contextvar. + +Set by ``RequestIDMiddleware`` for HTTP requests and WebSocket connections. Log lines and audit +events read it, so call sites pass only what they alone know. +""" + +from contextvars import ContextVar +from typing import Literal + +from pydantic import BaseModel + + +class RequestContext(BaseModel): + request_id: str + transport: Literal["http", "ws"] + client_ip: str | None = None + user_id: str | None = None # set later by auth once known + # Last permission checked and its resource, for a later audit ``not_found`` event. + action: str | None = None + resource: str | None = None + + +request_context_var: ContextVar[RequestContext | None] = ContextVar("request_context", default=None) + + +def get_request_context() -> RequestContext | None: + return request_context_var.get() + + +def set_request_user(user_id: str | None) -> None: + """Record the authenticated user on the current context, if there is one.""" + ctx = request_context_var.get() + if ctx is not None: + ctx.user_id = user_id diff --git a/tenxgraph_api/src/app/core/threads.py b/tenxgraph_api/src/app/core/threads.py index d7c2a3c..ff44678 100644 --- a/tenxgraph_api/src/app/core/threads.py +++ b/tenxgraph_api/src/app/core/threads.py @@ -15,6 +15,8 @@ from tenxgraph.storage.checkpointer import BaseCheckpointer from tenxgraph.utils.thread_info import ThreadInfo +from tenxgraph_api.src.app.core.audit import audit_not_found + async def require_thread( checkpointer: BaseCheckpointer, @@ -47,7 +49,9 @@ async def require_thread( return thread if run and not server_thread_ids and not await _taken(checkpointer, thread_id): return None - raise ThreadNotFoundError(thread_id) + error = ThreadNotFoundError(thread_id) + await audit_not_found(error, identity) # one event, on every transport + raise error async def _taken(checkpointer: BaseCheckpointer, thread_id: Any) -> bool: diff --git a/tenxgraph_api/src/app/main.py b/tenxgraph_api/src/app/main.py index a74d052..62f9792 100644 --- a/tenxgraph_api/src/app/main.py +++ b/tenxgraph_api/src/app/main.py @@ -17,6 +17,7 @@ init_logger, setup_middleware, ) +from tenxgraph_api.src.app.core.audit import configure_audit from tenxgraph_api.src.app.core.auth.route_guard import ( DEFAULT_PUBLIC_PATHS, assert_all_routes_protected, @@ -192,6 +193,11 @@ async def lifespan(app: FastAPI): container=container, ) + # Audit sinks, once, now that the auth class is bound (or not). + from tenxgraph_api import BaseAuth + + configure_audit(graph_config.audit, auth_enabled=container.try_get(BaseAuth) is not None) + await _prepare_checkpointer() # Fail fast, in development and production, while a configured backend is unreachable. @@ -291,7 +297,7 @@ async def lifespan(app: FastAPI): # attach_injector(app, injector=injector) setup_fastapi(container=container, app=app) -init_logger(settings.LOG_LEVEL) +init_logger(settings.LOG_LEVEL, settings.LOG_FORMAT) # init error handler init_errors_handler(app) @@ -299,6 +305,7 @@ async def lifespan(app: FastAPI): # A malformed "media" section should stop the boot, not surface on the first delete. graph_config.media # noqa: B018 graph_config.metrics # noqa: B018 +graph_config.audit # noqa: B018 # init routes # The AG-UI endpoint is opt-in through 10xgraph.json; the WebSocket routes can be turned off there. diff --git a/tenxgraph_api/src/app/routers/checkpointer/router.py b/tenxgraph_api/src/app/routers/checkpointer/router.py index 49d5298..c680eee 100644 --- a/tenxgraph_api/src/app/routers/checkpointer/router.py +++ b/tenxgraph_api/src/app/routers/checkpointer/router.py @@ -7,6 +7,7 @@ from tenxgraph.core.authz import Identity from tenxgraph.core.state import Message +from tenxgraph_api.src.app.core.audit import audit_event from tenxgraph_api.src.app.core.auth.permissions import RequirePermission from tenxgraph_api.src.app.core.config.settings import get_settings from tenxgraph_api.src.app.core.validation.client_filters import parse_filters_param @@ -126,6 +127,13 @@ async def put_state( identity, payload.state, ) + await audit_event( + "change", + "success", + action="threads:write", + resource=f"thread/{thread_id}", + identity=identity, + ) return success_response( res, @@ -164,6 +172,13 @@ async def clear_state( config, identity, ) + await audit_event( + "change", + "success", + action="threads:delete", + resource=f"thread/{thread_id}", + identity=identity, + ) return success_response( res, @@ -259,6 +274,13 @@ async def put_message_feedback( message_id, payload.feedback, ) + await audit_event( + "change", + "success", + action="threads:write", + resource=f"message/{message_id}", + identity=identity, + ) return success_response( parse_message_output(get_settings(), result), @@ -355,6 +377,13 @@ async def delete_message( identity, message_id, ) + await audit_event( + "change", + "success", + action="threads:delete", + resource=f"message/{message_id}", + identity=identity, + ) return success_response( {"success": True, "message": "Message deleted successfully"}, @@ -493,6 +522,13 @@ async def delete_thread( identity, thread_id, ) + await audit_event( + "change", + "success", + action="threads:delete", + resource=f"thread/{thread_id}", + identity=identity, + ) return success_response( res, diff --git a/tenxgraph_api/src/app/routers/graph/router.py b/tenxgraph_api/src/app/routers/graph/router.py index 92db794..4e37cb4 100644 --- a/tenxgraph_api/src/app/routers/graph/router.py +++ b/tenxgraph_api/src/app/routers/graph/router.py @@ -17,6 +17,7 @@ from tenxgraph.core.realtime.queue import LiveInputQueue from tenxgraph.core.state import StreamChunk, StreamEvent +from tenxgraph_api.src.app.core.audit import audit_event from tenxgraph_api.src.app.core.auth.permissions import ( RequirePermission, authorize_resource, @@ -658,6 +659,13 @@ async def stop_graph( await require_thread(service.checkpointer, identity, stop_request.thread_id) result = await service.stop_graph(stop_request.thread_id, identity, stop_request.config) + await audit_event( + "change", + "success", + action="graph:stop", + resource=f"thread/{stop_request.thread_id}", + identity=identity, + ) if not _is_production(): logger.info("Graph stop completed for thread %s", log_safe(stop_request.thread_id)) @@ -720,6 +728,13 @@ async def fix_graph( identity, fix_request.config, ) + await audit_event( + "change", + "success", + action="graph:fix", + resource=f"thread/{fix_request.thread_id}", + identity=identity, + ) if not _is_production(): logger.info("Graph fix completed for thread %s", log_safe(fix_request.thread_id)) @@ -738,14 +753,32 @@ async def fix_graph( async def _ws_token_revoked(identity: Identity | None) -> bool: """Whether the socket's token was revoked since the handshake (an outage counts as yes). - Never revoked when auth is off (``identity`` is ``None``). + Never revoked when auth is off (``identity`` is ``None``). A refusal is an ``auth`` audit + event. """ if identity is None: return False try: - return await token_revoked(identity) + revoked = await token_revoked(identity) + reason = "revoked" except TokenRevocationUnavailableError: + revoked, reason = True, "revocation_unavailable" + if revoked: + await audit_event("auth", "failed", reason=reason, user_id=identity.user_id) + return revoked + + +async def _ws_credential_error(websocket: WebSocket, identity: Identity | None) -> bool: + """Whether the socket's credential no longer holds (expired, revoked or backend down). + + Checked before each ``/v1/graph/ws`` run and on a timer during a ``/v1/graph/live`` + session: the token is re-verified (catches expiry) and the revocation backend is asked. + """ + if not ws_identity_still_valid(websocket, identity): + user_id = identity.user_id if identity is not None else None + await audit_event("auth", "failed", reason="invalid_token", user_id=user_id) return True + return await _ws_token_revoked(identity) async def _ws_run_gate(websocket: WebSocket, identity: Identity | None) -> str: @@ -754,7 +787,7 @@ async def _ws_run_gate(websocket: WebSocket, identity: Identity | None) -> str: The handshake guard runs once, so a socket that stays open must be re-checked before each run: the token may have expired or been revoked, and every run counts against the limit. """ - if not ws_identity_still_valid(websocket, identity) or await _ws_token_revoked(identity): + if await _ws_credential_error(websocket, identity): logger.info("WebSocket credential no longer valid; closing") await websocket.send_text( StreamChunk( @@ -1153,16 +1186,6 @@ async def __aiter__(self) -> Any: yield item -async def _live_credential_error(websocket: WebSocket, identity: Identity | None) -> bool: - """Whether a live session's credential no longer holds (expired, revoked or backend down). - - Same two checks as ``_ws_run_gate`` uses before each ``/v1/graph/ws`` run: the token is - re-verified (catches expiry) and the revocation backend is asked (an outage counts as - revoked, so it fails closed). - """ - return not ws_identity_still_valid(websocket, identity) or await _ws_token_revoked(identity) - - async def _close_live(websocket: WebSocket, code: str, message: str) -> None: """End a live session before it starts: a fatal error event, then close 1008.""" with contextlib.suppress(Exception): @@ -1326,7 +1349,7 @@ async def close_session_expired() -> None: # The handshake guard ran before the init frame; re-check before the session (the run) # starts, then on a timer for as long as it lasts. - if await _live_credential_error(websocket, identity): + if await _ws_credential_error(websocket, identity): await close_session_expired() return @@ -1446,7 +1469,7 @@ async def credential_watchdog() -> None: """End the session once its token expires or is revoked.""" while True: await asyncio.sleep(REALTIME_RECHECK_INTERVAL) - if await _live_credential_error(websocket, identity): + if await _ws_credential_error(websocket, identity): await close_session_expired() return diff --git a/tenxgraph_api/src/app/routers/media/router.py b/tenxgraph_api/src/app/routers/media/router.py index abfd50d..aa532ce 100644 --- a/tenxgraph_api/src/app/routers/media/router.py +++ b/tenxgraph_api/src/app/routers/media/router.py @@ -12,6 +12,7 @@ from starlette.datastructures import UploadFile as StarletteUploadFile from tenxgraph.core.authz import Identity +from tenxgraph_api.src.app.core.audit import audit_event from tenxgraph_api.src.app.core.auth.permissions import RequirePermission from tenxgraph_api.src.app.core.config.media_settings import get_media_settings from tenxgraph_api.src.app.core.utils.log_sanitizer import log_safe @@ -230,6 +231,13 @@ async def upload_file( except ValueError as exc: raise HTTPException(status_code=413, detail=client_error_detail(exc)) + await audit_event( + "change", + "success", + action="files:upload", + resource=f"file/{result.get('file_id')}", + identity=identity, + ) return success_response(FileUploadResponse(**result), request) @@ -383,6 +391,9 @@ async def delete_file( if not deleted: raise HTTPException(status_code=404, detail="File not found") + await audit_event( + "change", "success", action="files:delete", resource=f"file/{file_id}", identity=identity + ) return success_response(FileDeleteResponse(file_id=file_id, deleted=True), request) diff --git a/tenxgraph_api/src/app/routers/store/router.py b/tenxgraph_api/src/app/routers/store/router.py index 9794554..f451949 100644 --- a/tenxgraph_api/src/app/routers/store/router.py +++ b/tenxgraph_api/src/app/routers/store/router.py @@ -6,6 +6,7 @@ from injectq.integrations import InjectAPI from tenxgraph.core.authz import Identity +from tenxgraph_api.src.app.core.audit import audit_event from tenxgraph_api.src.app.core.auth.permissions import RequirePermission from tenxgraph_api.src.app.utils.response_helper import success_response from tenxgraph_api.src.app.utils.swagger_helper import generate_swagger_responses @@ -46,6 +47,13 @@ async def create_memory( """Store a memory item using the configured store.""" result = await service.store_memory(payload, identity) + await audit_event( + "change", + "success", + action="memory:write", + resource=f"memory/{result.memory_id}", + identity=identity, + ) return success_response(result, request, message="Memory stored successfully") @@ -128,6 +136,13 @@ async def update_memory( raise HTTPException(status_code=422, detail="memory_id is required and cannot be empty") result = await service.update_memory(memory_id, payload, identity) + await audit_event( + "change", + "success", + action="memory:write", + resource=f"memory/{memory_id}", + identity=identity, + ) return success_response(result, request, message="Memory updated successfully") @@ -157,6 +172,13 @@ async def delete_memory( result = await service.delete_memory( memory_id, config_payload, identity, options=options_payload ) + await audit_event( + "change", + "success", + action="memory:delete", + resource=f"memory/{memory_id}", + identity=identity, + ) return success_response(result, request, message="Memory deleted successfully") @@ -176,6 +198,9 @@ async def forget_memory( """Forget memories based on filters.""" result = await service.forget_memory(payload, identity) + await audit_event( + "change", "success", action="memory:delete", resource="memory", identity=identity + ) return success_response(result, request, message="Memories removed successfully") diff --git a/tenxgraph_api/src/app/routers/tasks/router.py b/tenxgraph_api/src/app/routers/tasks/router.py index 3575c0a..78c56d7 100644 --- a/tenxgraph_api/src/app/routers/tasks/router.py +++ b/tenxgraph_api/src/app/routers/tasks/router.py @@ -20,6 +20,7 @@ from tenxgraph_api.jobs.context import cancel from tenxgraph_api.jobs.scheduler import sources from tenxgraph_api.jobs.worker import broker +from tenxgraph_api.src.app.core.audit import audit_event from tenxgraph_api.src.app.core.auth.permissions import RequirePermission from tenxgraph_api.src.app.core.cache_store import BaseCacheStore from tenxgraph_api.src.app.core.config.settings import get_settings @@ -157,6 +158,13 @@ async def start_task( except Exception: await store.delete(_schedule_key(record.schedule_id)) raise + await audit_event( + "change", + "success", + action="tasks:trigger", + resource=f"schedule/{record.schedule_id}", + identity=identity, + ) return success_response({"schedule_id": record.schedule_id}, request, status_code=202) task_id = broker.id_generator() @@ -175,6 +183,13 @@ async def start_task( except Exception: await state.update_status(store, task_id, task.task_name, "failure", config) raise + await audit_event( + "change", + "success", + action="tasks:trigger", + resource=f"task/{task_id}", + identity=identity, + ) return success_response({"task_id": task_id}, request, status_code=202) @@ -227,6 +242,13 @@ async def change_schedule( record.schedule = body.schedule await _save_schedule(store, record) await _add_schedule(task, record) + await audit_event( + "change", + "success", + action="tasks:trigger", + resource=f"schedule/{schedule_id}", + identity=identity, + ) return success_response(record.model_dump(mode="json", exclude={"config"}), request) @@ -243,10 +265,24 @@ async def cancel_task( if schedule is not None and _visible(schedule, identity, widen=True): await _source().delete_schedule(task_id) await store.delete(_schedule_key(task_id)) + await audit_event( + "change", + "success", + action="tasks:cancel", + resource=f"schedule/{task_id}", + identity=identity, + ) return success_response({"schedule_id": task_id, "deleted": True}, request) record = await state.read_status(store, task_id) if schedule is not None or record is None or not _visible(record, identity, widen=True): raise HTTPException(status.HTTP_404_NOT_FOUND, NOT_FOUND) await cancel(task_id) record = await state.read_status(store, task_id) or record + await audit_event( + "change", + "success", + action="tasks:cancel", + resource=f"task/{task_id}", + identity=identity, + ) return success_response({"task_id": task_id, "status": record.status}, request) diff --git a/tests/cli/test_auth_cli.py b/tests/cli/test_auth_cli.py index 3ca7cc9..14f5ced 100644 --- a/tests/cli/test_auth_cli.py +++ b/tests/cli/test_auth_cli.py @@ -1,6 +1,5 @@ """``10xgraph audit`` and ``10xgraph build`` on the shared auth rules.""" - from __future__ import annotations import io @@ -56,3 +55,24 @@ def test_build_warns_that_the_image_would_not_boot(tmp_path, monkeypatch) -> Non assert code == 0 assert "authorization-removed" in text assert "would not boot" in text + + +def test_audit_command_warns_when_production_has_no_audit_log(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "production") + _write(tmp_path, monkeypatch, GOOD) + result = AuditCommand._audit_log_check() + assert result.status == "warn" + assert "audit log off in production" in result.detail + _write(tmp_path, monkeypatch, {**GOOD, "audit": {"enabled": False}}) + assert AuditCommand._audit_log_check().status == "warn" + + +def test_audit_command_passes_with_the_audit_log_on_or_outside_production( + tmp_path, monkeypatch +) -> None: + monkeypatch.setenv("MODE", "production") + _write(tmp_path, monkeypatch, {**GOOD, "audit": {"enabled": True}}) + assert AuditCommand._audit_log_check().status == "pass" + monkeypatch.setenv("MODE", "development") + _write(tmp_path, monkeypatch, GOOD) + assert AuditCommand._audit_log_check().status == "pass" diff --git a/tests/cli/test_group6_cli_templates.py b/tests/cli/test_group6_cli_templates.py index 63975bd..676bd46 100644 --- a/tests/cli/test_group6_cli_templates.py +++ b/tests/cli/test_group6_cli_templates.py @@ -220,3 +220,19 @@ def test_revoke_command_rejects_a_top_level_token_revocation(monkeypatch, tmp_pa cmd = RevokeCommand(output=OutputFormatter(stream=io.StringIO(), quiet=True)) with pytest.raises(ValueError, match="`token_revocation` goes inside `cache_store`"): cmd._cache_store("redis://x", "10xgraph.json") + + +def test_production_env_sets_json_logs_in_every_target() -> None: + from tenxgraph_api.cli.templates.defaults import ( + generate_docker_compose_content, + generate_dockerfile_content, + ) + + assert "- LOG_FORMAT=json" in generate_docker_compose_content("svc", 8000) + assert "ENV LOG_FORMAT=json" in generate_dockerfile_content( + "3.12", 8000, "requirements.txt", True + ) + yaml = __import__("pytest").importorskip("yaml") + deployment, _ = yaml.safe_load_all(generate_k8s_manifest_content("svc", 8000)) + env = deployment["spec"]["template"]["spec"]["containers"][0]["env"] + assert {"name": "LOG_FORMAT", "value": "json"} in env diff --git a/tests/cli/test_init_prod.py b/tests/cli/test_init_prod.py index 1f1d285..f96dd6b 100644 --- a/tests/cli/test_init_prod.py +++ b/tests/cli/test_init_prod.py @@ -280,3 +280,20 @@ def test_production_graph_builds_a_pg_checkpointer_without_services(tmp_path: Pa ) assert done.returncode == 0, done.stderr assert done.stdout.strip().endswith("PgCheckpointer") + + +def test_production_template_writes_the_audit_log(tmp_path: Path) -> None: + import json + + target = tmp_path / "audited" + command = InitCommand(OutputFormatter(stream=io.StringIO())) + assert command.execute(path=str(target), agent_name="Audited", mode="full", yes=True) == 0 + config = json.loads((target / "10xgraph.json").read_text()) + assert config["audit"] == {"enabled": True, "sinks": ["log"], "include_reads": True} + + +def test_summary_shows_the_audit_log(monkeypatch) -> None: + context, _, _ = _run_prompts(monkeypatch, _FULL) + stream = io.StringIO() + InitCommand(output=OutputFormatter(stream=stream))._print_summary(context) + assert "Audit log" in stream.getvalue() diff --git a/tests/unit_tests/test_audit.py b/tests/unit_tests/test_audit.py new file mode 100644 index 0000000..e1fd288 --- /dev/null +++ b/tests/unit_tests/test_audit.py @@ -0,0 +1,459 @@ +"""Audit events: access (allowed, denied, not_found) and auth failures on HTTP and WebSocket.""" + +# ruff: noqa: S101 + +import asyncio +import importlib +import io +import json +import logging +from typing import Any + +import pytest +from fastapi import Depends, FastAPI, HTTPException, WebSocket +from fastapi.testclient import TestClient +from injectq import InjectQ +from injectq.integrations import setup_fastapi +from pydantic import ValidationError +from starlette.websockets import WebSocketDisconnect +from tenxgraph.core.authz import Identity +from tenxgraph.core.exceptions import ThreadNotFoundError + +from tenxgraph_api import AuditEvent, BaseAuditSink +from tenxgraph_api.src.app.core.audit import AuditConfig, configure_audit, set_sinks +from tenxgraph_api.src.app.core.audit.sinks import AUDIT_LOGGER_NAME, LogAuditSink +from tenxgraph_api.src.app.core.auth.base_auth import BaseAuth +from tenxgraph_api.src.app.core.auth.permissions import ( + WS_BEARER_SUBPROTOCOL, + RequirePermission, + _failure_reason, + authorize_resource, +) +from tenxgraph_api.src.app.core.auth.revocation import ( + TokenRevocationConfig, + TokenRevocationUnavailableError, + revoke_token, +) +from tenxgraph_api.src.app.core.cache_store import BaseCacheStore, InMemoryCacheStore +from tenxgraph_api.src.app.core.config.graph_config import GraphConfig +from tenxgraph_api.src.app.core.config.setup_middleware import RequestIDMiddleware +from tenxgraph_api.src.app.core.exceptions import UserAccountError +from tenxgraph_api.src.app.core.exceptions.handle_errors import init_errors_handler +from tenxgraph_api.src.app.core.request_context import get_request_context +from tenxgraph_api.src.app.core.threads import require_thread + + +ROUTER = "tenxgraph_api.src.app.routers.graph.router" +WS_POLICY_VIOLATION = 1008 +CLAIMS: dict[str, dict[str, Any]] = { + "alice": {"user_id": "alice", "org": "acme"}, + "revoked": {"user_id": "bob", "jti": "dead"}, + "reserved": {"user_id": "__system__:memory"}, + "bad-scope": {"user_id": "carol", "scope": 5}, +} + + +class _Auth(BaseAuth): + def authenticate(self, request, response, credential): # type: ignore[override] + if credential is None: + return None + if credential.credentials == "invalid": + raise HTTPException(status_code=401, detail="bad token") + return CLAIMS.get(credential.credentials) + + +class _Deny(_Auth): + async def authorize(self, identity, permission, resource=None): # type: ignore[override] + return False + + +class _NoThreads: + """A checkpointer where no thread is visible.""" + + async def aget_thread(self, config): + return None + + async def aget_thread_owner(self, thread_id): + return None + + +class _Recorder(BaseAuditSink): + def __init__(self) -> None: + self.events: list[AuditEvent] = [] + + async def emit(self, event: AuditEvent) -> None: + self.events.append(event) + + +class _Broken(BaseAuditSink): + async def emit(self, event: AuditEvent) -> None: + raise RuntimeError("sink down") + + +@pytest.fixture +def sink(): + recorder = _Recorder() + set_sinks([recorder]) + yield recorder + set_sinks([]) + + +def _build(auth: BaseAuth | None) -> TestClient: + container = InjectQ() + container.bind_instance(BaseAuth, auth, allow_none=True) + container.bind_instance(BaseCacheStore, InMemoryCacheStore()) + container.bind_instance(TokenRevocationConfig, TokenRevocationConfig()) + app = FastAPI() + setup_fastapi(container, app) + init_errors_handler(app) + app.add_middleware(RequestIDMiddleware) + + @app.get("/threads/{thread_id}") + async def read( + thread_id: str, who: Identity | None = Depends(RequirePermission("threads", "read")) + ): + ctx = get_request_context() + return {"ctx_user": ctx.user_id if ctx else None} + + @app.get("/missing/{thread_id}") + async def missing( + thread_id: str, who: Identity | None = Depends(RequirePermission("threads", "read")) + ): + await require_thread(_NoThreads(), who, thread_id) # type: ignore[arg-type] + + @app.delete("/storage/{thread_id}") + async def storage( + thread_id: str, who: Identity | None = Depends(RequirePermission("threads", "delete")) + ): + raise ThreadNotFoundError(thread_id) # a 404 raised by storage, not the lookup + + @app.delete("/files/{file_id}") + async def file( + file_id: str, who: Identity | None = Depends(RequirePermission("files", "delete")) + ): + raise HTTPException(status_code=404, detail="File not found") # outside the reach + + @app.post("/invoke") + async def invoke( + body: dict, who: Identity | None = Depends(RequirePermission("graph", "invoke")) + ): + return {"ok": True} + + @app.websocket("/ws") + async def ws( + websocket: WebSocket, who: Identity | None = Depends(RequirePermission("graph", "stream")) + ): + await websocket.accept(subprotocol=WS_BEARER_SUBPROTOCOL if who else None) + frame = await websocket.receive_json() + try: + await authorize_resource(who, "graph:stream", frame["thread_id"]) + await require_thread(_NoThreads(), who, frame["thread_id"], run=True) # type: ignore[arg-type] + await websocket.send_json({"ok": True}) + except HTTPException as exc: + await websocket.send_json({"error": exc.status_code}) + except ThreadNotFoundError: + await websocket.send_json({"error": 404}) + await websocket.close() + + client = TestClient(app) + client.container = container # type: ignore[attr-defined] + return client + + +def _get(client: TestClient, path: str, token: str | None = None): + headers = {"Authorization": f"Bearer {token}"} if token else {} + return client.get(path, headers=headers) + + +def _ws(client: TestClient, token: str | None, thread_id: str = "t1") -> Any: + subprotocols = [WS_BEARER_SUBPROTOCOL, token] if token else None + try: + with client.websocket_connect("/ws", subprotocols=subprotocols) as conn: + conn.send_json({"thread_id": thread_id}) + return conn.receive_json() + except WebSocketDisconnect as exc: + return exc.code + + +def _summary(events: list[AuditEvent]) -> list[tuple]: + return [(e.event, e.result, e.action, e.resource, e.reason, e.transport) for e in events] + + +# -- access on HTTP ----------------------------------------------------------- + + +def test_allowed_http_carries_identity_and_request_context(sink): + client = _build(_Auth()) + response = _get(client, "/threads/t1", "alice") + assert response.status_code == 200 + assert response.json() == {"ctx_user": "alice"} # set_request_user ran + [event] = sink.events + assert _summary([event]) == [("access", "allowed", "threads:read", "thread/t1", None, "http")] + assert event.user_id == "alice" + assert event.roles == [] + assert event.tenant == {} + assert event.client_ip == "testclient" + assert event.request_id == response.headers["X-Request-ID"] + + +def test_denied_http(sink): + assert _get(_build(_Deny()), "/threads/t1", "alice").status_code == 403 + assert _summary(sink.events) == [ + ("access", "denied", "threads:read", "thread/t1", None, "http") + ] + + +def test_not_found_from_thread_lookup_is_one_event(sink): + assert _get(_build(_Auth()), "/missing/t9", "alice").status_code == 404 + assert _summary(sink.events) == [ + ("access", "allowed", "threads:read", "thread/t9", None, "http"), + ("access", "not_found", "threads:read", "thread/t9", None, "http"), + ] + assert sink.events[1].user_id == "alice" + + +def test_not_found_raised_by_storage_is_recorded_by_the_404_handler(sink): + client = _build(_Auth()) + response = client.delete("/storage/t9", headers={"Authorization": "Bearer alice"}) + assert response.status_code == 404 + assert _summary(sink.events)[-1] == ( + "access", + "not_found", + "threads:delete", + "thread/t9", + None, + "http", + ) + assert sink.events[-1].user_id == "alice" + + +def test_http_404_after_allowed_check_is_one_not_found(sink): + client = _build(_Auth()) + response = client.delete("/files/f1", headers={"Authorization": "Bearer alice"}) + assert response.status_code == 404 + assert _summary(sink.events) == [ + ("access", "allowed", "files:delete", "file/f1", None, "http"), + ("access", "not_found", "files:delete", "file/f1", None, "http"), + ] + assert sink.events[1].user_id == "alice" + + +def test_404_without_a_permission_check_emits_nothing(sink): + assert _get(_build(_Auth()), "/no/such/route", "alice").status_code == 404 + assert sink.events == [] + + +def test_auth_off_records_access_with_null_user(sink): + assert _get(_build(None), "/threads/t1").status_code == 200 + [event] = sink.events + assert (event.result, event.user_id, event.resource) == ("allowed", None, "thread/t1") + + +# -- auth failures -------------------------------------------------------------- + + +def _revoke(client: TestClient) -> None: + store = client.container.get(BaseCacheStore) # type: ignore[attr-defined] + asyncio.run(revoke_token(store, "dead")) + + +@pytest.mark.parametrize( + ("token", "reason", "user_id"), + [ + (None, "missing_token", None), + ("invalid", "invalid_token", None), + ("unknown", "invalid_token", None), + ("revoked", "revoked", "bob"), + ("reserved", "reserved_id", None), + ("bad-scope", "claims_rejected", "carol"), + ], +) +def test_auth_failures_on_http_and_ws(sink, token, reason, user_id): + client = _build(_Auth()) + _revoke(client) + sink.events.clear() # the revocation itself is a change event + assert _get(client, "/threads/t1", token).status_code == 401 + assert _ws(client, token) == WS_POLICY_VIOLATION + assert [(e.event, e.result, e.reason, e.user_id) for e in sink.events] == [ + ("auth", "failed", reason, user_id) + ] * 2 + assert [e.transport for e in sink.events] == ["http", "ws"] + assert all(e.action is None and e.roles is None for e in sink.events) + + +def test_expired_token_reason(): + expired = UserAccountError(message="expired", error_code="EXPIRED_TOKEN", status_code=401) + credential = type("C", (), {"credentials": "x"})() + assert _failure_reason(credential, expired) == "expired" # type: ignore[arg-type] + assert _failure_reason(None, expired) == "missing_token" + broken = UserAccountError( + message="x", error_code="JWT_SETTINGS_NOT_CONFIGURED", status_code=500 + ) + assert _failure_reason(credential, broken) == "auth_error" # type: ignore[arg-type] + + +# -- WebSocket ------------------------------------------------------------------ + + +def test_ws_handshake_allowed_then_frame_thread_checks(sink): + assert _ws(_build(_Auth()), "alice", "t7") == {"error": 404} + assert _summary(sink.events) == [ + ("access", "allowed", "graph:stream", None, None, "ws"), # handshake, no thread yet + ("access", "allowed", "graph:stream", "thread/t7", None, "ws"), # the run frame + ("access", "not_found", "graph:stream", "thread/t7", None, "ws"), + ] + + +def test_ws_handshake_denied(sink): + assert _ws(_build(_Deny()), "alice") == WS_POLICY_VIOLATION + assert _summary(sink.events) == [("access", "denied", "graph:stream", None, None, "ws")] + + +# -- switches and failure modes --------------------------------------------------- + + +def test_include_reads_false_keeps_denials_and_not_found(): + recorder = _Recorder() + set_sinks([recorder], include_reads=False) + try: + assert _get(_build(_Auth()), "/threads/t1", "alice").status_code == 200 + assert _get(_build(_Auth()), "/missing/t2", "alice").status_code == 404 + assert _get(_build(_Deny()), "/threads/t1", "alice").status_code == 403 + assert _get(_build(_Auth()), "/threads/t1", None).status_code == 401 + finally: + set_sinks([]) + assert [(e.event, e.result) for e in recorder.events] == [ + ("access", "not_found"), + ("access", "denied"), + ("auth", "failed"), + ] + + +def test_disabled_emits_nothing(sink): + configure_audit(AuditConfig(), auth_enabled=True) # enabled: false (the default) + client = _build(_Deny()) + _get(client, "/threads/t1", "alice") + _get(client, "/threads/t1", None) + _ws(client, "alice") + assert sink.events == [] + + +def test_sink_error_is_logged_and_the_request_continues(sink): + set_sinks([_Broken(), sink]) + stream = io.StringIO() + handler = logging.StreamHandler(stream) + app_logger = logging.getLogger("tenxgraph_api") + app_logger.addHandler(handler) + try: + assert _get(_build(_Auth()), "/threads/t1", "alice").status_code == 200 + finally: + app_logger.removeHandler(handler) + assert "Audit sink _Broken failed: RuntimeError: sink down" in stream.getvalue() + assert len(sink.events) == 1 # the next sink still got the event + + +# -- config ----------------------------------------------------------------------- + + +def test_config_defaults_and_validation(tmp_path): + config = AuditConfig() + assert (config.enabled, config.sinks, config.include_reads) == (False, ["log"], True) + AuditConfig(sinks=["log", "publisher", "my_pkg.audit:Sink"]) + for bad in ({"enabled": True, "level": 1}, {"sinks": []}, {"sinks": ["file"]}): + with pytest.raises(ValidationError): + AuditConfig.model_validate(bad) + with pytest.raises(ValidationError): + AuditConfig.model_validate({"enabled": "yes"}) + + path = tmp_path / "10xgraph.json" + path.write_text(json.dumps({"agent": "x:y", "audit": {"enabled": True, "typo": 1}})) + with pytest.raises(ValidationError): + _ = GraphConfig(str(path)).audit + path.write_text(json.dumps({"agent": "x:y"})) + assert GraphConfig(str(path)).audit.enabled is False + + +def test_configure_audit_warns_and_skips_publisher_without_publisher(caplog): + app_logger = logging.getLogger("tenxgraph_api") + stream = io.StringIO() + handler = logging.StreamHandler(stream) + app_logger.addHandler(handler) + try: + configure_audit(AuditConfig(enabled=True, sinks=["log", "publisher"]), auth_enabled=False) + from tenxgraph_api.src.app.core.audit import audit_enabled + + assert audit_enabled() + finally: + app_logger.removeHandler(handler) + set_sinks([]) + out = stream.getvalue() + assert "auth is off" in out + assert '"publisher" but no events publisher is configured' in out + + +# -- the log sink ------------------------------------------------------------------- + + +def test_log_sink_writes_one_json_line_without_secrets(): + set_sinks([LogAuditSink()]) + handler = logging.getLogger(AUDIT_LOGGER_NAME).handlers[0] + stream = io.StringIO() + old = handler.setStream(stream) # type: ignore[attr-defined] + try: + client = _build(_Auth()) + headers = {"Authorization": "Bearer alice"} + prompt = "my secret prompt" + assert ( + client.post("/invoke", json={"messages": [prompt]}, headers=headers).status_code == 200 + ) + _get(client, "/threads/t1", "invalid") + finally: + handler.setStream(old) # type: ignore[attr-defined] + set_sinks([]) + text = stream.getvalue() + assert "Bearer" not in text and 'invalid"' not in text and prompt not in text + lines = [json.loads(line) for line in text.splitlines()] + assert [line["event"] for line in lines] == ["access", "auth"] + assert set(lines[0]) == set(AuditEvent.model_fields) + assert lines[0]["v"] == 1 and lines[0]["ts"].endswith("Z") + assert lines[0]["action"] == "graph:invoke" and lines[0]["user_id"] == "alice" + assert lines[1]["reason"] == "invalid_token" and lines[1]["user_id"] is None + assert not logging.getLogger(AUDIT_LOGGER_NAME).propagate + + +# -- WebSocket re-checks during a session --------------------------------------------- + + +@pytest.mark.parametrize( + ("valid", "revoked", "reason"), + [ + (False, False, "invalid_token"), + (True, True, "revoked"), + (True, None, "revocation_unavailable"), + ], +) +def test_ws_session_recheck_records_auth_failure(sink, monkeypatch, valid, revoked, reason): + router = importlib.import_module(ROUTER) + + async def token_revoked(identity): + if revoked is None: + raise TokenRevocationUnavailableError("down") + return revoked + + monkeypatch.setattr(router, "ws_identity_still_valid", lambda ws, user: valid) + monkeypatch.setattr(router, "token_revoked", token_revoked) + assert asyncio.run(router._ws_credential_error(None, Identity(user_id="alice"))) + assert [(e.event, e.reason, e.user_id) for e in sink.events] == [("auth", reason, "alice")] + + +def test_ws_session_recheck_passes_quietly(sink, monkeypatch): + router = importlib.import_module(ROUTER) + + async def token_revoked(identity): + return False + + monkeypatch.setattr(router, "ws_identity_still_valid", lambda ws, user: True) + monkeypatch.setattr(router, "token_revoked", token_revoked) + assert not asyncio.run(router._ws_credential_error(None, Identity(user_id="alice"))) + assert not asyncio.run(router._ws_credential_error(None, None)) # auth off + assert sink.events == [] diff --git a/tests/unit_tests/test_audit_changes.py b/tests/unit_tests/test_audit_changes.py new file mode 100644 index 0000000..eb10357 --- /dev/null +++ b/tests/unit_tests/test_audit_changes.py @@ -0,0 +1,403 @@ +"""Audit ``change`` events: one after a successful write or delete, none after a failure.""" + +# ruff: noqa: S101 + +import contextlib +import importlib +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException, UploadFile +from tenxgraph.core.authz import Identity + +from tenxgraph_api import AuditEvent, BaseAuditSink +from tenxgraph_api.src.app.core.audit import set_sinks +from tenxgraph_api.src.app.core.auth import revocation +from tenxgraph_api.src.app.core.cache_store import InMemoryCacheStore +from tenxgraph_api.src.app.routers.checkpointer import router as cp +from tenxgraph_api.src.app.routers.checkpointer.schemas.checkpointer_schemas import ( + ConfigSchema, + FeedbackSchema, + StateSchema, +) +from tenxgraph_api.src.app.routers.media import router as media +from tenxgraph_api.src.app.routers.store.schemas.store_schemas import ( + DeleteMemorySchema, + ForgetMemorySchema, + StoreMemorySchema, + UpdateMemorySchema, +) +from tests.unit_tests.jobs.test_routes import ADD, Env + + +store = importlib.import_module("tenxgraph_api.src.app.routers.store.router") + + +class _Recorder(BaseAuditSink): + def __init__(self) -> None: + self.events: list[AuditEvent] = [] + + async def emit(self, event: AuditEvent) -> None: + self.events.append(event) + + +@pytest.fixture +def sink(): + recorder = _Recorder() + set_sinks([recorder]) + yield recorder + set_sinks([]) + + +ME = Identity(user_id="alice") + + +def _changes(sink: _Recorder) -> list[AuditEvent]: + return [e for e in sink.events if e.event == "change"] + + +def _seen(sink: _Recorder) -> list[tuple]: + for e in _changes(sink): + assert e.result == "success" + assert e.user_id == "alice" + return [(e.action, e.resource) for e in _changes(sink)] + + +@pytest.mark.asyncio +async def test_checkpointer_changes(sink): + service = AsyncMock() + request = MagicMock() + kw = {"request": request, "service": service, "identity": ME} + await cp.put_state(thread_id="t1", payload=StateSchema(state={"a": 1}), **kw) + await cp.clear_state(thread_id="t1", **kw) + await cp.put_message_feedback( + thread_id="t1", message_id="m1", payload=FeedbackSchema(feedback={"up": True}), **kw + ) + await cp.delete_message(thread_id="t1", message_id="m1", payload=ConfigSchema(), **kw) + await cp.delete_thread(thread_id="t1", payload=ConfigSchema(), **kw) + assert _seen(sink) == [ + ("threads:write", "thread/t1"), + ("threads:delete", "thread/t1"), + ("threads:write", "message/m1"), + ("threads:delete", "message/m1"), + ("threads:delete", "thread/t1"), + ] + + +@pytest.mark.asyncio +async def test_checkpointer_failure_emits_nothing(sink): + service = AsyncMock() + service.delete_thread.side_effect = RuntimeError("down") + with pytest.raises(RuntimeError): + await cp.delete_thread( + request=MagicMock(), thread_id="t1", payload=ConfigSchema(), service=service, + identity=ME, + ) # fmt: skip + with pytest.raises(HTTPException): + await cp.delete_thread( + request=MagicMock(), thread_id=" ", payload=ConfigSchema(), service=service, + identity=ME, + ) # fmt: skip + assert sink.events == [] + + +@pytest.mark.asyncio +async def test_store_changes(sink): + service = AsyncMock() + service.store_memory.return_value = MagicMock(memory_id="mem1") + kw = {"request": MagicMock(), "service": service, "identity": ME} + await store.create_memory(payload=StoreMemorySchema(content="secret text"), **kw) + await store.update_memory(memory_id="mem1", payload=UpdateMemorySchema(content="x"), **kw) + await store.delete_memory(memory_id="mem1", payload=DeleteMemorySchema(), **kw) + await store.forget_memory(payload=ForgetMemorySchema(), **kw) + assert _seen(sink) == [ + ("memory:write", "memory/mem1"), + ("memory:write", "memory/mem1"), + ("memory:delete", "memory/mem1"), + ("memory:delete", "memory"), + ] + assert "secret" not in "".join(e.model_dump_json() for e in sink.events) + + +@pytest.mark.asyncio +async def test_store_failure_emits_nothing(sink): + service = AsyncMock() + service.delete_memory.side_effect = RuntimeError("down") + with pytest.raises(RuntimeError): + await store.delete_memory( + request=MagicMock(), memory_id="m", payload=None, service=service, identity=ME + ) + with pytest.raises(HTTPException): + await store.delete_memory( + request=MagicMock(), memory_id=" ", payload=None, service=service, identity=ME + ) + assert sink.events == [] + + +@pytest.mark.asyncio +async def test_media_changes(sink): + service = AsyncMock() + service.upload_file.return_value = { + "file_id": "f1", + "filename": "private-name.txt", + "mime_type": "text/plain", + "size_bytes": 4, + "extracted_text": None, + "url": "graph://media/f1", + } + service.delete_file.return_value = True + upload = MagicMock(spec=UploadFile) + upload.filename = "private-name.txt" + upload.content_type = "text/plain" + upload.read = AsyncMock(side_effect=[b"data", b""]) + await media.upload_file(request=MagicMock(), file=upload, service=service, identity=ME) + await media.delete_file(request=MagicMock(), file_id="f1", service=service, identity=ME) + seen = _seen(sink) + assert seen == [("files:upload", "file/f1"), ("files:delete", "file/f1")] + assert "private-name" not in "".join(e.model_dump_json() for e in sink.events) + + +@pytest.mark.asyncio +async def test_media_failure_emits_nothing(sink): + service = AsyncMock() + service.delete_file.return_value = False + with pytest.raises(HTTPException): + await media.delete_file(request=MagicMock(), file_id="f1", service=service, identity=ME) + service.upload_file.side_effect = ValueError("too large") + upload = MagicMock(spec=UploadFile) + upload.filename = "a.txt" + upload.content_type = "text/plain" + upload.read = AsyncMock(side_effect=[b"data", b""]) + with pytest.raises(HTTPException): + await media.upload_file(request=MagicMock(), file=upload, service=service, identity=ME) + assert sink.events == [] + + +def test_task_changes(sink, monkeypatch): + env = Env(monkeypatch) + run = env.call("POST", f"/v1/tasks/{ADD}", user="alice", json={"args": [1, 2]}) + task_id = run.json()["data"]["task_id"] + sched = env.call( + "POST", f"/v1/tasks/{ADD}", user="alice", json={"args": [1, 2], "schedule": {"every": 3}} + ) + schedule_id = sched.json()["data"]["schedule_id"] + env.call("PATCH", f"/v1/tasks/{schedule_id}", user="alice", json={"schedule": {"every": 5}}) + env.call("POST", f"/v1/tasks/{schedule_id}/cancel", user="alice") + assert _seen(sink) == [ + ("tasks:trigger", f"task/{task_id}"), + ("tasks:trigger", f"schedule/{schedule_id}"), + ("tasks:trigger", f"schedule/{schedule_id}"), + ("tasks:cancel", f"schedule/{schedule_id}"), + ] + + +def test_task_failures_emit_nothing(sink, monkeypatch): + env = Env(monkeypatch) + assert ( + env.call( + "POST", f"/v1/tasks/{ADD}", user="alice", json={"kwargs": {"config": 1}} + ).status_code + == 422 + ) + assert env.call("POST", "/v1/tasks/missing/cancel", user="alice").status_code == 404 + assert ( + env.call( + "PATCH", "/v1/tasks/missing", user="alice", json={"schedule": {"every": 5}} + ).status_code + == 404 + ) + assert _changes(sink) == [] + + +@pytest.mark.asyncio +async def test_revocation_events(sink): + cache = InMemoryCacheStore() + await revocation.revoke_token(cache, "jti-1") + await revocation.revoke_user(cache, "bob") + assert [(e.event, e.result, e.action, e.resource) for e in sink.events] == [ + ("change", "success", "tokens:revoke", "token/jti-1"), + ("change", "success", "users:revoke", "user/bob"), + ] + # Outside a request there is no context: those fields are null. + assert sink.events[0].request_id is None + assert sink.events[0].transport is None + + +@pytest.mark.asyncio +async def test_revocation_failure_emits_nothing(sink): + cache = AsyncMock() + cache.set.side_effect = RuntimeError("down") + with pytest.raises(RuntimeError): + await revocation.revoke_token(cache, "jti-1") + assert sink.events == [] + + +def test_revoke_cli_configures_audit_and_emits(tmp_path, monkeypatch): + import json + + from tenxgraph_api.cli.commands.revoke import RevokeCommand + from tenxgraph_api.src.app.core.audit import audit_enabled + + (tmp_path / "10xgraph.json").write_text( + json.dumps( + { + "agent": "g:app", + "cache_store": {"type": "redis", "url": "redis://localhost:6379/0"}, + "audit": {"enabled": True, "sinks": ["log"]}, + } + ) + ) + cache = AsyncMock() + cache.now.return_value = 100.0 + captured: list[AuditEvent] = [] + + async def emit(self, event): + captured.append(event) + + from tenxgraph_api.src.app.core.audit.sinks import LogAuditSink + + monkeypatch.setattr(LogAuditSink, "emit", emit) + monkeypatch.setattr( + "tenxgraph_api.src.app.core.cache_store.loader.build_cache_store", + lambda cfg: (cache, "test"), + ) + cmd = RevokeCommand.__new__(RevokeCommand) + cmd.output = MagicMock() + cmd.handle_error = MagicMock(side_effect=lambda e: (_ for _ in ()).throw(e)) + try: + assert cmd.execute(user="bob", config=str(tmp_path / "10xgraph.json")) == 0 + assert audit_enabled() + finally: + set_sinks([]) + assert [(e.action, e.resource, e.user_id) for e in captured] == [ + ("users:revoke", "user/bob", None) + ] + + +def _revoke_cmd(): + from tenxgraph_api.cli.commands.revoke import RevokeCommand + + cmd = RevokeCommand.__new__(RevokeCommand) + cmd.output = MagicMock() + cmd.handle_error = MagicMock(side_effect=lambda e: (_ for _ in ()).throw(e)) + return cmd + + +def _revoke_project(tmp_path, sinks): + import json + + (tmp_path / "10xgraph.json").write_text( + json.dumps( + { + "agent": "g:app", + "cache_store": {"type": "redis", "url": "redis://localhost:6379/0"}, + "audit": {"enabled": True, "sinks": sinks}, + } + ) + ) + return str(tmp_path / "10xgraph.json") + + +def test_revoke_cli_loads_custom_sink_from_project(tmp_path, monkeypatch): + import sys + + pkg = tmp_path / "my_audit_pkg" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "audit.py").write_text( + "from tenxgraph_api import BaseAuditSink\n" + "SEEN = []\n" + "class MySink(BaseAuditSink):\n" + " async def emit(self, event):\n" + " SEEN.append(event)\n" + ) + config = _revoke_project(tmp_path, ["my_audit_pkg.audit:MySink"]) + cache = AsyncMock() + cache.now.return_value = 100.0 + monkeypatch.setattr(sys, "path", list(sys.path)) + monkeypatch.setattr( + "tenxgraph_api.src.app.core.cache_store.loader.build_cache_store", + lambda cfg: (cache, "test"), + ) + monkeypatch.setattr( + "tenxgraph_api.src.app.core.cache_store.RedisCacheStore.from_url", + lambda url: cache, + ) + try: + # --redis-url used to skip the sys.path insert. + assert _revoke_cmd().execute(user="bob", config=config, redis_url="redis://x/0") == 0 + seen = sys.modules["my_audit_pkg.audit"].SEEN + assert [(e.action, e.resource) for e in seen] == [("users:revoke", "user/bob")] + finally: + set_sinks([]) + for name in ("my_audit_pkg", "my_audit_pkg.audit"): + sys.modules.pop(name, None) + + +def test_revoke_cli_broken_sink_warns_and_still_revokes(tmp_path, monkeypatch): + config = _revoke_project(tmp_path, ["no_such_pkg.audit:Nope"]) + cache = AsyncMock() + cache.now.return_value = 100.0 + captured: list[AuditEvent] = [] + + async def emit(self, event): + captured.append(event) + + from tenxgraph_api.src.app.core.audit.sinks import LogAuditSink + + monkeypatch.setattr(LogAuditSink, "emit", emit) + monkeypatch.setattr( + "tenxgraph_api.src.app.core.cache_store.loader.build_cache_store", + lambda cfg: (cache, "test"), + ) + cmd = _revoke_cmd() + try: + assert cmd.execute(user="bob", config=config) == 0 + finally: + set_sinks([]) + cmd.output.warning.assert_called_once() + cache.set.assert_called() + assert [(e.action, e.resource) for e in captured] == [("users:revoke", "user/bob")] + + +def _graph_env(monkeypatch, graph, **service_calls): + @contextlib.asynccontextmanager + async def guard(_thread_id): + yield + + monkeypatch.setattr(graph, "require_thread", AsyncMock()) + monkeypatch.setattr(graph, "thread_write_guard", guard) + service = MagicMock() + service.checkpointer = MagicMock() + for name, effect in service_calls.items(): + mock = AsyncMock() + if isinstance(effect, Exception): + mock.side_effect = effect + else: + mock.return_value = effect + setattr(service, name, mock) + return service + + +@pytest.mark.asyncio +async def test_graph_fix_and_stop_emit_change(sink, monkeypatch): + graph = importlib.import_module("tenxgraph_api.src.app.routers.graph.router") + service = _graph_env(monkeypatch, graph, fix_graph={"ok": True}, stop_graph={"ok": True}) + fix = MagicMock(thread_id="t1", config={}) + stop = MagicMock(thread_id="t2", config={}) + await graph.fix_graph(MagicMock(), fix, service, ME) + await graph.stop_graph(MagicMock(), stop, service, ME) + assert _seen(sink) == [("graph:fix", "thread/t1"), ("graph:stop", "thread/t2")] + + +@pytest.mark.asyncio +async def test_graph_fix_and_stop_failure_emit_nothing(sink, monkeypatch): + graph = importlib.import_module("tenxgraph_api.src.app.routers.graph.router") + service = _graph_env( + monkeypatch, graph, fix_graph=RuntimeError("x"), stop_graph=RuntimeError("y") + ) + with pytest.raises(RuntimeError): + await graph.fix_graph(MagicMock(), MagicMock(thread_id="t1", config={}), service, ME) + with pytest.raises(RuntimeError): + await graph.stop_graph(MagicMock(), MagicMock(thread_id="t2", config={}), service, ME) + assert sink.events == [] diff --git a/tests/unit_tests/test_audit_sinks.py b/tests/unit_tests/test_audit_sinks.py new file mode 100644 index 0000000..f1d9f6d --- /dev/null +++ b/tests/unit_tests/test_audit_sinks.py @@ -0,0 +1,165 @@ +"""Audit sinks: publisher sink, custom ``module:Class`` sinks and the sink error counter.""" + +# ruff: noqa: S101 + +import logging +import sys +import types +from typing import Any + +import pytest +from tenxgraph.runtime.publisher.base_publisher import BasePublisher +from tenxgraph.utils.background_task_manager import BackgroundTaskManager +from tenxgraph.utils.metrics import counter + +from tenxgraph_api import AuditEvent, BaseAuditSink +from tenxgraph_api.src.app.core.audit import _sink_failed +from tenxgraph_api.src.app.core.audit import sinks as sinks_module +from tenxgraph_api.src.app.core.audit.sinks import PublisherAuditSink, build_sinks + + +COUNTER = "tenxgraph_audit_sink_errors_total" + + +class _Publisher(BasePublisher): + def __init__(self, kind: str = "events") -> None: + super().__init__({}) + self.kind = kind # type: ignore[assignment] + + async def publish(self, event): + return None + + async def close(self): + return None + + def sync_close(self): + return None + + +class _Container: + """Stands in for ``InjectQ.get_instance()``.""" + + publisher: BasePublisher | None = None + + @classmethod + def get_instance(cls): + return cls + + @classmethod + def try_get(cls, key): + return cls.publisher + + +class _Mine(BaseAuditSink): + async def emit(self, event: AuditEvent) -> None: + return None + + +_INSTANCE = _Mine() + + +def _factory() -> BaseAuditSink: + return _Mine() + + +def _make_module(monkeypatch, **attrs: Any) -> None: + module = types.ModuleType("audit_sink_fixture") + for name, value in attrs.items(): + setattr(module, name, value) + monkeypatch.setitem(sys.modules, "audit_sink_fixture", module) + + +@pytest.fixture +def container(monkeypatch): + _Container.publisher = None + monkeypatch.setattr(sinks_module, "InjectQ", _Container) + yield _Container + _Container.publisher = None + + +def _event() -> AuditEvent: + return AuditEvent(event="change", result="success", action="threads:delete", user_id="u1") + + +# -- publisher -------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_publisher_sink_sends_the_event_dump(container, monkeypatch): + sent: list[dict] = [] + + def fake_publish(event, publisher, task_manager, only): + sent.append({"event": event, "publisher": publisher, "only": only, "tm": task_manager}) + + monkeypatch.setattr(sinks_module, "publish_event", fake_publish) + container.publisher = publisher = _Publisher() + [sink] = build_sinks(["publisher"]) + assert isinstance(sink, PublisherAuditSink) + record = _event() + await sink.emit(record) + assert len(sent) == 1 + assert sent[0]["publisher"] is publisher + assert sent[0]["only"] == "events" + assert isinstance(sent[0]["tm"], BackgroundTaskManager) + assert sent[0]["tm"] is sink._tasks # its own manager, not the container's + assert sent[0]["event"].data == record.model_dump() + assert sent[0]["event"].user_id == "u1" + + +def test_publisher_listed_without_publisher_warns_and_is_skipped(container, caplog): + with caplog.at_level(logging.WARNING, logger="tenxgraph_api"): + assert build_sinks(["log", "publisher"])[0].__class__.__name__ == "LogAuditSink" + assert len(build_sinks(["publisher"])) == 0 + assert any('"publisher"' in r.message and "skipped" in r.message for r in caplog.records) + + +def test_observability_only_publisher_is_skipped(container, caplog): + container.publisher = _Publisher(kind="observability") + with caplog.at_level(logging.WARNING, logger="tenxgraph_api"): + assert build_sinks(["publisher"]) == [] + assert any("skipped" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_publisher_sink_never_raises_into_the_request_path(container): + """With a real publisher and no running manager the event is queued or shed, not raised.""" + container.publisher = _Publisher() + [sink] = build_sinks(["publisher"]) + await sink.emit(_event()) + + +# -- custom sinks ----------------------------------------------------------------- + + +@pytest.mark.parametrize("attr", ["_INSTANCE", "_Mine", "_factory"]) +def test_custom_sink_instance_subclass_or_factory(monkeypatch, attr): + _make_module(monkeypatch, _INSTANCE=_INSTANCE, _Mine=_Mine, _factory=_factory) + [sink] = build_sinks([f"audit_sink_fixture:{attr}"]) + assert isinstance(sink, _Mine) + if attr == "_INSTANCE": + assert sink is _INSTANCE + + +@pytest.mark.parametrize( + "path, attrs", + [ + ("no_such_audit_module_xyz:Sink", {}), + ("audit_sink_fixture:Missing", {}), + ("audit_sink_fixture:NotASink", {"NotASink": object}), + ("audit_sink_fixture:Number", {"Number": 5}), + ("audit_sink_fixture:bad_factory", {"bad_factory": lambda: "nope"}), + ], +) +def test_bad_custom_sink_refuses_boot(monkeypatch, path, attrs): + _make_module(monkeypatch, **attrs) + with pytest.raises(ValueError, match="audit.sinks"): + build_sinks([path]) + + +# -- counter ---------------------------------------------------------------------- + + +def test_sink_failure_increments_the_counter(): + before = counter(COUNTER).value + _sink_failed(_Mine(), RuntimeError("down")) + assert counter(COUNTER).value == before + 1 diff --git a/tests/unit_tests/test_config_editor.py b/tests/unit_tests/test_config_editor.py index cd3f584..e1beb00 100644 --- a/tests/unit_tests/test_config_editor.py +++ b/tests/unit_tests/test_config_editor.py @@ -920,7 +920,7 @@ def test_tracing_and_metrics_sections() -> None: assert all( s.get("self_managed", False) is False for s in build_schema()["sections"] - if s["id"] not in ("observability", "metrics") + if s["id"] not in ("observability", "metrics", "audit") ) assert metrics["key"] == "metrics" and metrics["optional"] is True assert "metrics" in KNOWN_TOP_LEVEL_KEYS @@ -1010,3 +1010,45 @@ def test_nothing_traced_warning_counts_otlp() -> None: {"agent": "a:b", "observability": {"otlp": {"enabled": True, "endpoint": "http://c:4318"}}} ) assert _levels(on, "observability") == [] + + +# --------------------------------------------------------------------------- +# audit log section +# --------------------------------------------------------------------------- + + +def test_audit_section_is_next_to_metrics_and_lists_log_format() -> None: + sections = {s["id"]: s for s in build_schema()["sections"]} + audit = sections["audit"] + assert audit["key"] == "audit" and audit["self_managed"] and audit["widget"] == "audit" + assert audit["off_summary"] == "No audit events are recorded." + assert [e["name"] for e in audit["env"]] == ["LOG_FORMAT"] + assert "audit" in KNOWN_TOP_LEVEL_KEYS and section_for_key("audit") == "audit" + assert "default" not in json.dumps([f["label"] for f in audit["fields"]]).lower() + + +def test_audit_key_round_trips_through_the_store(tmp_path: Path) -> None: + store = ConfigFileStore(tmp_path / "10xgraph.json") + audit = { + "enabled": True, + "sinks": ["log", "publisher", "my_pkg.sink:Sink"], + "include_reads": False, + } + store.save({"agent": "graph.react:app", "audit": audit}, None) + loaded = store.load().config + assert loaded["audit"] == audit + assert _levels(validate_config(loaded), "audit") == [] + assert _levels(validate_config(loaded), "audit.sinks") == [] + + +def test_audit_unknown_key_and_bad_values_are_errors() -> None: + base = {"agent": "graph.react:app"} + issues = validate_config({**base, "audit": {"enabled": True, "level": "full"}}) + assert _levels(issues, "audit.level") == ["error"] + issues = validate_config({**base, "audit": {"enabled": "yes", "include_reads": 1}}) + assert _levels(issues, "audit.enabled") == ["error"] + assert _levels(issues, "audit.include_reads") == ["error"] + for sinks in (["syslog"], [], "log", ["log", 3]): + issues = validate_config({**base, "audit": {"enabled": True, "sinks": sinks}}) + assert _levels(issues, "audit.sinks") == ["error"], sinks + assert _levels(validate_config({**base, "audit": []}), "audit") == ["error"] diff --git a/tests/unit_tests/test_json_logs.py b/tests/unit_tests/test_json_logs.py new file mode 100644 index 0000000..bd21911 --- /dev/null +++ b/tests/unit_tests/test_json_logs.py @@ -0,0 +1,116 @@ +"""LOG_FORMAT=json output, request context on log lines, and LOG_FORMAT validation.""" + +# ruff: noqa: S101 + +import io +import json +import logging + +import pytest +from pydantic import ValidationError +from starlette.applications import Starlette +from starlette.responses import PlainTextResponse +from starlette.routing import Route, WebSocketRoute +from starlette.testclient import TestClient + +from tenxgraph_api.src.app.core.config.settings import Settings +from tenxgraph_api.src.app.core.config.setup_logs import ( + JsonFormatter, + RequestContextFilter, +) +from tenxgraph_api.src.app.core.config.setup_middleware import RequestIDMiddleware +from tenxgraph_api.src.app.core.request_context import get_request_context, set_request_user +from tenxgraph_api.src.app.core.utils.log_sanitizer import SanitizingFormatter + + +def _logger(formatter: logging.Formatter, name: str): + stream = io.StringIO() + handler = logging.StreamHandler(stream) + handler.setFormatter(SanitizingFormatter(formatter)) + handler.addFilter(RequestContextFilter()) + log = logging.getLogger(name) + log.handlers = [handler] + log.propagate = False + log.setLevel(logging.INFO) + return log, stream + + +def _lines(stream: io.StringIO) -> list[dict]: + return [json.loads(line) for line in stream.getvalue().splitlines()] + + +def test_json_shape_and_exception(): + log, stream = _logger(JsonFormatter(), "test_json_shape") + try: + raise ValueError("boom") + except ValueError: + log.exception("failed %s", "x") + (entry,) = _lines(stream) + assert entry["level"] == "ERROR" + assert entry["logger"] == "test_json_shape" + assert entry["message"] == "failed x" + assert entry["ts"].endswith("Z") and len(entry["ts"]) == len("2026-01-01T00:00:00.000Z") + assert "ValueError: boom" in entry["exc_info"] + assert "request_id" not in entry + + +def test_json_mode_still_masks_secrets(): + log, stream = _logger(JsonFormatter(), "test_json_mask") + secret = "sk-abcdefghijklmnopqrstuvwxyz0123456789" + log.info("calling with api_key=%s", secret) + out = stream.getvalue() + assert secret not in out + assert _lines(stream)[0]["message"] == "calling with api_key=sk-a...6789" + # Text already baked into the message (f-strings) is redacted too. + log.info(f"calling with api_key={secret}") + assert "***API_KEY***" in _lines(stream)[1]["message"] + + +def _app(): + seen: list = [] + + async def http(request): + logging.getLogger("test_json_ctx").info("in http") + seen.append(get_request_context()) + return PlainTextResponse("ok") + + async def ws(websocket): + await websocket.accept() + set_request_user("u-1") + logging.getLogger("test_json_ctx").info("in ws") + await websocket.close() + + app = Starlette(routes=[Route("/", http), WebSocketRoute("/ws", ws)]) + app.add_middleware(RequestIDMiddleware) + return app, seen + + +def test_request_context_in_http_and_ws_log_lines(): + log, stream = _logger(JsonFormatter(), "test_json_ctx") + app, seen = _app() + client = TestClient(app) + resp = client.get("/") + with client.websocket_connect("/ws"): + pass + http_entry, ws_entry = _lines(stream) + assert http_entry["request_id"] == resp.headers["X-Request-ID"] + assert http_entry["transport"] == "http" + assert ws_entry["transport"] == "ws" + assert ws_entry["user_id"] == "u-1" + assert ws_entry["request_id"] != http_entry["request_id"] + assert seen[0].client_ip == "testclient" + assert get_request_context() is None # reset after the request + + +def test_text_mode_unchanged(): + fmt = logging.Formatter("%(name)s - %(levelname)s - %(message)s") + log, stream = _logger(fmt, "test_text_mode") + log.info("hello") + assert stream.getvalue() == "test_text_mode - INFO - hello\n" + + +def test_log_format_validation(): + assert Settings(LOG_FORMAT="JSON").LOG_FORMAT == "json" + assert Settings().LOG_FORMAT == "text" + with pytest.raises(ValidationError, match="LOG_FORMAT"): + Settings(LOG_FORMAT="xml") diff --git a/tests/unit_tests/test_store_router.py b/tests/unit_tests/test_store_router.py index 58547b4..52a8e90 100644 --- a/tests/unit_tests/test_store_router.py +++ b/tests/unit_tests/test_store_router.py @@ -40,7 +40,7 @@ async def test_create_memory_calls_service( from tenxgraph_api.src.app.routers.store.schemas.store_schemas import StoreMemorySchema mock_success_response.return_value = {"data": {}} - mock_service.store_memory.return_value = {"id": "mem-1"} + mock_service.store_memory.return_value = MagicMock(memory_id="mem-1") payload = StoreMemorySchema(content="Test memory", metadata=None) result = await create_memory(