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

Route management: the allowlist guard's decisions, the CRUD over route
definitions, and the boot validation for `config.routes`.

This is the only module the guard and the admin API call. It owns the rules
the stores can't know about — whether two enabled routes collide, which reason
a rejection carries, what the dry run reports — and leaves the stores to hold
definitions and the cache to remember decisions.

## Off by default, deny by default

`enabled?: false` is the historical behaviour: every `POST` is captured. With
routes on, a request is captured only if it passes the IP rules and matches an
enabled route; everything else is rejected **before** the store is touched, so a
rejected request never becomes a record, a dispatch, or a delivery.

## Decision order

  1. **Global IP rules.** Cheap bitmask scans, no route lookup, so a denied
     sender costs nothing else.
  2. **Route matching**, via `Ankusa.Routes.Cache` (keyed by the snapshot's
     epoch) and, on a miss, the compiled pattern list.
  3. **The route's own IP rules**, if it declares any: they *replace* the
     global list for that route, and deny when none of them matches. A global
     deny has already won by then.

A route declares rules so a provider can be pinned to its published ranges
while a global ban list still applies to everything — see
`docs/configuration.md`.

## Tenants

A route is owned by a tenant (`Ankusa.Routes.Route`) and its id is unique
across all of them. The write and read functions take a `tenant:` option for
a caller acting for one tenant (the tenant-scoped API): a route another tenant
owns is then `{:error, :not_found}` to it, an id or path that another tenant
holds is a conflict that does not name the holder (`{:conflict, nil}`), and
the tenant in the body, if any, must be that tenant. Without the option the
caller is the operator and sees every tenant's routes.

# `decision`

```elixir
@type decision() :: {:ok, String.t()} | {:reject, :no_route | :method | :ip_denied}
```

# `reason`

```elixir
@type reason() :: :matched | :no_route | :method | :ip_denied
```

# `authorize`

```elixir
@spec authorize(atom(), String.t(), [String.t()], Ankusa.Net.ip()) :: decision()
```

Decide a request against the IP rules and the route table; `segments` are
already-normalized path segments (`Ankusa.Routes.Matcher.normalize/2`).

# `authorize_path`

```elixir
@spec authorize_path(atom(), String.t(), [String.t()], String.t(), Ankusa.Net.ip()) ::
  {decision(), boolean()}
```

`authorize/4` for a raw request, normalizing the path first and reporting
whether route matching came from the cache — how the guard makes a cache hit
observable in telemetry.

# `capture`

```elixir
@spec capture(atom(), String.t(), [String.t()], String.t(), Ankusa.Net.ip()) ::
  {{:ok, Ankusa.Routes.Route.t()} | {:reject, :no_route | :method | :ip_denied},
   boolean()}
```

`authorize_path/5`, answering with the matched route itself rather than its
id: the guard hands it to ingest, which holds the route's tenant and source
binding against what the resolver says (`Ankusa.Edge.Ingest.lookup/3`). The
route is the one the decision already read, so this costs nothing extra.

# `create`

```elixir
@spec create(atom(), term(), keyword()) ::
  {:ok, Ankusa.Routes.Route.t()}
  | {:error, {:invalid, String.t(), String.t()}}
  | {:error, {:conflict, String.t() | nil}}
  | {:error, :too_many_routes | :store_unavailable}
```

Create a route.

The id must be free, and an enabled route must not share a path with another
enabled route serving one of the same methods — the two would both match, and
"which one wins" would be an implementation detail. Disabled routes are free to
collide: they capture nothing.

Every write here is audited (`Ankusa.Audit`): the route store is pluggable (ETS,
or Redis shared by a fleet), so the audit row cannot share a batch with the
change. It is written first, pending, and finished with the outcome. Options:
`audit:`, the `Ankusa.Audit.Context` of the change.

# `delete`

```elixir
@spec delete(atom(), String.t(), keyword()) ::
  :ok | {:error, :not_found | :store_unavailable}
```

Delete a route definition. Audited like `create/3`. With `tenant:`, a route
that tenant does not own is `{:error, :not_found}`, and the store checks the
owner in the delete itself.

# `dry_run`

```elixir
@spec dry_run(atom(), map()) ::
  {:ok, map()} | {:error, {:invalid, String.t(), String.t()}}
```

