---
title: Web Capabilities
description: Publish backend HTTP endpoints and call allowed upstream origins through explicit Capability bindings.
---

Backend HTTP uses two independent Capability pairs. Inbound traffic binds Web
Ingress to one or more `lenso.http.endpoint@1` providers. Outbound traffic
binds a consumer to the policy-bounded HTTP Egress provider of
`lenso.http.client@1`.

If you are building your first backend, follow [Build a Web backend](/docs/web)
first. That tutorial covers the Host prerequisite, Endpoint crate, linked
registration, Ingress configuration, real socket calls, and removal. Return to
this page for the detailed HTTP contract, Auth, OpenAPI, middleware, and Egress
reference.

```mermaid
flowchart LR
  H[HTTP client] --> I[Web Ingress]
  I --> E[HTTP Endpoint providers]
  I --> O[Optional OpenAPI document]
  O -. explicitly bound descriptions .-> E
  M[Business Plugin] --> C[HTTP Client Capability]
  C --> X[HTTP Egress]
  X --> U[Allowed upstream]
```

These backend contracts are separate from target-owned browser UI. Web Shell,
Browser Adapter, and UI Contribution Plugins compose pages and generated
browser clients; they do not own backend HTTP routing.

## Publish an inbound endpoint

Use the endpoint macro so `describe` and `handle` share one compile-time checked
route table:

```rust title="src/http.rs"
use lenso::prelude::*;
use lenso_capability_http_endpoint::{
    prelude::*,
    response::{self, HeaderValue, StatusCode, header},
};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct CreateOrder {
    total_cents: u64,
}

#[derive(Serialize)]
struct CreatedOrder<'a> {
    id: &'a str,
}

#[lenso::plugin]
#[derive(Clone, Debug, Default)]
struct OrdersHttp;

#[endpoint]
impl OrdersHttp {
    #[post("orders.create", "/orders")]
    async fn create(
        &self,
        _context: Ctx,
        Json(_order): Json<CreateOrder>,
    ) -> Result<HandleResponse, EndpointHandleInvocationError> {
        Ok(response::json(
            StatusCode::CREATED,
            &CreatedOrder { id: "order-42" },
        )?.with_header(
            &header::LOCATION,
            &HeaderValue::from_static("/orders/order-42"),
        )?)
    }
}
```

`#[endpoint]` collects the handlers in one `impl`. Use `#[get]`, `#[post]`,
`#[put]`, `#[patch]`, `#[delete]`, `#[head]`, `#[options]`, or `#[query]`; each attribute
declares a stable route ID followed by its path. The `http_endpoint!` static
table remains available for generated code and existing Plugins.

`#[query]` declares the HTTP QUERY method for safe, idempotent searches that
need structured request content. It is different from a URL query string;
decode URL parameters with `QueryParams<T>`:

```rust title="src/http.rs"
#[endpoint]
impl OrdersHttp {
    #[query("orders.search", "/orders/search")]
    async fn search(
        &self,
        Json(filter): Json<SearchFilter>,
        QueryParams(page): QueryParams<Page>,
    ) -> Result<HandleResponse, EndpointHandleInvocationError> {
        self.search_orders(filter, page).await
    }
}
```

A QUERY request with content must declare the correct `Content-Type`.
`Json<T>` enforces this before the handler runs. The old `Query<T>` extractor
name remains temporarily available for migration; new code should use
`QueryParams<T>`.

The endpoint owns HTTP-shaped business behavior. Web Ingress owns listener and
protocol mechanics, route matching, path parameters, size and concurrency
limits, deadlines, disconnect cancellation, request IDs, and stable HTTP error
mapping. Route collisions fail activation.

Use `response::json`, `response::problem`, `response::text`, and
`response::empty` for common responses. They accept typed status codes, set the
appropriate content type, and keep serialization failures on the Endpoint
error path. `HandleResponse::with_header` accepts validated `http` header names
and values when a response needs `Location`, `WWW-Authenticate`, or another
explicit header. Construct the generated wire DTO directly only for unusual
binary response bodies.

## Add an optional OpenAPI document

OpenAPI support is an ordinary opt-in Plugin, not an Ingress setting. Endpoint
authors can attach an OpenAPI 3.1 Operation Object to a route while keeping the
route declaration authoritative for its method, path, and stable
`operationId`:

