GraphQL transport
Osyra exposes its GraphQL API at a single path, /graphql, on the public API
host. The server registers:
- HTTP
POSTfor ordinary query and mutation envelopes. - HTTP
GETfor GraphQL query transport. A non-upgradeGETis a protected request, not the WebSocket carve-out. - Multipart form requests where the GraphQL client uses that transport.
- WebSocket subscriptions (
chatCompletionStream,inferenceStream) as a long-lived stream of results.
This page is the reference for both: the request envelope, how you authenticate on
each, and the one place the authentication mechanism deliberately differs — the
WebSocket connection_init carve-out.
For how a GraphQL operation is translated into the gRPC calls behind it, see gRPC↔GraphQL translation. For the token model itself, see Authentication and authorization.
HTTP transport (queries and mutations)
Most queries and all mutations are sent as an HTTP POST to /graphql with a
JSON body. The server also registers GraphQL HTTP GET for queries. Except for
the two body-verified public operations below, both methods are authenticated.
POST /graphql HTTP/1.1
Host: api.osyra.ai
Content-Type: application/json
X-Api-Key: <the-key-shown-once-at-creation>
{
"query": "query($id: ID!) { workspace(id: $id) { id name } }",
"variables": { "id": "<workspace-id-from-console>" },
"operationName": null
}The body is the standard GraphQL-over-HTTP envelope:
| Field | Required | Meaning |
|---|---|---|
| query | yes | The GraphQL document (a query or mutation). |
| variables | no | A JSON object of variable values referenced by query. |
| operationName | no | Names which operation to run when the document defines more than one. |
Authentication on the HTTP transport
Every protected HTTP query or mutation carries exactly one credential:
| Credential | Header |
|---|---|
| IAM-issued Osyra API key | X-Api-Key: <key> |
| OAuth access JWT | Authorization: Bearer <access_token> |
Do not send both headers. Repeated or comma-coalesced credential values are also
rejected as ambiguous. An osy_* API key in Authorization: Bearer is not
accepted on /graphql: that OpenAI-client compatibility exception is limited to
GET /v1/models and POST /v1/chat/completions,
POST /v1/chat/completions/stream, and POST /v1/embeddings.
The gateway authenticates the selected credential before the operation runs, then rate-limits, evaluates policy, and audits the request. An API key is exchanged through IAM for the same short-lived, workspace-scoped session contract used by the downstream JWT middleware. Edge verifies an OAuth access token locally against cached public keys fetched from IAM over gRPC. Only after authentication and authorization succeed do resolvers execute and fan out to backends over gRPC.
There are exactly two anonymous GraphQL HTTP exceptions. Both are POST /graphql, and Edge must parse a valid, at-most-64-KiB GraphQL JSON body and
prove that the document contains only one allowlisted root operation:
- query
publicPlans - mutation
resendVerificationEmail
Fragments, subscriptions, extra root fields, an unreadable body, an oversized
body, or any other operation make the request protected. HTTP GET never gets
this anonymous-operation exception. The only unauthenticated GET /graphql
handling is the valid WebSocket upgrade described below, which merely moves
authentication to connection_init.
Response envelope
A response is the standard GraphQL JSON shape — data, and errors when
something fails. Osyra enriches each error with an additive extensions.osyra
block so you can correlate and classify it:
{
"data": null,
"errors": [
{
"message": "memory backend not deployed in this environment",
"extensions": {
"code": "OSY-OME-NOT-WIRED",
"osyra": {
"request_id": "…",
"timestamp": "…",
"code": "OSY-OME-NOT-WIRED"
}
}
}
]
}The flat extensions.code is the stable contract; the extensions.osyra block
adds the request id and a timestamp. See error codes for the
OSY-* families, including the *-NOT-WIRED signals a
typed Unwired* fallback
returns when a backend is not deployed.
Operation safety limits
Because one document can fan out across several gRPC backends, the gateway bounds each operation before any backend is touched:
- Fixed complexity limit — a weighted cost is computed for the whole document
and an over-budget operation is rejected at parse/validate time. List edges on
cyclic relationships (for example
organization.workspaces,organization.members,user.organizations) carry a deliberately high weight. - Alias-amplification guard — the number of aliased selections targeting any single root field is capped, so an over-aliased document is rejected before the resolver fans out.
- Automatic persisted queries (APQ) and a query cache are enabled, matching the default gqlgen server behaviour.
WebSocket transport (subscriptions)
Subscriptions are long-lived, so they ride a WebSocket on the same
/graphql path, using the graphql-transport-ws protocol (with graphql-ws
offered for backward compatibility). The two subscriptions are
chatCompletionStream and
inferenceStream; each streams chunks back
as the model produces them.
The handshake is an ordinary HTTP GET upgrade:
GET /graphql HTTP/1.1
Host: <your API host>
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Protocol: graphql-transport-wsThe connection_init JWT carve-out
A browser cannot set an Authorization header on a WebSocket handshake — the
JavaScript WebSocket API has no header interface. If the gateway demanded the
bearer on the upgrade GET, browser subscriptions would be impossible. So the
gateway applies one narrow, deliberate carve-out:
A
GETrequest to/graphqlwhose headers are a valid WebSocket upgrade (Connection: Upgrade+Upgrade: websocket, per RFC 6455) is let past the header-bearer check so the upgrade can complete. Authentication is then performed on theconnection_initmessage instead.
This carve-out is implemented as a twin pair — one in the JWT middleware and one in the OPA-policy middleware — and it is scoped as tightly as possible:
- It applies only to a
GETUpgrade: websocketon the exact/graphqlpath. A protectedPOST /graphqloperation still requires its ordinaryX-Api-Keyor OAuth bearer header; a non-upgradeGET /graphqland a WebSocket upgrade aimed at any other route do not receive this carve-out. - The acceptance predicate is byte-for-byte the same one the WebSocket library
uses to admit the handshake, so a forged
Upgrade: websocketthat would not actually complete a handshake cannot use the carve-out to slip past auth either.
Once the socket is open, the client sends a connection_init message carrying the
OAuth access JWT in its payload. API keys are not accepted on the WebSocket
transport, including API keys disguised as Bearer values. The gateway accepts the
JWT from any of three places in that payload:
{
"type": "connection_init",
"payload": {
"authorization": "Bearer <access_token>"
}
}| Payload field | Form |
|---|---|
| authorization | "Bearer <token>" (the Bearer scheme is stripped, case-insensitively) |
| token | the raw token, no scheme |
| headers.authorization | "Bearer <token>" inside a headers map |
The gateway then validates that JWT with the same JWKS-backed validator the HTTP OAuth path uses — identical signature, issuer, audience, and expiry checks — and extracts the full identity (user, organization, workspace, permissions, roles) into the connection context. Authentication is fail-closed:
- A
connection_initwith no token is rejected (authorization required). - An invalid or expired token fails validation and the connection is refused.
- If the client completes the HTTP upgrade but never sends
connection_initwithin the init deadline (15 seconds), the gateway closes the connection — an unauthenticated socket cannot be parked open.
On success the gateway replies with a connection_ack carrying a connection_id,
and the subscription can start. Each subscription resolver still independently
gates on authentication plus the ai:invoke / ai:execute permission, so the
connection_init identity is enforced again at the operation level.
Mid-stream re-validation
A streamed completion can outlive the access token that opened it. To stop a
revoked or expired token from continuing to stream, the gateway re-validates the
connection_init bearer for the lifetime of the stream: chatCompletionStream
and inferenceStream re-check revocation and expiry as the stream runs and
terminate on the next fixed re-validation tick after the token is revoked or
expires. The default interval is 30 seconds, matching the WebSocket keep-alive
cadence; it is bounded re-validation, not instantaneous push revocation. The
re-validator is the same validator used at connection_init, so the HTTP
request, the WebSocket init, and the live stream all share one revocation
contract.
Keep-alive and origin
| Parameter | Value | Purpose |
|---|---|---|
| Keep-alive ping interval | 30s | Detect dead peers and keep intermediaries from idling the socket. |
| Ping/pong timeout | 60s | Maximum wait for a pong before the peer is considered gone. |
| connection_init deadline | 15s | An upgraded socket that never authenticates is closed. |
| Origin check | same-host | A browser handshake's Origin must match the API host. A request with no Origin header (a non-browser CLI/SDK client) is allowed and still authenticates via connection_init. |
Choosing a transport
| You want to… | Transport | Auth |
|---|---|---|
| Run a protected query | HTTP GET /graphql or POST /graphql | X-Api-Key or OAuth Authorization: Bearer |
| Run a protected mutation | HTTP POST /graphql | X-Api-Key or OAuth Authorization: Bearer |
| Fetch public plans or resend a verification email | Body-verified HTTP POST /graphql containing only the allowlisted root field | None |
| Stream a chat completion or inference | WebSocket on /graphql | OAuth JWT in the connection_init payload |
See also
- gRPC↔GraphQL translation — the edge BFF pattern behind both transports.
- Authentication and authorization — the token model and the full request chain.
- Subscriptions reference — the generated schema for
chatCompletionStreamandinferenceStream. - Error codes — the
OSY-*code families returned inextensions.