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 ​

  • BridgeUserAccessProviderKind — netbird, tailscale, wireguard. Selects the agent-side driver that programs the provider on the addressed Node.
  • BridgeSiteToSiteTunnelKind — wireguard, ipsec, openvpn. Selects the agent-side driver that establishes the tunnel to the remote endpoint.
  • BridgeSiteToSiteTunnelRoutingPolicy — bidirectional, 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 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.
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.com/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.com/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.com/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.com/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.