Skip to content

Bridge HTTP API

This is the reference for the Bridge Orchestrator HTTP surface. It maps each operation to its OpenAPI schema, the per-call ReBAC gate, the audit relation it stamps, the outbox event it emits, and the closed Problem.code taxonomy. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract — for the bounded-context narrative (the bridge aggregates, their value objects, and the configure pipeline) see ../../contexts/bridge/model.md.

The Bridge Orchestrator configures a Resource of kind bridge. Every operation hangs off that Resource under /v1/projects/{project_id}/resources/{resource_id}/bridge/... and covers four sub-surfaces:

  • Relay — a per-Resource singleton keyed on resource_id. It has no slug; there is exactly one relay per bridge Resource. The write is a PUT full-replace, not a POST create.
  • User-access providers — a many-aggregate keyed on (resource_id, slug), each Create/Read/Update/Delete-shaped. A provider selects a remote-access driver (netbird, tailscale, or wireguard).
  • Public ingress rules — a many-aggregate keyed on (resource_id, slug) with an additional (resource_id, sni_host) uniqueness constraint. Each rule SNI-routes inbound TLS to a target Node + port inside the bridge Resource's owning Domain.
  • Site-to-site tunnels — a many-aggregate keyed on (resource_id, slug), each carrying a tunnel driver kind (wireguard, ipsec, or openvpn), a routing policy, and the CIDR prefixes it routes.

Operations

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
GET…/bridge/relayGetBridgeRelayresource#observe on the addressed Resourcebridge.relay.read(none)n/a
PUT…/bridge/relayConfigureBridgeRelayresource#manage on the addressed Resourcebridge.relay.configurebridge.RelayConfigured8 KiB
POST…/bridge/user-accessCreateBridgeUserAccessProviderresource#managebridge.user_access.configurebridge.UserAccessProviderConfigured8 KiB
GET…/bridge/user-accessListBridgeUserAccessProvidersresource#observebridge.user_access.read(none)n/a
GET…/bridge/user-access/{slug}GetBridgeUserAccessProviderresource#observebridge.user_access.read(none)n/a
PATCH…/bridge/user-access/{slug}UpdateBridgeUserAccessProviderresource#managebridge.user_access.updatebridge.UserAccessProviderConfigured8 KiB
DELETE…/bridge/user-access/{slug}DeleteBridgeUserAccessProviderresource#managebridge.user_access.removebridge.UserAccessProviderRemovedn/a
POST…/bridge/ingressCreateBridgeIngressRuleresource#managebridge.ingress.configurebridge.PublicIngressRuleConfigured8 KiB
GET…/bridge/ingressListBridgeIngressRulesresource#observebridge.ingress.read(none)n/a
GET…/bridge/ingress/{slug}GetBridgeIngressRuleresource#observebridge.ingress.read(none)n/a
PATCH…/bridge/ingress/{slug}UpdateBridgeIngressRuleresource#managebridge.ingress.updatebridge.PublicIngressRuleConfigured8 KiB
DELETE…/bridge/ingress/{slug}DeleteBridgeIngressRuleresource#managebridge.ingress.removebridge.PublicIngressRuleRemovedn/a
POST…/bridge/site-to-siteCreateBridgeSiteToSiteTunnelresource#managebridge.site_to_site.configurebridge.SiteToSiteTunnelConfigured8 KiB
GET…/bridge/site-to-siteListBridgeSiteToSiteTunnelsresource#observebridge.site_to_site.read(none)n/a
GET…/bridge/site-to-site/{slug}GetBridgeSiteToSiteTunnelresource#observebridge.site_to_site.read(none)n/a
PATCH…/bridge/site-to-site/{slug}UpdateBridgeSiteToSiteTunnelresource#managebridge.site_to_site.updatebridge.SiteToSiteTunnelConfigured8 KiB
DELETE…/bridge/site-to-site/{slug}DeleteBridgeSiteToSiteTunnelresource#managebridge.site_to_site.removebridge.SiteToSiteTunnelRemovedn/a

