Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

FeatureWhat it does
Query-guardReject over-deep/complex or introspection queries at the edge.
Persisted queries + safelistSend a query hash instead of the full query; or lock serving to a pre-registered allowlist.
GraphiQL explorerServe the in-browser IDE to a browser GET.
Data connectorServe a GraphQL API generated from a managed database — no resolver code.
SubscriptionsServe a subscription as a graphql-sse event stream off a messaging topic.
FederationCompose several subgraph handlers into one supergraph gateway.
Guest supergraph runsLet a handler run a supergraph operation in-process.
Cookie session authAuthenticate 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_filter is 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.

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) and introspection is 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": true entry in its boatramp:function-manifest custom 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. Just boatramp deploy (or PUT /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, and
  • Query._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 — @key entities, @external/@shareable, root and entity fetches — is supported. The exotic Federation v2 corners (@interfaceObject, progressive @override, deep @requires chains) are not yet planned; a query that needs them will not compose or plan.