6 Commits
Author SHA1 Message Date
glm-5.3-flash 0f16c4f2f9 chore: release alkhttp 0.8.1
- version bump 0.8.0 -> 0.8.1 (patch per the dep-wave convention:
  0.4.1 rode alkcall 0.7.1; alkcall 0.10.1 is a patch release with no
  API or wire-format change, see the preceding consume commit)
- changelog: cut [0.8.1] with the alkcall 0.10.1 consumption (the
  adopt-window ordering fix; no surface reached through the HTTP API)
- README dependency snippet 0.7.0 -> 0.8.1 (was left stale by the
  0.8.0 release)

Verification: cargo test (467), clippy --all-targets -D warnings,
fmt --check, cargo publish --dry-run clean
2026-10-22 06:29:32 +00:00
glm-5.3-flash cc80188e3d chore(deps): consume alkcall 0.10.1
Picks up the adoption-window ordering fix (alkcall review 013, routed
from alkgit's wave-2 gate): a chunk routed by the channels demux inside
the adopt_channel install->drain window was delivered ahead of
earlier-parked early arrivals, inverting the byte-stream order (alkgit
pkt-line desync), plus a related loss variant that parked a chunk
forever. This crate reaches the fixed paths via the shared
ChannelManager; no alkcall API surface used by this crate changed.

The requirement is tightened 0.10 -> 0.10.1 so downstream projects
composing this crate with alkcall resolve the fixed version rather than
an older 0.10.x lockfile entry.

Verified: cargo test (467), cargo test --all-features (530), clippy
--all-targets -- -D warnings, fmt --check, fuzz corpus replay 21/21.
2026-10-22 06:28:51 +00:00
glm-5.3-flash dfa0216186 review: post-adoption pass over the alkcall-0.10 wiring pair (0766f4b, ad63507)
Three inline findings from the session review of ADR-072/ADR-073:

- fix(auth): Basic door parsing no longer requires UTF-8 — the decoded
  password bytes pass through to resolve_from_token byte-faithfully, so
  RFC 7617 legacy ISO-8859-1 credentials resolve instead of parsing to
  None. ADR-073 already specced raw-bytes passthrough ("decoded and fed
  as the AuthToken raw bytes"); the String::from_utf8 round-trip
  contradicted it.
- test: pin the with_ws_max_sessions fix against the shape it fixed —
  a builder call after with_ws_max_sessions (the pre-router_state()
  builder silently reverted the cap).
- test: pin that ownership wiring survives a later HttpAdapter builder
  rebuild at the HTTP surface.

Verified: cargo test (451), --all-features (530), clippy --all-targets
-D warnings, fmt --check, fuzz corpus replay 21/21, cargo doc.
2026-10-17 16:25:07 +00:00
glm-5.3-flash 0766f4bebe feat(server): door-surface Basic auth tolerance (review 008)
review 008 resolution:

- AH-1: door_auth_middleware on extras doors resolves
  Authorization: Basic base64(user:pass) — password field as the
  token (GitHub-PAT shape, git-lfs / git smart-HTTP clients) — through
  the same IdentityProvider::resolve_from_token backend as Bearer; no
  new trait method, resolve-fails -> None fallback preserved. Bearer
  surfaces stay Bearer-only: the gateway endpoints, /openapi.json,
  /healthz, the MCP nest, and the WS upgrade path never resolve Basic.
  The 'Bearer-only is the auth mechanism' doc line flips (ADR-073).
- AH-2 ride-along: extras door route tables are static — the merged
  router owns the decoy fallback, a second fallback panics at
  construction; documented on with_extra_routes and ADR-046 §1.

docs: ADR-073, ADR-046 §1/§4 amendment, ADR-004 presentation table,
review 008 marked resolved; new server::* re-exports; dep:base64
behind the server feature.

Verification: cargo test (449 pass, incl. door Basic resolve-through
and gateway-stays-Bearer-only pins), test --all-features (528),
clippy --all-targets -D warnings, fmt --check, fuzz corpus replay
(21/21), cargo doc.
2026-10-17 16:09:36 +00:00
glm-5.3-flash ad63507124 feat(server): thread alkcall 0.10 ownership wiring onto the HTTP surface
Ride the alkcall 0.10 gateway ownership seam (review 012, ADR-048
amendment) into the door layer:

- HttpAdapter::with_ownership_provider /
  HttpAdapter::with_static_ownership_fallback mirror alkcall's builder
  shape; OwnershipProvider re-exported next to GatewayDispatch
- RouterState carries the wiring; GatewayState::dispatch is the single
  spine builder for both the gateway routes and the MCP nest
- loud default passes through: a resource_type-gated op on an unwired
  spine is refused (FORBIDDEN envelope -> 403/401) on HTTP exactly as
  on the native wire
- WS native-session path untouched (consults ownership natively)

fix: with_ws_max_sessions never stored the fresh session-slot
semaphore; later builder calls silently reverted the cap. Builder
state now assembled through one router_state() helper.

infra: fuzz/shared follows to alkcall 0.10 (two alkcall copies in one
build would type-clash); alkhttp 0.8.0 per the dep-wave convention.

Verification: cargo test (444 pass, incl. 3 new call_resource_gated_*
HTTP-surface pins), clippy --all-targets -D warnings, fmt --check,
test --all-features (520), fuzz corpus replay (21/21), doc.
2026-10-17 16:00:12 +00:00
glm-5.3-flash 1cac63fdba docs(review): 008 — HTTP-door consumer evidence (LFS Basic auth, route-fallback constraint)
Report-only from alkgit's LFS POC: AH-1 the shared bearer middleware
is Bearer-only while real git-lfs authenticates Basic on the batch;
AH-2 extras doors cannot carry a second route fallback (docs-only
record). No mechanism change fixed here; dispositions session-owned.
2026-10-17 07:28:45 +00:00
19 changed files with 1126 additions and 153 deletions

No files matched your search

+93
View File
@@ -4,6 +4,99 @@ All notable changes to this crate are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this crate adheres to [Semantic Versioning](https://semver.org/).
## [0.8.1] — 2026-10-22
### Changed
- **`alkcall` dependency consumed at 0.10.1** (patch pickup; the
adoption-window ordering fix, alkcall review 013, routed from
alkgit's wave-2 gate). A chunk routed by the channels demux inside
the `adopt_channel` install→drain window was delivered **ahead of**
earlier-parked early arrivals, inverting the channel's byte-stream
order, and a related loss variant parked a chunk forever after the
drain had removed its queue. This crate reaches the fixed paths via
the shared `ChannelManager`; no alkcall API surface used here
changed — no code change in this crate. The dependency requirement
is tightened `0.10` → `0.10.1` so downstream projects composing
this crate with alkcall resolve the fixed version rather than an
older 0.10.x lockfile entry.
Patch bump 0.8.0 → 0.8.1 per the dep-wave convention (0.4.1 rode
alkcall 0.7.1): alkcall 0.10.1 is a patch release with no API or
wire-format change (`EventEnvelope`, the channels 8-byte chunk
header), so nothing semver-relevant moves on this crate's surface.
## [0.8.0] — 2026-10-17
### Added
- **Door-surface Basic auth tolerance (review 008 AH-1, ADR-073).**
The extras-door surface now resolves `Authorization: Basic
base64(user:pass)` — the password field acts as the token and rides
the same `IdentityProvider::resolve_from_token()` backend as Bearer
(the GitHub-PAT shape `git-lfs` and git's smart-HTTP transport
authenticate with). No new trait method; the provider's grant table
governs exactly as it does for Bearer, and "unparseable /
unresolvable → None" fallback semantics are preserved. Basic is
scoped to the door surface: `bearer_auth_middleware` stays
Bearer-only on the gateway endpoints, `/openapi.json`, `/healthz`,
and the MCP nest, and the WS upgrade path is untouched — a Basic
credential contributes no identity on any path a deployment has not
explicitly mounted doors on. New public API:
`server::door_auth_middleware`, `server::extract_door_identity`
(`bearer_auth_middleware` / `extract_bearer_identity` keep their
Bearer-only semantics and their coverage).
### Changed
- **`alkcall` dependency bumped to 0.10.0** (the gateway ownership-seam
release, alkcall review 012) and the loud ownership default passes
through to the HTTP surface (ADR-072):
`HttpAdapter::with_ownership_provider` /
`HttpAdapter::with_static_ownership_fallback` thread the provider
into both spines this crate constructs — the gateway endpoints'
per-request spine and the MCP nest's spine (single wiring point in
`GatewayState::dispatch`). An unwired, undeclared spine now refuses
a `resource_type`-gated op with a `FORBIDDEN` envelope (**403** with
identity / **401** without) — the behavior break is inherited, not
re-decided; the WS native-session path is untouched (it consults
ownership natively).
- The gateway test-state builder threads ownership configuration; the
three dispositions (unwired refusal, live grant table, declared
static fallback) are pinned at the HTTP surface by
`call_resource_gated_*` regression tests.
### Documented
- **Extras door route tables are static (review 008 AH-2).** One doc
line on the door-facing surface (`HttpAdapter::with_extra_routes`)
and in ADR-046: the merged router already owns the decoy fallback
and axum refuses a second, so a door attempting `fallback_layer`
panics at construction — enumerate multi-segment namespace shapes
(`{user}/{repo}/...`) as pattern routes. Not a mechanism ask; the
next door author should not rediscover the panic.
### Fixed
- `HttpAdapter::with_ws_max_sessions` never stored the freshly built
session-slot semaphore on the adapter — any subsequent builder call
rebuilt the router with the pre-cap-change semaphore, silently
reverting the cap. Builder state is now assembled in one shared
`router_state()` helper, so every `with_*` rebuild sees the current
configuration. Pinned by the WS session-cap integration test running
with a builder call after `with_ws_max_sessions`, and by an
ownership-wiring-survives-rebuild test at the HTTP surface.
- Door-surface Basic parsing no longer requires the password field to
be UTF-8: the decoded password bytes reach
`IdentityProvider::resolve_from_token()` byte-faithfully, so RFC 7617
legacy ISO-8859-1 credentials (e.g. a latin-1 user password) that a
provider resolves still authenticate instead of parsing to `None`.
Minor bump 0.7.0 → 0.8.0 per the dep-wave convention (0.4.0 rode
alkcall 0.7.0, 0.5.0 rode 0.8.0, 0.7.0 rode 0.9.0): the re-exported
alkcall types are on this crate's public API, so a semver-moving
upstream is semver-relevant here.
## [0.7.0] — 2026-10-14
### Changed
Generated
+4 -3
View File
@@ -27,9 +27,9 @@ dependencies = [
[[package]]
name = "alkcall"
version = "0.9.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3562f1f7b348cdeb3d9181eaf57b0a2fb3f988d4280e8e225dadfd7f763cb84"
checksum = "690d747f42baf385dc22cd120f79d43cf1ea239bc426675e0e1e225b57fc031a"
dependencies = [
"async-trait",
"bytes",
@@ -48,12 +48,13 @@ dependencies = [
[[package]]
name = "alkhttp"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"alkcall",
"arc-swap",
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"futures",
"http",
+4 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "alkhttp"
version = "0.7.0"
version = "0.8.1"
edition = "2021"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
@@ -25,7 +25,7 @@ default = ["server", "client", "h2", "http1"]
# driver. `openapi` rides along because /openapi.json is part of the
# default surface (ADR-042/045); `http1` rides along so the hyper
# auto-builder always has at least one protocol compiled.
server = ["dep:axum", "dep:hyper", "dep:hyper-util", "dep:uuid", "openapi", "http1"]
server = ["dep:axum", "dep:base64", "dep:hyper", "dep:hyper-util", "dep:uuid", "openapi", "http1"]
# Consumer side: the shared outbound client host and the HTTP-backed
# import adapters (`from_jsonschema`, `from_openapi` with `openapi`,
# `from_mcp` with `mcp`).
@@ -46,9 +46,10 @@ h2 = ["server", "dep:hyper", "hyper-util/http2", "hyper/http2"]
http1 = ["server", "dep:hyper", "hyper-util/http1", "hyper/http1"]
[dependencies]
alkcall = { version = "0.9", features = ["gateway"] }
alkcall = { version = "0.10.1", features = ["gateway"] }
arc-swap = { version = "1", optional = true }
axum = { version = "0.8", optional = true, features = ["ws"] }
base64 = { version = "0.22", optional = true }
bytes = "1"
futures = "0.3"
http = "1"
+1 -1
View File
@@ -161,7 +161,7 @@ A lean single-side build takes `default-features = false` plus the side
it needs:
```toml
alkhttp = { version = "0.7.0", default-features = false, features = ["server"] }
alkhttp = { version = "0.8.1", default-features = false, features = ["server"] }
```
## Security posture
+2
View File
@@ -69,6 +69,8 @@ types — the former `alknet-core` and `alknet-call` merged).
| [069](decisions/069-webtransport-out-of-scope.md) | WebTransport Out of Scope | h3/WebTransport removed from alkhttp scope entirely (an alknet concern); supersedes the deferral framing of ADR-044 |
| [070](decisions/070-from-wss-consumer-adapter.md) | `from_wss` Consumer Adapter | Import a remote node's operations over WSS — same-protocol importer, channels-over-WS as transport; `wss` feature gate |
| [071](decisions/071-dispatch-schema-guard.md) | Dispatch-Spine `services/schema` Op-Path Guard | `GatewayDispatch` applies the GET `/schema` visibility+ACL checks to the meta-op's inner `name` (review-002 PRJ-16); alkcall CF-004 is the complete fix, this stays as defense-in-depth (now via the promoted `alkcall::gateway` shared check — spine + guard themselves live in alkcall per ADR-048) |
| [072](decisions/072-http-surface-ownership-wiring.md) | HTTP-Surface Ownership Wiring (Gateway + MCP) | alkcall 0.10's loud ownership default passes through: `HttpAdapter::with_ownership_provider` / `with_static_ownership_fallback` thread the provider into the gateway and MCP spines (single wiring in `GatewayState::dispatch`) |
| [073](decisions/073-door-surface-basic-auth-tolerance.md) | Door-Surface Basic Auth Tolerance | `door_auth_middleware` on extras doors resolves `Basic base64(user:pass)` (password field as the token) through the same `resolve_from_token` backend; gateway/MCP/WS stay Bearer-only (review 008 AH-1) |
## Relevant Open Questions
@@ -36,7 +36,7 @@ Credential presentation per handler:
|---------|------------------------|-------------|
| SshAdapter | SSH public key handshake | `resolve_from_fingerprint()` |
| CallAdapter | AuthToken in first frame | `resolve_from_token()` |
| HttpAdapter | `Authorization: Bearer` header | `resolve_from_token()` |
| HttpAdapter | `Authorization` header — Bearer (all HTTP surfaces); Basic additionally on the extras-door surface (ADR-073, the password field as the token) | `resolve_from_token()` |
| DnsAdapter | AuthToken in query labels | `resolve_from_token()` |
| WebTransportAdapter | AuthToken in CONNECT headers | `resolve_from_token()` |
| GitAdapter | Signed push certificate | `resolve_from_fingerprint()` |
@@ -95,6 +95,16 @@ the one-way constraint is that the assembly layer can inject routes
and they coexist with the default surface. axum's `Router::merge` /
`Router::nest` are the natural primitives.
**Route tables are static (review 008 AH-2).** The merged router
already owns the fallback route (the decoy) — axum refuses a second
fallback, and a door attempting `fallback_layer`/`fallback` on the
merged router panics at construction, not at request time. Door route
tables must therefore be statically enumerated: multi-segment
repository namespaces (`/{user}/{repo}/objects/batch` shape) are
pattern routes with wildcard segments, never a dynamic fallback match.
This is a recorded constraint of the router assembly, not a mechanism
ask.
### 2. Custom routes are raw HTTP, not call-protocol operations
A custom route is a raw axum handler — it receives an HTTP request and
@@ -134,16 +144,21 @@ is natural (`/v1/...` doesn't collide).
### 4. Custom routes carry the same auth middleware by default; per-route opt-out is the deployment's choice
Custom routes run under the same Bearer-auth resolution as the default
surface (the `Authorization: Bearer` → `resolve_from_token` path). A
deployment that wants a custom route to be unauthenticated (a public
webhook receiver, a health endpoint with a different shape than
`/healthz`) applies axum middleware to opt that route out of auth —
the deployment owns its custom routes' middleware stack. The
`HttpAdapter` provides the identity provider and the default auth
middleware; the custom `Router` the assembly layer passes in can
layer its own middleware on top. This is standard axum composition; no
alknet-specific mechanism.
Custom routes run under the door-surface auth middleware: Bearer
resolution identically to the default surface
(`Authorization: Bearer` → `resolve_from_token`), plus — ADR-073 —
Basic tolerance (`Basic base64(user:pass)`; the password field rides
the same `resolve_from_token` backend). The default token-auth surface
(gateway endpoints, `/openapi.json`, `/healthz`, MCP) stays
Bearer-only: Basic resolution is scoped to the door surface
specifically constructed for legacy clients. A deployment that wants a
custom route to be unauthenticated (a public webhook receiver, a
health endpoint with a different shape than `/healthz`) applies axum
middleware to opt that route out of auth — the deployment owns its
custom routes' middleware stack. The `HttpAdapter` provides the
identity provider and the default middlewares; the custom `Router` the
assembly layer passes in can layer its own middleware on top. This is
standard axum composition; no alknet-specific mechanism.
### 5. Custom routes are not part of the published `to_openapi` doc
@@ -0,0 +1,89 @@
# ADR-072: HTTP-Surface Ownership Wiring (Gateway + MCP)
## Status
Accepted
## Context
alkcall 0.10.0 landed the gateway ownership seam (alkcall review 012,
downstream evidence from alkgit's LFS POC): the dispatch spine's
previously-silent static `Identity.resources` fallback becomes loud — a
`resource_type`-gated op served through an unwired spine is refused
with a `FORBIDDEN` error envelope unless the deployment wires a
runtime ownership provider
(`GatewayDispatch::with_ownership_provider`) or declares the static
fallback explicitly
(`GatewayDispatch::with_static_ownership_fallback`). The change closes
the two-authorization-paths drift: a namespace-ACL-gated op served
through an HTTP door previously ran against the token-carried static
claim, while the same op on the native wire consulted the revocable
grant table.
alkhttp is the only live spine consumer. It constructs the spine
internally in two places — the gateway endpoints' per-request spine
(`gateway::routes::GatewayState`) and the MCP nest's spine
(`server::build_router`) — so an assembly layer wiring a provider for
the native wire cannot reach those spines through alkcall's API. Once
alkhttp rode 0.10, any `resource_type`-gated op in the registry would
break on the HTTP surface even for deployments that wired providers
correctly.
## Decision
**alkhttp mirrors alkcall's builder shape on `HttpAdapter` and threads
the wiring into every spine it constructs.**
- `HttpAdapter::with_ownership_provider(Arc<dyn OwnershipProvider>)`
and `HttpAdapter::with_static_ownership_fallback()` — the two
alkcall-named opt-ins, one method each, no alkcall-specific
vocabulary changed. `OwnershipProvider` is re-exported from
`alkhttp::gateway` next to `GatewayDispatch`.
- The wiring lives on `RouterState` and is threaded by
`GatewayState::dispatch()` (routes) and `RouterState::dispatch()`
(the MCP nest — the gateway routes extract `GatewayState` via
`FromRef`, so `GatewayState` is the single wiring point).
- Both spines share one wiring: the gateway endpoints and the MCP
surface are the same door contract; no separate MCP wiring exists.
The door contract (alkcall review 012, unchanged here): any
deployment serving `resource_type`-gated ops through the HTTP surface
must wire the same provider the native wire consults —
visible-surface = authorized-surface. The loud default passes through
from alkcall unchanged: an unwired, undeclared spine refuses a
`resource_type`-gated op with the spine's `FORBIDDEN` envelope, which
the gateway error mapping serves as 403 (with identity) / 401
(without).
The WS native-session path needs no wiring — it speaks the native
call protocol, which consults ownership natively in alkcall's serving
layer (alkcall ADR-048).
## Consequences
**Positive:**
- The HTTP surface cannot silently diverge from the native wire's
ownership truth — alkcall's loud default is inheritable at the door
layer, and `with_static_ownership_fallback` keeps the static-claims
posture a code-review-visible declaration here too.
- alkcall's builder shape is mirrored on alkhttp's builder, so an
assembly layer configures both spines the same way.
**Negative:**
- Deployments upgrading alkhttp to the 0.10-riding release must wire
the provider (or declare the fallback) before serving
`resource_type`-gated ops over HTTP — the 403 refusal is the
reminder, not a compile error.
## References
- alkcall review 012
(`/workspace/@alkdev/alkcall/docs/reviews/012-gateway-ownership-seam-consumer-input.md`)
— the consumer evidence and the two recorded decisions
- alkcall 0.10.0 CHANGELOG (the loud-default behavior break)
- [ADR-048](048-websocket-native-session-not-gateway.md) — the WS path
this does not touch
- [Review 008](../../reviews/008-http-door-consumer-review.md)
— the same consumption round's alkhttp-filed findings (AH-1, AH-2)
@@ -0,0 +1,87 @@
# ADR-073: Door-Surface Basic Auth Tolerance (git / git-lfs Clients)
## Status
Accepted
## Context
Review 008 AH-1, from alkgit's LFS POC (POC-L): a real `git-lfs`
3.7.0 client authenticates the batch endpoint with **Basic**
credentials (URL userinfo — the spec-shape behavior of the official
client; config `http://user:pass@host/repo`). The shared identity
middleware was Bearer-only — the module doc said so explicitly
("Bearer-only is the auth mechanism") — so a door mounted under the
shared auth layer received the batch request with the middleware
already having folded in `None`, and the door had to re-parse the
`Authorization` header itself (Basic *or* Bearer) to get any identity.
The middleware is shared by the gateway endpoints and `to_mcp`. Every
HTTP-door surface whose real client authenticates with Basic (LFS
today; git's smart-HTTP transport is tomorrow's `git` door) would
silently re-implement the fallback locally — the quiet-bypass shape
alkcall review 012 records for the ownership seam, one layer up in the
same stack.
## Decision
**Split the auth surface into token surfaces and the door surface,
and teach the door surface Basic — through the same resolution
backend, with no new `IdentityProvider` method.**
- `bearer_auth_middleware` stays Bearer-only and continues to cover
the token-auth surfaces: the gateway endpoints, `/openapi.json`,
`/healthz`, and the `to_mcp` nest. A Basic header contributes no
identity there. `/call`, `/batch`, `/subscribe`, `/publish`, `/mcp`,
and the WS upgrade path are token surfaces — this is the "no blanket
method" constraint: a Basic credential never gains identity anywhere
the deployment has not explicitly set up for it.
- `door_auth_middleware` (new) is applied by `build_router` to the
extras-door router (`HttpAdapter::with_extra_routes`) — the surface
legacy clients authenticate on.
- Both middlewares resolve through `IdentityProvider::resolve_from_token()`.
Basic parsing: `Basic base64(user:pass)` — the password field (the
GitHub-PAT shape; the user segment is ignored) is decoded and fed as
the `AuthToken` raw bytes. A colon-less value is treated as a bare
password. No new trait method: the provider's grant table governs a
Basic credential exactly as it governs the same credential presented
as Bearer — and the same backend mechanism that grants privileges to
tokens grants them to these credentials (SOCKS5
userinfo-style integrations can follow the same pattern later).
- Fallback semantics unchanged: no header → `None`; unparseable or
unresolvable credential → `None` (unauthenticated, not an error).
A parse-time failure never becomes a 4xx of its own.
- Doors may opt out with their own middleware stack as before
([ADR-046](046-assembly-layer-custom-http-routes.md) §4).
## Consequences
**Positive:**
- `git-lfs` / `git` doors work against a deployment whose provider
maps password-tokens through `resolve_from_token`, with zero
door-side header re-parsing.
- Scoping is structural (per-middleware), not
per-path-inspection: Basic tolerance exists only where the
assembly layer mounted the door surface.
**Negative:**
- A door surface deployment that does *not* want its door credentials
resolvable as Basic must now ensure the provider does not resolve
those values as tokens — or opt the door out of the shared layer.
- Scheme prefixes remain case-sensitive ("Bearer "/"Basic "), matching
the pre-existing Bearer parsing; strictly RFC-noncompliant
lowercase-scheme clients parse to `None`.
## References
- [Review 008](../../reviews/008-http-door-consumer-review.md) — AH-1
evidence and disposition space
- [ADR-004](004-auth-as-shared-core.md) — the shared-core resolution
backend this rides
- [ADR-046](046-assembly-layer-custom-http-routes.md) — the extras-door
surface the door middleware covers (§4 amended; AH-2 static-route
constraint recorded there)
- alkcall review 012 — the same-round quiet-bypass analysis this
avoids repeating at the door layer
@@ -0,0 +1,102 @@
# Review 008 — First HTTP-door consumer evidence: shared auth vs LFS Basic, and the extras route-fallback constraint
## Status
Resolved — 2026-10-17. AH-1: door-surface Basic tolerance landed as
[ADR-073](../architecture/decisions/073-door-surface-basic-auth-tolerance.md)
(`door_auth_middleware` on extras doors; password field through the
same `resolve_from_token` backend; token surfaces stay Bearer-only).
AH-2: recorded on the door-facing surface
([ADR-046](../architecture/decisions/046-assembly-layer-custom-http-routes.md)
§1). Both ride the alkhttp 0.8.0 release (with the alkcall 0.10
ownership wiring, ADR-072). Filed 2026-10-17 as consumer input from
alkgit's LFS
POC (POC-L, 2026-10-16; findings at
`/workspace/@alkdev/alkgit/docs/research/pocl-findings.md`; the POC
crate is `/workspace/lfs-poc`, which served a real `git-lfs` 3.7.0
client through `with_extra_routes` doors). Ask ledger source: alkgit's
`docs/research/substrate-recomposition.md` §Upstream-push
candidates #6/#9 (the alkhttp-named items; the gateway-dispatch half
of #6 is filed where it belongs — alkcall review 012).
## Scope
Two findings, both from the same consumption round (an LFS batch-API
door on the extras router). Neither was discovered by a source sweep;
both anchors were re-verified by targeted read this session. Severity
and shape are the consumer seat's; the disposition belongs to this
crate's own session.
## AH-1 [small, contract-visible] — The shared bearer middleware is Bearer-only; LFS clients authenticate with Basic on the batch
**Anchor.** `src/server/auth.rs` — the shared identity middleware
(`bearer_auth_middleware`); the module doc states the semantics
explicitly: *"Malformed `Authorization` header (not `Bearer <token>`)
→ `None` (treated as no-token, not an error — Bearer-only is the auth
mechanism)"* (lines 1–20, verified this session).
**Evidence.** Real `git-lfs` 3.7.0 authenticates the batch endpoint
with **Basic** credentials (URL userinfo — the spec-shape behavior of
the official client; its config is `http://user:pass@host/repo`). An
extras door mounted on the shared middleware therefore receives the
batch request with the middleware already having resolved `None`
(the Basic header is not `Bearer`) — the door had to parse the
Authorization header a second time (Basic *or* Bearer) to get any
identity at all. The POC's door resolved both schemes locally and
passed the real client; the shared middleware contributed nothing on
this request shape.
**Why it matters beyond LFS.** The middleware is shared by gateway
endpoints and `to_mcp`; any HTTP-door surface whose
real-world client authenticates with Basic (LFS today; git's
smart-HTTP transport's basic auth is tomorrow's `git` door) silently
loses identity resolution at the middleware and every door
re-implements the fallback — the exact quiet-bypass shape review 012
(alkcall) records for the ownership seam, one layer up in the same
stack.
**Recommended shape (own session, to be re-derived)**: tolerate Basic
userinfo in the shared middleware — parse `Basic base64(user:pass)`
into the identity provider's resolve path (or expose a documented
per-door hook that receives the raw header before the None fold-in),
keeping the current "resolve fails → None" fallback semantics. The
doc line "Bearer-only is the auth mechanism" should flip to match
whatever lands. Small surface; the decision is whether Basic
resolution belongs in the shared middleware (uniform) or stays a
door concern (explicit).
**Verification status**: middleware semantics re-read this session;
the client behavior is POC-L's recorded route-shape fact (real
client, live). Re-verify by pointing a stock `git-lfs` at a
shared-middleware door during resolution.
## AH-2 [recorded constraint, no ask yet] — Extras doors cannot carry a route fallback; door route tables must be static shapes
**Anchor/evidence.** POC-L, live: the merged extras router already
owns a fallback route (the decoy), and axum refuses a second merge —
a door attempting `fallback_layer`/merge fallback on the merged
router panics **at construction**. Consequence: door route tables
must be statically enumerated; multi-segment repo namespaces
(`/user/repo/objects/batch` style) need enumerated pattern routes
(`/{user}/{repo}/...` shape), not a dynamic-fallback match. The POC
door's static shapes worked for every e2e case.
**Disposition space.** This is a structural fact of the current
router assembly, currently undocumented on the door-facing surface —
worth one doc line in the extras/hooks documentation ("route tables
are static; no second fallback; enumerate multi-segment patterns
explicitly") regardless of whether any mechanism change is ever
wanted. Not a mechanism ask today; recorded so the next door author
does not rediscover the construction panic.
## What this review asks for
| # | Item | Class |
|---|---|---|
| AH-1 | Basic tolerance (or a documented pre-resolution hook) in the shared identity middleware | small change + doc flip, own session |
| AH-2 | doc line: extras route tables are static; no second fallback | docs-only, ride-along |
Nothing here blocks any door surface that resolves identity
door-side (the proven compensation), but the shared-middleware trap
(AH-1) compounds alkcall review 012's gateway trap for every future
External-facing door — the two are worth one resolution round each.
+7 -6
View File
@@ -27,9 +27,9 @@ dependencies = [
[[package]]
name = "alkcall"
version = "0.9.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3562f1f7b348cdeb3d9181eaf57b0a2fb3f988d4280e8e225dadfd7f763cb84"
checksum = "690d747f42baf385dc22cd120f79d43cf1ea239bc426675e0e1e225b57fc031a"
dependencies = [
"async-trait",
"bytes",
@@ -48,12 +48,13 @@ dependencies = [
[[package]]
name = "alkhttp"
version = "0.6.0"
version = "0.8.1"
dependencies = [
"alkcall",
"arc-swap",
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"futures",
"http",
@@ -1405,7 +1406,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1756,7 +1757,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2513,7 +2514,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
+1 -1
View File
@@ -14,7 +14,7 @@ mcp = ["alkhttp/mcp"]
[dependencies]
alkhttp = { path = "../.." }
alkcall = "0.9"
alkcall = "0.10"
serde_json = "1"
bytes = "1"
futures = "0.3"
+3
View File
@@ -1,8 +1,11 @@
pub mod error;
pub mod routes;
pub use alkcall::core::ownership::OwnershipProvider;
pub use alkcall::gateway::{schema_disclosure_denial, GatewayDispatch, DEFAULT_DEADLINE};
pub use routes::CallRequest;
#[cfg(feature = "mcp")]
pub(crate) use routes::GatewayState;
/// The maximum number of operations accepted in a single batch,
/// enforced identically by the HTTP `POST /batch` endpoint, the
+223 -7
View File
@@ -82,21 +82,30 @@ type ByteStream = futures::stream::BoxStream<'static, Result<Bytes, axum::Error>
#[derive(Clone)]
pub(crate) struct GatewayState {
registry: Arc<OperationRegistry>,
ownership_provider: Option<Arc<dyn alkcall::core::ownership::OwnershipProvider>>,
static_ownership_fallback: bool,
}
impl GatewayState {
pub(crate) fn new(registry: Arc<OperationRegistry>) -> Self {
Self { registry }
}
fn dispatch(&self) -> GatewayDispatch {
GatewayDispatch::new(Arc::clone(&self.registry))
pub(crate) fn dispatch(&self) -> GatewayDispatch {
let mut dispatch = GatewayDispatch::new(Arc::clone(&self.registry));
if let Some(provider) = &self.ownership_provider {
dispatch = dispatch.with_ownership_provider(Arc::clone(provider));
}
if self.static_ownership_fallback {
dispatch = dispatch.with_static_ownership_fallback();
}
dispatch
}
}
impl FromRef<RouterState> for GatewayState {
fn from_ref(state: &RouterState) -> Self {
GatewayState::new(Arc::clone(&state.registry))
Self {
registry: Arc::clone(&state.registry),
ownership_provider: state.ownership_provider.clone(),
static_ownership_fallback: state.static_ownership_fallback,
}
}
}
@@ -803,6 +812,7 @@ fn error_event(operation: &str) -> Result<Event, Infallible> {
mod tests {
use super::*;
use alkcall::core::auth::IdentityProvider;
use alkcall::core::ownership::OwnershipStore;
use alkcall::core::types::Capabilities;
use alkcall::registry::discovery::{
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
@@ -887,6 +897,201 @@ mod tests {
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) })
}
// --- Review 012 / ADR-048 amendment: ownership wiring through the
// --- HTTP gateway surface -------------------------------------------
fn namespace_acl() -> AccessControl {
AccessControl {
resource_type: Some("blob-namespace".to_string()),
resource_action: Some("read".to_string()),
..Default::default()
}
}
fn namespace_spec(name: &str) -> OperationSpec {
let mut spec = external_spec(name, namespace_acl());
spec.resource_id_path = Some("$.namespace".to_string());
spec
}
fn registry_with_namespace_gated_op() -> Arc<OperationRegistry> {
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
namespace_spec("ns/stat"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
fn identity_with_resources(
id: &str,
resource_type: &str,
actions: Option<Vec<&str>>,
) -> Identity {
let mut resources = HashMap::new();
if let Some(actions) = actions {
resources.insert(
resource_type.to_string(),
actions.into_iter().map(|a| a.to_string()).collect(),
);
}
Identity {
id: id.to_string(),
scopes: Vec::new(),
resources,
}
}
async fn post_call(
router: axum::Router,
token: &str,
operation: &str,
input: serde_json::Value,
) -> (axum::http::StatusCode, serde_json::Value) {
let req = Request::builder()
.method("POST")
.uri("/call")
.header("content-type", "application/json")
.header(axum::http::header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::from(
serde_json::to_vec(&json!({
"operation": operation,
"input": input,
}))
.unwrap(),
))
.unwrap();
let (status, body) = send(router, req).await;
(status, body)
}
#[tokio::test]
async fn call_resource_gated_op_without_provider_is_refused() {
let provider: Arc<dyn IdentityProvider> =
Arc::new(StaticIdentityProvider::new().with_token(
"tok-a",
identity_with_resources("token-carrier", "blob-namespace", Some(vec!["read"])),
));
let router =
build_router_with_ownership(registry_with_namespace_gated_op(), provider, None, false);
let (status, body) =
post_call(router, "tok-a", "ns/stat", json!({ "namespace": "ns-1" })).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"the loud default refuses a resource-gated op on an unwired spine even with a static claim: {body}"
);
assert_eq!(body.get("code"), Some(&json!("FORBIDDEN")));
}
#[tokio::test]
async fn call_resource_gated_op_with_wired_provider_consults_the_live_grant_table() {
let store = Arc::new(alkcall::core::ownership::InMemoryOwnershipStore::new());
store
.record(
&identity_with_resources("tenant-a", "blob-namespace", None),
"blob-namespace",
"ns-1",
)
.await
.unwrap();
let idp: Arc<dyn IdentityProvider> = Arc::new(
StaticIdentityProvider::new()
.with_token(
"tok-a",
identity_with_resources("tenant-a", "blob-namespace", None),
)
.with_token(
"tok-claim",
identity_with_resources("token-carrier", "blob-namespace", Some(vec!["read"])),
),
);
let router = build_router_with_ownership(
registry_with_namespace_gated_op(),
Arc::clone(&idp),
Some(store),
false,
);
let (status, body) = post_call(
router.clone(),
"tok-a",
"ns/stat",
json!({ "namespace": "ns-1" }),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"a live grant admits without any static claim on the identity: {body}"
);
let (status, body) = post_call(
router,
"tok-claim",
"ns/stat",
json!({ "namespace": "ns-1" }),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"a token-carried static claim is denied without a live grant: {body}"
);
assert_eq!(body.get("code"), Some(&json!("FORBIDDEN")));
}
#[tokio::test]
async fn call_resource_gated_op_with_declared_static_fallback_serves_the_static_claim() {
let provider: Arc<dyn IdentityProvider> =
Arc::new(StaticIdentityProvider::new().with_token(
"tok-a",
identity_with_resources("token-carrier", "blob-namespace", Some(vec!["read"])),
));
let router =
build_router_with_ownership(registry_with_namespace_gated_op(), provider, None, true);
let (status, body) =
post_call(router, "tok-a", "ns/stat", json!({ "namespace": "ns-1" })).await;
assert_eq!(
status,
StatusCode::OK,
"the declared fallback keeps the ADR-011 static-claim check: {body}"
);
}
#[tokio::test]
async fn ownership_fallback_wiring_survives_a_later_builder_rebuild() {
let provider: Arc<dyn IdentityProvider> =
Arc::new(StaticIdentityProvider::new().with_token(
"tok-a",
identity_with_resources("token-carrier", "blob-namespace", Some(vec!["read"])),
));
let adapter = crate::server::HttpAdapter::new(provider, registry_with_namespace_gated_op())
.with_static_ownership_fallback()
.with_decoy(crate::server::DecoyConfig::NotFound);
let (status, body) = post_call(
adapter.router().clone(),
"tok-a",
"ns/stat",
json!({ "namespace": "ns-1" }),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"the wiring must survive the with_decoy state rebuild — the pre-router_state() \
builder shape silently dropped values set by earlier calls: {body}"
);
}
fn registry_with_echo() -> Arc<OperationRegistry> {
let registry = OperationRegistry::new();
registry
@@ -1126,10 +1331,21 @@ mod tests {
fn build_router(
registry: Arc<OperationRegistry>,
provider: Arc<dyn IdentityProvider>,
) -> axum::Router {
build_router_with_ownership(registry, provider, None, false)
}
fn build_router_with_ownership(
registry: Arc<OperationRegistry>,
provider: Arc<dyn IdentityProvider>,
ownership_provider: Option<Arc<dyn alkcall::core::ownership::OwnershipProvider>>,
static_ownership_fallback: bool,
) -> axum::Router {
let state = RouterState {
registry: Arc::clone(&registry),
identity_provider: Arc::clone(&provider),
ownership_provider,
static_ownership_fallback,
decoy: crate::server::DecoyConfig::NotFound,
openapi_doc: crate::server::adapter::CachedOpenAPIDoc::new(&registry),
ws_sessions: Arc::new(crate::websocket::WsSessions::new()),
+231 -101
View File
@@ -48,7 +48,7 @@ use hyper_util::service::TowerToHyperService;
use parking_lot::Mutex;
use tracing::error;
use super::auth::bearer_auth_middleware;
use super::auth::{bearer_auth_middleware, door_auth_middleware};
use super::decoy::{decoy_fallback, decoy_method_not_allowed};
use super::healthz::healthz;
use super::state::{DecoyConfig, RouterState};
@@ -89,6 +89,8 @@ pub const RESERVED_PATHS: &[&str] = &[
pub struct HttpAdapter {
identity_provider: Arc<dyn alkcall::core::auth::IdentityProvider>,
registry: Arc<OperationRegistry>,
ownership_provider: Option<Arc<dyn alkcall::core::ownership::OwnershipProvider>>,
static_ownership_fallback: bool,
decoy: DecoyConfig,
extra_routes: Option<Router>,
alpn: &'static [u8],
@@ -133,6 +135,8 @@ impl HttpAdapter {
let state = RouterState {
registry: Arc::clone(&registry),
identity_provider: Arc::clone(&identity_provider),
ownership_provider: None,
static_ownership_fallback: false,
decoy: decoy.clone(),
openapi_doc: openapi_doc.clone(),
ws_sessions: Arc::clone(&ws_sessions),
@@ -145,6 +149,8 @@ impl HttpAdapter {
Self {
identity_provider,
registry,
ownership_provider: None,
static_ownership_fallback: false,
decoy,
extra_routes: None,
alpn,
@@ -162,42 +168,33 @@ impl HttpAdapter {
/// Set the decoy surface for unregistered paths and rebuild the
/// router (custom routes are preserved — SRV-05).
pub fn with_decoy(mut self, decoy: DecoyConfig) -> Self {
self.decoy = decoy.clone();
let state = RouterState {
registry: Arc::clone(&self.registry),
identity_provider: Arc::clone(&self.identity_provider),
decoy,
openapi_doc: self.openapi_doc.clone(),
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Arc::clone(&self.ws_session_slots),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
};
self.decoy = decoy;
self.router = build_router(self.router_state(), self.extra_routes.clone());
self
}
/// Mount assembly-provided custom routes under the door-surface
/// auth middleware (ADR-046, ADR-073) and rebuild the router.
/// Reserved paths (`RESERVED_PATHS`) are rejected before the merge.
///
/// The door middleware resolves Bearer and — ADR-073, review 008
/// AH-1 — `Basic base64(user:pass)` (the password field through the
/// same `IdentityProvider::resolve_from_token()` backend), the
/// surface legacy clients (`git-lfs`, git's smart-HTTP transport)
/// authenticate on. The token-auth surface this merges onto stays
/// Bearer-only.
///
/// Door route tables are static (review 008 AH-2): the merged
/// router already owns the decoy fallback and axum refuses a
/// second one, so a door that attempts `fallback_layer`/`fallback`
/// panics at construction — enumerate multi-segment namespace
/// shapes ({user}/{repo}/... style) as pattern routes instead.
pub fn with_extra_routes(mut self, routes: Router) -> Self {
// `extra_routes` is borrowed, not consumed (SRV-05): a builder
// call after `with_extra_routes` must keep the custom routes in
// the rebuild — `with_extra_routes` stores a verified clone, so
// re-merging it is safe.
self.router = build_router(state, self.extra_routes.clone());
self
}
/// Mount assembly-provided custom routes under the bearer-auth
/// middleware (ADR-046) and rebuild the router. Reserved paths
/// (`RESERVED_PATHS`) are rejected before the merge.
pub fn with_extra_routes(mut self, routes: Router) -> Self {
let state = RouterState {
registry: Arc::clone(&self.registry),
identity_provider: Arc::clone(&self.identity_provider),
decoy: self.decoy.clone(),
openapi_doc: self.openapi_doc.clone(),
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Arc::clone(&self.ws_session_slots),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
};
self.router = build_router(state, Some(routes.clone()));
self.router = build_router(self.router_state(), Some(routes.clone()));
self.extra_routes = Some(routes);
self
}
@@ -211,18 +208,8 @@ impl HttpAdapter {
/// Default: [`crate::websocket::DEFAULT_WS_MAX_SESSIONS`] (64).
pub fn with_ws_max_sessions(mut self, max_sessions: usize) -> Self {
self.ws_max_sessions = max_sessions;
let state = RouterState {
registry: Arc::clone(&self.registry),
identity_provider: Arc::clone(&self.identity_provider),
decoy: self.decoy.clone(),
openapi_doc: self.openapi_doc.clone(),
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Self::rebuild_session_slots(max_sessions),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
};
self.router = build_router(state, self.extra_routes.clone());
self.ws_session_slots = Self::rebuild_session_slots(max_sessions);
self.router = build_router(self.router_state(), self.extra_routes.clone());
self
}
@@ -244,17 +231,7 @@ impl HttpAdapter {
/// Default: [`crate::websocket::DEFAULT_WS_IDLE_TIMEOUT`] (60 s).
pub fn with_ws_idle_timeout(mut self, idle_timeout: Option<Duration>) -> Self {
self.ws_idle_timeout = idle_timeout;
let state = RouterState {
registry: Arc::clone(&self.registry),
identity_provider: Arc::clone(&self.identity_provider),
decoy: self.decoy.clone(),
openapi_doc: self.openapi_doc.clone(),
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Arc::clone(&self.ws_session_slots),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
};
let state = self.router_state();
self.router = build_router(state, self.extra_routes.clone());
self
}
@@ -296,17 +273,7 @@ impl HttpAdapter {
openables: Vec<crate::websocket::OpenableAlpn>,
) -> Self {
self.ws_openable_alpns = Some(openables.into());
let state = RouterState {
registry: Arc::clone(&self.registry),
identity_provider: Arc::clone(&self.identity_provider),
decoy: self.decoy.clone(),
openapi_doc: self.openapi_doc.clone(),
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Arc::clone(&self.ws_session_slots),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
};
let state = self.router_state();
self.router = build_router(state, self.extra_routes.clone());
self
}
@@ -327,17 +294,7 @@ impl HttpAdapter {
/// extension instead.
pub fn with_ws_op_register_acl(mut self, acl: alkcall::registry::spec::AccessControl) -> Self {
self.ws_op_register_acl = acl;
let state = RouterState {
registry: Arc::clone(&self.registry),
identity_provider: Arc::clone(&self.identity_provider),
decoy: self.decoy.clone(),
openapi_doc: self.openapi_doc.clone(),
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Arc::clone(&self.ws_session_slots),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
};
let state = self.router_state();
self.router = build_router(state, self.extra_routes.clone());
self
}
@@ -348,6 +305,60 @@ impl HttpAdapter {
Arc::new(tokio::sync::Semaphore::new(max_sessions))
}
/// The router state assembled from the adapter's current
/// configuration; every builder's rebuild goes through here.
fn router_state(&self) -> RouterState {
RouterState {
registry: Arc::clone(&self.registry),
identity_provider: Arc::clone(&self.identity_provider),
ownership_provider: self.ownership_provider.clone(),
static_ownership_fallback: self.static_ownership_fallback,
decoy: self.decoy.clone(),
openapi_doc: self.openapi_doc.clone(),
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Arc::clone(&self.ws_session_slots),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
}
}
/// Wire the ownership provider (alkcall 0.10 review 012 / ADR-048
/// amendment) every dispatch spine this adapter serves consults —
/// the gateway endpoints' spine and the MCP nest's spine alike.
/// The door contract applies unchanged: any HTTP-door deployment
/// serving `resource_type`-gated ops through the gateway or MCP
/// surface must wire the same provider the native wire path
/// consults (visible-surface = authorized-surface). Without a
/// provider (or a declared [`Self::with_static_ownership_fallback`])
/// a `resource_type`-gated op served over HTTP is refused with a
/// `FORBIDDEN` envelope — the spine's loud default.
///
/// Default: `None` (no provider wired).
pub fn with_ownership_provider(
mut self,
provider: Arc<dyn alkcall::core::ownership::OwnershipProvider>,
) -> Self {
self.ownership_provider = Some(provider);
self.router = build_router(self.router_state(), self.extra_routes.clone());
self
}
/// Declare the static `Identity.resources` fallback
/// ([`Self::with_ownership_provider`]'s doc for the disposition
/// space): ops whose `access_control` gates on a `resource_type`
/// are served on the HTTP surface through the token-carried static
/// claims instead of being refused for the missing provider wiring.
/// A code-review-visible declaration of "no runtime ownership model
/// here," never the accidental default.
///
/// Default: `false` (the loud default is active).
pub fn with_static_ownership_fallback(mut self) -> Self {
self.static_ownership_fallback = true;
self.router = build_router(self.router_state(), self.extra_routes.clone());
self
}
/// The configured decoy surface (assembly introspection).
pub fn decoy(&self) -> &DecoyConfig {
&self.decoy
@@ -369,7 +380,7 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
#[cfg(feature = "mcp")]
let mcp_router: Router<RouterState> = {
let dispatch = crate::gateway::GatewayDispatch::new(Arc::clone(&state.registry));
let dispatch = state.dispatch();
Router::new()
.nest_service(
"/mcp",
@@ -390,13 +401,29 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
// cache (SRV-09), threaded through `RouterState` below.
.route("/openapi.json", get(openapi_json_handler))
.route("/healthz", get(healthz))
// The token-auth surface's bearer layer, applied before the
// extras merge: routes registered before this call — the
// gateway endpoints, /openapi.json, /healthz — resolve
// Bearer-only (ADR-073: a Basic header contributes no identity
// on these paths; Basic is scoped to the door surface below).
.route_layer(from_fn_with_state(
Arc::clone(&auth_state),
bearer_auth_middleware,
))
.fallback(decoy_fallback)
.method_not_allowed_fallback(decoy_method_not_allowed);
let with_extras = match extra_routes {
Some(extra) => {
enforce_reserved_paths(&extra);
let extra: Router<RouterState> = extra.with_state(());
// The door surface's auth layer (ADR-073): Basic-tolerant
// in addition to Bearer — exactly one resolution per door
// request (SRV-11), and the legacy-client surface this is
// scoped to.
let extra: Router<RouterState> = extra.with_state(()).route_layer(from_fn_with_state(
Arc::clone(&auth_state),
door_auth_middleware,
));
default.merge(extra)
}
None => default,
@@ -409,28 +436,13 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
// surface. Idempotent for the routers the earlier call covered.
let with_extras = with_extras.method_not_allowed_fallback(decoy_method_not_allowed);
// Applied after the merges (ADR-046 §4): the bearer-auth layer wraps
// every route registered before this call (the gateway endpoints,
// /openapi.json, /healthz, and the extra routes) — axum 0.8
// semantics are that `route_layer` wraps the routes registered
// before it, not the ones after. Routes that must not pass through
// this layer (the /mcp nest and the WS upgrade route, each with its
// own auth) are merged/registered after it — SRV-11: before the
// reorder, a route carrying an inner auth layer was also wrapped by
// this one and resolved the token twice (the SRV-10 double-resolve,
// the old comment claiming the opposite).
let with_auth = with_extras.route_layer(from_fn_with_state(
Arc::clone(&auth_state),
bearer_auth_middleware,
));
// Merged after the router-wide route_layer on purpose: the /mcp
// nest carries its own bearer layer (applied around the nested
// service, `from_fn_with_state` above), so registering it here keeps
// exactly one token resolution per request (SRV-11). nest_service
// Merged after the default surface's route_layer: the /mcp nest
// carries its own bearer layer (applied around the nested service,
// `from_fn_with_state` above), so registering it here keeps exactly
// one token resolution per request (SRV-11). nest_service
// registers a plain Route endpoint (no MethodRouter), so the decoy
// 405 fallback has no interplay with this merge.
let with_mcp = with_auth.merge(mcp_router);
let with_mcp = with_extras.merge(mcp_router);
// Registered after the router-wide route_layer on purpose: axum's
// route_layer applies only to earlier-registered routes, so the WS
@@ -764,7 +776,7 @@ async fn openapi_json_handler(
mod tests {
use super::*;
use crate::server::auth::ResolvedIdentity;
use alkcall::core::auth::IdentityProvider;
use alkcall::core::auth::{Identity, IdentityProvider};
use alkcall::core::types::ProtocolHandler;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -1235,6 +1247,8 @@ mod tests {
RouterState {
registry: empty_registry(),
identity_provider: idp,
ownership_provider: None,
static_ownership_fallback: false,
decoy: DecoyConfig::default(),
openapi_doc: CachedOpenAPIDoc::new(&OperationRegistry::new()),
ws_sessions: Arc::new(crate::websocket::WsSessions::new()),
@@ -1298,6 +1312,122 @@ mod tests {
assert_eq!(&body[..], b"none");
}
#[tokio::test]
async fn extra_doors_resolve_basic_credentials_but_the_gateway_stays_bearer_only() {
struct PasswordProvider;
impl IdentityProvider for PasswordProvider {
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
None
}
fn resolve_from_token(
&self,
token: &alkcall::core::auth::AuthToken,
) -> Option<Identity> {
(String::from_utf8_lossy(&token.raw) == "lfs-token").then(|| Identity {
id: "lfs-client".to_string(),
scopes: vec![],
resources: std::collections::HashMap::new(),
})
}
}
let idp: Arc<dyn IdentityProvider> = Arc::new(PasswordProvider);
let extra = Router::new().route(
"/v1/whoami",
get(|ResolvedIdentity(identity): ResolvedIdentity| async move {
match identity {
Some(id) => id.id,
None => "none".to_string(),
}
}),
);
let app = build_router(router_state(Arc::clone(&idp)), Some(extra));
let response = get_with_bearer(
app.clone(),
"/v1/whoami",
Some("Basic dXNlcjpsZnMtdG9rZW4="),
)
.await;
assert_eq!(response.status(), axum::http::StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(
&body[..],
b"lfs-client",
"a Basic password resolves on the door surface"
);
let registry = OperationRegistry::new();
registry
.register(alkcall::registry::registration::HandlerRegistration::new(
alkcall::registry::spec::OperationSpec::new(
"echo/run",
alkcall::registry::spec::OperationType::Query,
alkcall::registry::spec::Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
alkcall::registry::spec::AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
None,
),
alkcall::registry::registration::HandlerKind::Once(
alkcall::registry::registration::make_handler(|input, ctx| async move {
alkcall::protocol::wire::ResponseEnvelope::ok(ctx.request_id, input)
}),
),
alkcall::registry::registration::OperationProvenance::Local,
None,
None,
alkcall::core::types::Capabilities::new(),
))
.unwrap();
let gateway_state = RouterState {
registry: Arc::new(registry),
identity_provider: Arc::clone(&idp),
ownership_provider: None,
static_ownership_fallback: false,
decoy: DecoyConfig::default(),
openapi_doc: CachedOpenAPIDoc::new(&OperationRegistry::new()),
ws_sessions: Arc::new(crate::websocket::WsSessions::new()),
ws_session_slots: Arc::new(tokio::sync::Semaphore::new(
crate::websocket::DEFAULT_WS_MAX_SESSIONS,
)),
ws_idle_timeout: Some(crate::websocket::DEFAULT_WS_IDLE_TIMEOUT),
ws_openable_alpns: None,
ws_op_register_acl: alkcall::registry::spec::AccessControl::default(),
};
let app = build_router(gateway_state, None);
use tower::ServiceExt;
let req = axum::http::Request::builder()
.method("POST")
.uri("/call")
.header("content-type", "application/json")
.header(
axum::http::header::AUTHORIZATION,
"Basic dXNlcjpsZnMtdG9rZW4=",
)
.body(axum::body::Body::from(
serde_json::to_vec(&serde_json::json!({
"operation": "echo/run",
"input": {},
}))
.unwrap(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(
response.status(),
axum::http::StatusCode::UNAUTHORIZED,
"a Basic credential contributes no identity on the token-auth gateway surface"
);
}
#[tokio::test]
async fn extra_route_with_own_layer_can_opt_out_of_default_auth() {
async fn public_identity(
+218 -16
View File
@@ -1,25 +1,42 @@
//! Shared Bearer auth axum middleware ([ADR-004]).
//! Shared identity-auth axum middlewares ([ADR-004], [ADR-073]).
//!
//! Resolves the `Authorization: Bearer` header via
//! `IdentityProvider::resolve_from_token()` and stashes the resolved
//! `Option<Identity>` in request extensions. Shared by the HTTP gateway
//! endpoints and the `to_mcp` service.
//! Two middleware shapes, one resolution backend
//! (`IdentityProvider::resolve_from_token()`), both stashing the
//! resolved `Option<Identity>` in request extensions:
//!
//! Resolution semantics:
//! - **[`bearer_auth_middleware`]** — Bearer-only. Applied to the
//! gateway endpoints, `/openapi.json`, `/healthz`, and the `to_mcp`
//! service. Token-auth surfaces (the gateway is the sole HTTP invoke
//! path; MCP's core protocol is token-based) never resolve credentials
//! presented as Basic.
//! - **[`door_auth_middleware`]** — Basic-tolerant (ADR-073). Applied to
//! the extras-door router (`HttpAdapter::with_extra_routes`), the
//! surface legacy clients authenticate on: `git-lfs`'s batch API and
//! git's smart-HTTP transport send `Basic base64(user:pass)`, with
//! the password field acting as the token (the GitHub-PAT shape). The
//! password bytes ride the same
//! `IdentityProvider::resolve_from_token()` backend as a Bearer
//! token — no separate resolution mechanism, no new trait method;
//! the provider's grant table governs exactly as it does for Bearer.
//! Basic resolution is deliberately scoped to the door surface: a
//! Basic credential is never resolved on the bearer-auth surfaces
//! unless the deployment explicitly mounts it there.
//!
//! Common resolution semantics (both middlewares):
//! - No `Authorization` header → `None` (request proceeds; the route
//! handler / `AccessControl` decides whether to reject).
//! - Malformed `Authorization` header (not `Bearer <token>`) → `None`
//! (treated as no-token, not an error — Bearer-only is the auth
//! mechanism).
//! - Token present but resolution fails → `None` (treat as
//! - Unrecognized or unparseable `Authorization` header → `None`
//! (treated as no-token, not an error).
//! - Credential present but resolution fails → `None` (treat as
//! unauthenticated, matching the call protocol's per-request identity
//! resolution behavior).
//!
//! This middleware resolves identity and stashes it; it does NOT enforce
//! These middlewares resolve identity and stash it; they do NOT enforce
//! `AccessControl` (the route handlers / `GatewayDispatch::invoke()` do)
//! or map `CallError` codes to HTTP status (the error mapping does).
//!
//! [ADR-004]: https://docs.rs/alkhttp (docs/architecture/decisions)
//! [ADR-073]: https://docs.rs/alkhttp (docs/architecture/decisions)
use std::convert::Infallible;
use std::sync::Arc;
@@ -31,9 +48,13 @@ use axum::middleware::Next;
use axum::response::Response;
use http::request::Parts;
/// Axum middleware that resolves the `Authorization: Bearer` header via
/// Axum middleware for the token-auth surfaces (the gateway endpoints,
/// `/openapi.json`, `/healthz`, `to_mcp`): resolves the
/// `Authorization: Bearer` header via
/// `IdentityProvider::resolve_from_token()` and stashes the resolved
/// `Option<Identity>` in request extensions.
/// `Option<Identity>` in request extensions. Bearer-only — a Basic
/// header contributes no identity (ADR-073 keeps Basic scoped to
/// [`door_auth_middleware`]).
///
/// The state is `Arc<dyn IdentityProvider>` so the middleware can be applied
/// via `middleware::from_fn_with_state(idp.clone(), bearer_auth_middleware)`
@@ -48,6 +69,26 @@ pub async fn bearer_auth_middleware(
next.run(request).await
}
/// Axum middleware for the extras-door surface (`HttpAdapter::
/// with_extra_routes`, ADR-073): resolves the `Authorization` header —
/// Bearer first, then Basic (the password field as the token via the
/// same `IdentityProvider::resolve_from_token()` backend) — and stashes
/// the resolved `Option<Identity>` in request extensions. This is the
/// surface legacy clients (`git-lfs`, git's smart-HTTP transport)
/// authenticate on with the Basic scheme.
///
/// The state is `Arc<dyn IdentityProvider>` (same shape as
/// [`bearer_auth_middleware`]).
pub async fn door_auth_middleware(
State(identity_provider): State<Arc<dyn IdentityProvider>>,
mut request: Request,
next: Next,
) -> Response {
let identity = extract_door_identity(&request, identity_provider.as_ref());
request.extensions_mut().insert(identity);
next.run(request).await
}
/// Extract the `Authorization: Bearer <token>` header and resolve it to
/// an `Option<Identity>`. Returns `None` if no token is present (the
/// request proceeds unauthenticated; the route handler / `AccessControl`
@@ -57,12 +98,63 @@ pub async fn bearer_auth_middleware(
pub fn extract_bearer_identity(
request: &Request,
identity_provider: &dyn IdentityProvider,
) -> Option<Identity> {
resolve_token_str(
bearer_token(request.headers().get(AUTHORIZATION)).map(String::into_bytes),
identity_provider,
)
}
/// Extract the `Authorization` header with Bearer-first, Basic-tolerant
/// semantics (ADR-073): a `Basic base64(user:pass)` header resolves the
/// password field (the token; the Git-PAT shape — user, `x-access-token`
/// and similar are ignored by resolution) through the same
/// `IdentityProvider::resolve_from_token()` backend as a Bearer token.
/// The password bytes are passed to the backend with no UTF-8
/// round-trip, so RFC 7617 legacy ISO-8859-1 credentials reach the
/// provider byte-faithfully. A colon-less value is treated as a bare
/// password. Unparseable or
/// unresolvable credentials resolve to `None` — the same no-token
/// fallback as [`extract_bearer_identity`].
pub fn extract_door_identity(
request: &Request,
identity_provider: &dyn IdentityProvider,
) -> Option<Identity> {
let header = request.headers().get(AUTHORIZATION)?;
let token_str = header.to_str().ok()?.strip_prefix("Bearer ")?;
let token = AuthToken {
raw: token_str.as_bytes().to_vec(),
let credential = match bearer_token(Some(header)) {
Some(token) => token.into_bytes(),
None => basic_password(header)?,
};
resolve_token_str(Some(credential), identity_provider)
}
fn bearer_token(header: Option<&http::HeaderValue>) -> Option<String> {
header?
.to_str()
.ok()?
.strip_prefix("Bearer ")
.map(str::to_owned)
}
fn basic_password(header: &http::HeaderValue) -> Option<Vec<u8>> {
use base64::Engine as _;
let encoded = header.to_str().ok()?.strip_prefix("Basic ")?;
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded.trim())
.ok()?;
let password = match decoded.iter().position(|&byte| byte == b':') {
Some(colon) => &decoded[colon + 1..],
None => &decoded[..],
};
Some(password.to_vec())
}
fn resolve_token_str(
token_bytes: Option<Vec<u8>>,
identity_provider: &dyn IdentityProvider,
) -> Option<Identity> {
let token = AuthToken { raw: token_bytes? };
identity_provider.resolve_from_token(&token)
}
@@ -107,6 +199,19 @@ mod tests {
}
}
struct PasswordTokenProvider {
password: String,
}
impl IdentityProvider for PasswordTokenProvider {
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
(String::from_utf8_lossy(&token.raw) == self.password).then(sample_identity)
}
}
struct StaticProvider {
identity: Option<Identity>,
}
@@ -165,6 +270,71 @@ mod tests {
assert!(identity.is_none());
}
#[test]
fn door_extract_resolves_basic_password_through_the_same_token_backend() {
let idp = PasswordTokenProvider {
password: "s3cret".to_string(),
};
let req = request_with_authorization(Some("Basic dXNlcjpzM2NyZXQ="));
let identity = crate::server::auth::extract_door_identity(&req, &idp);
assert!(identity.is_some(), "Basic password resolves as the token");
assert_eq!(identity.unwrap().id, "worker-a");
let req = request_with_authorization(Some("Basic OnMzY3JldA=="));
assert!(crate::server::auth::extract_door_identity(&req, &idp).is_some());
let req = request_with_authorization(Some("Bearer s3cret"));
assert!(crate::server::auth::extract_door_identity(&req, &idp).is_some());
}
#[test]
fn door_extract_passes_non_utf8_basic_password_bytes_through() {
// RFC 7617 legacy ISO-8859-1 credential: "passwörd" (ö = 0xF6 in
// latin-1) — invalid UTF-8, must still reach `resolve_from_token`
// as the raw bytes rather than parsing to `None`.
struct ByteProvider;
impl IdentityProvider for ByteProvider {
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
(token.raw == b"passw\xf6rd"[..]).then(sample_identity)
}
}
let req = request_with_authorization(Some("Basic dXNlcjpwYXNzd/ZyZA=="));
let identity = crate::server::auth::extract_door_identity(&req, &ByteProvider);
assert!(
identity.is_some(),
"a latin-1 password resolves byte-faithfully"
);
}
#[test]
fn door_extract_treats_colonless_basic_as_a_bare_password() {
struct BareProvider;
impl IdentityProvider for BareProvider {
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
(String::from_utf8_lossy(&token.raw) == "baretok").then(sample_identity)
}
}
let req = request_with_authorization(Some("Basic YmFyZXRvaw=="));
let identity = crate::server::auth::extract_door_identity(&req, &BareProvider);
assert!(identity.is_some());
}
#[test]
fn door_extract_returns_none_for_invalid_base64_or_failing_resolution() {
let idp = provider(None);
let req = request_with_authorization(Some("Basic !not-base64!"));
assert!(crate::server::auth::extract_door_identity(&req, idp.as_ref()).is_none());
let req = request_with_authorization(Some("Basic dXNlcjpwYXNz"));
assert!(crate::server::auth::extract_door_identity(&req, idp.as_ref()).is_none());
}
#[test]
fn extract_returns_none_when_token_present_but_resolution_fails() {
let idp = provider(None);
@@ -255,6 +425,38 @@ mod tests {
assert_eq!(&bytes[..], b"none");
}
#[tokio::test]
async fn middleware_stashes_identity_for_basic_on_the_door_middleware() {
let idp: std::sync::Arc<dyn IdentityProvider> =
std::sync::Arc::new(PasswordTokenProvider {
password: "s3cret".to_string(),
});
let app: Router<()> = Router::new()
.route(
"/",
get(|req: Request| async move {
let identity = req
.extensions()
.get::<Option<Identity>>()
.cloned()
.flatten();
match identity {
Some(id) => (StatusCode::OK, id.id),
None => (StatusCode::OK, "none".to_string()),
}
}),
)
.layer(from_fn_with_state(idp, door_auth_middleware));
let req = request_with_authorization(Some("Basic dXNlcjpzM2NyZXQ="));
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&bytes[..], b"worker-a");
}
#[tokio::test]
async fn resolved_identity_extractor_retrieves_stashed_some() {
let idp = provider(Some(sample_identity()));
+4 -1
View File
@@ -5,7 +5,10 @@ pub mod healthz;
pub mod state;
pub use adapter::{HttpAdapter, RESERVED_PATHS, WS_UPGRADE_PATH};
pub use auth::{bearer_auth_middleware, extract_bearer_identity, ResolvedIdentity};
pub use auth::{
bearer_auth_middleware, door_auth_middleware, extract_bearer_identity, extract_door_identity,
ResolvedIdentity,
};
pub use decoy::{decoy_fallback, decoy_method_not_allowed};
pub use healthz::healthz;
pub use state::DecoyConfig;
+24
View File
@@ -6,6 +6,12 @@ use std::sync::Arc;
use alkcall::core::auth::IdentityProvider;
use alkcall::registry::registration::OperationRegistry;
#[cfg(feature = "mcp")]
use axum::extract::FromRef;
#[cfg(feature = "mcp")]
use crate::gateway::GatewayState;
/// The stealth decoy surface for paths that are not registered
/// operations and not part of the reserved default surface (the 6
/// gateway endpoints, `/healthz`, `/openapi.json`, the MCP route, the
@@ -39,6 +45,12 @@ pub enum DecoyConfig {
pub(crate) struct RouterState {
pub(crate) registry: Arc<OperationRegistry>,
pub(crate) identity_provider: Arc<dyn IdentityProvider>,
/// The ownership provider threaded into every dispatch spine this
/// state serves (gateway routes and the MCP nest). `None` + no
/// static-fallback declaration means resource-gated ops are refused
/// by the spine's loud default.
pub(crate) ownership_provider: Option<Arc<dyn alkcall::core::ownership::OwnershipProvider>>,
pub(crate) static_ownership_fallback: bool,
pub(crate) decoy: DecoyConfig,
pub(crate) openapi_doc: crate::server::adapter::CachedOpenAPIDoc,
pub(crate) ws_sessions: Arc<crate::websocket::WsSessions>,
@@ -79,6 +91,16 @@ impl axum::extract::FromRef<RouterState> for DecoyConfig {
}
}
impl RouterState {
/// A dispatch spine wired with this state's ownership configuration
/// (the MCP nest's builder; the gateway routes extract
/// [`GatewayState`] via `FromRef`).
#[cfg(feature = "mcp")]
pub(crate) fn dispatch(&self) -> crate::gateway::GatewayDispatch {
GatewayState::from_ref(self).dispatch()
}
}
impl axum::extract::FromRef<RouterState> for crate::server::adapter::CachedOpenAPIDoc {
fn from_ref(state: &RouterState) -> Self {
state.openapi_doc.clone()
@@ -99,6 +121,8 @@ mod tests {
let state = RouterState {
registry: Arc::new(OperationRegistry::new()),
identity_provider: Arc::new(NoopProvider),
ownership_provider: None,
static_ownership_fallback: false,
decoy: DecoyConfig::Redirect {
to: "https://example.com".to_string(),
},
+7 -3
View File
@@ -806,9 +806,13 @@ async fn session_cap_rejects_over_limit_with_503_and_frees_slots_on_end() {
}
// The adapter's built-in surface: WS route + bearer middleware, the
// whole deployment shape the cap knob configures.
let adapter =
HttpAdapter::new(Arc::new(StaticTok), Arc::clone(&registry)).with_ws_max_sessions(1);
// whole deployment shape the cap knob configures. A builder call
// after `with_ws_max_sessions` must not revert the cap (the
// pre-`router_state()` builder shape rebuilt the router from the
// un-stored fresh semaphore).
let adapter = HttpAdapter::new(Arc::new(StaticTok), Arc::clone(&registry))
.with_ws_max_sessions(1)
.with_decoy(alkhttp::server::DecoyConfig::default());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = format!("ws://{}", listener.local_addr().unwrap());
let app: axum::Router = adapter.router().clone();