Every path above is rooted at /v1/projects/{project_id}/resources/{resource_id} — the table elides that prefix for width. There are seventeen operations across the four sub-surfaces.

  • body_cap = 8 KiB (8192 bytes) is enforced before the JSON decoder runs on every body-carrying operation — the relay PUT, the three Create operations, and the three Update operations. An over-cap body surfaces as 413 request_body_too_large. The largest documented body shapes (the user-access and site-to-site configure requests) sit well under the cap; the ceiling refuses the authenticated-DoS vector an unbounded body would expose.
  • Every handler short-circuits to 501 bridge_not_provisioned when the bridge dependency bundle is nil — i.e. until and unless the production composition root supplies the four application services plus the ReBAC authorizer. The wiring is opt-in and only activates when the server boots with a configured datasource (PLEXSPHERE_DSN).

Relay write semantics

The relay write is a PUT, not a POST, and it is a full-replace last-writer-wins write:

  • There is no optimistic-concurrency precondition — no If-Match header and no body revision / version field. The relay is a per-Resource singleton with no revision column, so a concurrency token would invent persistence the aggregate does not carry.
  • An identical-config PUT short-circuits as an idempotent no-op: re-applying the current configuration neither advances state nor re-emits a distinct effect.
  • The concurrency mechanism was deliberately resolved to last-writer-wins; concurrent writers do not collide on a 409, the last PUT to land wins.

The three many-aggregate sub-surfaces (user-access, ingress, site-to-site) use the conventional POST create / PATCH update shape. A PATCH is a full replacement of the aggregate's mutable configuration, not a partial merge; the {slug} path segment is the aggregate identity and is never carried in the body.

Authorization model

