Github|...

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:

ComponentRule
TableExact.
Projected columnsExact set; order does not matter.
Related subqueriesExact: the alias, the parent key, and the subquery’s own shape.
ORDER BYExact 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 / STARTMasked. 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.

sp00ky.yml
sync:
  queryAllowlist: warn                  # off (default) | warn | enforce

clientTypes:
  - format: typescript
    output: ./src/schema.gen.ts
    queries: ./src/lib/query.ts         # the app's q* query module
    # allowlistOutput: ./src/lib/query.ts.allowlist.json   # default
    # app: web                          # default: the frontend app in apps:
KeyWhat it does
sync.queryAllowlistoff (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[].queriesPath to the app’s query module, relative to sp00ky.yml. Only for format: typescript. When set, spky generate records the allowlist from it.
clientTypes[].allowlistOutputWhere the JSON goes. Defaults to <queries>.allowlist.json next to the module. Commit it: deploy reads it, it does not regenerate.
clientTypes[].appApp 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.

src/lib/query.ts
import type { Db } from './db';

// Every exported q* function is recorded by spky generate.
export const qThreads = (db: Db, limit: number) =>
  db.query('thread')
    .related('author')
    .orderBy('created_at', 'desc')
    .orderBy('id', 'asc')
    .limit(limit)
    .build();

// The argument reaches .where(), so it is recorded as a $param.
export const qThread = (db: Db, id: string) =>
  db.query('thread').where({ id }).limit(1).build();

// The whole predicate comes from the caller: recorded as whereMode "any".
export const qGamesWindow = (db: Db, where: GameWhere, limit: number) =>
  db.query('game').where(where).orderBy('sort_index', 'asc').limit(limit).build();

// r* reads are never allowlisted: they are not synced views.
export const rGameCount = (db: Db) =>
  db.remoteQuery('SELECT count() FROM game GROUP ALL', {});

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:

src/lib/query.ts
// A builder that picks its predicate at runtime records only the branch the
// proxy argument takes. Give the generator real arguments for the others.
export const allowlistSamples = {
  qGames: [
    [{ collections: [] }],
    [{ collections: ['c1', 'c2'] }],
  ],
};

// Match any builder-grammar predicate on this shape.
export const allowlistWhereAny = ['qSearch'];

// Do not run these at all.
export const allowlistSkip = ['qLegacyExport'];
ExportPurpose
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.

spky generate
#   ...
#   Query allowlist: src/lib/query.ts -> src/lib/query.ts.allowlist.json

git add src/lib/query.ts.allowlist.json

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.

src/lib/query.ts.allowlist.json
{
  "generator": 1,
  "app": "web",
  "sourceHash": "3f1c…",
  "entries": [
    {
      "name": "qGamesWindow",
      "surql": "SELECT * FROM game ORDER BY sort_index asc LIMIT 1;",
      "whereMode": "any"
    },
    {
      "name": "qThread",
      "surql": "SELECT * FROM thread WHERE id = $id LIMIT 1;",
      "whereMode": "static"
    }
  ]
}

Three commands publish it to the database as root-only _00_query_allowlist rows, keyed <app>__<version>, which are never synced to clients:

CommandVersionNotes
spky deploythe frontend’s package.json versionRuns after the schema is applied, before the new frontend is live.
spky release <app>the announced versionPublishes the allowlist together with the release row, no restart needed.
spky devdevOne 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.

Warning

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.

src/db.ts
import { SyncedDb } from '@spooky-sync/client-solid';
import { schema, SURQL_SCHEMA } from './schema.gen';

export const db = new SyncedDb<typeof schema>({
  schema,                    // schema.policy.queryAllowlist mirrors sync.queryAllowlist
  schemaSurql: SURQL_SCHEMA,
  database: { endpoint: 'ws://localhost:8666/rpc', namespace: 'main', database: 'main' },
  logLevel: 'info',
  // Only if you really need raw remote reads while the allowlist is on:
  // allowRawRemote: true,
});

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.

Note

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

  1. Switch on warn

    Set sync.queryAllowlist: warn and point clientTypes[].queries at the query module. Nothing is refused yet.

  2. Generate and commit

    Run spky generate, fix any q* export the generator could not record (or list it in allowlistSkip), and commit the .allowlist.json.

  3. Deploy and watch for misses

    spky deploy. Every shape the generator did not see now shows up as an allowlist miss (warn mode): admitted line in the SSP logs (spky logs --grep 'allowlist miss') and in GET /info under query_allowlist.last_refused, with the SurrealQL and the reason.

  4. Cover the misses

    Add allowlistSamples for branchy builders, allowlistWhereAny for caller-supplied predicates, then spky generate again and spky release <app> to publish the new list without a restart. Repeat until counters.warned stops climbing.

  5. Enforce

    Set sync.queryAllowlist: enforce, regenerate the client (schema.policy changes), 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:

{
  "query_allowlist": {
    "mode": "enforce",
    "loaded_at_epoch_ms": 1757923200000,
    "entries": 42,
    "sources": [
      { "app": "web", "version": "1.4.0", "released_at": "2026-09-15T08:00:00Z", "entries": 42 },
      { "app": "web", "version": "1.3.2", "released_at": "2026-09-10T15:12:00Z", "entries": 40 }
    ],
    "skipped": [["qLegacyExport#0", "unknown table legacy"]],
    "counters": {
      "checked": 1180, "allowed_static": 1102, "allowed_any": 61,
      "allowed_builtin": 14, "warned": 0, "refused": 3
    },
    "last_refused": [
      {
        "at_epoch_ms": 1757923400000,
        "table": "game",
        "surql": "SELECT id, pgn FROM game WHERE white = $w;",
        "reason": "shape of query on game is not in the allowlist (42 entries from 2 releases)"
      }
    ]
  }
}

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

SymptomCause and fix
query allowlist ... not found; run spky generate and commit it on deployThe JSON is not committed, or allowlistOutput points somewhere else than spky generate wrote. Generate, commit, deploy again.
query allowlist ... is stale: ... changed since spky generateThe 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 queriesThe 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* exportThose 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 deployThe 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 schemaExpected: 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 matchesA proxy reached the subquery’s where and was inlined as a literal. Record that builder with allowlistSamples and real values.

Next