quest'n'click 4.5.0 · r1

Built for quest'n'click 4.5.0, revision r1. Whether it is published, and which one is current, is recorded at publication.json.

Install and run the Bridge

Français · Type: how-to · Audience: admin · Context: Reality Bridge · Status: ready · Applies from: 4.3.0

The Reality Bridge, local profile (SQLite) · contract: stable · verified by CI (support matrix of 4.4.0, 2026-10-23): Node 22.13+ with node:sqlite, on Ubuntu; Chromium and WebKit end to end · automated checks: jobs reality, reality-xcheck; CI plan reality; 2 test files · people's passes: none · known limits: bridge-init-force, bridge-sql-no-retention, bridge-worker-not-a-sandbox

The Reality Bridge, distributed profile (Postgres) · contract: experimental · verified by CI (support matrix of 4.4.0, 2026-10-23): Postgres 16 in CI; The qualification profile: HTTPS, three instances (ops/qualify/) · automated checks: jobs bridge-postgres, deployment-smoke; CI plan reality; 1 test file · people's passes: bridge-postgres-https not run (#77) · known limits: bridge-init-force, bridge-sql-no-retention, bridge-worker-not-a-sandbox

This page takes you from nothing to a Bridge your players reach over HTTPS, then describes the player's routes, the stores, retention, the reference deployment and the speedrun leaderboards it can also serve. Every command and option: The Bridge's command line.

Overview

The reference Reality Bridge (bridge/src/, the package quest-n-click-bridge) serves one game. It is a small Node service: pairing, connectors under Biscuit capabilities, a demonstration webhook, signed events, Server-Sent Events. Since 4.1.10 it keeps its state in a store (the 4.1.9 JSON-lines journal, SQLite, or Postgres), and one deployment may run several instances and serve several tenants. What a Bridge is, for authors: The Reality Bridge: what it is and Signals and the manifest. Why it is built this way: docs/dev/THREAT-MODEL.md and docs/dev/threat-models/constellation.md.

What you get

A serve process listening on 127.0.0.1:8787 behind your HTTPS reverse proxy, holding one game's keys and configuration in a private folder, keeping its state in a store (SQLite recommended), answering /readyz, and ready for each connector you grant a token to. The game's reality.bridge points at its public URL.

Context and versions

Prerequisites

Where to run the commands

On the machine that runs the Bridge, in one of three places:

WhereThe commandThe game's manifest comes from
The engine repositorynpm run bridge -- <command> (with GAME=<id> for a game other than the current one)the game's content
A created projectnpx quest-n-click bridge <command>, once quest-n-click-bridge is installed beside the enginethe game's content
The standalone package, on a servernpx quest-n-click-bridge <command>init --manifest=<game>/dist/reality-manifest.json

The package quest-n-click-bridge is the quest-n-click-bridge-<version>.tgz archive a release attaches: one JavaScript module, its Datalog policies and its SQL schema. It needs Biscuit's WebAssembly and zod; pg only for Postgres, @opentelemetry/api only for the measures. The examples below use the engine repository's form.

Steps

  1. On a server, install the package from the release's archive (in the engine repository or a created project, skip this step):

    # on the server, in the Bridge's folder (for example /srv/bridge)
    npm install ./quest-n-click-bridge-<version>.tgz
    