All seventeen operations accept the same first-success-wins credential triple as the rest of /v1: a psk_… API token Bearer, an OIDC JWT Bearer, or a plexsphere_session cookie. A missing or unresolved credential is 401 unauthenticated; the ReBAC gate then decides the 403, which is rendered as a PermissionDenied body.

  • Read paths (GetBridgeRelay, the three List… operations, and the three Get…{slug} operations) gate observe on the addressed Resource object (resource:<resource_id>) — the SAME object the mutating paths gate manage on. Binding the read decision to the object actually returned closes a cross-tenant BOLA an earlier project:<project_id>-keyed gate exposed (a caller able to observe any Project could read any bridge Resource by pairing their own project_id with a victim's resource_id). The resource definition derives observe from owner + maintainer + operator + viewer + parent->observe, so a legitimate Project observer still resolves through the parent->observe arm. The project_id path parameter is NOT load-bearing for authorization.
  • Mutating paths (the relay PUT, the three Create, three Update, and three Delete operations) gate manage on the addressed Resource object (resource:<resource_id>), the aggregate-scope object the mutation acts on.

Every gate decision — granted or denied — and every body-shape or domain-invariant rejection emits an audit row through the audit sink, stamped with the operation's audit relation (the bridge.<surface>.* values in the Operations table). A denial is written audit-first, before the response is flushed, so a flaky audit backend cannot land a silent denial. The audit relations name the operation namespace (dotted-snake), not a SpiceDB schema relation.

Path & query parameters

OperationParameterTypeRequiredNotes
every operationproject_id (path)string (uuid)yesOwning Project; the object the read gate checks. Malformed → 400 invalid_project_id.
every operationresource_id (path)string (uuid)yesAddressed bridge Resource; the object the write gate checks. Malformed → 400 invalid_resource_id.
every /{slug} operationslug (path)string (kebab-case, maxLength 63)yesStable identity of the user-access provider, ingress rule, or site-to-site tunnel within its Resource. Malformed → 400 invalid_slug.

The bridge surface carries no query parameters; the List… operations return the full set for the addressed Resource ordered by slug ascending.

Schemas

The OpenAPI spec is the authoritative source for field shapes. The schemas this surface uses are:

  • Relay: BridgeRelayConfigureRequest (enabled, listen_port) and BridgeRelayResponse (resource_id, enabled, listen_port, created_at, updated_at).
  • User-access provider:BridgeUserAccessProviderCreateRequest / BridgeUserAccessProviderUpdateRequest (kind, interface_name, listen_port, max_peers, auth_secret_ref, and a free-form routing_policy JSONB document; the create body also carries the slug), the BridgeUserAccessProviderResponse projection, and the BridgeUserAccessProviderList wrapper.
  • Public ingress rule: BridgeIngressRuleCreateRequest / BridgeIngressRuleUpdateRequest (sni_host, target_node_id, target_port, optional nullable acme_account_ref; the create body also carries the slug), the BridgeIngressRuleResponse projection, and the BridgeIngressRuleList wrapper.
  • Site-to-site tunnel: BridgeSiteToSiteTunnelCreateRequest / BridgeSiteToSiteTunnelUpdateRequest (kind, remote_host, remote_port, auth_secret_ref, a non-empty allowed_subnets CIDR list, and a routing_policy; the create body also carries the slug), the BridgeSiteToSiteTunnelResponse projection, and the BridgeSiteToSiteTunnelList wrapper.

Enums

  • BridgeUserAccessProviderKindnetbird, tailscale, wireguard. Selects the agent-side driver that programs the provider on the addressed Node.
  • BridgeSiteToSiteTunnelKindwireguard, ipsec, openvpn. Selects the agent-side driver that establishes the tunnel to the remote endpoint.
  • BridgeSiteToSiteTunnelRoutingPolicybidirectional, ingress_only, egress_only. bidirectional routes both ways, ingress_only admits remote-initiated traffic, egress_only admits locally-initiated traffic.

Common field rules

  • A listen_port (relay, user-access), a target_port (ingress), and a remote_port (site-to-site) must each fall inside the closed range 1..65535; outside it the write is rejected with 400 relay_port_out_of_range.
  • An auth_secret_ref (user-access, site-to-site) is an opaque reference of the form secret:<domain>/<project>/<name>(:<version>)?. The platform stores the reference, never the material; a malformed reference is 400 secret_ref_malformed.
  • A site-to-site allowed_subnets list must be non-empty; an empty list is 400 allowed_subnet_empty.
  • An ingress target_node_id must resolve to a Node inside the bridge Resource's owning Domain; a Node outside it is 400 target_node_not_in_domain.

Error taxonomy

All error responses use the shared Problem envelope (application/problem+json); the 403 path uses the PermissionDenied shape with reason = insufficient_relation. The Bridge Orchestrator surface adds the closed bridge domain taxonomy below — ten codes in all: the six single-aggregate codes followed by the four cross-aggregate validation codes. Each code maps to exactly one repo / service / domain sentinel and one HTTP status.

The first six are raised inside a single aggregate boundary, before any persistence write:

CodeStatusWhereMeaning
resource_not_bridge409every mutating bridge op, before any persistence writeThe addressed Resource exists but is not of kind bridge.
relay_port_out_of_range400relay PUT, user-access create/patch, site-to-site create/patchA listen_port (relay, user-access) or remote_port (site-to-site) was outside the closed range 1..65535.
slug_conflict409user-access / ingress / site-to-site create & patchCollided on (resource_id, slug) (all three) or on (resource_id, sni_host) (ingress).
target_node_not_in_domain400ingress create/patchThe rule's target_node_id resolves to a Node outside the bridge Resource's owning Domain.
allowed_subnet_empty400site-to-site create/patchAn empty allowed_subnets list was supplied.
secret_ref_malformed400user-access / site-to-site create & patchAn auth_secret_ref did not match the opaque secret:<domain>/<project>/<name>(:<version>)? form.

Cross-aggregate validation codes

The remaining four are raised by the cross-aggregate validator. It runs on every mutating op after the ReBAC resource#manage check has passed and before the persist transaction opens, scanning the addressed Resource's sibling aggregates for the cross-row invariants the per-table SQL constraints cannot express. A refusal therefore fires against the addressed Resource's resource#manage relation and stamps the audit Outcome invariant_violation — distinct from the identity-conflict outcome the slug_conflict 409 uses. The candidate's own row is excluded on update, so a no-op re-save never self-conflicts. The narrative — the total failure-precedence order, the per-entry-point prefix each candidate runs, and the three sub-validators — lives in ../../contexts/bridge/validation.md.

CodeStatusWhereMeaning
bridge_port_conflict409relay PUT, user-access create/patch, ingress create/patchA candidate relay/user-access/ingress mutation binds a host port (or a (target_node_id, target_port) slot) already claimed by a sibling aggregate within the bridge; raised by the cross-aggregate validator before persistence.
subnet_overlap_with_mesh409site-to-site create/patchA tunnel allowed_subnets entry overlaps the Domain mesh CIDR; the mesh check takes precedence over the sibling-tunnel check.
subnet_overlap_with_tunnel409site-to-site create/patchA tunnel allowed_subnets entry overlaps a sibling tunnel within the bridge.
acme_directory_unreachable422ingress create/patchThe acme_account_ref names an ACME directory that is unreachable, returned a server error, timed out, or advertised a malformed directory document; the validator refuses issuance before persistence.

Each example pairs the request line with the application/problem+json response body the validator emits:

http
PUT /v1/projects/{project_id}/resources/{resource_id}/bridge/relay
json
{
  "type": "https://plexsphere.dev/errors/bridge-port-conflict",
  "title": "Bridge Port Conflict",
  "status": 409,
  "code": "bridge_port_conflict",
  "detail": "target_node_id 7c9e6679-7425-40de-944b-e07fc1f90ae7 target_port 8443 is already claimed by a sibling ingress rule within the bridge"
}
http
POST /v1/projects/{project_id}/resources/{resource_id}/bridge/site-to-site
json
{
  "type": "https://plexsphere.dev/errors/subnet-overlap-with-mesh",
  "title": "Subnet Overlap With Mesh",
  "status": 409,
  "code": "subnet_overlap_with_mesh",
  "detail": "allowed_subnet 10.99.42.0/24 overlaps the domain mesh CIDR 10.99.0.0/16"
}
http
POST /v1/projects/{project_id}/resources/{resource_id}/bridge/site-to-site
json
{
  "type": "https://plexsphere.dev/errors/subnet-overlap-with-tunnel",
  "title": "Subnet Overlap With Tunnel",
  "status": 409,
  "code": "subnet_overlap_with_tunnel",
  "detail": "allowed_subnet 10.50.0.0/16 overlaps the allowed_subnets of sibling tunnel paris-dc within the bridge"
}
http
POST /v1/projects/{project_id}/resources/{resource_id}/bridge/ingress
json
{
  "type": "https://plexsphere.dev/errors/acme-directory-unreachable",
  "title": "ACME Directory Unreachable",
  "status": 422,
  "code": "acme_directory_unreachable",
  "detail": "the ACME directory for the configured acme_account_ref is unreachable"
}

The surface REUSES the shared transport-boundary codes with their standard semantics:

CodeStatusWhereMeaning
invalid_project_id400every operationMalformed Project UUID.
invalid_resource_id400every operationMalformed Resource UUID.
invalid_slug400every /{slug} operationSlug is not valid kebab-case.
invalid_body400every body-carrying operationBody cannot be decoded as the operation's request envelope.
request_body_too_large413every body-carrying operationRequest body exceeded the 8 KiB bridge ceiling.
unauthenticated401every operationMissing or unresolved credential.
permission_denied403every operationCaller lacks the required ReBAC relation (resource#observe for reads, resource#manage for writes), both on the addressed Resource; rendered as a PermissionDenied body.
resource_not_found404every operationBridge Resource (or named provider / rule / tunnel) not visible to the caller. The bridge surface never resolves a parent Project, so it never emits project_not_found.
bridge_not_provisioned501every operationThe bridge application services / authorizer are not wired in this build.
internal500every operationServer-side failure path; the wire body stays generic.

Cross-references

  • ../../contexts/bridge/model.md — the bridge aggregates (relay singleton, user-access provider, public ingress rule, site-to-site tunnel), their value objects, and the ubiquitous language.
  • ../../contexts/bridge/validation.md — the cross-aggregate validation pipeline behind the four bridge_port_conflict / subnet_overlap_with_mesh / subnet_overlap_with_tunnel / acme_directory_unreachable refusals: the failure-precedence order, the per-entry-point prefix, and the host-port / subnet-overlap / ACME-feasibility sub-validators.
  • ./resources.md — the Tenancy Resource surface; a bridge Resource is an ordinary Resource of kind bridge, and its resource#manage relation gates every bridge mutation.
  • ./projects.md — the parent Project surface; a legitimate Project observer still resolves every bridge read through the resource#observe permission's parent->observe derivation.
  • ./index.md — the HTTP API surface map.
  • ../../../api/openapi/plexsphere-v1.yaml — OpenAPI 3.1 spec; the GetBridgeRelay / ConfigureBridgeRelay, the user-access Create / List / Get / Update / Delete, the ingress Create / List / Get / Update / Delete, and the site-to-site Create / List / Get / Update / Delete operations, plus their BridgeRelay*, BridgeUserAccessProvider*, BridgeIngressRule*, and BridgeSiteToSiteTunnel* schemas and the three bridge enums.