Serve a GraphQL API
A GraphQL API on boatramp is a normal Wasm handler that speaks GraphQL (for
example built with async-graphql). Point boatramp at it, turn on
[handlers.graphql], and the platform treats GraphQL as a protocol it
understands — guarding it, resolving persisted queries, and (optionally)
federating several subgraphs into one supergraph. boatramp stays GraphQL-aware,
not a GraphQL engine: it parses a query only as far as the guard, persisted
queries, and the federation planner need; your schema and resolver logic stay in
your handler.
Everything below is opt-in per site and off by default.
What you can turn on
GraphQL support is a set of independent, composable features — reach for the ones you need:
| Feature | What it does |
|---|---|
| Query-guard | Reject over-deep/complex or introspection queries at the edge. |
| Persisted queries + safelist | Send a query hash instead of the full query; or lock serving to a pre-registered allowlist. |
| GraphiQL explorer | Serve the in-browser IDE to a browser GET. |
| Data connector | Serve a GraphQL API generated from a managed database — no resolver code. |
| Subscriptions | Serve a subscription as a graphql-sse event stream off a messaging topic. |
| Federation | Compose several subgraph handlers into one supergraph gateway. |
| Guest supergraph runs | Let a handler run a supergraph operation in-process. |
| Cookie session auth | Authenticate a browser app from an HttpOnly session cookie. |
Turn it on
// boatramp.cfg — the site's handler config
handlers: (
enabled: true,
graphql: (
enabled: true,
max_depth: Some(12),
max_complexity: Some(500),
introspection: Some(false),
),
)
The GraphiQL explorer
graphql: ( enabled: true, graphiql: true, introspection: Some(true) )
With graphiql on, opening the endpoint in a browser (any Accept: text/html
request) serves the GraphiQL IDE, which posts queries back to the same URL. Pair it with
introspection: Some(true) so the explorer can load your schema. It’s a developer
convenience — leave it (and introspection) off in production.
GraphQL from your database (no resolver code)
boatramp already runs your site’s database as a managed workload, so it can also expose it as a GraphQL API directly — no handler, no resolvers. Turn on the data connector and name what to expose:
graphql: (
enabled: true,
data: (
enabled: true,
tables: {
"users": (
columns: ["id", "name"],
// Row-level isolation: only rows whose `tenant` equals the request's
// host-asserted `project` claim are visible.
row_filter: [( column: "tenant", claim: "project" )],
),
},
),
)
boatramp introspects the database, generates the schema (an object type per table plus
users, users_by_pk, and where/order_by/limit/offset arguments), and answers
each query by compiling it to one parameterized SQL statement. It is a compiler, not
an execution engine: a query it can’t lower is rejected, never run partially — the database
does the executing.
Two guarantees make this safe to point at real data:
- Deny-by-default. Only the tables and columns you list are exposed; everything else is invisible and unqueryable. Selecting an unexposed column is an error, not a leak.
- Fail-closed row isolation. A table’s
row_filteris applied to every access, its value bound from a verified claim (e.g.project). If the claim is absent the request is denied — a missing claim never widens access.
Every value is a bound parameter (injection-safe), and every identifier comes only from the introspected, exposed schema. It’s off by default; managed libsql is supported today.
Multi-tenant SaaS: isolate by a claim from your own app token
By default a row_filter binds the host-asserted project claim — good for a project-per-tenant
model. For a SaaS that keeps many tenants as rows inside one project (the Shopify shape),
isolate by a claim from your app’s own bearer token instead. Point the connector at your
IdP’s issuer + JWKS, and bind the tenant claim your tokens carry:
data: (
enabled: true,
// Verify the caller's `Authorization: Bearer` against your IdP; its claims become
// bindable. Map the app's public JWKS into a host env var (like a secret), or give a URL.
claims_from_token: ( issuer: "https://console.acme.com", jwks_env: "APP_JWKS" ),
tables: {
"portfolio_item": (
columns: ["id", "title", "tenant_id"],
row_filter: [( column: "tenant_id", claim: "tid" )], // `tid` from the verified token
),
},
)
The claim value is used only from a fully verified token — signature (RSA / EC / Ed25519,
pinned to the JWKS key, never the token’s alg), iss, exp/nbf, and a resolvable kid.
A missing, expired, forged, or wrong-issuer token contributes no claim, so the filter denies
(never “all rows”). The host-asserted project still applies and a token can never override
it, so app-token isolation nests inside the project boundary. The same claim isolates at
every depth (through relationships) and on every write (an insert is forced to carry the
tenant), so there is nothing to miss. This is exactly what makes it safe to expose one shared
database to many tenants with no resolver code.
Browser session auth (httpOnly cookie)
A browser SPA can authenticate with the app token entirely out of JavaScript — the token
stays in an HttpOnly cookie the JS can’t read (XSS-safe). Opt the site in with cookie_auth
and boatramp treats the named cookie as the bearer wherever the bearer already flows, including
the data connector’s claims_from_token check above: a verified cookie isolates a SaaS tenant
by its token claim exactly as a header bearer would, with no token ever exposed to JavaScript.
This is a general handler auth method, not GraphQL-specific — see
Authenticate a browser with a session cookie
for the config, the cookie attributes it requires (HttpOnly; Secure; SameSite=Lax; __Host-),
and the CSRF model.
Relationships. Foreign keys become relationship fields — a to-one field for each outgoing FK and a to-many field for the rows that reference this one. A nested query resolves in one SQL statement (relationships compile to correlated JSON subqueries), so there is no N+1, and the row filter applies inside each relationship too — a nested row a tenant shouldn’t see stays hidden.
Mutations are opt-in:
graphql: ( enabled: true, data: ( enabled: true, mutations: true, tables: { … } ) )
You get insert_<table>, update_<table>, and delete_<table>, each returning
{ affected_rows }. Writes run in a transaction, use only exposed columns, and the row
filter is enforced on every write: an inserted row is forced to belong to the tenant, and an
update/delete only touches the tenant’s rows. An unbounded update/delete (no where) is
refused.
A wasm-resolved field. A field can be served by a wasm function instead of a column, listed per table:
"users": ( columns: ["id", "name"], resolvers: { "recommendations": "recommender" } )
The connector resolves the row’s columns from SQL, then fills the delegated field with a
single batched invoke to the function (a local _entities fetch, joined by key — no N+1).
The map is also the allowlist: only these fields delegate, only to these functions. This is
GraphQL→SQL and GraphQL→Wasi blended at field grain; the coarser form is a SQL source acting
as a federation subgraph composed with wasm subgraphs (see Federation).
The query-guard
With the guard on, an incoming GraphQL operation is parsed at the edge and rejected before your handler runs when it:
- exceeds
max_depth(deepest selection nesting, with fragments expanded so a query can’t hide depth behind a fragment), or - exceeds
max_complexity(total field count — a schema-free cost proxy), or - is a schema-introspection query (
__schema/__type) andintrospectionis not allowed.
This is defense-in-depth over the per-handler fuel cap against the deep/wide
query denial-of-service class the fuel cap can’t fully catch. A rejection is a
GraphQL-shaped 400.
Every query-bearing POST is inspected — the body is buffered up to a 1 MiB edge
cap regardless of its declared length, so a chunked or oversized request can’t
slip past the guard by omitting or misstating Content-Length. A GraphQL request
is small; a query body over the cap is refused with a GraphQL-shaped 413 rather
than passed through. Only an upload/form POST (multipart/form-data,
application/x-www-form-urlencoded), which carries no query the edge parses, passes
through untouched.
Persisted queries + safelist
graphql: ( enabled: true, persisted_queries: true ) // or: safelist: true
With persisted_queries, a client may send a query hash
(extensions.persistedQuery.sha256Hash) instead of the full query. The edge
resolves the hash to the stored query and hands the full query to your handler.
On a first miss it returns PersistedQueryNotFound; the client re-sends the
query alongside the hash and the edge registers it (after verifying the hash).
safelist mode is stronger: only pre-registered hashes run and the edge
never registers a new one — persisted queries become a query allowlist, a
real security control.
The safelist (managing the allowlist)
Curate the project’s trusted operations with the safelist endpoint. Registering an operation returns its hash (validated for parse + depth/complexity first, so a bad operation is rejected here, not at run time):
# Register a trusted operation (returns { "hash": "<sha256>" })
curl -X POST https://api.example.com/api/projects/acme/graphql/safelist \
-H 'content-type: application/json' \
-d '{"query": "{ me { name } }"}'
curl https://api.example.com/api/projects/acme/graphql/safelist # list
curl -X DELETE https://api.example.com/api/projects/acme/graphql/safelist/<hash> # remove
Run the supergraph from a guest
A function may run a GraphQL operation against the project’s composed supergraph
in-process (cross-subgraph planning, no network hop) through the host’s graphql
capability — a WIT interface a guest imports, exposing run(query, variables) (the full
operation) and run-persisted(hash, variables) (by safelist hash). It is how a guest
operates the unified API without a network round-trip. Guest runs are deny-by-default:
only safelisted operations run (register them above — run hashes the supplied query and
checks the same allowlist), the function forwards its own bearer (re-verified by each
subgraph — no escalation), and the run dispatches at the guest’s own call depth against the
shared in-process cap (so a run → subgraph-fetch → run chain can’t loop). Grant it by
importing graphql (and allowing it in the site’s allow_imports).
Subscriptions
A GraphQL subscription operation sent to a graphql-enabled site is served as a
graphql-sse event stream (the “distinct connections” mode). The subscription’s
single root field names a messaging topic; a producer — a mutation handler, a
function, a consumer — publishes each event to that topic (via the messaging
binding), and each is delivered to the client as a graphql-sse next event, with
Last-Event-ID resume and a heartbeat, bounded by the site’s stream connection caps.
subscription { messageAdded { id body } } # streams the "messageAdded" topic
Because the frames are standard graphql-sse, a normal GraphQL client (Apollo Client,
urql, or the graphql-sse library) consumes the subscription directly.
The host only fans out — it does not execute the subscription. The payload your
producer publishes to the topic is delivered verbatim as the next event’s data, so
publish the execution result for each event — the JSON your resolver would return,
e.g. {"data": {"messageAdded": {"id": "1", "body": "hi"}}}. No handler component
runs per event.
Federation
For a multi-team schema, run several subgraph handlers and let boatramp compose them into one supergraph.
Register subgraphs
Publish each subgraph’s SDL to the project registry:
curl -X PUT --data-binary @accounts.graphql \
https://api.example.com/api/projects/acme/graphql/subgraphs/accounts
curl -X PUT --data-binary @reviews.graphql \
https://api.example.com/api/projects/acme/graphql/subgraphs/reviews
Each publish recomposes the whole supergraph and rejects the change if it
does not compose (a field co-owned without @shareable, or SDL that does not
parse) — a bad publish never corrupts the registry. Read the composed
supergraph with GET /api/projects/acme/graphql/supergraph.
A function subgraph registers itself — no hand-written SDL, and often no separate call at all:
-
Zero-touch. A component that self-declares a subgraph — a
"subgraph": trueentry in itsboatramp:function-manifestcustom section (any guest toolchain that emits the marker qualifies) — is auto-registered on deploy: boatramp reads the marker from the uploaded component, introspects the pending version’s_service { sdl }, and publishes it. Justboatramp deploy(orPUT /api/functions/accounts) — the subgraph joins the supergraph automatically. -
Explicit. For a hand-written subgraph (or to register out-of-band), call it directly — boatramp introspects the deployed function and publishes:
curl -X PUT https://api.example.com/api/projects/acme/graphql/subgraphs/accounts/function
boatramp invokes the function anonymously (the SDL is public), publishes the returned SDL
(recomposed + validated like any subgraph), and records it as a function backend. If the
function is not deployed yet the explicit call is a 409; if it does not answer
{ _service { sdl } } (not a federation subgraph) it is a 422; a schema that does not
compose is a 400.
Once a function is a registered subgraph, each later redeploy refreshes its registered
SDL automatically — boatramp introspects the pending version before it goes live and
refuses the deploy (400) if the new schema no longer composes with the rest of the
supergraph, so the composed graph is never left stale or broken. An ordinary, non-subgraph
function deploy is unaffected (no marker, no registry entry → no-op).
For a coordinated migration across several subgraphs (an entity-key change, moving a
field’s ownership) where an intermediate step can’t compose, use the escape hatches:
deploy the new version without touching the registry with
PUT /api/functions/accounts?register_subgraph=false, or unregister the subgraph first
with DELETE /api/projects/acme/graphql/subgraphs/accounts, then re-register when the set
composes again.
A subgraph can also be SQL-backed — the data connector
acting as a federation subgraph. Register it by naming a site’s managed database and
what to expose; boatramp introspects the database and generates the @key SDL for you
(no hand-written SDL):
curl -X PUT https://api.example.com/api/projects/acme/graphql/subgraphs/accounts/sql \
-H 'content-type: application/json' \
-d '{"site": "accounts", "config": {"enabled": true, "tables": {"users": {"columns": ["id", "name"]}}}}'
The gateway then resolves that subgraph’s fetches by compiling to SQL — both its root
fields and its _entities fetches (a keyed SELECT), so a SQL source is a full
federation citizen, composable with wasm subgraphs.
The gateway
Mark a site as the gateway:
graphql: ( enabled: true, federated: true )
A query to that site is planned against the registered subgraphs — root
fields are grouped by their owning subgraph, and a field owned by another
subgraph on a @key entity becomes a dependent _entities fetch joined on the
entity key — and executed by dispatching each fetch to its subgraph
function over the in-process invoke path (no network hop), stitching the results
by key. A subgraph named accounts is invoked as the function named accounts.
The registry (the SDL) and the deployed subgraph function are separate: if you
register a subgraph’s SDL but never deploy a function of that name, a query that
routes to it fails with an explicit subgraph \accounts` is registered but no
function named `accounts` is deployed` error rather than a silently-wrong result.
Deploy each registered subgraph as a function of the same name.
The subgraph contract
A boatramp subgraph is just a function whose handler is a federation subgraph — it must expose the standard federation contract:
Query._service { sdl }returning its SDL, andQuery._entities(representations: [_Any!]!): [_Entity]!resolving entities by their@key.
You do not write these by hand: a GraphQL library with federation support
provides them. With async-graphql, derive your entity types and mark their
key with #[graphql(entity)] resolvers; the library generates _service and
_entities. boatramp’s gateway then speaks exactly this contract to your
subgraph — the schema semantics stay in your code.
Scope. Core federation —
@keyentities,@external/@shareable, root and entity fetches — is supported. The exotic Federation v2 corners (@interfaceObject, progressive@override, deep@requireschains) are not yet planned; a query that needs them will not compose or plan.