```rust title="src/http.rs"
#[endpoint]
impl OrdersHttp {
    #[get("orders.read", "/orders/{order_id}")]
    #[openapi({
        summary: "Read an order",
        responses: {
            "200": {
                description: "Order",
                content: {
                    "application/json": {
                        schema: {
                            "type": "object",
                            required: ["id"],
                            properties: { id: { "type": "string" } }
                        }
                    }
                }
            }
        }
    })]
    async fn read(
        &self,
        Path(path): Path<OrderPath>,
    ) -> Result<HandleResponse, EndpointHandleInvocationError> {
        // The business Endpoint remains the final authority.
        self.read_order(path.order_id).await
    }
}
```

The JSON-like DSL accepts ordinary identifier keys without quotes. Quote keys
such as `"type"`, `"application/json"`, and `"$ref"`. The previous raw JSON
string form remains compatible, and `http_endpoint!` route tables can use
`openapi = openapi_operation!({ ... })`. The macros validate the DSL at compile
time and reject an author-supplied `operationId` that could drift from the
stable route ID. Method and path always come from the route declaration.

The annotation itself remains optional. Routes without OpenAPI metadata are
valid and receive a minimal fallback response only if an explicitly selected
OpenAPI Plugin includes them in its document.

To publish a document, App Composition must make all three choices explicitly:

1. Select one published `lenso-openapi` crate as a `lenso.openapi` Instance.
2. Bind the Endpoint providers to document to that Instance's `many`
   `lenso.http.endpoint@1` requirement.
3. Bind the OpenAPI Instance's own HTTP Endpoint to Web Ingress.

Only that bound subset appears in the generated OpenAPI 3.1 document. The
Plugin does not discover providers globally or infer a public server URL from
the listener. Its immutable configuration controls document metadata and the
static route:

```json title="lenso.openapi configuration"
{
  "title": "Orders API",
  "version": "1.0.0",
  "description": "Public order operations",
  "document_path": "/openapi.json",
  "servers": [{"url": "https://api.example.com"}],
  "components": {
    "securitySchemes": {
      "bearer": {"type": "http", "scheme": "bearer"}
    }
  }
}
```

There is deliberately no `enabled` field. Removing the `lenso.openapi`
Instance and its bindings removes `/openapi.json` without changing the
business Endpoints or Web Ingress. Swagger UI, Redoc, pages, assets, and
navigation remain application- or Console-owned UI concerns.

## Authenticate an inbound request

Web Ingress selects one `Authorization` credential and places its scheme and
value in `HandleRequest::credential`. It does not authenticate the caller. The
Endpoint resolves the explicitly bound `AuthClient` during activation, submits
the protocol-neutral evidence, and attaches the returned `ActorAssertion` to
the invocation context before calling the business Capability:

```rust title="src/http.rs"
use std::rc::Rc;

use lenso_auth_sdk::ActorAssertion;
use lenso_capability_auth::AuthClient;
use lenso_capability_http_endpoint::{
    EndpointHandleInvocationError, ExtractorFuture, FromRequest,
    HandleRequest, HandleResponse, Path, endpoint,
};
use lenso_http_auth::{
    AuthClientSource, AuthenticatedHttpActor, extract_authenticated_actor,
};
use lenso_kernel::InvocationContext;
use serde::Deserialize;

#[derive(Deserialize)]
struct OrderPath {
    order_id: String,
}

struct UserActor {
    subject: String,
}

impl AuthenticatedHttpActor for UserActor {
    const KIND: &'static str = "user";

    fn from_assertion(assertion: &ActorAssertion) -> Self {
        Self { subject: assertion.subject().to_owned() }
    }
}

impl AuthClientSource for OrdersHttp {
    fn auth_client(&self) -> Result<Rc<AuthClient>, EndpointHandleInvocationError> {
        Ok(self.auth.clone())
    }
}

impl FromRequest<OrdersHttp> for UserActor {
    fn from_request<'a>(
        provider: &'a OrdersHttp,
        context: &'a mut InvocationContext,
        request: &'a HandleRequest,
    ) -> ExtractorFuture<'a, Self> {
        extract_authenticated_actor(provider, context, request)
    }
}

#[endpoint]
impl OrdersHttp {
    #[get("orders.read", "/orders/{order_id}")]
    async fn read(
        &self,
        actor: UserActor,
        context: InvocationContext,
        Path(path): Path<OrderPath>,
    ) -> Result<HandleResponse, EndpointHandleInvocationError> {
        self.handle_authenticated(context, actor.subject, path.order_id).await
    }
}
```

