Metadata-Version: 2.5
Name: fakts
Version: 5.4.0
Summary: asynchronous service provider ( tailored to support dynamic client-server relations)
Project-URL: Homepage, https://github.com/jhnnsrs/fakts
Project-URL: Repository, https://github.com/jhnnsrs/fakts
Project-URL: Documentation, https://jhnnsrs.github.io/fakts
Author-email: jhnnsrs <jhnnsrs@gmail.com>
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: aiohttp>=3.11.18
Requires-Dist: arkitekt-spec>=3.4
Requires-Dist: certifi>=2025.4.26
Requires-Dist: koil>=3.3.6
Requires-Dist: pydantic>=2.11
Provides-Extra: crypto
Requires-Dist: cryptography>=42; extra == 'crypto'
Provides-Extra: mesh
Requires-Dist: arkitekt-mesh>=0.1; extra == 'mesh'
Provides-Extra: rath
Requires-Dist: rath>=3.0; extra == 'rath'
Description-Content-Type: text/markdown

# fakts

[![codecov](https://codecov.io/gh/jhnnsrs/fakts/branch/main/graph/badge.svg?token=UGXEA2THBV)](https://codecov.io/gh/jhnnsrs/fakts)
[![PyPI version](https://badge.fury.io/py/fakts.svg)](https://pypi.org/project/fakts/)
[![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://pypi.org/project/fakts/)
![Maintainer](https://img.shields.io/badge/maintainer-jhnnsrs-blue)
[![PyPI pyversions](https://img.shields.io/pypi/pyversions/fakts.svg)](https://pypi.python.org/pypi/fakts/)
[![PyPI status](https://img.shields.io/pypi/status/fakts.svg)](https://pypi.python.org/pypi/fakts/)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)

> **Renamed.** This client used to be published as `fakts-next` (imported as
> `fakts_next`). It is published as `fakts` now, imported as `fakts`; the
> `fakts_next` name is gone, so update both the dependency and the imports.

Fakts is an **asynchronous app configuration and service-discovery client** for
dynamic client-server deployments. An app declares *what* it needs (a
manifest with required services); fakts negotiates *where and how* to get it:
it discovers the server, registers the app (with one-time user approval),
fetches possible paths to find declared services, resolves every required service to a
**reachable** address, and hands out OAuth2 tokens — caching everything so
all of this happens exactly once.

Fakts powers app configuration for the [Arkitekt](https://arkitekt.live)
platform, but contains no Arkitekt-specific logic: it speaks a small,
documented HTTP protocol that any server can implement.

## Install

```bash
pip install fakts
```

Optional extras:

```bash
pip install "fakts[rath]"    # FaktsAuthLink: rath GraphQL requests carry fakts tokens
pip install "fakts[crypto]"  # signed alias challenges (Ed25519 verification)
pip install "fakts[mesh]"    # reach services that are only on the deployment's mesh
```

## Quickstart

```python
from fakts import build_device_code_fakts, Manifest, Requirement

fakts = build_device_code_fakts(
    url="http://localhost:8000",
    manifest=Manifest(
        identifier="my-app",
        version="0.1.0",
        scopes=["openid"],
        requirements=[
            Requirement(key="rekuest", service="live.arkitekt.rekuest"),
        ],
    ),
)

async with fakts:
    alias = await fakts.aget_alias("rekuest")   # a verified, reachable address
    url = alias.to_http_path("graphql")         # http(s)://host:port/path/graphql
    ws = alias.to_ws_path("graphql")            # ws(s)://...
    token = await fakts.aget_token()            # OAuth2 access token
```

The **first run** opens the browser: the server shows what `my-app` is asking
for, the user approves it once, and the granted configuration is cached in
`.fakts_cache.json`. **Every later run starts instantly and silently** from
the cache — until the manifest changes (new scopes or requirements
automatically invalidate the cache) or the cached services stop answering
(fakts then re-fetches the configuration once and heals itself).

Everything also works synchronously (via [koil](https://github.com/jhnnsrs/koil)) —
same instance, no asyncio boilerplate:

```python
with fakts:
    alias = fakts.get_alias("rekuest")
    token = fakts.get_token()
```

## Core concepts

### Manifest & requirements

The `Manifest` is your app's identity card: a globally unique `identifier`,
a `version`, the OAuth2 `scopes` it needs, and a list of `Requirement`s —
the services it wants to talk to, referenced by a `key` of your choosing and
a globally unique `service` identifier (reverse-domain style). Requirements
can be `optional=True`: the app keeps working when they are absent.

### The remote protocol: discover → authorize

A `RemoteGrant` is composed of two pluggable parts:

| Role | Question it answers | Implementations |
|---|---|---|
| **Discovery** | *Where is the coordination server?* | `WellKnownDiscovery` (`/.well-known/fakts`), `StaticDiscovery` |
| **Authorizer** | *How do we get a session?* | `DeviceCodeAuthorizer` (browser approval), `RedeemAuthorizer` (pre-issued provisioning token, headless), `StaticAuthorizer` (a credential from an earlier session) |

Protocol v1 had a third role: a *claimer* that traded an approval artifact
for the configuration. It is gone because the OAuth token endpoint returns
both at once — there is nothing left to trade.

The builders (`build_device_code_fakts`, `build_redeem_fakts`) wire the
common combinations for you; compose `RemoteGrant` yourself for anything
exotic. The exact HTTP exchanges are specified in
[The Fakts protocol](#the-fakts-protocol) below.

### ActiveFakts: what the server grants

The granted configuration contains the deployment's identity (`self`), the
OAuth2 session held for your app (`auth` — a `client_id` and a rotating
refresh token, never a client secret), and one `Instance` per service, each
with a list of `Alias`es — candidate addresses for reaching that service (a
deployment may expose the same service on a LAN address, a VPN address and a
public address).

`instances` only ever contains services that were actually granted. The
sibling `statuses` map reports the outcome per requirement key
(`granted` / `denied` / `unavailable`), so a user declining an optional
service is distinguishable from a deployment that does not offer it.
Servers that do not report statuses simply omit the field; the client
then treats missing services as `unknown`.

```python
match await fakts.aget_grant_status("kabinet"):
    case GrantStatus.DENIED: ...       # the user said no — respect it
    case GrantStatus.UNAVAILABLE: ...  # the deployment can't offer it
    case GrantStatus.GRANTED: ...

# or simply, for graceful degradation:
if alias := await fakts.aget_alias_or_none("kabinet"):
    enable_kabinet_features(alias)
```

### Alias resolution: challenge once, then stick

On the first `aget_alias(key)`, fakts *challenges* the aliases of every
required service (a concurrent HTTP probe against each alias's challenge
path) and keeps the first one that answers. If the instance carries a
`challenge_key`, the probe is cryptographically verified: the service must
sign a fresh nonce with its identity key, so an impostor answering 200
does not pass (see the protocol section). Subsequent calls return the
selected alias instantly — no further probing. The winning alias is also
persisted as the preferred one, so the next process start challenges the
last-known-good address first. Pass `force_refresh=True` to re-resolve, or
`omit_challenge=True` to skip probing entirely.

### Caching & self-healing

The cache (`FileCache` by default in the builders) stores the granted
session across runs, keyed to a hash of your manifest *and* the server url —
change either and the cache invalidates itself. If a *cached* configuration
turns out to be stale (services moved), fakts reloads from the grant once
and retries before failing.

Under protocol v2 the cache is no longer just configuration: it holds a live
refresh token that rotates on every renewal, so it is written at mode `0600`
and updated far more often than before. Two consequences are worth knowing:

- **Give each app its own cache path.** The default is relative to the
  working directory, which is rarely what you want for anything but a script.
- **Processes sharing a cache cooperate rather than compete.** Each rotation
  revokes its predecessor, so a process whose token was rotated away adopts
  the one it finds on disk instead of re-authorizing — which would otherwise
  invalidate its siblings' credentials too.

When a session cannot be renewed at all — the authorization was revoked, or
it hit the server's maximum age — fakts raises `NeedsReauthenticationError`
rather than silently opening a browser, because re-approving an app replaces
its registration and disconnects every other process using it. Catch it and
call `fakts.alogin()` at a point where prompting is appropriate:

```python
try:
    token = await fakts.aget_token()
except NeedsReauthenticationError:
    await fakts.alogin()     # prompts only if the session cannot be revived
    token = await fakts.aget_token()
```

`alogin()` is idempotent — a healthy session returns immediately without a
prompt. `arefresh()` is the blunter tool: it *always* re-runs the grant, which
replaces the app's client registration and disconnects sibling processes.

To end a session, `alogout()` revokes it and forgets it locally. A server that
advertises a `revocation_endpoint` in its well-known document (RFC 7009) is told
first, so the refresh token stops renewing for every holder of a copy; the access
token already issued ages out by itself. Against a server that advertises none,
the session is only forgotten here, and a sibling process still holding it will
re-persist it on its next rotation.

## The Fakts protocol

Everything a server needs to implement to speak fakts. This section
describes **protocol version `2`**, which is an extension of the OAuth 2.0
device authorization grant ([RFC 8628][rfc8628]) rather than a protocol of
its own. The server advertises the version it speaks in the well-known
document (`protocol_version`); clients treat a missing value as `"1"` and
refuse to continue, since v1 and v2 share no endpoints.

What is genuinely fakts-specific — service instances, aliases, signed alias
challenges, per-requirement consent and grant statuses — rides along as
extension members on otherwise ordinary OAuth messages. Everything else is
standard, so an off-the-shelf OAuth library recognises most of the exchange.

```
discover ── GET  {url}/.well-known/fakts          Where is the server?
demand   ── POST {device_authorization_endpoint}  Register + stage a user code
consent  ── browser at verification_uri_complete  One-time user approval
token    ── POST {token_endpoint}, polled         Tokens *and* configuration
renew    ── POST {token_endpoint}                 grant_type=refresh_token
use      ── alias challenges, report endpoint
```

Two properties of the token endpoint shape everything downstream:

- It returns the configuration **together with** the tokens. There is no
  separate "claim" step, because there is no intermediate artifact to trade.
- It re-renders that configuration on **every** response, including every
  refresh. Aliases are host-aware, so configuration drift reaches clients
  without anyone re-approving anything.

[rfc8628]: https://datatracker.ietf.org/doc/html/rfc8628

### 1. Discovery — `GET {url}/.well-known/fakts`

Returns a document that is simultaneously the fakts descriptor and OAuth 2.0
authorization-server metadata:

```json
{
  "name": "My Deployment",
  "version": "0.1.0",
  "protocol_version": "2",
  "description": "...",
  "base_url": "https://example.com/lok/f/",

  "issuer": "https://example.com",
  "token_endpoint": "https://example.com/lok/o/token/",
  "device_authorization_endpoint": "https://example.com/lok/o/app-authorization/",
  "jwks_uri": "https://example.com/lok/o/jwks/",
  "grant_types_supported": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_basic"],
  "configure": "https://example.com/configure/{code}"
}
```

`name` and `token_endpoint` are required. The fakts members carry **no
prefix** — they sit alongside the standard ones in the same flat object,
and unknown members must be ignored rather than rejected (the same document
also advertises `mesh_*` and `hub_*` endpoints that most clients skip).

**Every endpoint URL is absolute.** Deployments commonly sit under a
script-name prefix (`/lok` above), so a client that rebuilds an endpoint by
appending to `issuer` will get the path wrong. Take the URLs as given.

The one exception is the **report endpoint**, which is not advertised;
clients derive it as `{base_url}report/`.

### 2. Demand — `POST {device_authorization_endpoint}`

A JSON body, not form encoding — this is where v2 extends OAuth, because
OAuth has no slot for "here is what my app needs, ask the user which parts
to grant":

```json
{
  "manifest": {"identifier": "my-app", "version": "0.1.0", "scopes": ["openid"],
               "requirements": [{"key": "rekuest", "service": "live.arkitekt.rekuest",
                                 "optional": false}]},
  "expiration_time_seconds": 300,
  "redirect_uris": [],
  "requested_client_kind": "development",
  "requested_client_role": "interface"
}
```

The manifest is a **nested object** here. (The redeem grant in §5 sends the
same manifest as a JSON *string* in a form field — the encodings genuinely
differ, so do not share a code path between them.)

This request also performs client registration: the server mints a *public*
OAuth client for the app (`client_secret: ""`,
`token_endpoint_auth_method: "none"`) and binds it to the device code when
the user approves. One client per app, not one per installation — under v2
it is the *token* that identifies an installation.

The response is RFC 8628 plus the minted `client_id`:

```json
{
  "status": "granted",
  "device_code": "...", "user_code": "WDJB-MJHT", "client_id": "6f0c...",
  "token_endpoint": "https://example.com/lok/o/token/",
  "verification_uri": "https://example.com/configure/",
  "verification_uri_complete": "https://example.com/configure/WDJB-MJHT",
  "expires_in": 300, "interval": 5
}
```

Clients must **carry `client_id` forward** — into polling, and into every
later refresh. It is never derived from the manifest identifier. They should
send the user to `verification_uri_complete` as given rather than deriving an
approval URL.

Two error shapes are specific to this endpoint and easy to mistake for
success: failure is reported as **HTTP 200** with `{"status": "error",
"error": "..."}`, and throttling as **HTTP 429** with a bare
`{"error": "slow_down"}` and no `status` key at all.

### 3. Token — `POST {token_endpoint}`, polled

Standard RFC 8628 polling, form-encoded:

```
grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=...&client_id=...
```

The RFC 8628 error codes replace v1's bespoke status envelope:

| `error` | client behaviour |
|---|---|
| `authorization_pending` | keep polling |
| `slow_down` | `interval += 5`, keep polling |
| `access_denied` | the user refused — a legitimate outcome, not a fault |
| `expired_token` | the code expired before approval |

Success carries the tokens **and** the configuration as top-level members:

```json
{
  "access_token": "eyJ...", "refresh_token": "...", "token_type": "Bearer",
  "expires_in": 3600, "scope": "openid read",
  "client_id": "6f0c...",

  "self": {"deployment_name": "my-deployment",
           "alias": {"id": "self", "host": "example.com", "ssl": true, "path": "lok", "challenge": "ht"}},
  "instances": {
    "rekuest": {"service": "live.arkitekt.rekuest", "identifier": "3",
                "aliases": [{"id": "lan", "host": "10.0.0.4", "port": 8080, "challenge": "ht"}],
                "challenge_key": {"kind": "ed25519", "key": "<base64>"}}
  },
  "statuses": {"rekuest": "granted", "kabinet": "denied"}
}
```

`instances` is keyed by the **requirement key** from the manifest, not by
service identifier. `statuses` is `granted` | `denied` | `unavailable`;
`denied` means the user declined an optional requirement, `unavailable`
that the deployment could not offer it. Unknown values must not break
older clients.

The granted `scope` may legitimately be narrower than what was requested —
that is what per-requirement consent *means* — so clients must not validate
one against the other.

### 4. Renew — `grant_type=refresh_token`

```
grant_type=refresh_token&refresh_token=...&client_id=...
```

Both parameters are required: the endpoint authenticates the client before
it validates the token, and then checks the token belongs to that client. A
bare refresh token is not a usable credential.

**Refresh tokens rotate**: each use issues a new one and revokes its
predecessor immediately. This has consequences that are easy to get wrong,
so they are stated as obligations below.

### 5. Redeem — the headless grant

For CI runners and deployed containers, where no human is available. An
extension grant at the same token endpoint:

```
grant_type=urn:fakts:grant-type:redeem&redeem_token=...&manifest=<json string>
```

Note the URN has no `params:oauth` segment — it is a fakts URN. Returns the
same combined response as §3, including a refresh token. It never returns a
client secret; v2 issues none.

### 6. Report — `POST {base_url}report/`

Optional, best-effort telemetry that lets the server flag broken
compositions. Authenticated with the access token, like any other resource:

```
Authorization: Bearer <access_token>
```

```json
{
  "alias_reports": {"rekuest": {"alias_id": "lan", "reason": null, "valid": true}},
  "functional": true
}
```

Keyed by requirement key. A failing report must never break the app.

### 7. Using the configuration

Alias challenges are unchanged from v1. Each alias advertises a `challenge`
path; the client GETs it and requires a 200 before treating the alias as
usable, trying them in order until one answers.

When an instance carries a `challenge_key`, a plain 200 is no longer
enough. The client sends a random nonce and the service must return a
signature over the domain-separated message
`fakts-challenge-v1:<nonce>`, made with the matching Ed25519 private key:

```
GET {alias.challenge_path}?nonce=<random>
→ {"signature": "<base64>"}
```

The client verifies against the pinned key and **never downgrades**: an
instance with a key that answers without a valid signature is rejected,
which is what stops anything on the network from impersonating a service
by answering first.

### Client and server obligations

Rotation only works if both sides hold up their end. These are normative.

**Servers MUST NOT apply refresh-token reuse detection to fakts clients**,
or MUST accept a superseded token for a short grace window. Fakts clients
are public, share a process-local cache, and legitimately race. OAuth 2.1
recommends reuse detection with family-wide revocation; applying it here
turns a recoverable collision into a hard failure that needs a human.

**Clients MUST persist a rotated refresh token before using the new access
token**, at mode `0600`. The server commits the rotation when it answers, so
a token that is used but not persisted is simply lost.

**Clients MUST NOT re-run an interactive grant automatically.** Approving an
app again causes the server to replace its client registration and delete the
old one — which severs every other process sharing that credential. Automatic
recovery is only safe for non-interactive grants (redeem).

**Multi-process deployments SHOULD share one cache** and let the loser of a
rotation race adopt the winner's credential, rather than each re-authorizing.

### Transport

Plain HTTP against a **loopback** host is always acceptable, and needs no
configuration on either side. Plain HTTP against a **network** host is a
supported deployment mode, but since v2 puts a rotating refresh token on the
wire it must be opted into explicitly on both ends — clients via
`allow_insecure_transport` (or `FAKTS_ALLOW_INSECURE_TRANSPORT=1`), servers
via whatever their OAuth library requires.

## Design notes

Most of fakts is machinery for *negotiating* configuration that a smaller app
would simply hardcode. Each piece is there because the obvious simpler choice
breaks in a real client–server deployment. The decisions worth knowing:

**Negotiation instead of static configuration.** The obvious approach is to
bake service URLs into the app (or read them from a config file). But in a
client–server world the deployment owns the topology: the same service may live
on a LAN address, a VPN address and a public one; services move; each
deployment mints its own OAuth2 client. So the app declares *what* it needs (the
manifest) and the deployment decides *where* — fakts negotiates once and caches
the result. When you genuinely don't need negotiation (config injected by a
container, or hardcoded in a test) the static path is still there: see
[`EnvGrant` and `HardFaktsGrant`](#containers-configuration-from-the-environment).

**`RemoteGrant` is two pluggable parts, not one.** The remote flow could be a
single object, but its two questions vary independently: *where is the server*
(well-known URL, static) and *how do we get a session*
(device-code browser flow, pre-issued redeem token, an existing credential).
Splitting Discovery / Authorizer into runtime-checkable protocols lets you
compose new combinations — and implement a part in your own code — without
touching the orchestration. See
[discover → authorize](#the-remote-protocol-discover--authorize).

It used to be three parts: protocol v1 separated *getting approved* from
*fetching the config*, because approval yielded a claim token that a second
request exchanged. Adopting the OAuth device grant collapsed both into the
token endpoint, and the third part had nothing left to do.

**Challenge once, then stick.** Two tempting extremes are both wrong: re-probing
every alias on every `aget_alias` is slow (each probe carries a timeout) and
churny, while trusting the cached address blindly hands back dead endpoints when
a service moves. fakts instead challenges all required services once on first
resolution, keeps the first alias that answers, and returns it instantly
thereafter — persisting it as the preferred address so the *next* process start
tries the last-known-good first. Re-resolution happens only on
`force_refresh=True` or the cached-config self-heal path. See
[alias resolution](#alias-resolution-challenge-once-then-stick).

**Signed challenges are opt-in per service instance.** Always requiring
signatures would force every deployment — including legacy ones and TLS-only
services whose certificate already proves identity — to mint and manage an
Ed25519 key. Never allowing them means a host that merely answers `200` on a
shared network can impersonate a service. Per-instance opt-in puts the bar
exactly where a deployment wants it, and once a key is pinned there is no silent
downgrade to a plain `200`. The honest limit: over plain http a signature
authenticates the *probe*, not the channel — use `ssl: true` aliases for real
channel security. See [using the configuration](#4-using-the-configuration).

**The cache key is a hash of the manifest *and* the server URL.** A single
path-keyed cache file would silently serve stale data across two boundaries that
matter: a changed manifest is a changed *permission* boundary (new scopes or
requirements), and a changed URL is a different *deployment* (dev vs prod).
Hashing both means either change invalidates the cache automatically. Writes are
atomic (temp file + rename) and a corrupt cache is treated as a miss and
reloaded — caching is an optimization, never a thing that can break startup. See
[caching & self-healing](#caching--self-healing).

**Denial is a first-class outcome, not an error.** If a user declining an
optional service looked the same as an outage, app authors would mark everything
required and users would grant everything — defeating consent. So the server's
per-key `statuses` distinguish `granted` / `denied` / `unavailable`,
`ServiceNotGrantedError` (a subclass of `AliasNotFoundError`) is catchable, and
`aget_alias_or_none` lets an app degrade gracefully. The end user stays in
control of what an app may reach. See
[what the server grants](#activefakts-what-the-server-grants).

**One instance, sync *and* async.** Rather than ship two clients that drift
apart, fakts writes the async methods (`aget_alias`, `aget_token`, …) and
derives the synchronous ones from them via [koil](https://github.com/jhnnsrs/koil),
so both surfaces share a single implementation and a single event loop.

## Recipes

### Headless / CI: redeem grant

No browser available? Have the server issue a redeem token and use:

```python
from fakts import build_redeem_fakts

fakts = build_redeem_fakts(
    url="http://localhost:8000",
    manifest=manifest,
    token="my-redeem-token",
)
```

### Containers: configuration from the environment

When the configuration is provisioned from the outside (compose files,
mounted secrets), skip the server negotiation entirely:

```python
from fakts import Fakts, EnvGrant

# reads $FAKTS (inline JSON) or $FAKTS_FILE (path to a JSON file)
fakts = Fakts(grant=EnvGrant(), manifest=manifest)
```

### Testing: no server at all

`build_testing_fakts` maps each key to a URL (or a full `Alias`). Every key
becomes a requirement *and* a granted instance, aliases resolve without being
challenged, and tokens come from a list:

```python
from fakts import build_testing_fakts

fakts = build_testing_fakts(
    {"rekuest": "http://localhost:8090"},
    tokens=["first", "second"],   # handed out in order; the last one repeats
    token_lifetime=0,             # 0: every aget_token() renews; None: never expires
)

async with fakts:
    alias = await fakts.aget_alias("rekuest")
    assert await fakts.aget_token() == "first"
    assert fakts.token_fetches == 1
```

The embedded auth points at a `.invalid` host, so anything that escapes the
testing seams and tries a real refresh fails loudly instead of reaching a
server. For a configuration you already have as an `ActiveFakts`, use
`Fakts(grant=HardFaktsGrant(fakts=...), manifest=...)` from
`fakts.grants.hard`.

### GraphQL via rath

With the `[rath]` extra, `fakts.contrib.rath.auth.FaktsAuthLink` puts the
access token on every request and renews it once when a request is rejected.
It takes a `TokenLoader` — anything with `aget_token()` and
`arefresh_token(stale_token)`, which a `Fakts` is:

```python
from fakts.contrib.rath.auth import FaktsAuthLink

auth = FaktsAuthLink(token_loader=fakts)
```

The renewal never prompts: a rejected request raises
`NeedsReauthenticationError` rather than opening a browser mid-request.

### Mesh-only services

Some deployments put services on a private mesh (a tailnet) instead of a
public address. The server marks those aliases `kind: "mesh"`. By default the
builders use the mesh **when it is available**, and stay quiet when it is not:

- Without the bindings (`pip install "fakts[mesh]"`) the mesh is off, and
  mesh aliases are skipped.
- With them, the login asks the server for a key to join the mesh with. The
  server may grant none (the user opted out of the mesh, or the organization
  has none), and then mesh aliases are skipped, too.
- A node is only started when a service is reachable no other way: while no
  node is running, a service's mesh aliases wait until its other aliases have
  failed. Later runs reuse the node's state (under
  `~/.local/state/arkitekt/mesh` on Linux).

Choose explicitly with `mesh`:

```python
from fakts import MeshOptions, MeshProxy, build_device_code_fakts

build_device_code_fakts(url=url, manifest=manifest)                           # MeshOptions(auto=True)
build_device_code_fakts(url=url, manifest=manifest, mesh=MeshOptions())       # the same, but report what is missing
build_device_code_fakts(url=url, manifest=manifest, mesh=MeshProxy(url="http://localhost:1055"))  # a running proxy
build_device_code_fakts(url=url, manifest=manifest, mesh=None)                # off
```

A mesh alias comes back with `proxy` set, so an HTTP client that honours it
reaches the service over the mesh. For traffic that cannot go through an HTTP
proxy, an alias resolved through a node this process runs also offers:

```python
local = await alias.aforward()      # "127.0.0.1:P", forwarding TCP to the alias
ice = await alias.aturn()           # TurnInfo: the node's TURN relay, as an ICE server
```

Both raise `arkitekt_spec`'s `MeshError` for an alias reached through a
`MeshProxy` (there is no node to forward through) or one that is not on the
mesh; `fakts.MeshError` is a subclass of it, so catching that one catches both.
If the node cannot start or join, only mesh aliases are affected: their
resolution error names why, every non-mesh alias keeps resolving, and fakts
does not retry the join on every call.

`build_redeem_fakts` takes `mesh` too, but the redeem grant cannot ask for a
join key: a node only comes up if it already joined on this machine, or use a
`MeshProxy`.

## Error handling

All errors derive from `FaktsError` and carry the URL contacted, the status
code and (truncated) response body where applicable:

| Error | Raised when |
|---|---|
| `NotEnteredError` | A method needing the context was called outside `with`/`async with` |
| `GrantError` / `RemoteGrantError` | The grant could not load the configuration: `DiscoveryError` (finding the server), `DemandError` and its subclasses `UserDeniedError`, `DeviceCodeExpiredError`, `DeviceCodeTimeoutError`, `RetrieveError` (getting a session) |
| `CompositionError` | One or more *required* services could not be resolved to a working alias |
| `AliasNotFoundError` | `aget_alias(key)` for a key that is not resolvable (not in the manifest, or its challenges failed) |
| `ServiceNotGrantedError` | Subclass of `AliasNotFoundError`: the key *is* declared, but the server granted no instance (user declined, or service unavailable) — catch it (or use `aget_alias_or_none`) to degrade gracefully |
| `NeedsReauthenticationError` | The session can only be recovered by a human — call `alogin()` where prompting is appropriate |
| `MeshError` | The mesh node could not start or join (its `code` is the node's own: `needs_login`, `locked`, `timeout`, ...; `None` when fakts refused, e.g. the `[mesh]` extra is missing). A subclass of `arkitekt_spec`'s `MeshError`, which `aforward()`/`aturn()` raise for an alias with no node of this process behind it |

## Fakts options

| Option | Default | Effect |
|---|---|---|
| `manifest` | required | What the app is and which services it requires |
| `grant` | required | Where the configuration comes from (`RemoteGrant`, `EnvGrant`, `HardFaktsGrant`, ...) |
| `cache` | `NoCache()` | Where the session is kept across runs; the builders pass a `FileCache` |
| `ssl_context` | certifi's CAs | TLS verification for every request fakts makes |
| `allow_insecure_transport` | `False` | Permit sending credentials over plain http to a non-loopback host (loopback never needs it) |
| `reauth_policy` | `ReauthPolicy.ON_LOGIN` | When token renewal may run an interactive grant: `NEVER`, `ON_LOGIN` (only `alogin()`/`aload()`/`arefresh()` may prompt), or `ALWAYS` |
| `mesh` | `None` (builders: `MeshOptions(auto=True)`) | `MeshOptions()` or `MeshProxy(url=...)` to reach mesh-only aliases (see [mesh-only services](#mesh-only-services)) |
| `allow_auto_load` | `True` | If `False`, `aget_*` raises instead of loading implicitly — call `aload()` yourself |
| `refetch_on_alias_failure` | `True` | Reload from the grant once when aliases from a *cached* config fail their challenges — only when that needs no human; otherwise it raises and asks you to `alogin()` |
| `alias_challenge_timeout` | `3` | Seconds per alias challenge probe |
| `delete_on_exit` | `False` | Reset the cache and loaded state on exit |

`build_device_code_fakts` and `build_redeem_fakts` set most of these for you.
Beyond `url` and `manifest` they take `cache_file`, `no_cache`, `ssl_context`,
`allow_insecure_transport` and `mesh`; the device-code builder also takes
`headless`, `timeout`, `requested_client_kind` and `requested_client_role`.

## Development

```bash
uv sync --all-extras --dev                         # install (Python >= 3.11)
uv run pytest -m "not integration"                 # unit tests, no docker
uv run pytest                                      # everything; needs docker (Fakts server, mesh lab)
uv run ruff check && uv run ruff format --check    # lint (blocking in CI)
uv run basedpyright                                # types (blocking in CI)
```
