Appearance
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 aPUTfull-replace, not aPOSTcreate. - 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, orwireguard). - 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, oropenvpn), a routing policy, and the CIDR prefixes it routes.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| GET | …/bridge/relay | GetBridgeRelay | resource#observe on the addressed Resource | bridge.relay.read | (none) | n/a |
| PUT | …/bridge/relay | ConfigureBridgeRelay | resource#manage on the addressed Resource | bridge.relay.configure | bridge.RelayConfigured | 8 KiB |
| POST | …/bridge/user-access | CreateBridgeUserAccessProvider | resource#manage | bridge.user_access.configure | bridge.UserAccessProviderConfigured | 8 KiB |
| GET | …/bridge/user-access | ListBridgeUserAccessProviders | resource#observe | bridge.user_access.read | (none) | n/a |
| GET | …/bridge/user-access/{slug} | GetBridgeUserAccessProvider | resource#observe | bridge.user_access.read | (none) | n/a |
| PATCH | …/bridge/user-access/{slug} | UpdateBridgeUserAccessProvider | resource#manage | bridge.user_access.update | bridge.UserAccessProviderConfigured | 8 KiB |
| DELETE | …/bridge/user-access/{slug} | DeleteBridgeUserAccessProvider | resource#manage | bridge.user_access.remove | bridge.UserAccessProviderRemoved | n/a |
| POST | …/bridge/ingress | CreateBridgeIngressRule | resource#manage | bridge.ingress.configure | bridge.PublicIngressRuleConfigured | 8 KiB |
| GET | …/bridge/ingress | ListBridgeIngressRules | resource#observe | bridge.ingress.read | (none) | n/a |
| GET | …/bridge/ingress/{slug} | GetBridgeIngressRule | resource#observe | bridge.ingress.read | (none) | n/a |
| PATCH | …/bridge/ingress/{slug} | UpdateBridgeIngressRule | resource#manage | bridge.ingress.update | bridge.PublicIngressRuleConfigured | 8 KiB |
| DELETE | …/bridge/ingress/{slug} | DeleteBridgeIngressRule | resource#manage | bridge.ingress.remove | bridge.PublicIngressRuleRemoved | n/a |
| POST | …/bridge/site-to-site | CreateBridgeSiteToSiteTunnel | resource#manage | bridge.site_to_site.configure | bridge.SiteToSiteTunnelConfigured | 8 KiB |
| GET | …/bridge/site-to-site | ListBridgeSiteToSiteTunnels | resource#observe | bridge.site_to_site.read | (none) | n/a |
| GET | …/bridge/site-to-site/{slug} | GetBridgeSiteToSiteTunnel | resource#observe | bridge.site_to_site.read | (none) | n/a |
| PATCH | …/bridge/site-to-site/{slug} | UpdateBridgeSiteToSiteTunnel | resource#manage | bridge.site_to_site.update | bridge.SiteToSiteTunnelConfigured | 8 KiB |
| DELETE | …/bridge/site-to-site/{slug} | DeleteBridgeSiteToSiteTunnel | resource#manage | bridge.site_to_site.remove | bridge.SiteToSiteTunnelRemoved | n/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 relayPUT, the three Create operations, and the three Update operations. An over-cap body surfaces as413 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_provisionedwhen 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-Matchheader and no bodyrevision/ 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
PUTshort-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 lastPUTto 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 threeList…operations, and the threeGet…{slug}operations) gateobserveon the addressed Resource object (resource:<resource_id>) — the SAME object the mutating paths gatemanageon. Binding the read decision to the object actually returned closes a cross-tenant BOLA an earlierproject:<project_id>-keyed gate exposed (a caller able to observe any Project could read any bridge Resource by pairing their ownproject_idwith a victim'sresource_id). Theresourcedefinition derivesobservefromowner + maintainer + operator + viewer + parent->observe, so a legitimate Project observer still resolves through theparent->observearm. Theproject_idpath parameter is NOT load-bearing for authorization. - Mutating paths (the relay
PUT, the three Create, three Update, and three Delete operations) gatemanageon 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
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| every operation | project_id (path) | string (uuid) | yes | Owning Project; the object the read gate checks. Malformed → 400 invalid_project_id. |
| every operation | resource_id (path) | string (uuid) | yes | Addressed bridge Resource; the object the write gate checks. Malformed → 400 invalid_resource_id. |
every /{slug} operation | slug (path) | string (kebab-case, maxLength 63) | yes | Stable 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) andBridgeRelayResponse(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-formrouting_policyJSONB document; the create body also carries theslug), theBridgeUserAccessProviderResponseprojection, and theBridgeUserAccessProviderListwrapper. - Public ingress rule:
BridgeIngressRuleCreateRequest/BridgeIngressRuleUpdateRequest(sni_host,target_node_id,target_port, optional nullableacme_account_ref; the create body also carries theslug), theBridgeIngressRuleResponseprojection, and theBridgeIngressRuleListwrapper. - Site-to-site tunnel:
BridgeSiteToSiteTunnelCreateRequest/BridgeSiteToSiteTunnelUpdateRequest(kind,remote_host,remote_port,auth_secret_ref, a non-emptyallowed_subnetsCIDR list, and arouting_policy; the create body also carries theslug), theBridgeSiteToSiteTunnelResponseprojection, and theBridgeSiteToSiteTunnelListwrapper.
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.bidirectionalroutes both ways,ingress_onlyadmits remote-initiated traffic,egress_onlyadmits locally-initiated traffic.
Common field rules
- A
listen_port(relay, user-access), atarget_port(ingress), and aremote_port(site-to-site) must each fall inside the closed range1..65535; outside it the write is rejected with400 relay_port_out_of_range. - An
auth_secret_ref(user-access, site-to-site) is an opaque reference of the formsecret:<domain>/<project>/<name>(:<version>)?. The platform stores the reference, never the material; a malformed reference is400 secret_ref_malformed. - A site-to-site
allowed_subnetslist must be non-empty; an empty list is400 allowed_subnet_empty. - An ingress
target_node_idmust resolve to a Node inside the bridge Resource's owning Domain; a Node outside it is400 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:
| Code | Status | Where | Meaning |
|---|---|---|---|
resource_not_bridge | 409 | every mutating bridge op, before any persistence write | The addressed Resource exists but is not of kind bridge. |
relay_port_out_of_range | 400 | relay PUT, user-access create/patch, site-to-site create/patch | A listen_port (relay, user-access) or remote_port (site-to-site) was outside the closed range 1..65535. |
slug_conflict | 409 | user-access / ingress / site-to-site create & patch | Collided on (resource_id, slug) (all three) or on (resource_id, sni_host) (ingress). |
target_node_not_in_domain | 400 | ingress create/patch | The rule's target_node_id resolves to a Node outside the bridge Resource's owning Domain. |
allowed_subnet_empty | 400 | site-to-site create/patch | An empty allowed_subnets list was supplied. |
secret_ref_malformed | 400 | user-access / site-to-site create & patch | An 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.
| Code | Status | Where | Meaning |
|---|---|---|---|
bridge_port_conflict | 409 | relay PUT, user-access create/patch, ingress create/patch | A 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_mesh | 409 | site-to-site create/patch | A tunnel allowed_subnets entry overlaps the Domain mesh CIDR; the mesh check takes precedence over the sibling-tunnel check. |
subnet_overlap_with_tunnel | 409 | site-to-site create/patch | A tunnel allowed_subnets entry overlaps a sibling tunnel within the bridge. |
acme_directory_unreachable | 422 | ingress create/patch | The 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/relayjson
{
"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-sitejson
{
"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-sitejson
{
"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/ingressjson
{
"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:
| Code | Status | Where | Meaning |
|---|---|---|---|
invalid_project_id | 400 | every operation | Malformed Project UUID. |
invalid_resource_id | 400 | every operation | Malformed Resource UUID. |
invalid_slug | 400 | every /{slug} operation | Slug is not valid kebab-case. |
invalid_body | 400 | every body-carrying operation | Body cannot be decoded as the operation's request envelope. |
request_body_too_large | 413 | every body-carrying operation | Request body exceeded the 8 KiB bridge ceiling. |
unauthenticated | 401 | every operation | Missing or unresolved credential. |
permission_denied | 403 | every operation | Caller 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_found | 404 | every operation | Bridge 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_provisioned | 501 | every operation | The bridge application services / authorizer are not wired in this build. |
internal | 500 | every operation | Server-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 fourbridge_port_conflict/subnet_overlap_with_mesh/subnet_overlap_with_tunnel/acme_directory_unreachablerefusals: 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 kindbridge, and itsresource#managerelation gates every bridge mutation../projects.md— the parent Project surface; a legitimate Project observer still resolves every bridge read through theresource#observepermission'sparent->observederivation../index.md— the HTTP API surface map.../../../api/openapi/plexsphere-v1.yaml— OpenAPI 3.1 spec; theGetBridgeRelay/ConfigureBridgeRelay, the user-accessCreate/List/Get/Update/Delete, the ingressCreate/List/Get/Update/Delete, and the site-to-siteCreate/List/Get/Update/Deleteoperations, plus theirBridgeRelay*,BridgeUserAccessProvider*,BridgeIngressRule*, andBridgeSiteToSiteTunnel*schemas and the three bridge enums.