diff --git a/10xgraph.json b/10xgraph.json index bad6e381..75e90da7 100644 --- a/10xgraph.json +++ b/10xgraph.json @@ -4,13 +4,15 @@ "store": "graph.dummy_store:store", "env": ".env", "auth": null, - "rate_limit": { - "enabled": true, - "backend": "memory", - "requests": 100, - "window": 60, - "by": "ip", - "trusted_proxy_headers": false, - "exclude_paths": ["/health", "/docs", "/redoc", "/openapi.json"] + "cache_store": { + "type": "memory", + "rate_limit": { + "enabled": true, + "requests": 100, + "window": 60, + "by": "ip", + "trusted_proxy_headers": false, + "exclude_paths": ["/health", "/docs", "/redoc", "/openapi.json"] + } } -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md index e775ffe2..b59144f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,106 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [1.0.0rc4] - 2026-11-01 + +The fourth release candidate of `10xgraph-api` 1.0. It requires `10xgraph` 1.0.0rc4 +(checkpointers have `aping()`). Everything the API shares between workers lives in one +`BaseCacheStore`, configured by one `cache_store` object in `10xgraph.json`, and the startup +check no longer refuses to boot over a Redis that nothing configured needs. +`10xgraph-client` stays at 1.0.0-rc.2. + +### Breaking + +- Removed, with no deprecation aliases (release candidates): `ConcurrencyLimiter` and + `core/concurrency.py`, `TokenRevocationBackend` (with `InMemoryTokenRevocation`, + `RedisTokenRevocation`, `build_token_revocation`), `BaseRateLimitBackend` (with + `MemoryRateLimitBackend`, `RedisRateLimitBackend` and the rate-limit factory). Run locks and + caps, idempotency, the ownership cache, token revocation and rate limiting all use the bound + `BaseCacheStore`; implement one to replace Redis. +- `cache_store` is one object that holds the store and everything it backs: + `{"type": "memory" | "redis" | "custom", "url": "${REDIS_URL}", "path": "module:attr", + "rate_limit": {...}, "token_revocation": true}`. `url` goes with type `redis` (optional: + empty uses the `REDIS_URL` environment variable; `$VAR` / `${VAR}` expanded, an unset variable + is a config error) and `path` with type `custom` + (a `BaseCacheStore` instance or zero-argument factory). No `cache_store`, or no `type`, is the + in-memory store; `rate_limit` and `token_revocation` are off when absent. Unknown keys inside + `cache_store`, `rate_limit` and `token_revocation` are config errors listing the valid keys. +- The top-level `redis`, `rate_limit` and `token_revocation` keys are config errors at load that + say where each setting goes; a leftover `rate_limit` can no longer turn rate limiting off + without a word. `rate_limit` inside `cache_store` has no `backend` or `redis` key. + `token_revocation` is `true`, `false`, `null` or `{"enabled", "token_lifetime", "user_ttl"}`. +- `REDIS_URL` no longer selects the cache store, and `Settings.REDIS_URL` is gone: the store + comes only from `cache_store` (write `"url": "${REDIS_URL}"` to keep reading the variable). + This fixes rc3 refusing to boot an in-memory server in production with a stale `REDIS_URL`. +- `cache_store.type` `"redis"` without the `redis` package refuses to boot with + `pip install "10xgraph-api[redis]"`; it never falls back to a per-process store. +- `GET /health/ready`: the `redis` key in `checks` is renamed `cache` and reports the bound + cache store (`{"checkpointer": "ok", "store": "ok", "cache": "ok"}`). Readiness and the + startup check only call `aping()` on the bound checkpointer, store and cache store; a + component's `readiness_check()` hook and the probing of private checkpointer attributes are + gone. +- `10xgraph init` asks only the agent name, template and AI provider. Production writes the + recommended setup: jwt auth, `"authorization": "ownership"`, `"id_generator": "uuid"` and + `"cache_store": {"type": "redis", "rate_limit": {...}, "token_revocation": true}` (no `url`: + Redis reads `REDIS_URL`); Quick Start writes no `cache_store`. The flags `--auth`, + `--cache-store memory|redis`, `--rate-limit/--no-rate-limit`, + `--token-revocation/--no-token-revocation` and `--websocket` override parts of it for scripts. + The summary lists the written choices and the next steps point to `10xgraph config`. +- `thread_name_generator` takes `"random"` (the default), `"first_message"`, `"none"` or + `"module:attr"`. With the key unset, new threads are now named (randomly) and the name is + saved; before, the default name was generated and dropped. `GraphConfig.thread_name_generator_path` + is replaced by `GraphConfig.thread_name_generator`. + +### Added + +- `BaseCacheStore` with `RedisCacheStore` (also Valkey, Dragonfly, KeyDB) and + `InMemoryCacheStore`, exported from `tenxgraph_api`. A store has an abstract `aping()` and an + optional `describe()` that names its endpoint (never credentials) in logs and startup errors. + One INFO line at boot names the store and the config key it came from, for example + `Cache store: RedisCacheStore (cache_store.url, localhost:6379)`. +- A contract suite for custom cache stores in `tenxgraph_api.testing.cache_store_contract`. +- `id_generator` in `10xgraph.json`: `uuid`, `hex`, `short`, `int`, `bigint`, `timestamp`, + `snowflake` (`SnowFlakeIdGenerator`, needs the `snowflakekit` extra and reads the `SNOWFLAKE_*` + environment variables) or `"module:attr"` (a `BaseIDGenerator` instance, subclass or + zero-argument factory). The API binds it for the loaded graph, so new thread, message and + memory ids all come from it. Boot refuses when the graph is also compiled with its own + generator (`StateGraph(id_generator=...)`), and when a `PgCheckpointer`'s `id_type` cannot + hold its ids. Unset keeps the graph's own generator. +- `thread_name_generator: "first_message"`: the user's first message, whitespace collapsed, + cut to at most 60 characters on a word boundary (`FirstMessageThreadNameGenerator`). +- `10xgraph config` edits token revocation as a section with `enabled`, `token_lifetime` and + `user_ttl`. + +### Changed + +- The startup check is fatal for the cache store only when a fail-closed feature uses it: + `cache_store.token_revocation` on (with auth), or `cache_store.rate_limit` with + `fail_open: false`. Otherwise an unreachable cache store is one WARNING (`... unreachable: + locks and caps fall back to per process ...`) and the server starts; `/health/ready` reports + `cache: fail` and stays 200. Startup errors name each component's class, the config key of + the cache store (`cache_store.url`, `cache_store.path ...`) and its `describe()` endpoint. + The hint reads "Start the backend, fix its URL, or set STARTUP_CHECKS=false to skip this + check." +- `10xgraph revoke` writes to the cache store `cache_store` selects (`--redis-url` overrides) + and refuses with a clear error when that is the in-memory store. +- Shutdown closes the cache store once, after the graph and after pending lease releases. The + authorization backend no longer closes a Redis client. +- `token_revocation` makes the cache store required (startup check, `/health/ready`) only when + auth is on, the same rule the loader uses to enable it. +- `cache_store` is parsed once per raw value, so its config warnings (such as + `trusted_proxy_headers` without `trusted_proxies`) are not repeated per readiness probe. +- A rate-limit denial stays a 429 when reading the cache store clock for `Retry-After` fails; + it no longer turns into an allow or a 503. +- The in-memory cache store keeps slot and key expiries on the monotonic clock, so a wall clock + jump (host suspend, NTP step) no longer expires a held thread lock early. `now()` and + `oldest_expiry` are still epoch seconds. +- `revoke_user` stores the cutoff rounded up to the microsecond, so a token issued at the + cutoff itself is always revoked (it was rounded to the nearest). +- Thread names from `thread_name_generator` get the user's messages first, then the replies + (before: the run's output messages only). + +--- + ## [1.0.0rc3] - 2026-10-30 The third release candidate of `10xgraph-api` 1.0. It requires `10xgraph` 1.0.0rc3 (stores have diff --git a/CLAUDE.md b/CLAUDE.md index be7abf4a..47926f48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,13 +5,13 @@ core framework see `agentflow/CLAUDE.md`; for the TS client, docs, or playground for the monorepo overview see the workspace-root `CLAUDE.md`. - Package name (PyPI): `10xgraph-api` (formerly `10xscale-agentflow-cli`, last release 0.6.x) -- Version: `1.0.0rc3` (`pyproject.toml`; 0.7.0 was the first release under the 10xGraph name). `CLI_VERSION` +- Version: `1.0.0rc4` (`pyproject.toml`; 0.7.0 was the first release under the 10xGraph name). `CLI_VERSION` and `tenxgraph_api.__version__` are single-sourced from the installed distribution metadata (falling back to `pyproject.toml` only for a non-installed source checkout). - Requires: Python >= 3.12 ยท Status: `5 - Production/Stable` - Console entry points: `10xgraph = tenxgraph_api.cli.main:main`, plus the deprecated alias `agentflow = tenxgraph_api.cli.main:legacy_main` (prints a notice to stderr; removed in 2.0) -- Depends on the core framework: `10xgraph>=1.0.0rc3,<2.0` (import `tenxgraph`). Must not be +- Depends on the core framework: `10xgraph>=1.0.0rc4,<2.0` (import `tenxgraph`). Must not be installed alongside the old `10xscale-agentflow`: both provide an `agentflow` module. ## Rename compatibility (kept until 2.0) @@ -52,7 +52,7 @@ Public exports from the package root (`from tenxgraph_api import ...`): `BaseAut | Command | Purpose | Notable options | |---|---|---| | `10xgraph dev` | The only server command: local Uvicorn dev server (reload on) via `commands/api.py::APICommand`, opens the hosted playground when ready. `api` and `play` were removed (use `dev --no-open` / `dev`); production runs Gunicorn in the `10xgraph build` image | `--config/-c` (default `10xgraph.json`), `--host/-H`, `--port/-p` (8000), `--reload/--no-reload`, `--open/--no-open`, `-v/-q` | -| `10xgraph init` | Interactively scaffold a project (guided prompts pick dev vs production, auth, rate limit) | `--path/-p`, `--force/-f`, `--name`, `--template` (quick-start\|production), `--auth` (none\|jwt\|custom), `--rate-limit` (none\|memory\|redis), `--provider` (google\|openai\|anthropic), `--websocket/--no-websocket` (default off; writes `websocket.enabled`). Prod `pyproject.toml` depends on `10xgraph[]` and `10xgraph-api[jwt,redis,websocket]` as chosen, `--yes/-y`, `--non-interactive`, `--dry-run`. There is **no `--prod` flag** | +| `10xgraph init` | Interactively scaffold a project. Asks only agent name, template (Quick Start \| Production) and AI provider. Quick Start writes no `cache_store`. Production writes the recommended setup without asking: `auth` jwt, `authorization` ownership, `id_generator` uuid, `cache_store` redis (no `url`: `REDIS_URL`) with `rate_limit` (100 per 60 s, by user, fail closed) and `token_revocation`; the summary table lists these. The next steps point to `10xgraph config` for WebSocket, AG-UI, ID generator, thread naming and every other setting. Flags are scripted overrides only | `--path/-p`, `--force/-f`, `--name`, `--template` (quick-start\|production), `--auth` (none\|jwt\|custom), `--cache-store` (memory\|redis, production default redis), `--rate-limit/--no-rate-limit` (production default on; adds `cache_store.rate_limit`), `--token-revocation/--no-token-revocation` (production default on with auth, ignored with auth none; adds `cache_store.token_revocation`), quick-start ignores these three, `--provider` (google\|openai\|anthropic), `--websocket/--no-websocket` (default off; writes `websocket.enabled`). Prod `pyproject.toml` depends on `10xgraph[]` and `10xgraph-api[jwt,redis,websocket]` as chosen, `--yes/-y`, `--non-interactive`, `--dry-run`. There is **no `--prod` flag** | | `10xgraph build` | Generate a `Dockerfile` (and optionally `docker-compose.yml` / `k8s.yaml`). The builder stage strips CLI assets, runs `tenxgraph_api/_slim_image.py` (uninstalls typer/rich/questionary and what only they need, unless another package needs them) and removes pip, so the image carries no CLI code paths | `--output/-o`, `--force/-f`, `--python-version` (3.13), `--port`, `--docker-compose/--no-docker-compose`, `--k8s/--no-k8s`, `--service-name` | | `10xgraph eval` | Run agent evaluations; discovers `*_eval.py`/`eval_*.py`, runs cases (optionally `--parallel`), writes JSON and HTML reports to `eval_reports/` and opens the HTML report in the browser (only at an interactive terminal: skipped with `--no-open`, `--no-report`, `-q`, json/jsonl, `--non-interactive`, `CI`, non-TTY; `evaluation.open_report` in `10xgraph.json` sets the default). Simulations print per-turn step lines via `ProgressRun.step` | `--output/-o`, `--no-report`, `--threshold/-t`, `--parallel/-p`, `--max-concurrency/-c`, `--open/--no-open` | | `10xgraph test` | Run project tests via pytest (args after `--` forwarded verbatim) | `--coverage/-C`, `--html`, `-k`, path arg | @@ -86,28 +86,30 @@ Parsed by `tenxgraph_api/src/app/core/config/graph_config.py`. Supported keys: |---|---| | `agent` (required) | `"module:attribute"` resolving to a `CompiledGraph`. The loader accepts a `CompiledGraph` object, a sync/async factory returning one, or a callable. | | `env` | Path to a `.env` file, loaded at config-load time | -| `thread_name_generator` | `"module:attr"` -> a `ThreadNameGenerator` | +| `thread_name_generator` | `"random"` (default, also when unset: `DefaultThreadNameGenerator`), `"first_message"` (`FirstMessageThreadNameGenerator`: the user's first message, whitespace collapsed, cut to at most 60 characters on a word boundary; random when there is no text), `"none"` (threads stay unnamed but are saved) or `"module:attr"` -> a `ThreadNameGenerator` subclass or instance. Chosen by `loader.resolve_thread_name_generator`; `GraphService._save_thread_name` saves every generated name with `aput_thread`, in the background after the run. The generator gets the user's message texts first, then the replies | +| `id_generator` | Unset: the graph keeps the generator it was compiled with. A name (`graph_config.ID_GENERATOR_NAMES`: `uuid`, `hex`, `short`, `int`, `bigint`, `timestamp`, `snowflake` = `SnowFlakeIdGenerator`, needs the `snowflakekit` extra, reads `SNOWFLAKE_*` env vars) or `"module:attr"` (a `BaseIDGenerator` instance, subclass or zero-arg factory). `loader.apply_id_generator` rebinds `BaseIDGenerator`, `"generated_id_type"` and `"generated_id"` as factories in the graph's container, the API container and `InjectQ.get_instance()`, so thread ids (`GraphService._new_thread_id`), message ids (core `Message` default factory) and memory ids use it. Refuses to boot when the graph is compiled with a non-default generator too, or when a `PgCheckpointer`'s `id_type` cannot hold the ids | | `auth` | `null`, the string `"jwt"`, or `{"method": "custom", "path": "module:attr"}` | | `authorization` | `"module:attr"` -> an `AuthorizationBackend` (RBAC / per-tool access) | | `checkpointer` | `"module:attr"` -> a `BaseCheckpointer` | | `injectq` | `"module:attr"` -> an InjectQ container | | `store` | `"module:attr"` -> a `BaseStore` | -| `redis` | Redis URL string | -| `rate_limit` | Object (see below) | +| `cache_store` | One object, `graph_config.CacheStoreConfig`: `type` (`memory` default \| `redis` \| `custom`), `url` (type redis only, optional: empty uses the `REDIS_URL` environment variable, a set value wins; `$VAR` / `${VAR}` expanded when the store is built, unset variable is a config error; `RedisCacheStore`, also Valkey, Dragonfly, KeyDB), `path` (type custom only, required; `"module:attr"` -> a `BaseCacheStore` instance or zero-arg factory), `rate_limit` (object, see below), `token_revocation` (see its row). No key or no `type`: `InMemoryCacheStore` (per process). Unknown keys are config errors listing the valid ones. Nothing else picks the store (`REDIS_URL` alone does not). Selected and bound at boot by `core/cache_store/loader.py::load_and_bind_cache_store` (one INFO line `Cache store: (, )`, e.g. `cache_store.url`), closed once in the lifespan; type redis without the `redis` package refuses to boot. Holds the run locks and caps, idempotency, ownership cache L2, token revocation and rate-limit counters. Top-level `redis`, `rate_limit` and `token_revocation` keys are config errors at `GraphConfig` load (`check_cache_store_settings`) saying where the setting goes | | `ag_ui` | `{"enabled": bool}`, default off. Mounts `POST /v1/ag-ui` (AG-UI protocol, for CopilotKit and other AG-UI clients). Needs the `ag-ui` extra | | `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 the ownership/rbac backends via `OwnershipAuthorizationBackend(server_thread_ids=...)`. AG-UI and `/v1/graph/live` are exempt: they pass `client_thread_ids=True` to `authorize` because their frontends make their own ids, and the caller owns a new one. `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) | -| `token_revocation` | `"redis"` / `"memory"` / `"module:attr"` -> `core/auth/revocation.py::TokenRevocationBackend`, 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); backend errors fail closed (503) | +| `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 | | `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` | | `graph_introspection` | `"open"` or `"scoped"` (default scoped in production): `GET /v1/graph`, `/v1/graph/tools`, `/v1/graph:StateSchema` need an explicit `graph:read` scope (`RequirePermission(..., introspection=True)`) | -`rate_limit` object: `enabled`, `requests` (default 100), `window` secs (60), `by` (`ip` | -`user` | `global`), `backend` (`memory` | `redis` | `custom`), `trusted_proxy_headers` (honour +`cache_store.rate_limit` object (read through `GraphConfig.rate_limit`, None when absent or +`enabled: false`): `enabled`, `requests` (default 100), `window` secs (60), `by` (`ip` | +`user` | `global`), `trusted_proxy_headers` (honour `X-Forwarded-For` only when true), `trusted_proxies` (networks the header is honoured from; empty means loopback and private networks only, `DEFAULT_TRUSTED_PROXIES`), `trusted_proxy_hops`, -`exclude_paths`, `fail_open` (on backend error: allow vs deny), -and for redis backend a `redis` sub-object `{ "url", "prefix" }` (or shorthand URL string). For -`custom`, bind a `BaseRateLimitBackend` in InjectQ. +`exclude_paths`, `fail_open` (on a cache store error: allow vs deny with 503 +`RATE_LIMIT_UNAVAILABLE`; default true). Counters live in the cache store +(`core/middleware/rate_limit/limiter.py::check`). Any other key is a config error listing the +valid ones (`RATE_LIMIT_KEYS`). ## Auth @@ -142,14 +144,14 @@ and for redis backend a `redis` sub-object `{ "url", "prefix" }` (or shorthand U stop/fix + all checkpointer read/write/delete): a thread is accessible only to its owner; a foreign `invoke`/`stream` is rejected up front (403), never reaching the graph. - **Scalable, not a DB call per request.** Ownership is immutable, so it is cached by - `core/auth/ownership_resolver.py::ThreadOwnershipResolver`: in-process LRU (L1) + optional - shared Redis (L2, reuses `config.redis`/`settings.REDIS_URL`). The backing lookup is + `core/auth/ownership_resolver.py::ThreadOwnershipResolver`: in-process LRU (L1) + the + cache store as L2 (skipped when it is in-memory). The backing lookup is `BaseCheckpointer.aget_thread_owner` (implemented in pg/in-memory/sqlite; base raises `NotImplementedError`). Cache is evicted on thread delete (`CheckpointerService.delete_thread`), - which also leaves a tombstone for one L1 TTL (L1 + Redis) so the deleted id cannot be + which also leaves a tombstone for one L1 TTL (L1 + cache store) so the deleted id cannot be recreated by someone else while another worker still caches the old owner - (`ThreadOwnershipResolver.recently_deleted`); the L2 client is closed in the lifespan shutdown. -- **Identity reuse:** with `rate_limit.by == "user"` the limiter's verified identity is reused by + (`ThreadOwnershipResolver.recently_deleted`); the resolver never closes the cache store. +- **Identity reuse:** with `cache_store.rate_limit.by == "user"` the limiter's verified identity is reused by `RequirePermission` for backends with `BaseAuth.reusable_within_request = True` (`JwtAuth`), see `core/auth/identity_cache.py`. Look up the app container inside a request with `core/utils/container.py::current_container()`, not `InjectQ.get_instance()`. @@ -186,15 +188,15 @@ and for redis backend a `redis` sub-object `{ "url", "prefix" }` (or shorthand U else a default `MediaFetchPolicy`, else skips (core without it). The policy lives in core; the API has no config for it. - **Per-thread run lock** (`routers/graph/thread_lock.py`): invoke, stream, each WS run and - AG-UI take a one-slot lease `runs:thread:` from `core/concurrency.py` (Redis when - configured, else per process; 60 s lease renewed while alive) before the graph starts, and + AG-UI take a one-slot lease `runs:thread:` from `core/leases.py::LeaseManager` over the cache store (shared + with a Redis cache store, else per process; 60 s lease renewed while alive) before the graph starts, and release it in `finally` (error, cancel, disconnect via `release_when_done`). Routers lock when the request names a thread (streaming routes before the response headers, so the 409 is real); `GraphService` locks a thread it creates (`_lock_new_thread`). Busy: wait up to `THREAD_BUSY_WAIT_SECONDS` (30), then 409 "thread is busy" in the standard error envelope (WS: error chunk `data.reason = "thread is busy"`, socket stays open). Request option `on_busy` (`"wait"` | `"cancel"`, top level next to `recursion_limit`; not on AG-UI): `cancel` calls - `GraphService.stop_graph` while waiting. `/v1/graph/live` takes the lock for the session (and a run slot) too, refused with a fatal `thread_busy` / `too_many_runs` error event and close 1013. Every entry point takes lock + run slot through `routers/graph/run_guard.py::acquire_run_guard`. A Redis error falls back to a per-process count; a refresh that finds its lease gone sets `Lease.lost` and takes it again. + `GraphService.stop_graph` while waiting. `/v1/graph/live` takes the lock for the session (and a run slot) too, refused with a fatal `thread_busy` / `too_many_runs` error event and close 1013. Every entry point takes lock + run slot through `routers/graph/run_guard.py::acquire_run_guard`. A cache store error falls back to a per-process count; a refresh that finds its lease gone sets `Lease.lost` and takes it again. - **Production error chunks:** `graph_service.sanitize_error_chunk` (called in `stream_chunks`, so stream, WS and AG-UI share it) replaces `data.reason` / `data.error` / `data.message` and `state.execution_meta.internal_data["error"]` of `ERROR` chunks with the @@ -216,8 +218,14 @@ and for redis backend a `redis` sub-object `{ "url", "prefix" }` (or shorthand U from `RunAgentInput.tools` go to the graph as the server-owned `remote_tools` run-config key (via `GraphService.stream_chunks(..., server_config=...)`); `RunAgentInput.resume` becomes the graph `resume` input. `GraphInputSchema.resume` also resumes over `/v1/graph/invoke|stream`. -- **Ping**: `GET /ping` (liveness). **Readiness**: `GET /health/ready` (checkpointer, Redis, - store; 200 or 503 with only ok/fail/skipped per component; `routers/ping/readiness.py`). +- **Ping**: `GET /ping` (liveness). **Readiness**: `GET /health/ready` (`aping()` on the + bound checkpointer, store and cache store; keys `checkpointer`, `store`, `cache`; 200 or 503 + with only ok/fail per component; a failed `cache` counts only when + `cache_store.token_revocation` is on (with auth) or `cache_store.rate_limit.fail_open` is + false; `routers/ping/readiness.py`). The startup check + (`routers/ping/startup_checks.py`, `STARTUP_CHECKS`) applies the same rule at boot: required + components retry for `STARTUP_CHECK_TIMEOUT_SECONDS` then refuse the boot; an optional + unreachable cache store is one WARNING and the server starts. Routers are wired in `routers/setup_router.py` (`init_routes`). The `a2a.py` / `a2ui.py` stubs were removed in the 0.5.0 readiness pass; they were fully commented out and never mounted. @@ -230,7 +238,7 @@ env vars are tolerated). Notable vars: `APP_NAME`, `APP_VERSION`, `MODE` (`produ anything else refuses to start, unset is `development`), `LOG_LEVEL`, `MAX_REQUEST_SIZE` (10MB default), security headers (`SECURITY_HEADERS_ENABLED`, `HSTS_*`, `FRAME_OPTIONS`, `CSP_POLICY`, ...), `ORIGINS` (CORS, default `*` with a wildcard warning), `ALLOWED_HOST`, `ROOT_PATH`/`DOCS_PATH`/`REDOCS_PATH`, -`REDIS_URL`, `SENTRY_DSN`, `SNOWFLAKE_*` (epoch/node/worker/bit layout), `JWT_SECRET_KEY`/ +`REDIS_*` client limits and `REDIS_KEY_PREFIX` (the URL lives in `cache_store.url`), `SENTRY_DSN`, `SNOWFLAKE_*` (epoch/node/worker/bit layout), `JWT_SECRET_KEY`/ `JWT_ALGORITHM`, `OTEL_ENABLED`/`OTEL_SERVICE_NAME`/`OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_LEVEL`, `STRICT_PRODUCTION_CHECKS` (production refuses wildcard `ALLOWED_HOST`/`ORIGINS` or enabled docs). Debug follows `MODE` (on in development, off in production); a leftover `IS_DEBUG` is ignored with a warning. Media: `MEDIA_REQUIRE_OWNER` defaults on in @@ -239,7 +247,8 @@ production. In production: set `MODE=production`, a non-`*` `ORIGINS`, and a str Middleware is pure ASGI. Order, outermost first: CORS, request id, security headers, trusted host, rate limit, request size, gzip (`setup_middleware.py`). Run and WebSocket connection caps -use `core/concurrency.py`: Redis leases when Redis is configured, per process otherwise. +are leases in the cache store (`core/leases.py`): shared with a Redis cache store, per process +with the in-memory one. Production error responses hide only 5xx details; raise `request_config.ClientInputError` for 422 messages that must survive production. diff --git a/README.md b/README.md index 2aaa9676..d5d434f3 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,10 @@ Other frameworks give you the graph. 10xGraph gives you the graph and the server - **Authentication.** `"auth": "jwt"`, or your own `BaseAuth` subclass. - **Scoped authorization on every endpoint.** Routes require `resource:action` scopes such as `graph:invoke`, `graph:stream` or `checkpointer:read`, checked by a pluggable `AuthorizationBackend`. The server refuses to start if any non-public route has no guard. - **Owner-only threads.** In production mode a thread is readable and writable only by the user who created it, on every step that touches it. Ownership checks are cached in-process and optionally in Redis, so they do not hit the database on each request. -- **Rate limiting.** Sliding-window limits with a memory, Redis or custom backend. +- **Rate limiting.** Sliding-window limits kept in the API's cache store: in memory, on Redis, or in your own `BaseCacheStore`. - **Production hardening.** Request size limits (10 MB default), security headers, error and log sanitization, startup warnings for insecure settings, and Snowflake IDs for multi-node deployments. - **Deployment files.** `10xgraph build --docker-compose --k8s` writes a `Dockerfile`, `.dockerignore`, `docker-compose.yml` and `k8s.yaml`. -- **A secure-by-default scaffold.** `10xgraph init --yes --template production --auth jwt --rate-limit redis` writes a project with JWT auth, owner-only thread access, a Redis rate limit, a prompt-injection validator, evals, tests, `.env.example` and a pre-commit config. +- **A secure-by-default scaffold.** `10xgraph init --yes --template production --auth jwt --rate-limit redis` writes a project with JWT auth, owner-only thread access, a Redis cache store for rate limits and token revocation, a prompt-injection validator, evals, tests, `.env.example` and a pre-commit config. Not built in: automatic per-tool permissions (user A may call `refund`, user B may not). Tools receive the caller's verified scopes and can check them with `tenxgraph.core.authz.has_scope`. @@ -86,7 +86,7 @@ pip install 10xgraph-api Optional extras; install only what you configure: ```bash -pip install "10xgraph-api[redis]" # Redis rate-limit / cache backend +pip install "10xgraph-api[redis]" # Redis cache store (locks, rate limits, revocation) pip install "10xgraph-api[jwt]" # JWT authentication pip install "10xgraph-api[media]" # Document text extraction (multimodal) pip install "10xgraph-api[images]" # Image resize / EXIF / format conversion (Pillow) @@ -323,7 +323,9 @@ The configuration file (`10xgraph.json`) defines your agent, authentication, and | `injectq` | string \| null | Path to an InjectQ container | | `store` | string \| null | Path to a data store | | `remote_tools` | array | Trusted schemas for tools executed by clients; attached at startup | -| `redis` | string \| null | Redis connection URL | +| `redis` | string \| null | Redis URL of the API's cache store (`${VAR}` expanded); falls back to `REDIS_URL` | +| `cache_store` | string \| null | Path to your own `BaseCacheStore`; wins over `redis` | +| `token_revocation` | bool \| object \| null | `true` rejects revoked tokens (`10xgraph revoke`); kept in the cache store | | `rate_limit` | object \| null | Sliding-window rate limiting configuration | | `thread_name_generator` | string \| null | Path to a custom thread name generator | | `ag_ui` | object | `{"enabled": true}` mounts `POST /v1/ag-ui` (needs the `ag-ui` extra) | @@ -527,7 +529,7 @@ sdist + wheel, checks the distribution metadata, and creates a GitHub Release wi notes and the artifacts attached. PyPI publishing is manual (`make publish`). ```bash -git tag v1.0.0rc3 && git push origin v1.0.0rc3 +git tag v1.0.0rc4 && git push origin v1.0.0rc4 ``` --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9df9f7b9..153d5160 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,4 +1,50 @@ -# 10xgraph-api 1.0.0rc3 +# 10xgraph-api 1.0.0rc4 + +The fourth 1.0 release candidate: the `1.0.0rc3` code plus one `BaseCacheStore` behind everything the API shares between workers (run locks, caps, idempotency, token revocation, rate limiting), configured by one `cache_store` object in `10xgraph.json`, a lighter `10xgraph init`, and a startup check that no longer refuses to boot over a Redis nothing needs. + +```bash +pip install -U --pre "10xgraph-api==1.0.0rc4" +``` + +This pulls `10xgraph` 1.0.0rc4. `10xgraph-client` stays at 1.0.0-rc.2 (no client changes). + +## Before you upgrade (changes in rc4) + +- **`BaseCacheStore` replaces three extension points.** `ConcurrencyLimiter`, `TokenRevocationBackend` (with its in-memory and Redis variants and `build_token_revocation`) and `BaseRateLimitBackend` (with its memory and Redis variants) are removed, with no aliases. If you wrote your own, implement `BaseCacheStore` instead. +- **`cache_store` is one object** in `10xgraph.json`: `{"type": "memory" | "redis" | "custom", "url": "${REDIS_URL}", "path": "module:attr", "rate_limit": {...}, "token_revocation": true}`. No `cache_store` means the in-memory store. +- **Top-level `redis`, `rate_limit` and `token_revocation` keys are config errors.** The error says where each setting goes: inside `cache_store`. +- **`REDIS_URL` no longer selects the store.** Write `"cache_store": {"type": "redis"}`; with no `url` it reads `REDIS_URL`. Type `redis` without the `redis` package refuses to boot with `pip install "10xgraph-api[redis]"`. +- **`/health/ready` reports `cache`, not `redis`.** Update monitors that read `checks.redis`. +- **Rate-limit counters reset once.** The Redis keys moved to the `10xgraph:ratelimit2` namespace. Other Redis key layouts are unchanged. +- **`10xgraph init` asks only the agent name, template and AI provider.** Production writes the recommended setup (jwt auth, ownership authorization, uuid ids, a Redis `cache_store` with rate limiting and token revocation); Quick Start writes no `cache_store`. Flags such as `--auth` and `--cache-store` override parts of it for scripts. +- **`thread_name_generator` takes `"random"` (the default), `"first_message"`, `"none"` or `"module:attr"`.** With the key unset, new threads are now named and the name is saved. `GraphConfig.thread_name_generator_path` is replaced by `GraphConfig.thread_name_generator`. +- **Requires `10xgraph` 1.0.0rc4.** A custom checkpointer must implement `aping()`. + +## What changed in rc4 + +**Added** +- `BaseCacheStore`, `RedisCacheStore` (also Valkey, Dragonfly, KeyDB) and `InMemoryCacheStore`, exported from `tenxgraph_api`, plus a contract suite for custom stores in `tenxgraph_api.testing.cache_store_contract`. +- `id_generator` in `10xgraph.json` (`uuid`, `hex`, `short`, `int`, `bigint`, `timestamp`, `snowflake` or `"module:attr"`), used for new thread, message and memory ids. +- `thread_name_generator: "first_message"`, and a token revocation section in `10xgraph config`. +- One INFO line at boot names the cache store and the config key it came from. + +**Changed** +- The startup check is fatal for the cache store only when a fail-closed feature uses it (token revocation with auth, or rate limiting with `fail_open: false`). Otherwise an unreachable cache store logs one warning, the server starts, and `/health/ready` reports `cache: fail` with status 200. +- Readiness and the startup check only call `aping()` on the bound checkpointer, store and cache store. +- `10xgraph revoke` writes to the cache store `cache_store` selects and refuses when it is the in-memory store. +- Shutdown closes the cache store once, after the graph and pending lease releases. +- The in-memory cache store keeps expiries on the monotonic clock, so a wall clock jump no longer expires a held thread lock early. + +**Fixed** +- A leftover `REDIS_URL` no longer blocks boot when nothing configured needs Redis (rc3 refused to start an in-memory server in production with a stale `REDIS_URL`). +- A rate-limit denial stays a 429 when reading the cache store clock for `Retry-After` fails. +- `revoke_user` rounds the cutoff up to the microsecond, so a token issued at the cutoff itself is always revoked. + +See [CHANGELOG.md](CHANGELOG.md) for the full list. The rc3 notes follow. + +--- + +## 10xgraph-api 1.0.0rc3 The third 1.0 release candidate: the `1.0.0rc2` code plus a startup check for every configured backend, a clear 503 when the rate limiter's backend is down, and a CORS fix so the hosted playground can reach a local `10xgraph dev` server. diff --git a/SECURITY.md b/SECURITY.md index 8b1b8da9..7d7511e7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,7 +7,7 @@ | `1.0.x` (`10xgraph-api`) | Yes | | `< 1.0` | No | -`10xgraph-api` is at 1.0 (release candidate `1.0.0rc3`). Security fixes are applied to the +`10xgraph-api` is at 1.0 (release candidate `1.0.0rc4`). Security fixes are applied to the latest published release only; there are no long-term support branches. The old `10xscale-agentflow-cli` package is no longer published; upgrade to `10xgraph-api` to receive fixes. @@ -102,7 +102,7 @@ Some protections keep state per process. With more than one worker or instance: expiring leases, so the cap holds across workers and a crashed worker's slots free up on their own within a minute. Without Redis each process counts its own, so the real cap is the limit times the number of workers. -- **Rate limiting.** Use `"backend": "redis"` in `rate_limit`; the memory backend counts per - process. +- **Rate limiting.** The counters live in the cache store: set `redis` (or `REDIS_URL`, or a + `cache_store`) to share them; the in-memory store counts per process. - **Media extraction cache.** Extracted document text is cached per process. This only costs repeated extraction work, not correctness. diff --git a/config-editor-ui/src/icons.js b/config-editor-ui/src/icons.js index de0e1007..27647a46 100644 --- a/config-editor-ui/src/icons.js +++ b/config-editor-ui/src/icons.js @@ -40,7 +40,8 @@ export const ICONS = { export const SECTION_ICONS = { "get-running": "flag", environment: "terminal", - core: "flag", auth: "lock", authorization: "shield", token_revocation: "key", rate_limit: "gauge", + core: "flag", auth: "lock", authorization: "shield", cache_store: "gauge", graph_introspection: "code", observability: "activity", runs: "play", websocket: "bolt", ag_ui: "external", remote_tools: "wrench", - media: "file", plugins: "layers", test: "flask", evaluation: "target", + 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 4b8d9afc..92f48a61 100644 --- a/config-editor-ui/src/pages.jsx +++ b/config-editor-ui/src/pages.jsx @@ -2,11 +2,13 @@ import { copyCommand, go, setSection } from "./actions.js"; import { Field, fieldGroups } from "./fields.jsx"; import { SECTION_ICONS } from "./icons.js"; import { - PAGES, WIDGET_FIELD_IDS, getPath, isAgentReady, isOn, issueAt, issuesFor, navItem, section, store, + PAGES, WIDGET_FIELD_IDS, isAgentReady, isObject, isOn, issuesFor, navItem, section, store, valueOrDefault, } from "./store.js"; import { Callout, Icon, IssueCallout, Segmented } from "./ui.jsx"; -import { AuthWidget, AuthorizationWidget, RemoteToolsWidget } from "./widgets.jsx"; +import { + AuthWidget, AuthorizationWidget, CacheStoreWidget, ChoiceWidget, RemoteToolsWidget, +} from "./widgets.jsx"; const Heading = ({ children, code }) => (

@@ -52,19 +54,17 @@ function authSummary() { return { text: String(a) }; } -function rateSummary() { - const r = store.config.rate_limit; - if (r === undefined || r === null) return { text: offText("rate_limit") }; - const v = (k) => valueOrDefault(["rate_limit", k]); - if (v("enabled") === false) return { text: "Settings kept, not enforced." }; - const by = { ip: "per IP", user: "per user", global: "across all clients" }[v("by")] || v("by"); - const warn = issueAt("rate_limit.backend")?.level === "warning"; - const custom = ["requests", "window", "by"].some((k) => getPath(store.config, ["rate_limit", k]) !== undefined - && getPath(store.config, ["rate_limit", k]) !== store.fields.get(`rate_limit.${k}`)?.default); - return { - text: `${v("requests")} requests per ${v("window")} s, ${by}${custom ? "" : " (default)"}.${warn ? " The memory backend counts per process." : ""}`, - warn, - }; +function cacheSummary() { + const c = isObject(store.config.cache_store) ? store.config.cache_store : {}; + const parts = [`${c.type || "memory"} store`]; + const rate = c.rate_limit; + if (isObject(rate) && rate.enabled !== false) { + const by = { ip: "per IP", user: "per user", global: "across all clients" }[rate.by ?? "ip"] || rate.by; + parts.push(`${rate.requests ?? 100} requests per ${rate.window ?? 60} s, ${by}`); + } + const revoke = c.token_revocation; + if (revoke === true || (isObject(revoke) && revoke.enabled !== false)) parts.push("token revocation on"); + return { text: `${parts.join("; ")}.` }; } function traceSummary() { @@ -81,7 +81,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()], ["rate_limit", rateSummary()], ["observability", traceSummary()]]; + const rows = [["auth", authSummary()], ["cache_store", cacheSummary()], ["observability", traceSummary()]]; const all = store.nav.flatMap((g) => g.items).filter((id) => id !== "get-running"); return ( <> @@ -153,13 +153,16 @@ function SectionWidget({ sec }) { if (sec.widget === "auth") return ; if (sec.widget === "authorization") return ; if (sec.widget === "remote_tools") return ; + if (sec.widget === "cache_store") return ; + if (sec.widget === "id_generator" || sec.widget === "thread_name_generator") return ; return null; } // Issues that no rendered field or widget shows inline. export function generalIssues(sec) { const inline = new Set([...sec.fields.map((f) => f.path.join(".")), ...Object.keys(WIDGET_FIELD_IDS)]); - return issuesFor(sec.id).filter((i) => !inline.has(i.path)); + const tabbed = new Set(["cache_store.rate_limit", "cache_store.token_revocation"]); + return issuesFor(sec.id).filter((i) => !inline.has(i.path) && !(sec.id === "cache_store" && tabbed.has(i.path))); } // Fields and widget of a section, no heading. Shared by the page and search results. @@ -170,7 +173,7 @@ export function SectionFields({ sec }) { <> {widget ?
: null}
- {fieldGroups(sec.fields).map((g, i) => ( + {fieldGroups(sec.fields.filter((f) => !f.managed)).map((g, i) => (
{g.name ? (

{g.name}

@@ -241,9 +244,11 @@ function search(terms) { const results = []; for (const sec of store.sections) { const secHit = hit(`${sec.title} ${sec.description} ${sec.key || ""}`); - const fields = sec.fields.filter((f) => secHit || hit(`${f.label} ${f.help || ""} ${f.path.join(".")}`)); + const matches = (f) => secHit || hit(`${f.label} ${f.help || ""} ${f.path.join(".")}`); + // Fields the widget draws are found through the widget. + const fields = sec.fields.filter((f) => !f.managed && matches(f)); const env = (sec.env || []).filter((v) => hit(`${v.name} ${v.purpose}`)); - const widget = !!sec.widget && secHit; + const widget = !!sec.widget && (secHit || sec.fields.some((f) => f.managed && matches(f))); if (fields.length || env.length || widget) results.push({ id: sec.id, sec, fields, env, widget }); } const envGroups = (store.schema.environment || []) diff --git a/config-editor-ui/src/sidebar.jsx b/config-editor-ui/src/sidebar.jsx index 384aeca5..1df92c27 100644 --- a/config-editor-ui/src/sidebar.jsx +++ b/config-editor-ui/src/sidebar.jsx @@ -29,11 +29,14 @@ function NavLink({ id }) { href={`#${id}`} aria-current={current ? "page" : undefined} onClick={(e) => { e.preventDefault(); go(id); }} ref={(el) => { if (el && current && window.innerWidth < 1024) el.scrollIntoView({ block: "nearest", inline: "nearest" }); }} - class={`focus-ring flex min-h-10 items-center gap-2.5 rounded-lg px-3 font-medium transition-colors duration-150 max-lg:min-h-11 max-lg:shrink-0 max-lg:whitespace-nowrap ${current + 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 ${current ? "bg-brand-soft text-ink" : "text-muted hover:bg-raised hover:text-ink"}`} > - {item.title} + + {item.title} + {item.subtitle ? {item.subtitle} : null} + ); diff --git a/config-editor-ui/src/store.js b/config-editor-ui/src/store.js index 54232982..b85c61c2 100644 --- a/config-editor-ui/src/store.js +++ b/config-editor-ui/src/store.js @@ -7,9 +7,9 @@ // are pages of their own rather than schema sections. export const NAV = [ { label: null, items: ["get-running"] }, - { label: "Before production", items: ["auth", "authorization", "rate_limit", "observability", "token_revocation"] }, + { label: "Before production", items: ["auth", "authorization", "cache_store", "observability", "graph_introspection"] }, { label: "Runs and streaming", items: ["runs", "websocket", "ag_ui", "remote_tools", "media"] }, - { label: "Project wiring", items: ["plugins"] }, + { label: "Project wiring", items: ["checkpointer", "id_generator", "thread_name_generator", "store", "injectq"] }, { label: "Developer tools", items: ["test", "evaluation"] }, { label: "Reference", items: ["environment"] }, ]; @@ -108,6 +108,9 @@ export function setPath(obj, path, value) { target[path[path.length - 1]] = value; } +// Features that stay on when their last setting is reset: an empty object means "on, all defaults". +const KEEP_EMPTY = new Set(["cache_store.rate_limit", "cache_store.token_revocation"]); + export function deletePath(obj, path) { const parents = []; let target = obj; @@ -120,6 +123,7 @@ export function deletePath(obj, path) { // Drop nested objects left empty, but never the section key itself. for (let i = parents.length - 1; i >= 1; i--) { const [parent, k] = parents[i]; + if (KEEP_EMPTY.has(path.slice(0, i + 1).join("."))) break; if (isObject(parent[k]) && Object.keys(parent[k]).length === 0) delete parent[k]; else break; } @@ -167,6 +171,8 @@ export function unmetHint(f) { } export const navItem = (id) => { const sec = section(id); - return PAGES[id] ? { id, ...PAGES[id] } : sec ? { id, title: sec.title, description: sec.description, icon: null } : null; + return PAGES[id] + ? { id, ...PAGES[id] } + : sec ? { id, title: sec.title, subtitle: sec.subtitle, description: sec.description, icon: null } : null; }; export const isAgentReady = () => /^[\w.]+:[\w.]+$/.test(String(store.config.agent || "")); diff --git a/config-editor-ui/src/widgets.jsx b/config-editor-ui/src/widgets.jsx index 02dff124..9a2ae71d 100644 --- a/config-editor-ui/src/widgets.jsx +++ b/config-editor-ui/src/widgets.jsx @@ -1,6 +1,9 @@ import { useEffect, useState } from "preact/hooks"; -import { commit, isObject, issueAt, store } from "./store.js"; -import { ChoiceGroup, Chips, FieldIssue, FieldMeta, Group, Icon, Row, Select, TextRow } from "./ui.jsx"; +import { Field, fieldGroups } from "./fields.jsx"; +import { commit, isObject, issueAt, setPath, store } from "./store.js"; +import { + Callout, ChoiceGroup, Chips, FieldIssue, FieldMeta, Group, Icon, Pill, Row, Segmented, Select, TextRow, Toggle, +} from "./ui.jsx"; // ------------------------------------------------------------------ auth @@ -34,6 +37,310 @@ export function AuthWidget() { ); } +// ------------------------------------------------------------------ cache store + +const CACHE_CARDS = [ + ["memory", "Memory", "This process only"], + ["redis", "Redis", "Shared. Redis, Valkey, Dragonfly, KeyDB"], + ["custom", "Custom", "Your BaseCacheStore class"], +]; +const DEFAULT_RATE_LIMIT = { requests: 100, window: 60, by: "ip" }; + +const cacheStore = () => (isObject(store.config.cache_store) ? store.config.cache_store : {}); + +// Edit cache_store in place; the key goes away when nothing is left in it. +function editCache(mutate) { + commit((s) => { + const next = isObject(s.config.cache_store) ? s.config.cache_store : {}; + mutate(next); + if (Object.keys(next).length) s.config.cache_store = next; + else delete s.config.cache_store; + }); +} + +const rateLimitOn = () => { + const r = cacheStore().rate_limit; + return isObject(r) && r.enabled !== false; +}; +const revocationOn = () => { + const r = cacheStore().token_revocation; + return r === true || (isObject(r) && r.enabled !== false); +}; + +function setCacheType(kind) { + editCache((c) => { + delete c.url; + delete c.path; + if (kind === "memory") delete c.type; + else c.type = kind; + }); +} + +function setRateLimit(on) { + editCache((c) => { + if (!on) delete c.rate_limit; + else if (isObject(c.rate_limit)) delete c.rate_limit.enabled; + else c.rate_limit = { ...DEFAULT_RATE_LIMIT }; + }); +} + +function setRevocation(on) { + editCache((c) => { + if (!on) delete c.token_revocation; + else if (isObject(c.token_revocation)) delete c.token_revocation.enabled; + else c.token_revocation = true; + }); +} + +const putCache = (path, value) => commit((st) => { + setPath(st.config, path, value); +}); + +function TypeCards({ value, onPick }) { + const move = (e, i) => { + const d = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0; + if (!d) return; + e.preventDefault(); + const next = CACHE_CARDS[(i + d + CACHE_CARDS.length) % CACHE_CARDS.length][0]; + onPick(next); + requestAnimationFrame(() => document.getElementById(`cache-type-${next}`)?.focus()); + }; + return ( +
+ {CACHE_CARDS.map(([v, title, detail], i) => { + const selected = v === value; + return ( + + ); + })} +
+ ); +} + +function InlineNote({ warn, children }) { + return ( +

+ {warn ? : null} + {children} +

+ ); +} + +function FeatureHead({ id, title, text, on, onChange }) { + return ( +
+
+

{title}

+

{text}

+
+ +
+ ); +} + +const LIMIT_BY = [["ip", "IP"], ["user", "User"], ["global", "Global"]]; + +function RateLimitPanel({ field, memory }) { + const [adv, setAdv] = useState(false); + const r = cacheStore().rate_limit; + const by = (isObject(r) && r.by) || "ip"; + const failOpen = !(isObject(r) && r.fail_open === false); + const advanced = ["trusted_proxy_headers", "trusted_proxy_hops", "trusted_proxies", "exclude_paths"]; + return ( + <> + {memory ? Counts per worker with the Memory store. : null} +
+ + +
+ Limit by +
+ putCache(["cache_store", "rate_limit", "by"], v)} + /> +
+ +
+
+ Fail open +
+ putCache(["cache_store", "rate_limit", "fail_open"], v)} + /> +

+ {failOpen ? "Allow requests if the store errors." : "Deny with 503 if the store errors."} +

+
+
+
+
+ + {adv ? ( +
+ {advanced.map((k) => )} +
+ ) : null} +
+ + ); +} + +const TABS = [["rate", "Rate limiting"], ["revoke", "Token revocation"]]; + +export function CacheStoreWidget({ sec }) { + const [tab, setTab] = useState("rate"); + const kind = cacheStore().type || "memory"; + const field = (path) => sec.fields.find((f) => f.path.join(".") === path); + const on = { rate: rateLimitOn(), revoke: revocationOn() }; + const memory = kind === "memory"; + const warned = { + rate: !!issueAt("cache_store.rate_limit"), + revoke: !!issueAt("cache_store.token_revocation"), + }; + const onKey = (e, i) => { + const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0; + const to = e.key === "Home" ? 0 : e.key === "End" ? TABS.length - 1 : d ? (i + d + TABS.length) % TABS.length : -1; + if (to < 0) return; + e.preventDefault(); + setTab(TABS[to][0]); + document.getElementById(`cache-tab-${TABS[to][0]}`)?.focus(); + }; + return ( +
+ + + {kind === "redis" ? : null} + {kind === "custom" ? : null} + {memory ? ( +

+ Not shared between workers: each worker keeps its own locks, limits and revocations. +

+ ) : null} +
+ +
+ {TABS.map(([id, label], i) => { + const sel = tab === id; + return ( + + ); + })} +
+ +
+ {tab === "rate" ? ( + <> + + {on.rate ? + : warned.rate ? Off: one caller can exhaust the service. : null} + + ) : ( + <> + + {on.revoke ? ( + <> + {memory ? 10xgraph revoke cannot reach an in-memory store; pick Redis or Custom. : null} +
+ + +
+ + Fails closed: while the cache store is unreachable, authenticated requests get 503 and the server will not start. + + + ) : warned.revoke ? Off: a leaked token works until it expires. : null} + + )} +
+
+ ); +} + +// ------------------------------------------------------------------ choice (id generator, thread naming) + +// A dropdown of built-in names plus "custom" (a module:attr field). An unset key is the +// first option for id_generator ("Not set") and the default name for thread_name_generator. +export function ChoiceWidget({ sec }) { + const key = sec.fields[0].path[0]; + const names = sec.choices.map((c) => c[0]); + const value = store.config[key]; + const unsetValue = key === "thread_name_generator" ? "random" : ""; + const mode = value === undefined ? unsetValue : typeof value === "string" && names.includes(value) ? value : "custom"; + const options = [ + ...(key === "id_generator" ? [["", "Not set (the graph's own generator)"]] : []), + ...sec.choices.map(([v, label]) => [v, key === "thread_name_generator" && v === "random" ? `${label} (default)` : label]), + ["custom", "Custom (module:attr)"], + ]; + const detail = mode === "" ? "The graph keeps the generator it was compiled with." + : mode === "custom" ? "Your own class, as module:attribute." + : sec.choices.find((c) => c[0] === mode)?.[2]; + const pick = (v) => commit((s) => { + if (v === unsetValue) delete s.config[key]; + else s.config[key] = v === "custom" ? "" : v; + }); + const id = `f-${key}`; + return ( +
+ +
+ +