Query allowlist
Pin the synced queries the SSP will register to the shapes your app actually ships: generate the allowlist from your query module, roll it out in warn mode, then enforce.
A synced query is a SurrealQL string the client sends to the SSP, which materializes it as a view
and keeps it live. Table PERMISSIONS bound which rows a user can see, but nothing bounds which
queries a token holder may register: any client with a valid session can ask the SSP for an
expensive join, probe a column your app never projects, or hold arbitrary views open.
The query allowlist closes that gap. spky generate runs your app’s query module against a
recording client and writes down every shape the query builder produces; the SSP then refuses (or
logs) a registration whose shape is not on that list. It is opt-in, off by default, and rolls out in
two stages so nothing breaks while you discover the shapes the generator did not see.
Shape, not text
The allowlist is generated by running your builders with placeholder inputs, so what it records
is LIMIT 1 START 1, id = $id, _or branch names like white__or0. A live registration differs
in every one of those without differing in what it computes. The SSP therefore compares a
shape derived from the query plan, with everything a caller can vary at runtime masked:
| Component | Rule |
|---|---|
| Table | Exact. |
| Projected columns | Exact set; order does not matter. |
| Related subqueries | Exact: the alias, the parent key, and the subquery’s own shape. |
ORDER BY | Exact fields and directions, in order. |
WHERE (static entry) | The set of field op leaves and their AND/OR nesting is exact. $param names, literal values and the number of OR branches are masked, so (db = $a OR db = $b) and (db = $a) are one shape. |
WHERE (any entry) | See whereMode: any below. |
LIMIT / START | Masked. Windowed lists page, .one() is LIMIT 1. |
The generator emits raw SurrealQL and never hashes; both the allowlist entries and the incoming registration go through the same normaliser inside the SSP, so there is exactly one definition of “same shape”.
The SDK’s own registrations over _00_app_release and _00_user_feature (release announcements,
feature flags) are built in and always admitted. Raw reads that never become synced views
(useRemote, remoteQuery, queryRaw) are not allowlisted at all; see
Client side.
Enable it
Two keys in sp00ky.yml: the mode, and where the app’s query module lives.
| Key | What it does |
|---|---|
sync.queryAllowlist | off (default): never consulted. warn: decide and count, log a miss as allowlist miss, admit anyway. enforce: refuse a miss with HTTP 403 not_allowlisted. |
clientTypes[].queries | Path to the app’s query module, relative to sp00ky.yml. Only for format: typescript. When set, spky generate records the allowlist from it. |
clientTypes[].allowlistOutput | Where the JSON goes. Defaults to <queries>.allowlist.json next to the module. Commit it: deploy reads it, it does not regenerate. |
clientTypes[].app | App name the allowlist rows are keyed by. Defaults to the frontend app in apps:. |
The mode reaches the SSP as SPKY_SSP_QUERY_ALLOWLIST, set for you by spky dev and by
spky deploy (merged into the ssp role’s infra env). The generated schema.gen.ts carries it as
schema.policy.queryAllowlist so the client SDK can mirror it.
The query module
The generator loads the module and calls every exported function whose name starts with q
as fn(db, ...args), where db is a recording client and each argument is a proxy that survives
whatever the function does with it on the way to the builder (stringifies, is 1 in arithmetic,
iterates as a one-element array, returns nested proxies for any property). A value that reaches
.where() binds as a plain $param; one that reaches .limit() or .offset() renders as 1.
A db.query(<proxy>) whose table is chosen at runtime fans out to one entry per schema table,
named qName[<table>]. db.useRemote, db.preload and the mutation members throw on the
recording client, so an r* remote read misfiled as a q* fails the run instead of silently
vanishing from the allowlist.
whereMode: any
A builder whose whole where comes from a caller argument, qGamesWindow above, has no
enumerable predicate. The generator records it as whereMode: "any" and the SSP matches such an
entry on its skeleton (the shape with the root WHERE removed), then admits the actual
predicate only if it is builder grammar: field op $param leaves joined by AND/OR, every field a
column of the scanned table, at most 64 comparisons, nested at most 4 deep. That keeps the probing
surface at “what the builder can express over the table’s own columns”, which permissions already
bound row-wise.
Sidecar exports
Three optional exports in the query module steer the generator:
| Export | Purpose |
|---|---|
allowlistSamples: Record<string, unknown[][]> | Extra invocations with real arguments, one entry per argument array, recorded as static entries named qName#<i>. Use it for a builder that picks a predicate at runtime (opts.collections.length ? _or : owner): a proxy takes exactly one branch (arrays are length 1, strings are truthy). Also the fix for a proxy reaching a subquery where, which is inlined as the literal proxy:<path> and can never match. |
allowlistWhereAny: string[] | Force whereMode: "any" for those exports. |
allowlistSkip: string[] | Do not run those exports at all. |
Entries are deduplicated by SurrealQL text (the alphabetically first name wins; if any duplicate is
any, the kept entry is any) and sorted by name.
Generate and publish
spky generate runs the TypeScript codegen for the entry, then hands the query module, the fresh
schema.gen.ts and the app name to spooky-query-allowlist, the bin of
@spooky-sync/query-allowlist. It resolves the app’s own node_modules/.bin/spooky-query-allowlist
by walking up from the query module, and falls back to npx -y @spooky-sync/query-allowlist@<cli version>.
Add the package as a devDependency of the app to skip the npx round trip.
The output is plain JSON; sourceHash is a sha256 of the query module bytes and is what deploy
compares to decide whether the file is stale.
Three commands publish it to the database as root-only _00_query_allowlist rows, keyed
<app>__<version>, which are never synced to clients:
| Command | Version | Notes |
|---|---|---|
spky deploy | the frontend’s package.json version | Runs after the schema is applied, before the new frontend is live. |
spky release <app> | the announced version | Publishes the allowlist together with the release row, no restart needed. |
spky dev | dev | One row, overwritten on every start. |
Each app keeps its current and previous version, so a rollout never refuses the release still open in someone’s tab; older rows are deleted. The SSP loads the table at every Ready transition and again whenever a row changes (it receives the table’s ingest notification), so a new release takes effect without an SSP restart. A miss also triggers one reload, throttled to once per 5 s, so an SSP that missed the notification during a deploy still picks the new release up on the first miss.
Under enforce, spky deploy and spky release fail when the
JSON is missing or its sourceHash no longer matches the query module. Deploying it
would refuse every query of the new release. Under warn the same conditions print a
warning and continue.
Client side
The generated schema constant carries policy: { queryAllowlist: '<mode>' }. When it is warn
or enforce, db.useRemote, db.remoteQuery and db.queryRaw throw unless the client config
sets allowRawRemote: true: a hand-written SurrealQL string bypasses the builder, so it can never
be on the allowlist, and the SDK refuses it up front rather than letting the server do so later.
A registration the SSP refuses is logged at error level as query refused: not allowlisted,
with the SurrealQL, the parameter names and a hint. The view goes to remote-failed without
retries (retrying a shape the server will refuse again is noise); self-heal re-tries it after the
next deploy or release, when the allowlist may have changed.
This gate is client-side only. Direct SurrealDB reads under table PERMISSIONS are
unchanged by the allowlist; it governs what the SSP will materialize as a synced view.
Rollout recipe
- Switch on warn
Set
sync.queryAllowlist: warnand pointclientTypes[].queriesat the query module. Nothing is refused yet. - Generate and commit
Run
spky generate, fix anyq*export the generator could not record (or list it inallowlistSkip), and commit the.allowlist.json. - Deploy and watch for misses
spky deploy. Every shape the generator did not see now shows up as anallowlist miss (warn mode): admittedline in the SSP logs (spky logs --grep 'allowlist miss') and inGET /infounderquery_allowlist.last_refused, with the SurrealQL and the reason. - Cover the misses
Add
allowlistSamplesfor branchy builders,allowlistWhereAnyfor caller-supplied predicates, thenspky generateagain andspky release <app>to publish the new list without a restart. Repeat untilcounters.warnedstops climbing. - Enforce
Set
sync.queryAllowlist: enforce, regenerate the client (schema.policychanges), and deploy. From here a miss is a 403 and deploy refuses a stale allowlist.
Observability
GET /info on any SSP reports the loaded list and what the gate decided so far:
sources is one entry per _00_query_allowlist row that was loaded; skipped lists entries whose
SurrealQL the SSP could not compile, with the error; last_refused keeps the 20 most recent
misses (in warn mode too). The scheduler relays the SSP’s verdict to the client with its own
status, so a 403 reaches the client as a 403, not as a 500 that reads as an outage.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
query allowlist ... not found; run spky generate and commit it on deploy | The JSON is not committed, or allowlistOutput points somewhere else than spky generate wrote. Generate, commit, deploy again. |
query allowlist ... is stale: ... changed since spky generate | The query module changed after the last generate. Run spky generate and commit the new JSON. Under warn this is only a warning. |
sync.queryAllowlist is 'enforce' but no clientTypes entry declares queries | The mode is on but nothing feeds it; every registration except the built-ins will be refused. Add queries to the TypeScript clientTypes entry. |
Failed to execute spooky-query-allowlist (is Node installed ...) | The generator is a Node bin. Install Node, and add @spooky-sync/query-allowlist as a devDependency of the app so the local bin is found. |
Generator exits 1 with one line per q* export | Those builders threw when called with proxy arguments (a real network call, a switch over a proxy). Give them allowlistSamples, or list them in allowlistSkip if they are not synced views. |
| A view is refused right after a deploy | The SSP had not reloaded yet; the first miss triggers a reload (throttled to 5 s) and self-heal re-registers. If it persists, check /info.query_allowlist.sources for the version you just shipped. |
useRemote is disabled because sync.queryAllowlist is on for this schema | Expected: raw remote reads are gated client-side. Pass allowRawRemote: true in the client config, or express the read with the builder so it can be allowlisted. |
A related subquery entry never matches | A proxy reached the subquery’s where and was inlined as a literal. Record that builder with allowlistSamples and real values. |