# 21. HTTP-маршруты (http_route / static_route)

🔵 Агенты и глобальная конфигурация могут добавлять на сервер **собственные
HTTP-эндпоинты** без пересборки — по той же модели, что `script_tool` /
`script_hook`. Два вида маршрутов:

- **`http_route`** — обработчик на Rhai/JavaScript, отвечающий на запрос.
- **`static_route`** — отдача статики (HTML/CSS/JS/ассеты) из папки.

Это дополняет, а не заменяет фиксированные поверхности: `POST /v1/inbound/:template`,
`POST /v1/workflows/<name>/webhook` и каналы `telegram`/`email`.

---

## 21.1 Раскладка

```
config/
├── routes/                    # ГЛОБАЛЬНЫЕ маршруты — подхватываются сами
│   ├── weather.yaml + weather.rhai
│   └── assets.yaml + assets/  # папка статики для static_route
└── agents/
    └── felix/
        ├── agent.yaml         # route_files: [routes/card.yaml]
        └── routes/
            ├── card.yaml + card.rhai
            └── static/        # статика, добавляемая агентом
```

Глобальные маршруты читаются из `config/routes/*.yaml` автоматически.
Агентские подключаются явно из `agent.yaml`:

```yaml
# config/agents/felix/agent.yaml
route_files:
  - routes/card.yaml
```

Агентские маршруты регистрируются в **тот же** роутер, что и глобальные. По
соглашению (это warning, не ошибка) путь агентского маршрута начинается с
`/ext/<agent>/`.

---

## 21.2 `http_route` (скриптовый)

```yaml
kind: http_route
name: weather                 # id маршрута (по умолчанию — имя файла); пишется в access-лог
method: GET                   # GET|POST|PUT|PATCH|DELETE|HEAD|* (по умолчанию GET)
path: /ext/weather/:city      # сегменты :param и *rest; путь должен быть уникальным
script: weather.rhai          # .rhai или .js, относительно этого YAML
auth: public                  # public | bearer | token_env | user
token_env: ""                 # env с bearer-токеном (при auth: token_env)
max_operations: 10000000      # бюджет инструкций скрипта
timeout_ms: 30000             # потолок времени на обработчик
max_body_bytes: 1048576       # лимит тела запроса (по умолчанию 1 МиБ)
# + любые свои поля → доступны в скрипте как request.config.<field>
```

### Контракт скрипта

```javascript
fn handler(request) {
    // request = #{
    //   method: "GET",
    //   path: "/ext/weather/moscow",
    //   params: #{ city: "moscow" },      // захваты :param / *rest
    //   query: #{ unit: "c" },
    //   headers: #{ "content-type": "application/json" },
    //   body: "...",                       // сырое тело строкой ("" если нет)
    //   json: #{ ... },                    // распарсенное JSON-тело, или ()
    //   user_id: "...",                    // разрешённая идентичность, или ()
    //   user: "...",                       // канонический id пользователя, или ()
    //   config: #{ ... }                   // сырой YAML, включая свои поля
    // }
    if request.user_id == () {
        return #{ status: 401, body: #{ error: "unauthorized" } };
    }
    let r = http_get(`https://api.weather.example/${request.params.city}`);
    if r.status != 200 {
        return #{ status: 502, body: #{ error: r.error } };
    }
    #{ status: 200, body: parse_json(r.body) }
}
```

Возврат `#{ status, headers, body }` (все поля опциональны):

- `status` — по умолчанию `200`.
- `headers` — карта; по умолчанию `Content-Type` выбирается по типу `body`.
- `body` — **карта** → сериализуется в JSON (`application/json`); **строка** →
  отдаётся как есть (`text/plain`).
- Выброшенное исключение → `500`, ошибка логируется, тело пустое.

### Помощники

Тот же движок и набор помощников, что у `script_tool`: `http_get/post/put/patch/delete`,
`http(method, …)`, `env_var`, `now`, `sleep_ms`, `urlencode`/`urldecode`,
`parse_json`/`to_json`, `json_get`, `extract_script_json`, `print`/`debug`, а также
`db_query`/`db_schema`/`db_execute` для чтения/записи DuckDB (см. §7.3) и
`shell`/`exec`/`fs_read` (см. §7.3).