Explain what would happen to a request, without touching the decision cache
and without capturing anything: the answer to "why was my webhook rejected".

The request is `%{"method" =>, "path" =>, "ip" =>}`. The result names the
decision, the reason, the route that matched (if any), and the IP rule that
produced an IP decision, with the scope it came from.

# `enabled?`

```elixir
@spec enabled?(atom()) :: boolean()
```

Are routes enforced on this instance?

# `get`

```elixir
@spec get(atom(), String.t(), keyword()) ::
  {:ok, Ankusa.Routes.Route.t()} | {:error, :not_found}
```

Fetch one route definition (`tenant:`: only if that tenant owns it).

# `ip_rules`

```elixir
@spec ip_rules(atom()) :: %{
  default: :allow | :deny,
  rules: [Ankusa.Routes.Route.ip_rule()]
}
```

The global IP rules, parsed.

Reads the snapshot, falling back to the configured rules when no table is
published (a store that has not loaded). The guard is rejecting every request
in that state anyway — routes enabled with nothing loaded means nothing is
allowed — but the read stays total rather than crashing the API.

# `list`

```elixir
@spec list(atom(), keyword()) ::
  {:ok, %{routes: [Ankusa.Routes.Route.t()], next_cursor: String.t() | nil}}
```

List routes, id ascending, with cursor pagination.

`cursor` is the last id of the previous page (ids are opaque slugs, so
"greater than" is a total order over them). `limit` is clamped to `1..200` and
`next_cursor` is only set when there is another page. `tenant:` lists one
tenant's routes, by walking that tenant's index rather than the table.

# `meta`

```elixir
@spec meta(atom()) :: Ankusa.Routes.Snapshot.meta() | nil
```

The route table's `:meta` row (`Ankusa.Routes.Snapshot`: generation,
version, epoch, IP rules, trusted proxies, count), or `nil` when no store has
published one.

The guard treats `nil` as a rejection: routes are enabled, so a table that
isn't loaded means "nothing is allowed", not "everything is".

# `put_ip_rules`

```elixir
@spec put_ip_rules(atom(), term(), keyword()) ::
  {:ok, %{default: :allow | :deny, rules: [Ankusa.Routes.Route.ip_rule()]}}
  | {:error, {:invalid, String.t(), String.t()}}
  | {:error, :store_unavailable}
```

Replace the global IP rules.

The list is ordered, first match wins, and `default` decides the case where
nothing matched — the "obvious" `default: :allow` is only safe when the
surrounding network is already trusted. Audited like `create/3`.

# `replace`

```elixir
@spec replace(atom(), String.t(), term(), keyword()) ::
  {:ok, Ankusa.Routes.Route.t()}
  | {:error, {:invalid, String.t(), String.t()}}
  | {:error, {:conflict, String.t() | nil}}
  | {:error, :too_many_routes | :store_unavailable}
```

Replace a route wholesale, addressed by `id` (`PUT` semantics, idempotent).

A `PUT` for an id that does not exist yet creates it, so the cap applies; one
for an existing id keeps its `inserted_at`. Audited like `create/3`.

# `update`

```elixir
@spec update(atom(), String.t(), term(), keyword()) ::
  {:ok, Ankusa.Routes.Route.t()}
  | {:error, {:invalid, String.t(), String.t()}}
  | {:error, {:conflict, String.t() | nil}}
  | {:error, :not_found | :too_many_routes | :store_unavailable}
```

Partial update: `enabled`, `methods`, `ip_rules`, and `metadata` only.

`path`, `id`, `tenant_id` and `source_id` are immutable here — moving a route
changes which requests it captures and which tenant and source it admits them
into, which is a `PUT` (a full, reviewable definition), not a patch. Audited
like `create/3`.

# `validate_config!`

```elixir
@spec validate_config!(Ankusa.Config.t()) :: :ok
```

Validate `config.routes` at boot, raising `ArgumentError` — the same contract
as `Ankusa.ClaimCheck.validate_config!/1`, and for the same reason: a
misconfigured guard rejects real traffic, so it must not boot quietly.

Configuration that is only meaningful when routes are on (the seed) is only
checked when `enabled: true`; the rest is always checked, so a typo in a
config that is about to be switched on is caught by the same run that
loads it.

---

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