Start one

  1. Make the keys and the configuration. On the Internet, leave the demonstration webhooks out and keep the state in SQLite:

    # in the engine repository
    npm run bridge -- init --store=sqlite --no-demo-webhooks --audience=bridge.example.org --origin=https://game.example.org
    

    init reads the game's manifest and writes, under --dir (.cache/bridge by default), with mode 600:

    • config.json: the manifest and its hash, the Biscuit root's public key, the event-signing key (Ed25519, with a kid), the hash of the operator's token, the store, and one demonstration webhook per source unless --no-demo-webhooks (its secret and its own connector token: any player of that source's signals, may confirm pairings, for 30 days or --demo-days);
    • root.key: the Biscuit root's private half, read by grant only. serve never loads it: keep it where the Bridge is not, if you can;
    • admin-token: the operator's token, the only copy in clear.

    Nothing secret is printed. .cache/ is ignored by git. Without --store, init still writes the JSON-lines journal's configuration, for compatibility.

Limit · bridge-init-force init refuses to overwrite an existing config.json. --force replaces it, and every key and token with it: every connector needs a new token and every player a new pairing.

  1. Start it on the loopback:

    # in the engine repository
    npm run bridge -- serve --port=8787 --host=127.0.0.1 --trust-proxy
    

    It logs {"event":"bridge.listening",…,"store":"sqlite","pid":…} and keeps running. SIGTERM or Ctrl-C stops it: the streams end, the server closes, the store and its locks are released, exit 0. Run it under a supervisor (systemd, a container's restart policy).

  1. Put it behind HTTPS. The reverse proxy terminates TLS, forwards to 127.0.0.1:8787 and does not buffer text/event-stream. --trust-proxy makes the per-address limits see the client's address (X-Forwarded-For):

    • --trust-proxy alone trusts the loopback only;
    • --trust-proxy=10.0.0.0/8,192.0.2.7 names the proxies (addresses or IPv4 networks): the header is read only from them, and the client is its rightmost address that is not one of them.

    A proxy elsewhere than on the loopback (a PaaS's router) must be named: without it, every client shares the proxy's address and its one rate bucket. --origin (at init) lists the game's site for CORS; the player's routes answer it only.

  1. Set the game's reality.bridge to the Bridge's public URL (https://bridge.example.org/), then build and publish the game (Signals and the manifest).
  1. Give each connector its own token, as narrow as its job, and hand it over as a file:

    # in the engine repository
    npm run bridge -- grant --connector=mail-1 --source=mail --signals=mail.answer.correct,mail.answer.wrong --pair > mail-1.token
    

    Then run the connector (Connectors overview).

Expected result, and how to check it

To check a pairing, read the log. Never call GET /v1/pairings/<code>: that is the player's one-time claim, and calling it hands out, and uses up, the player's capability.

Common errors

MessageCauseFix
✖ the game declares no reality.signals: nothing to bridgeThe current game has no reality, or the standalone init has no --manifestPick the game (GAME=<id>), or pass --manifest=<game>/dist/reality-manifest.json
✖ …/config.json exists (--force replaces it, and every key and token with it)init run twiceKeep the existing one; --force only to start over
the game's manifest (…) is not the one configured (…) at serveThe game's signals changed since initA new init in a new folder for the new manifest, then new tokens and pairings
limits.<name> must be an integer of at least <n>, not <value>, or limits.<name> is no limit of the Bridge (…)A wrong value in config.json's limits (checked from 4.3.0)Fix the value, or remove it to keep the default (CLI)
journal in use by process <pid> (…lock): one Bridge per journalAnother Bridge (or compact) holds the journalStop it; if that process id now belongs to an unrelated process after a crash, look, then delete the lock
✖ … : not servingA restore or a migration down holds the store (a maintenance lease)Wait for it to end
✖ the leaderboards and the daily challenge keep their records in SQL: …A runs or daily section on the journalMove to SQLite (Stores)
✖ the Reality Bridge is a separate package: npm install quest-n-click-bridgeIn a created project, the package is missingInstall it beside the engine

Files or data changed

Recovery or going back

The player's side

The routes the game calls (CORS for the --origin sites only):

The connectors' routes (POST /v1/signals, POST /v1/pairings/<code>/confirm, POST /v1/hooks/<source>): Connectors overview.

Stores and profiles

ProfileStoreFor
local (stable)SQLite (node:sqlite, Node 22.13+, one file, WAL)one machine, one or a few processes
(4.1.9)the JSON-lines journala configuration written before 4.1.10, or init without --store; one process
distributed (experimental)Postgres (pg)several instances behind a load balancer

Retention, export and deletion

Limit · bridge-sql-no-retention A SQL store keeps every signal: compact is the journal's command and refuses a SQL store. Deleting a player removes its rows from the store, not from backup files already written: those are yours to expire.

Deleting a player's data everywhere (the Bridge, a mailbox, an SSH configuration, the connectors' memory, the device): Privacy and The Bridge's command line.

The reference deployment (4.1.18)

ops/qualify/ is the distributed profile as a reproducible deployment: an HTTPS proxy (Caddy), three Bridges built from the quest-n-click-bridge archive, one Postgres, two tenants, every image pinned by digest, the Bridges read-only and unprivileged, the database password and URL as secret files (BRIDGE_STORE_FILE), Postgres never published. npm run ops:qualify (in the engine repository, with Docker) brings it up, kills an instance during proposals, holds a stream on another, checks the tenants apart, then backs up, destroys the topology with its database and restores it (ops/qualify/README.md). The distributed profile stays experimental until a person's pass behind a real domain (24 to 48 hours) has passed.

Leaderboards and the daily challenge (4.1.16)

A configuration's runs section mounts /v1/runs; its daily section mounts /v1/daily, /v1/commit and /v1/reveal/<id>. Both keep their records in the SQL store (--store=sqlite:<file> or BRIDGE_STORE=postgres://…; serve refuses them on the journal), migrations 0002 to 0004. What they do for players and authors (submitting a run, the leaderboard's ranking, the daily seed, Mystery worlds): Speedrun for authors.

"runs": {
  "games": { "reference": { "dir": "/srv/games/reference", "fingerprint": { "logic": "…", "trustedExtensions": "…", "presentation": "…", "engine": "…" } } },
  "worker": ["/usr/bin/node", "/srv/quest-n-click/node_modules/tsx/dist/cli.mjs", "/srv/quest-n-click/tools/speedrun/worker.ts"],
  "workers": 2
},
"daily": { "games": { "reference": { "daily": "daily", "mystery": "mystery" } }, "kid": "daily-2026", "keyFile": "daily.jwk", "secretFile": "daily.secret" }

The speedrun worker

Each run is verified by a worker process the Bridge starts (the worker command). The worker's in-process refusal of the network is defence in depth, not a sandbox: a replay runs the game's code, and a hostile package or run could still reach the file system, start a process or open a socket another way. Run the worker command inside the deployment's isolation, for instance:

docker run --rm -i --network none --read-only --tmpfs /tmp:rw,size=64m --cap-drop ALL --security-opt no-new-privileges \
  --pids-limit 64 --memory 512m --memory-swap 512m --cpus 1 --user 65534:65534 -v /srv/games:/srv/games:ro web-scumm-worker \
  timeout -s KILL 70 node tools/speedrun/worker.ts

as the worker command: no network namespace, a read-only root, a bounded temporary folder, no capability, no secret of the Bridge in its environment (the Bridge passes PATH, GAME_DIR and NODE_OPTIONS only), limits on CPU, memory (no swap beyond it), processes and time (the Bridge kills the process group at timeoutMs + 5 s). Its standard output is capped at 1 MB and its standard error is not kept. Whatever folder is mounted is readable inside: keep the Bridge's own folder (config.json, its keys and token files) outside the engine folder mounted at /app. Killing the docker client does not stop its container: timeout -s KILL inside it (a little over the Bridge's timeoutMs) is what ends a replay that runs too long, so timed-out containers never pile up.

Since 4.1.17 this profile is one function (tools/speedrun/container.mjs, workerContainerArgs):

# in the engine repository
node tools/speedrun/container.mjs --image=<image> --app=<engine folder> [--games=<folder>]

prints the worker command, with no network unless --allow-network (which says so on standard error). CI's worker-container job (npm run e2e:worker-container, Linux and Docker) runs the reference run inside it, then a hostile script: HTTP, DNS and TCP refused, nothing written outside /tmp, the host's secrets unseen, a child process allowed but without a network, the container killed by its own timeout with nothing left running; then a run again.

Limit · bridge-worker-not-a-sandbox The worker refuses fetch, WebSocket, TCP, UDP and DNS in its own process, as a second line of defence. It is not a sandbox: the isolation is the container (or equivalent) you run it in.

Moderation

runs.adminTokenFile names the file of the token that moderates a tenant's leaderboards (POST /v1/runs/<id>/moderate, Authorization: Bearer …): beside config.json, mode 0600, at least 32 characters. serve refuses to start when it is missing, shorter, or open to others (on POSIX systems; on Windows the folder's ACL decides). Without it there is no moderation route (404, nothing counted). The token is compared as hashes of equal length (its length says nothing either); every refusal is an audit line (run.moderation-refused, never the bearer), and /healthz counts moderations accepted and refused.

References


Edit the source of this page · Report an error