`llm(...)` в маршрутах **недоступен** (нет процесса агента, на который списать
бюджет) — вызов бросит ошибку. Нет доступа к ядру; состояние между вызовами не
сохраняется (используй HTTP или внешнее хранилище/`db_*`).

---

## 21.3 `static_route` (файлы)

```yaml
kind: static_route
name: felix_assets
path: /ext/felix/*rest        # должно заканчиваться на *rest
dir: static/                  # папка относительно этого YAML
index: index.html             # отдаётся при обращении к "/" (опционально)
auth: public
cache_ttl_secs: 300           # Cache-Control (опционально)
mime:                         # переопределение content-type по расширению (опционально)
  .wasm: application/wasm
```

Поведение (только GET/HEAD):

- `*rest` резолвится в путь под `dir`; path traversal блокируется проверкой, что
  итоговый файл остаётся внутри `dir`.
- Dotfiles и `.env` не отдаются никогда (deny-list).
- Content-type определяется по расширению (html/css/js/json/svg/png/jpg/…).
- Нет файла → `404`; запрос на папку → `index`, если задан, иначе `404`.

Так агент отдаёт **статику**: `static_route` на папку `static/` внутри его
`routes/`.

---

## 21.4 Auth

| Режим | Поведение |
|---|---|
| `public` | открыто (dev по умолчанию) |
| `bearer` | требует `Authorization: Bearer <token>`, совпадающий с `auth.admin_tokens` (в лог пишется имя, не значение) |
| `token_env` | требует bearer-токен, равный значению env из `token_env` (не задан/пуст = отклонять всё, fail-closed) |
| `user` | требует валидную идентичность конечного пользователя: HMAC `X-User-Token`, когда задан `auth.web_user_secret_env`, иначе доверяет `X-User-Id` (dev). Отдаёт `request.user_id` / `request.user` |

`auth: user` доступно с первого релиза.

---

## 21.5 Роутинг и жизненный цикл

- Все маршруты регистрируются в один роутер. Конфликт `path`+`method` со
  встроенным маршрутом (`/`, `/health`, `/v1/*`, `/login`, `/proxy*`, `/widget*`,
  …) **или** с другим кастомным маршрутом отклоняется при загрузке (маршрут
  пропускается, пишется ошибка).
- Встроенные маршруты всегда выигрывают; кастомные не должны их затенять.
- **Приоритет совпадения** среди кастомных: точный литерал > `:param` >
  `*rest`. Точный `http_route` на `/ext/git/api/dashboard` всегда выигрывает у
  wildcard `static_route` на `/ext/git/*rest` — можно держать единый неймспейс
  для статики и API. При равной специфичности выигрывает зарегистрированный
  раньше.
- Рекомендуемые неймспейсы: глобальные → `/ext/...`, агентские → `/ext/<agent>/...`.
- **Live reload** (`kernel.live_reload`, по умолчанию вкл.): `config/routes/**` и
  агентские `route_files` перерегистрируются при изменении (add/remove/update),
  как инструменты и хуки.
- Обработчики синхронны → выполняются на blocking-пуле (не async-runtime),
  ограничены `timeout_ms` и `max_operations`.

---

## 21.6 Access-лог

Каждый запрос к кастомному маршруту пишется в `logs/access.jsonl` /
`logs/access.db`, как и встроенные, но с **именем маршрута** и его скоупом
(`global` / `<agent>`) вместо свободного пути. Тела, заголовки и значения
токенов не логируются.

---

## 21.7 Тестирование (без сервера)

```bash
route_tool config/routes/weather.yaml '{"method":"GET","path":"/ext/weather/moscow"}'
route_tool config/agents/felix/routes/card.yaml --method POST --body '{"user_id":"x"}'
route_tool config/routes/dash.yaml --args-file req.json    # request JSON из файла ('-' = stdin)
```

`route_tool` печатает фейковый запрос, результат скрипта (status + headers + body)
и ошибки скрипта — аналог `hook_tool` / `site_tool` для маршрутов. Для
`static_route` показывает, какие файлы резолвятся под `dir`. Query-параметры с
`?`/`&` удобнее передавать через `--args-file`, чтобы их не портил шелл.