`extract_authenticated_actor` owns evidence conversion, Auth invocation, stable `401`/`403`
responses, and assertion attachment. `OrdersHttp::auth` and its Orders client are created once from
`ActivateContext::dependencies()` and released during deactivation; do not
resolve bindings for every request. The target Orders Plugin must still verify
the assertion proof, issuer, validity, and operation audience before projecting
it into its own typed actor. See [Auth Plugin](/docs/web/auth-plugin/) for the
provider and target-side code.

Extractors run in handler-argument order. They may await clients stored on the
provider, enrich the context for later extractors and the handler, return an
intentional response, or preserve an Endpoint failure. `Path<T>`, `QueryParams<T>`,
`Json<T>`, and `RequestId` are built in. Invalid path, query, or JSON input is
rejected before the handler runs.

Use middleware for behavior that naturally wraps many handler arguments, such
as tracing or a shared response policy. Authentication is usually clearer as a
`UserActor` or `AdminActor` extractor because the expected identity kind is
visible in the handler signature. These are application-owned HTTP-edge identity
types implementing `AuthenticatedHttpActor`; Lenso does not impose one universal
user model. This trait is deliberately different from target-side `TypedActor`.
`AdminActor` should
only mean a distinct authenticated actor kind. Do not use the extractor to grant
roles, tenant access, or resource ownership—the target Plugin still owns those
decisions.

`handle_authenticated` is Endpoint-owned application code: it calls
`self.orders` with the assertion-bearing context, serializes the success value
with `response::json`, and maps the target's authorization rejection to an
explicit `403` Problem Details response.

Keep these mappings intentional:

| Outcome | Endpoint response |
| --- | --- |
| Missing, invalid, expired, or revoked credential | `401` Problem Details and `WWW-Authenticate` |
| Valid actor without permission for the target operation | Endpoint maps the target Plugin's authorization rejection to `403` |
| Malformed path, query, or body | `400` |
| JSON extractor with a missing or unsupported content type | `415` |
| Auth or business Capability unavailable | `503` |
| Response serialization or header construction fails | internal Endpoint failure, mapped to `503` |

Ingress owns transport errors; Auth owns evidence authentication; the target
business Plugin owns the final authorization decision. A successful Auth call
must not be treated as blanket permission.

Configure one immutable Ingress Instance in the Plan:

```json
{
  "bind_address": "0.0.0.0:8080",
  "max_request_body_bytes": 1048576,
  "max_request_head_bytes": 16384,
  "max_concurrent_requests": 128,
  "request_timeout_millis": 30000
}
```

The default is an ephemeral loopback listener. Web Ingress strips credential
and hop-by-hop headers before invocation and replaces untrusted external
request IDs.

## Call an upstream service

HTTP Egress requires at least one exact `http` or `https` origin. Paths,
credentials, queries, and fragments are invalid in `allowed_origins`.

```rust
use lenso_http_egress::HttpEgressConfig;
use std::time::Duration;

let config = HttpEgressConfig::new(["https://api.example.test"])?
    .with_transfer_limits(1024 * 1024, 16 * 1024, 4 * 1024 * 1024, 32 * 1024)?
    .with_max_concurrent_requests(64)?
    .with_timeouts(Duration::from_secs(5), Duration::from_secs(30))?;
```

Resolve the generated client from the consumer's declared dependencies:

```rust
use lenso_capability_http_client::{
    ClientClient, ClientInvocationError, SendRequest, SendRequestHeadersItem,
};
use lenso_kernel::{PluginDependencies, RuntimeFailure};

fn client(dependencies: &PluginDependencies) -> Result<ClientClient, RuntimeFailure> {
    ClientClient::from_dependencies(dependencies)
}

async fn send(client: &ClientClient) -> Result<i64, ClientInvocationError> {
    let response = client.send(SendRequest {
        method: "POST".into(),
        url: "https://api.example.test/orders".into(),
        headers: vec![SendRequestHeadersItem {
            name: "content-type".into(),
            value: "application/json".into(),
        }],
        body: br#"{"total_cents":4200}"#.as_slice().into(),
    }).await?;
    Ok(response.status)
}
```

The generated Domain Errors are `destination_not_allowed`, `invalid_request`,
`request_too_large`, `response_too_large`, `timeout`, and
`transport_failure`. The current provider does not follow redirects, use
system proxies, manage cookies, or retry requests implicitly.

Both HTTP contracts are portable and their TypeScript projections are
published by `@lenso/bun`.
However, the maintained Ingress and Egress providers are linked Rust Plugins;
a Bun Plugin must still receive the Capability through an execution path and
binding supported by its host. Contract portability is not automatic provider
installation.
