# `Ankusa.Routes.Router`
[🔗](https://github.com/jamescarr/ankusa/blob/ankusa-v0.7.0/packages/ankusa/lib/ankusa/routes/router.ex#L1)

The route-management API: CRUD over route definitions, the global IP rules,
and the dry run.

It listens on its own port (`routes.admin.port`), never on the ingest port:
managing routes is an operator action, and mixing it into the capture surface
would put a second, easily-forgotten endpoint next to the one that accepts
provider traffic.

## Authentication and scopes

Every route but `GET /health` needs a credential (`routes.admin.auth`, see
`Ankusa.Admin.Auth`): `401` without one, `403` without the scope. Reading
(`GET`, and the dry run `POST /admin/routes/test`) needs `routes:read`; every
other write needs `routes:write`.

There are two surfaces over the same definitions. The **operator surface**,
`/admin/routes` and `/admin/ip-rules`, spans tenants, so its scopes only count
together with `tenant:*`: a principal limited to some tenants must not edit
what every tenant's ingest is checked against. The **tenant surface**,
`/v1/tenants/:tenant/routes`, needs `tenant:<tenant>` (or `tenant:*`) instead,
and shows and changes only that tenant's routes: one that another tenant owns
is `404`, a body naming another tenant is `400`, and a principal without the
tenant is `403`. A tenant that is not `[A-Za-z0-9_-]{1,64}` is
`400 invalid_tenant`.

## Responding

| Method | Path | Success |
| --- | --- | --- |
| `GET` | `/admin/routes?enabled=&limit=&cursor=` | `200` `{"routes": [...], "next_cursor": id | null}` |
| `POST` | `/admin/routes` | `201` route |
| `GET` | `/admin/routes/:id` | `200` route |
| `PUT` | `/admin/routes/:id` | `200` route (idempotent; creates if absent) |
| `PATCH` | `/admin/routes/:id` | `200` route |
| `DELETE` | `/admin/routes/:id` | `204` |
| `GET` | `/v1/tenants/:tenant/routes?enabled=&limit=&cursor=` | `200` the page of that tenant's routes |
| `POST` | `/v1/tenants/:tenant/routes` | `201` route owned by the tenant |
| `GET` | `/v1/tenants/:tenant/routes/:id` | `200` route |
| `PUT` | `/v1/tenants/:tenant/routes/:id` | `200` route (idempotent; creates if absent) |
| `PATCH` | `/v1/tenants/:tenant/routes/:id` | `200` route |
| `DELETE` | `/v1/tenants/:tenant/routes/:id` | `204` |
| `GET` | `/admin/ip-rules` | `200` `{"default": ..., "rules": [...]}` |
| `PUT` | `/admin/ip-rules` | `200` the stored rules |
| `POST` | `/admin/routes/test` | `200` `{"decision": ..., "reason": ..., "route_id": ..., "ip_rule": ...}` |
| `GET` | `/health` | `200` `{"status": "ok", "routes": n}` |

Errors are JSON objects with a stable `error` code:
`invalid_route` (with `field` and `message`), `duplicate_route` (with
`conflicting_id`, left out on the tenant surface when another tenant holds the
id or the path), `too_many_routes` (with `max_routes`), `invalid_query`,
`invalid_tenant`, `invalid_ip_rules`, `invalid_request`, `invalid_body`,
`not_found`, and `store_unavailable` (a `503`: the backing store could not be
reached, or a write lost every race against concurrent writers — retry).

Writes are checked against the table they were validated against: two requests
creating the same id, or two enabled routes for the same path and method,
cannot both succeed however they interleave. `PUT /admin/ip-rules` replaces
the whole rule set, so it requires both `default` and `rules`.

`POST /admin/routes/test` is the dry run: it answers "what would happen to
this request" without capturing anything and without touching the decision
cache, so an operator can validate a route change before it goes live.

## Contract

`priv/openapi/admin.v1.yaml` is the HTTP contract for these endpoints (the
`routes` tag), and it is the source of truth:
`test/ankusa/routes/router_openapi_test.exs` builds each documented request
from the examples in it and asserts the response against the documented
schema and example, so this module answers what the document says or the suite
fails. Change one, change the other.

# `call`

# `init`

---

*Consult [api-reference.md](api-reference.md) for complete listing*
