Files
alknet/tasks/http/server/gateway-endpoints.md
glm-5.2 e855c8c7eb docs(http): decompose alknet-http spec into 19 implementation tasks
Break the alknet-http architecture spec into atomic, dependency-ordered
tasks in tasks/http/, following the taskgraph frontmatter conventions
used by the call/core/vault crates.

Tasks span 7 phases across 5 module subdirectories (server/, gateway/,
client/, adapters/, websocket/):
- Phase 0: crate-init (foundation)
- Phase 1: gateway-dispatch-spine, error-mapping, shared-http-client
  (shared infrastructure)
- Phase 2: http-adapter, bearer-auth-middleware, gateway-endpoints,
  healthz-decoy (HTTP server surface)
- Phase 3: to-openapi (OpenAPI gateway projection)
- Phase 4: from-openapi (OpenAPI adapter, reqwest forwarding)
- Phase 5: dispatcher-transport-abstraction, upgrade-handler,
  connection-overlay (WebSocket browser bidirectional path)
- Phase 6: from-mcp, to-mcp (MCP adapters, feature-gated)
- Phase 7: review-http, review-websocket, review-mcp, review-http-final
  (quality checkpoints)

The gateway-dispatch-spine task implements the thin shared core
recommended by the gateway-factoring research (concrete struct, not a
trait). The dispatcher-transport-abstraction task is a cross-crate
change to alknet-call (exposes EventEnvelope-level dispatch API for
non-QUIC transports) — the highest-risk task. WebTransport/h3 is
deferred per ADR-044 and has no tasks; from_wss is out of scope.

Validated: 19 tasks, no cycles, 8 parallel generations, critical path
length 8 (through the WebSocket strand).
2026-07-01 07:11:17 +00:00

194 lines
10 KiB
Markdown

