External request/response contract for the regional FastAPI control plane. For Firestore paths, document shapes, security rules, and limits, see Backend/Firebase/README.md.
- JSON field naming is camelCase everywhere.
- Use
clientIdin routes, responses, and examples. Never use snake case for the client identifier field in external contracts. - Python internals may use snake_case only behind Pydantic aliases. External request/response JSON stays camelCase.
- Apex API base URL is
https://api.<origin>/api, for examplehttps://api.gocloudlaunch.com/api. - Regional API base URL is
https://<regionId>.<origin>/api. <origin>is the current frontend origin host without protocol, for examplegocloudlaunch.com.- For a frontend loaded from
https://gocloudlaunch.com, global/read calls usehttps://api.gocloudlaunch.com/api/*; regionus-sanjose-1mutations and capacity calls usehttps://us-sanjose-1.gocloudlaunch.com/api/*. - FastAPI internal routes do not include
/api. Caddy strips/api/*before proxying to FastAPI. REACT_APP_API_ORIGINis only a local/dev override. When set, frontend API helpers send API calls to${REACT_APP_API_ORIGIN}/api/*. In production it is unset and API URLs are derived fromwindow.location.origin.- The apex API host serves global/account traffic:
GET /regions,POST /auth/check-access, andDELETE /account. - Native Apple clients use the same production regional hostname shape with origin
gocloudlaunch.com, and useapi.gocloudlaunch.comfor apex calls. Capacity, create, delete, and sync calls use the selected or target config region.
- Unauthenticated. Rate limited by Caddy like the rest of the API surface.
- Response
200:
{
"status": "ok",
"regionId": "us-sanjose-1"
}- Apex. Unauthenticated.
- Returns enabled regions only, sorted by
displayOrder. This is display-safe discovery data only; it never includes capacity, endpoint IPs, hostnames, WireGuard public keys, DNS settings, health state, orenabled. - Response
200:
{
"regions": [
{
"regionId": "us-sanjose-1",
"displayName": "San Jose",
"displayOrder": 1
},
{
"regionId": "us-ashburn-1",
"displayName": "Ashburn",
"displayOrder": 2
}
]
}- Apex. Requires Firebase bearer auth.
- Verifies that the authenticated user is provisioned and returns their role. Unprovisioned users are denied and disabled as before.
- Response
200:
{
"userId": "firebase-uid",
"email": "user@example.com",
"role": "user"
}- Requires Firebase bearer auth. Creates a client only for the authenticated user.
- Request:
{
"regionId": "us-sanjose-1",
"clientName": "Phone"
}clientNameis required and must be non-blank.- Response
200:
{
"clientId": "6f77fd32-ecf5-4dd7-9d96-6bb84de92df1",
"regionId": "us-sanjose-1",
"clientName": "Phone",
"status": "active",
"assignedTunnelIpv4": "10.0.0.2/32",
"assignedTunnelIpv6": "fd42:42:42::2/128",
"serverEndpointIpv4": "1.2.3.4",
"serverEndpointHostname": "wg.us-sanjose-1.gocloudlaunch.com",
"wireguardConfig": "..."
}- Requires Firebase bearer auth for a provisioned user.
- Regional: returns capacity for this API server's local region only.
- Signed-in clients fan this out per region after
GET /regions; guests never call it. allocatedClientCountcounts client docs with statuscreatingoractive.- Response
200:
{
"regionId": "us-sanjose-1",
"capacityLimit": 20,
"allocatedClientCount": 8
}- Requires Firebase bearer auth.
- Request body:
{
"userId": "firebase-uid",
"regionId": "us-sanjose-1",
"accountCleanup": false
}- Normal users can only pass their own UID. Admins can pass any target UID.
- The API verifies the client document at
Regions/{regionId}/Instances/{clientId}exists and matches the requested IDs before mutating WireGuard. accountCleanup(optional, defaults tofalse) marks this delete as part ofDELETE /account's cross-region peer removal. The receiving region honors it only for a recently authenticated self-delete by auser-role account -userIdmust equal the caller's own uid, the caller's Firebase sign-in must be recent, and the caller's role must permit account deletion - and re-checks all three against the replayed bearer token itself; the flag is never trusted on its own. A request that fails any of those checks fails the whole delete (400 INVALID_REQUESTfor the self-only/role checks,401 AUTH_REQUIREDfor stale auth); cleanup mode is never silently downgraded to an ordinary delete. When accepted, the handler skips its usual local policy reconcile and fleet poke, becauseDELETE /accountowns propagation for the whole account (see below).- Response
200:
{
"userId": "firebase-uid",
"clientId": "6f77fd32-ecf5-4dd7-9d96-6bb84de92df1",
"regionId": "us-sanjose-1",
"status": "removed"
}- Apex. Requires Firebase bearer auth from a recent sign-in, restricted to
user-role accounts. - Real ordering: snapshot the caller's client docs; remove their WireGuard peers (local directly,
remote via
DELETE /clients/{clientId}inaccountCleanupmode - see above); mark every one of the account's client documents non-active fleet-wide in one trusted repository operation (the fence), including clients on a region whose host was unreachable during peer removal; send exactly one best-effort policy refresh wave to every other enabled region whileUserRoles/{uid}and the caller's recent authentication still exist; queue the local policy reconcile separately; only then hard-delete the account's client docs, account doc, role doc, and Firebase Auth user. - The hard delete does not start until every other enabled region has accepted the refresh
(
202fromPOST /sync/refresh) or been recorded as unreachable. An accepted refresh no longer depends on the caller's token once its202returns. The fence commits before the refresh wave goes out, so from that point no policy pull anywhere - on any region, including one that races ahead of the refresh - can restore the deleting account's connectivity. - Request body: none.
- Response
200:
{
"userId": "firebase-uid",
"deletedClientCount": 3
}deletedClientCountis the count reported by the fence's own authoritative read of the account's client documents (grouped by region from each document's path), not the pre-removal snapshot. For a fleet in a consistent state the two counts agree, so this is not an observable contract change.- An unreachable region is an accepted risk, not a retry loop: it keeps an orphaned WireGuard peer
and a stale policy row until its next boot or a manual
cloudgateway-sync-peersrun (see docs/wireguard-drift-repair.md). Its Firestore client rows are already non-active by the time the account documents are hard-deleted, so the orphaned peer cannot be used to reach a live account.
- Requires Firebase bearer auth with admin role.
- Logically global and hosted by every regional API. It does not accept
regionIdand does not mutate regional state. - After access is granted, the API sends a best-effort SES email telling the user they can sign in.
Email failures are logged but do not change the
200response or roll back access. - Request:
{
"email": "user@example.com"
}- Response
200:
{
"userId": "firebase-uid",
"email": "user@example.com",
"role": "user",
"alreadyExisted": false
}- Requires Firebase bearer auth with admin role.
- Regional: reconciles this host's live WireGuard peer set against the region's
activeclient docs in Firebase (the same reconcile run at boot and bycloudgateway-sync-peers), and reconciles the cross-region mesh: every other enabled region withmeshEnabled == trueand complete/non-overlapping mesh fields becomes a server-to-server peer with matching routes onwg0(see docs/wireguard-drift-repair.md). Idempotent - mesh peers are re-applied every pass (this is what re-resolves each endpoint hostname); client peers keep the existing compare-then-apply behavior. regionIdmust equal this host's region or the request is rejected withREGION_MISMATCH; the dashboard fans out one call per region so each regional API only syncs itself. Mesh changes are inherently all-region operations, so the dashboard always syncs every enabled region ("Sync All Regions"), not a subset.- Only one pass runs per host at a time. This endpoint takes the host's WireGuard lock
non-blocking, so a request that arrives while a pass (or a client create/delete) holds it is
rejected immediately with
409 SYNC_IN_PROGRESSrather than queueing; retry after the running pass finishes. The boot and post-registration passes still wait for the lock. - Request:
{
"regionId": "us-ashburn-1"
}- Response
200:
{
"regionId": "us-ashburn-1",
"syncedAt": "2026-06-17T18:30:00.123456Z",
"added": 1,
"updated": 0,
"removed": 1,
"noChanges": false,
"log": "CloudGateway peer sync audit log\nregion: ...\n",
"meshEnabled": true,
"meshApplied": 1,
"meshAdded": 0,
"meshUpdated": 0,
"meshRemoved": 0,
"meshSkipped": 0,
"meshRoutesAdded": 0,
"meshRoutesRemoved": 0,
"meshStatusWritten": true,
"clientPeersDegraded": 0,
"meshPeers": [
{
"regionId": "us-sanjose-1",
"status": "applied",
"endpointHostname": "wg.us-sanjose-1.gocloudlaunch.com",
"endpointPort": 51820,
"allowedNetworkV4": "10.0.0.0/24",
"allowedNetworkV6": "fd42:42:42::/64"
}
],
"policyApplied": true,
"policyRowCount": 12,
"policyStatusWritten": true
}-
syncedAtis UTC, serialized with aZsuffix and (when non-zero) fractional seconds - parse it as ISO 8601 rather than matching the example literally. -
added/updated/removedcount client peer changes only.meshAppliedcounts every desired mesh peer applied this pass (re-applies included, so it is not just newly-added peers);meshAdded/meshUpdated/meshRemovedcount mesh peers that newly appeared, had endpoint/port/ allowed-IPs/keepalive drift repaired, or disappeared on the interface this pass;meshSkippedcounts candidate regions skipped for overlap or incomplete mesh fields;meshRoutesAdded/meshRoutesRemovedcount thewg0mesh route changes from the route sweep.meshEnabledis true only when this region's doc exists, isenabled: true, and carriesmeshEnabled: trueas observed this pass - it is not the rawRegions/{regionId}.meshEnabledflag.Mesh/{regionId}.meshEnabledpersists this same combined value, so a disabled region publishesfalsethere even while its Region doc saystrue.noChangesmeans no live mutation: no client add/update/remove, mesh add/update/remove, or route add/remove. It deliberately excludesmeshAppliedandmeshSkipped, so a stable pass can reportmeshApplied > 0,meshUpdated == 0, andnoChanges == true; skipped-only passes are alsonoChanges == true. -
meshStatusWrittenis false when the pass reconciled the interface but failed to persist itsMesh/{regionId}snapshot. The live peer set is still correct; only the durable snapshot the dashboard renders mesh link status from is stale, until the next successful pass overwrites it. A status-write failure never fails the sync, so this is the only machine-readable signal of it. The field is optional for consumers: regions are installed independently (sudo cloudgateway-install-api <ref>), so a dashboard build that ships before every region is reinstalled will see responses without it. Treat absent as unknown and render nothing - do not treat it as a missing required field, or every not-yet-upgraded region reportsINCOMPATIBLE_RESPONSEduring a rollout. -
clientPeersDegradedcounts active client documents this pass refused to build a peer from because their public key or tunnel IP was missing or malformed. Such a record is skipped rather than fatal: it is excluded from the desired set, mesh reconciliation and the route sweep still run to completion, and - when its public key is at least syntactically valid - its already-live peer is protected from the unknown-peer removal sweep so a malformed document never disconnects a connected user. Protection follows status, not shape: a degraded record that is also revoked or no longer active is not protected, and its peer is removed normally. A non-zero count means anInstances/*document needs repair; the response, the audit log, and theclient_peer_degradedlog event all report the count and the region only - never the public key, owner email, client name, or tunnel IP. LikemeshStatusWritten, the field is required on the server and optional for consumers, for the same staggered-rollout reason. -
meshPeerslists every mesh candidate this pass considered (not just applied ones), withstatusone ofapplied/skipped-overlap/skipped-incomplete.skipped-overlapandskipped-incompleteare persistent configuration failures, not pending work; runtime overlap defense remains active.appliedandskipped-overlapentries carry the complete current snapshot, includingendpointPort; incomplete entries retain their reason code and may omit invalid fields. It deliberately omits the peer's WireGuard public key - the durableMesh/{regionId}Firestore doc carries it. The current response shape is strict: missingmeshUpdatedor other required fields is incompatible. -
appliedAton eachMesh/{regionId}.peers.*entry is a Firestore server timestamp: the instant Firestore recorded the host's applied-state snapshot, not the instant WireGuard actually changed. It is slightly later than the real application, and deliberately so - a trustworthy source beats a precise one read off an untrusted host clock. Becausewrite_mesh_statuspersists the snapshot in a single write, every peer'sappliedAtshares its instant with the document'supdatedAt. Staleness is derived fromupdatedAt, so nothing in the 24h logic depends onappliedAt; it is an operator-facing "last recorded" label only. -
logis an admin audit artifact. It can include user emails, client names, client IDs, public keys, tunnel IPs, statuses, and removed-peer details. -
logis a plaintext audit report (no ANSI/color) listing each added/updated/removed peer: added/updated peers include the owningclientId/email, removed peers (host peers with no matching active client) are listed by public key only. It never contains private keys, full configs, or tokens. Its mesh section is server metadata only (region IDs, CIDRs, endpoint hostnames, route changes) and never includes a mesh peer's public key or any per-user data. -
This pass also reconciles the account-scoped ACL policy map (
reconcile_policy()), the same full pull-apply-read back-status passPOST /api/sync/refreshenqueues - see docs/wireguard-drift-repair.md. So Sync All is also the repair path for a dropped or lost policy poke, not just for peer/mesh drift. -
The policy leg is synchronous: unlike
POST /api/sync/refresh, this endpoint waits for the policy pass before responding. It coalesces with any pass already in flight and then waits for the coordinator to quiesce, so sustained pokes on the un-rate-limitedPOST /api/sync/refreshcan hold this response open for longer than one pass. That is accepted (depth-1 bounds the pending backlog, never the total work callers can trigger); the outcome reported always comes from a pass whose Firestore pull started after the Sync All request, so a longer wait only returns a fresher result. -
policyAppliedistrueonly when this pass'sreconcile_policy()call completed without raising. It staystrue/falsefor the entire response even though everything above it (added,meshApplied, ...) is about the independent peer/mesh pass - the two legs share a request but not a failure mode: a policy failure never fails this endpoint or touches the peer/mesh fields, and the response is still200. On failure the response carries exactly"policyApplied": falsewithpolicyRowCountandpolicyStatusWrittenboth omitted (response_model_exclude_none=Truedrops null fields rather than serializing them), so a caller should treat their absence as "policy leg failed," not as zero. On successpolicyRowCountis the row count read back from the live map (mirrorsPolicy/{regionId}.rowCount) andpolicyStatusWrittenmirrorsmeshStatusWritten's meaning for the policy status doc:falsemeans the map applied correctly but the best-effortPolicy/{regionId}write failed, so the dashboard should warn mildly rather than treat the pass as failed. All three fields are optional for consumers for the same staggered-rollout reason asmeshStatusWritten: a region that predates this release omits all three, and a dashboard build must treat their absence as unknown, not aspolicyApplied: false. -
The Firestore pull that feeds
reconcile_policy()is fail-closed per row, not per pass: a malformed entry (wrong type, a host prefix other than/32//128, an address outside the tunnel aggregate, or an invalid/out-of-range account slot) is skipped and counted in an aggregate skipped-row total, never logged with its uid, address, or slot, and never aborts the pass. Every participant in a duplicate address or duplicate account-slot collision is excluded, not just the later row in collection order - a collision removes connectivity for the colliding rows rather than granting it to whichever happened to win.
- Requires Firebase bearer auth for any provisioned user (
require_provisioned_user). Not admin-only - any client's own token is enough. - Regional: reconciles this API server's local region's account-scoped ACL policy map only. It never touches WireGuard peers.
- Request body: none.
- Response
202: deliberately carries no detail - no region health, no row counts, and no error information. A caller learns nothing from the response beyond "the request was accepted." - The reconcile is enqueued and the request returns immediately, so the caller's timeout never matters and each request costs approximately nothing.
- No dedicated secret and no rate limit. The caller's own Firebase token is replayed, matching the
existing cross-region pattern in
_delete_remote_client(Backend/API/src/routes.py:722). Depth-1 coalescing inreconcile_policy()bounds the pending backlog to one queued follow-up pass at a time - it does not bound the total number of sequential refreshes a caller can trigger over time, and there is no rate limit (see docs/wireguard-drift-repair.md). - Failure behaviour: because the pass is detached from the response, a failed apply is never
reported in this endpoint's response, and it does not surface in
Policy/{regionId}either -reconcile_policy()raises before it ever calls the status write, so a failed apply or a failed read-back leaves the previous successfulPolicy/{regionId}document exactly as it was, and no failure status is ever written there. The failure is visible only in host logs (policy_refresh_failed); a stale-but-validPolicydoc can sit next to a region that is actively failing to apply. UsePOST /api/admin/sync(Sync All) as the repair path for a dropped or failed poke - its response now distinguishes a policy failure viapolicyApplied(seePOST /admin/syncabove). - Poke sites:
POST /clientsandDELETE /clients/{clientId}(ordinary, non-cleanup deletes) call this on every other region, fire-and-forget after the response, so a dropped poke never blocks or fails the caller's request. A dropped poke leaves the un-poked region's policy map stale until the next fleet-wide client event or an admin Sync All - this is an accepted risk, not a bug (see access-control-list.md).DELETE /accountis different: it calls this endpoint synchronously, inside the request, exactly once per other enabled region, as the deliberate last propagation step before its hard delete (seeDELETE /accountabove); it does not also fire the fire-and-forget poke, and the per-client deletes it issues inaccountCleanupmode suppress their own poke so the account delete is never fanned out twice.
All controlled failures return this shape:
{
"error": {
"code": "REGION_MISMATCH",
"message": "Requested region does not match this API server.",
"requestId": "..."
}
}- Error codes are uppercase snake case.
- Required codes:
AUTH_REQUIRED,ADMIN_REQUIRED,USER_NOT_PROVISIONED,INVALID_REQUEST,REGION_DISABLED,REGION_MISMATCH,LIMIT_REACHED,CAPACITY_REACHED,CLIENT_NOT_FOUND,DUPLICATE_EMAIL,ACCOUNT_DISABLED,SYNC_IN_PROGRESS,WIREGUARD_APPLY_FAILED,FIREBASE_WRITE_FAILED,ROLE_DEFAULT_MISSING,ACCOUNT_SLOT_UNAVAILABLE,INTERNAL_ERROR. - HTTP status mapping:
401: auth failures (AUTH_REQUIRED).403: permission failures (ADMIN_REQUIRED,USER_NOT_PROVISIONED).400: invalid request and region errors (INVALID_REQUEST,REGION_DISABLED,REGION_MISMATCH).404: missing clients (CLIENT_NOT_FOUND).409: duplicate email, disabled account, capacity/limit failures, and a sync already running on the region (DUPLICATE_EMAIL,ACCOUNT_DISABLED,LIMIT_REACHED,CAPACITY_REACHED,SYNC_IN_PROGRESS).500: host mutation failures, missing/malformed role defaults, an unusable account-slot counter, and unexpected failures (WIREGUARD_APPLY_FAILED,FIREBASE_WRITE_FAILED,ROLE_DEFAULT_MISSING,ACCOUNT_SLOT_UNAVAILABLE,INTERNAL_ERROR).
ACCOUNT_SLOT_UNAVAILABLEis returned byPOST /usersandPOST /clientswhen an account needs a new account slot andCounters/accountSlots.nextSlotcannot supply one: the counter document is missing, malformed, exhausted, or has regressed to at or below a slot a live account already holds. Allocation fails closed rather than deriving a slot from the liveUserscollection, which would re-issue a hard-deleted account's slot. Nothing is written when it is raised - no user, slot, client, tunnel index, or counter - so the request is safe to retry once an operator restores the counter. It is an operational condition, not a client error: see "Account slot allocation" in access-control-list.md and the counter-loss runbook in docs/service-operations.md.
- Roles:
user,admin. - Client statuses:
creating,active,failed,removed. - Operation results:
success,failed,noop.