DocsBuilding a backend
Exposing a Verify Endpoint
A stateless HTTP route that recomputes a result from seeds and a nonce, with the request shape, the input bounds and the rate limits Galabet's demo API uses, and a plain Node version you can run.
A verify endpoint takes a server seed, a client seed, a nonce and a game, and answers with the result those inputs produce. It holds no session and reads no database. Anyone can call it with curl.
A player who checks a bet on the operator's own endpoint is asking the accused to mark its own homework. The check that counts is the one run on the player's side, in the browser verifier or with the library, and both are free. So why expose a route at all? It gives integrators and auditors something to diff their own port against, one request at a time. And a published, callable statement of how results are computed is one more thing an operator can be held to. The comment on Galabet's own controller puts it more briefly: the route exists for curl, for integrations, and for people who don't trust their browser.
Request and Response
The demo API has three routes under /api/verify. None of them is deployed, so the paths below are on a local API at http://localhost:3000.
| Route | Body | Answer |
|---|---|---|
POST /api/verify | game, serverSeed, clientSeed, nonce, optional params | result, cursor, floats, commitment, a complete record, and canonical, the record as canonical JSON |
POST /api/verify/record | A whole GFS record | What verifyRecord returns: ok, computed, claimed, commitmentOk, cursorOk, signatureOk, recordHash, reasons |
POST /api/verify/inspect | Anything | The inspectRecord report, or 400 with the library's message |
The first recomputes from raw inputs. The second checks a record somebody was given, commitment included. They are POST so that seeds travel in a body and stay out of URLs, which end up in access logs, browser history and referrer headers.
A Plain Node Version
No framework and no schema library here, so the validation is written out by hand. It enforces the same bounds as the demo's zod schema, which the next section lists.
import { createServer } from 'node:http';
import { commit, isGameName, play } from '@galabet/fair';
const MAX_BODY = 4096;
const PARAMS = { // [min, max, integer]
houseEdge: [0, 0.5, false], segments: [2, 100, true], rows: [8, 16, true],
mines: [1, 24, true], draws: [1, 40, true], decks: [1, 8, true],
};
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
function validate(body) {
if (!isObject(body)) return { issues: ['body: must be a JSON object'] };
const { game, params = {}, serverSeed, clientSeed, nonce, ...unknown } = body;
const issues = Object.keys(unknown).map((key) => `${key}: not allowed`);
if (!isGameName(game)) issues.push('game: not a GFS game');
if (typeof serverSeed !== 'string' || !/^[0-9a-fA-F]{64}$/.test(serverSeed.trim())) issues.push('serverSeed: must be 64 hex characters');
if (typeof clientSeed !== 'string' || clientSeed.length < 1 || clientSeed.length > 64 || clientSeed.includes(':')) {
issues.push('clientSeed: 1 to 64 characters, no ":"');
}
if (!Number.isInteger(nonce) || nonce < 0 || nonce > 1_000_000_000) issues.push('nonce: integer from 0 to 1000000000');
if (!isObject(params)) issues.push('params: must be an object');
else for (const [key, value] of Object.entries(params)) {
const [min, max, integer] = PARAMS[key] ?? [];
if (min === undefined) issues.push(`params.${key}: not allowed`);
else if (typeof value !== 'number' || value < min || value > max || (integer && !Number.isInteger(value))) {
issues.push(`params.${key}: ${min} to ${max}`);
}
}
if (issues.length) return { issues };
return { input: { game, params, serverSeed: serverSeed.trim().toLowerCase(), clientSeed, nonce } };
}
const server = createServer(async (req, res) => {
const send = (status, body) => res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body));
if (req.method !== 'POST' || req.url !== '/api/verify') return send(404, { message: 'not found' });
if (Number(req.headers['content-length']) > MAX_BODY) return send(413, { message: 'body too large' });
let raw = '';
for await (const chunk of req) {
raw += chunk;
if (raw.length > MAX_BODY) return send(413, { message: 'body too large' });
}
let body;
try { body = JSON.parse(raw); } catch { return send(400, { message: 'body is not JSON' }); }
const { input, issues } = validate(body);
if (issues) return send(400, { message: 'validation failed', issues });
try {
const { result, cursor, floats } = await play(input);
const { commitment } = await commit(input.serverSeed);
send(200, { result, cursor, floats, commitment });
} catch (error) {
send(400, { message: error.message }); // whatever the library refused that the checks above let through
}
});
await new Promise((ready) => server.listen(0, ready));
const url = `http://localhost:${server.address().port}/api/verify`;
const post = async (body) => {
const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
return `${response.status} ${JSON.stringify(await response.json())}`;
};
const serverSeed = '5C1F7D3E8A2B4C6D9E0F1A2B3C4D5E6F7A8B9C0D1E2F3A4B5C6D7E8F9A0B1C2D';
console.log(await post({ game: 'dice', serverSeed, clientSeed: 'galabet', nonce: 42 }));
console.log(await post({ game: 'dice', serverSeed, clientSeed: 'galabet', nonce: '42', debug: true }));
console.log(await post({ game: 'mines', params: { mine: 5 }, serverSeed, clientSeed: 'galabet', nonce: 42 }));
server.close();
200 {"result":56.12,"cursor":0,"floats":[0.5611712262034416],"commitment":"ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7"}
400 {"message":"validation failed","issues":["debug: not allowed","nonce: integer from 0 to 1000000000"]}
400 {"message":"validation failed","issues":["params.mine: not allowed"]}
The first call sends the public seed in capitals. The handler trims and lowercases it before doing anything else, so the roll is 56.12 and the commitment is the public one. The library itself refuses capitals, in play and in commit alike, with server seed must be 64 lowercase hex characters. Seeds get pasted out of emails and spreadsheets, and an endpoint that is forgiving about case saves a support ticket. The demo's schema does the same.
The second call has two faults and hears about both: a nonce sent as a string, and a field the route doesn't know.
The third is the reason for strict objects. mine is a typo for mines. Hand that straight to play and it ignores the stray key, falls back to three mines and returns [ 9, 17, 22 ], a confident answer to a question nobody asked. Someone checking a five-mine board would conclude the casino lied. A verifier that guesses is worse than one that refuses.
Input Bounds
These are the rules in the demo's verify.dto.ts for POST /api/verify. The object is strict at both levels, so an unknown key in the body or in params fails the request.
| Field | Accepted |
|---|---|
game | One of the nine seed-based names. Crash is a different profile and isn't verified here |
serverSeed | 64 hex characters after trimming, either case, lowercased before use |
clientSeed | 1 to 64 characters, no : |
nonce | Integer from 0 to 1,000,000,000 |
params.houseEdge | 0 to 0.5 |
params.segments | Integer, 2 to 100 |
params.rows | Integer, 8 to 16 |
params.mines | Integer, 1 to 24 |
params.draws | Integer, 1 to 40 |
params.decks | Integer, 1 to 8 |
Several of these bounds exist because play has none. It will take a Limbo houseEdge of -1 and a Wheel with 1,000,000,000 segments and return a number for each, 3.56 and 561171226 on the public inputs. The library puts no ceiling on the nonce either. A public endpoint shouldn't lend its name to results like those, so it sets the limits inspectRecord already applies to records.
The bounds also cap what one request can cost. The most expensive thing the schema allows is an eight-deck Blackjack shuffle, which is 416 cards and 52 HMAC digests. In the plain Node version the body is capped as well, at 4,096 bytes, before any JSON is parsed.
Both routes share one clientSeed rule, colon check included. The record route allows a larger nonce than the first, up to Number.MAX_SAFE_INTEGER against 1,000,000,000, because a record may come from an operator whose counters run longer than the demo's. All three routes wrap their library call in a try, so input the schema lets through and the library refuses still comes back as a 400. The plain Node version below does the same with its last catch.
Nothing Is Stored
The demo's verify controller has no constructor. No Redis client, no database handle, nothing injected. It can't store a submitted seed because it has nowhere to put one.
Keep yours the same, and extend that to logs. Nearly every seed sent to a verify route is one that has been revealed already, but the route can't tell, and sooner or later somebody on the operator's own staff will paste a live seed into it while debugging. If request bodies are logged, that seed is now in the log pipeline with everyone who can read it. The example also answers with cache-control: no-store so that no proxy in between keeps a copy.
The same reasoning rules out the friendlier design where the player sends a bet id and the server looks the rest up. That tells the player what the operator's database says. It is a support tool, and calling it verification would be wrong. The inputs have to come from the caller.
Rate Limits
An open route that does cryptographic work for anyone needs a limit. The demo allows 20 requests a second per address on each verify route, against an API-wide default of 10, with the counters kept in Redis.
Both halves of that sentence were learned. The counters first lived in process memory, and under PM2's cluster mode every worker kept its own, which multiplied each limit by the number of workers. And "per address" was forgeable while the API trusted the whole X-Forwarded-For chain, since a client can write anything it likes into that header. The API now trusts one proxy hop, and Nginx overwrites the header where it used to append to it. Both are in the project's list of mistakes.
The plain Node version above has no limiter. Put it behind a proxy that has one, or count requests per address in the same shared store your other limits use. NestJS code for the routes belongs to the NestJS recipe, and the routes are described as an API in Verify endpoints.