---
id: http/server/gateway-endpoints
name: Implement 5 gateway endpoints (search/schema/call/batch/subscribe) — axum route handlers
status: pending
depends_on: [http/server/http-adapter, http/gateway/gateway-dispatch-spine, http/gateway/error-mapping, http/server/bearer-auth-middleware]
scope: broad
risk: medium
impact: component
level: implementation
---
## Description
Implement the 5 fixed gateway endpoints in `src/server/gateway_routes.rs`.
These are the sole invoke path over HTTP (ADR-042, ADR-047): an HTTP
client invokes an operation via `POST /call` with
`{ "operation": "/fs/readFile", "input": {...} }`, discovers what it can
call via the `AccessControl`-filtered `GET /search`, and learns an
operation's shape via `GET /schema`. There is no per-operation
`POST /{service}/{op}` direct-call surface — the gateway is the invoke
path (ADR-047 supersedes ADR-036's direct-call surface).
### The 5 endpoints (http-server.md §"HTTP-to-call dispatch", http-adapters.md §"The gateway endpoint set")
| Endpoint | Call protocol | HTTP method | Purpose |
|----------|--------------|-------------|---------|
| `/search` | `services/list` | `GET` | List/search operations (AccessControl-filtered). Names + descriptions. |
| `/schema` | `services/schema` | `GET` | Get an operation's full `OperationSpec`. |
| `/call` | `call.requested` (Query/Mutation) | `POST` | Invoke an operation. Flat JSON body `{ operation, input }`. |
| `/batch` | multiple `call.requested` | `POST` | Invoke multiple operations. Array of `{ operation, input }`. |
| `/subscribe` | `call.requested` (Subscription) | `POST` (SSE) | Invoke a streaming operation. Body `{ operation, input }`; response `text/event-stream`. |
### `POST /call` dispatch (http-server.md §"HTTP-to-call dispatch")
1. The axum route handler reads the JSON body
`{ "operation": "/fs/readFile", "input": {...} }`.
2. Resolves the caller's identity from the `Authorization: Bearer` header
(via the shared `bearer_auth_middleware` — stashed in extensions as
`ResolvedIdentity`).
3. Calls `GatewayDispatch::invoke(identity, operation, input)` — the
shared dispatch spine (the `gateway-dispatch-spine` task). This builds
the root `OperationContext` (`internal: false`, `forwarded_for: None`)
and dispatches through `OperationRegistry::invoke()`.
4. The response (`ResponseEnvelope`) is serialized as the HTTP response
body (JSON). Errors map to HTTP status codes via the `error-mapping`
task (`call_error_to_http_response`).
`Internal` operations (ADR-015) return `404` (`NOT_FOUND`) — the gateway
dispatches only `External` operations, and the caller discovers which
`External` operations it can call via the `AccessControl`-filtered
`/search` endpoint. This is the per-caller API surface property: an HTTP
client cannot stub its toe on a path for an operation it can't call,
because there is no per-operation path — `/search` tells it what it can
call, `/call` invokes it, and the `AccessControl` check runs on `/call`
regardless.
### `GET /search` (AccessControl-filtered discovery)
Dispatches `services/list` through `GatewayDispatch::invoke()` with the
resolved caller identity. The `services/list` handler (already in
`OperationRegistry`) filters by `AccessControl::check(identity)` — the
client sees only the operations it is authorized to call. Returns
operation names + descriptions (not full schemas). Query parameters for
filtering/searching are a two-way-door extension (the v1 shape is "list
all I can call"; search/filter sugar is additive).
### `GET /schema`
Dispatches `services/schema` through `GatewayDispatch::invoke()` with
the resolved caller identity. Returns the operation's full
`OperationSpec` (input/output JSON Schemas, error schemas). The
`AccessControl` check runs (an unauthorized caller gets `FORBIDDEN`, not
the schema).
### `POST /batch`
Follows the same dispatch path as `/call` with an array of
`{ operation, input }` pairs (OQ-14). `batch` is a loop over
`GatewayDispatch::invoke()` in the gateway (research §6 open question
#3 — confirm `batch` is genuinely just a loop, no shared batch-specific
state, no transactional semantics). Returns an array of results (or
errors), one per entry, in order.
### `POST /subscribe` (SSE streaming projection)
A `Subscription` operation invoked via the gateway's `POST /subscribe`
endpoint projects its `call.responded` stream as Server-Sent Events. The
request body is `{ operation, input }` (the same flat JSON shape as
`/call`); the response is `text/event-stream` (negotiated via
`Accept: text/event-stream` on the `POST`). The axum route handler:
- Sets `Content-Type: text/event-stream`.
- For each `call.responded` event, writes an SSE `data:` frame (the
event's `output` serialized as JSON).
- On `call.completed`, closes the SSE stream (normal end).
- On `call.aborted`, closes the stream with an SSE error event.
- On HTTP client disconnect (detected as the response writer closing),
sends `call.aborted` for the in-flight subscription, which cascades
to descendants per ADR-016.
This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket
(websocket.md), the subscription projects directly onto the WS
connection — `call.responded` events as binary WS messages, no SSE
framing. WebTransport (`h3`, deferred per ADR-044) would project onto
WebTransport bidirectional streams.
### One-directional projection (http-server.md §"One-directional projection")
The HTTP/1.1 + HTTP/2 surface is a **lossy, one-directional projection**
of the call protocol. HTTP is request/response: the client initiates, the
server responds. The call protocol is bidirectional — both sides can
initiate calls. The HTTP projection carries only the client→server call
direction; the server→client call direction has no HTTP expression.
`Subscription` streaming is the one partial exception — the server
streams `call.responded` frames back over the SSE response — but even
there, the *call* is client-initiated; only the *results* flow
server→client. WebSocket restores the bidirectional call model for
browsers (the `websocket/` tasks).
### Constraints
- **The gateway is the sole invoke path over HTTP (ADR-042, ADR-047).**
No per-operation `POST /{service}/{op}` direct-call surface.
- **`External` operations only.** `Internal` operations return `404` on
the gateway's `/call`, matching the call protocol's `NOT_FOUND`.
- **Bearer-only auth.** Via the shared `bearer_auth_middleware`.
- **No secret material in HTTP responses.** Capabilities are used for
outbound calls (`from_openapi`), never serialized into HTTP response
bodies (ADR-014).
## Acceptance Criteria
- [ ] `POST /call` route handler reads `{ operation, input }` JSON body
- [ ] `/call` resolves identity via `ResolvedIdentity` extractor (shared middleware)
- [ ] `/call` dispatches via `GatewayDispatch::invoke(identity, operation, input)`
- [ ] `/call` response is `ResponseEnvelope` serialized as JSON
- [ ] `/call` errors mapped via `call_error_to_http_response` (error-mapping task)
- [ ] `Internal` op on `/call``404 NOT_FOUND`
- [ ] `External` op with `AccessControl` restrictions + unauthorized → `403 FORBIDDEN`
- [ ] `External` op with `AccessControl` restrictions + no identity → `401`
- [ ] `GET /search` dispatches `services/list` via `GatewayDispatch::invoke`
- [ ] `/search` results are `AccessControl::check(identity)`-filtered
- [ ] `/search` returns operation names + descriptions (not full schemas)
- [ ] `GET /schema` dispatches `services/schema` via `GatewayDispatch::invoke`
- [ ] `/schema` returns the operation's full `OperationSpec`
- [ ] `/schema` for unauthorized op → `403 FORBIDDEN`
- [ ] `POST /batch` dispatches an array of `{ operation, input }` via loop over `invoke`
- [ ] `/batch` returns an array of results (or errors), one per entry, in order
- [ ] `POST /subscribe` sets `Content-Type: text/event-stream`
- [ ] `/subscribe` writes `call.responded` events as SSE `data:` frames
- [ ] `/subscribe` closes stream on `call.completed`
- [ ] `/subscribe` writes SSE error event on `call.aborted`
- [ ] `/subscribe` sends `call.aborted` on HTTP client disconnect (ADR-016 cascade)
- [ ] No per-operation `POST /{service}/{op}` direct-call surface (ADR-047)
- [ ] No secret material in HTTP response bodies (ADR-014)
- [ ] Integration test: `/call` round-trip (External op → 200 + JSON body)
- [ ] Integration test: `/call` Internal op → 404
- [ ] Integration test: `/call` unauthorized → 403
- [ ] Integration test: `/call` unauthenticated + restricted op → 401
- [ ] Integration test: `/search` returns only AccessControl-allowed ops
- [ ] Integration test: `/schema` returns full spec for authorized op
- [ ] Integration test: `/batch` returns array of results in order
- [ ] Integration test: `/subscribe` streams SSE events until completed
- [ ] Integration test: `/subscribe` client disconnect → abort cascade
- [ ] `cargo test -p alknet-http` succeeds
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
## References
- docs/architecture/crates/http/http-server.md — HTTP-to-call dispatch, SSE projection, one-directional projection
- docs/architecture/crates/http/http-adapters.md — The gateway endpoint set, per-caller API surface
- docs/architecture/decisions/042-openapi-gateway-pattern.md — ADR-042 (5 fixed gateway endpoints)
- docs/architecture/decisions/047-remove-direct-call-http-surface.md — ADR-047 (gateway is sole invoke path)
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (Internal → 404)
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (disconnect → abort cascade)
- docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md — ADR-014 (no secrets in responses)
## Notes
> The 5 gateway endpoints are the sole HTTP invoke path (ADR-047). The
> /call handler delegates to GatewayDispatch::invoke (the shared spine);
> the error mapping is the error-mapping task; the auth is the shared
> bearer-auth-middleware. /subscribe is the SSE streaming projection —
> the one to_openapi-specific piece that does not go through the shared
> spine's request/response invoke (research §6 open question #5 —
> /subscribe is to_openapi-owned, not in the shared core). /batch is a
> loop over invoke (research §6 open question #3 — confirm no
> batch-specific shared state). The one-directional projection is a
> structural property of HTTP; WebSocket restores bidirectionality for
> browsers.
## Summary
> To be filled on completion