DocsRecipes
NestJS
From an empty Nest project to a seeds service with bet, rotate, history and verify routes, using a store provider, a zod pipe and a session header, distilled from Galabet's demo API.
There is no Nest module for @galabet/fair and it doesn't need one. The library is a handful of async functions with no state. What Nest contributes is the arrangement around them: a provider that holds seeds, a service that is the only thing allowed to touch a server seed, a pipe that refuses bad bodies, and a controller thin enough to read in one go. Galabet's demo API is built that way, and this recipe is a cut-down version of its demo.service.ts and demo.controller.ts.
We compiled and ran every file below against Nest 10.4.22 and zod 3, on the Fastify adapter the project uses. nest new gives you Express. Nothing here touches the adapter, but Express is the combination we did not run.
1. Create the Project
npm install -g @nestjs/cli
nest new fair-api
cd fair-api
npm install @galabet/fair zod
A new Nest project compiles to CommonJS. The library ships both module formats, so the imports below work as written.
2. Define the Store and Its Token
The service will ask for "a seed store" without knowing which one. TypeScript interfaces are erased at compile time, so Nest can't inject by interface. It needs a token that still exists when the program runs, and a string constant does the job.
import { Injectable } from '@nestjs/common';
import type { FairRecord } from '@galabet/fair';
export interface Session {
id: string;
serverSeed: string;
commitment: string;
clientSeed: string;
betsUnderSeed: number;
}
export interface SeedStore {
get(id: string): Promise<Session | null>;
put(session: Session): Promise<void>;
/** Reserve the next nonce in one atomic step. Returns the nonce this bet must use. */
reserveNonce(id: string): Promise<number>;
peekNonce(id: string): Promise<number>;
resetNonce(id: string): Promise<void>;
reveal(id: string, commitment: string, serverSeed: string): Promise<void>;
revealed(id: string): Promise<Record<string, string>>;
pushRecord(id: string, record: FairRecord): Promise<void>;
records(id: string): Promise<FairRecord[]>;
/** One action per session at a time. Resolves to a release function, or to null when the session is busy. */
lock(id: string): Promise<(() => Promise<void>) | null>;
}
export const SEED_STORE = 'SEED_STORE';
@Injectable()
export class MemorySeedStore implements SeedStore {
private sessions = new Map<string, string>();
private nonces = new Map<string, number>();
private seeds = new Map<string, Record<string, string>>();
private lists = new Map<string, FairRecord[]>();
private busy = new Set<string>();
async get(id: string) {
const raw = this.sessions.get(id);
return raw ? (JSON.parse(raw) as Session) : null;
}
async put(session: Session) {
this.sessions.set(session.id, JSON.stringify(session));
}
async reserveNonce(id: string) {
const next = (this.nonces.get(id) ?? 0) + 1;
this.nonces.set(id, next);
return next - 1;
}
async peekNonce(id: string) {
return this.nonces.get(id) ?? 0;
}
async resetNonce(id: string) {
this.nonces.delete(id);
}
async reveal(id: string, commitment: string, serverSeed: string) {
this.seeds.set(id, { ...this.seeds.get(id), [commitment]: serverSeed });
}
async revealed(id: string) {
return this.seeds.get(id) ?? {};
}
async pushRecord(id: string, record: FairRecord) {
this.lists.set(id, [record, ...(this.lists.get(id) ?? [])]);
}
async records(id: string) {
return this.lists.get(id) ?? [];
}
async lock(id: string) {
if (this.busy.has(id)) return null;
this.busy.add(id);
return async () => void this.busy.delete(id);
}
}
The memory store is for getting the routes working and for tests. It loses every live seed on restart, and that loss is permanent. It stores sessions as JSON strings on purpose, so that get hands back a copy the way a database would, and code that mutates a loaded session without calling put fails here as it would against Redis.
3. Write the Seeds Service
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { commit, createClientSeed, createServerSeed, play } from '@galabet/fair';
import type { FairRecord, GameName, GameParams } from '@galabet/fair';
import { SEED_STORE, type SeedStore, type Session } from './seed-store';
@Injectable()
export class SeedsService {
constructor(@Inject(SEED_STORE) private readonly store: SeedStore) {}
/** The only form of a session that leaves this class. Fields are picked, so a new one can't leak by default. */
private async publicView(s: Session) {
return { id: s.id, commitment: s.commitment, clientSeed: s.clientSeed, nonce: await this.store.peekNonce(s.id) };
}
private async load(id: string) {
const s = await this.store.get(id);
if (!s) throw new NotFoundException('session not found or expired');
return s;
}
private async withSession<T>(id: string, action: () => Promise<T>) {
const release = await this.store.lock(id);
if (!release) throw new ConflictException('another action is being processed; retry after it completes');
try {
return await action();
} finally {
await release();
}
}
private async freshSeed() {
const serverSeed = await createServerSeed();
return { serverSeed, commitment: (await commit(serverSeed)).commitment };
}
async create() {
const session: Session = { id: randomUUID(), ...(await this.freshSeed()), clientSeed: await createClientSeed(), betsUnderSeed: 0 };
await this.store.put(session);
return this.publicView(session);
}
async view(id: string) {
return this.publicView(await this.load(id));
}
bet(id: string, game: GameName, params: GameParams) {
return this.withSession(id, async () => {
const s = await this.load(id);
const nonce = await this.store.reserveNonce(id);
const out = await play({ game, params, serverSeed: s.serverSeed, clientSeed: s.clientSeed, nonce });
const record: FairRecord = {
spec: 'GFS/1.0',
profile: 'single-player',
game,
params,
commitment: s.commitment,
clientSeed: s.clientSeed,
nonce,
cursor: out.cursor,
result: out.result,
at: Date.now(),
};
await this.store.put({ ...s, betsUnderSeed: s.betsUnderSeed + 1 });
await this.store.pushRecord(id, record);
return { record, nonceNext: nonce + 1 };
});
}
rotate(id: string) {
return this.withSession(id, async () => {
const s = await this.load(id);
if (s.betsUnderSeed === 0) throw new BadRequestException('nothing to reveal: no bets under this seed yet');
await this.store.reveal(id, s.commitment, s.serverSeed);
const next: Session = { ...s, ...(await this.freshSeed()), betsUnderSeed: 0 };
await this.store.put(next);
await this.store.resetNonce(id);
return { revealed: { serverSeed: s.serverSeed, commitment: s.commitment, bets: s.betsUnderSeed }, next: await this.publicView(next) };
});
}
async history(id: string) {
await this.load(id);
const [records, revealed] = await Promise.all([this.store.records(id), this.store.revealed(id)]);
return records.map((r) => (revealed[r.commitment] ? { ...r, serverSeed: revealed[r.commitment] } : r));
}
}
The record is assembled from named values and serverSeed isn't one of them. It reaches a client in exactly one place, the revealed object that rotate returns, and by then a new seed has taken its place. history attaches revealed seeds to copies of the records when they're read, looked up by commitment, for the reasons given under Revealed Seeds Are Kept by Commitment.
The demo's service differs in one way worth knowing. Its publicView strips the seed with a rest pattern, const { serverSeed: _hidden, ...rest } = s, which sends whatever else the session happens to contain. Picking fields is the safer habit, and the plain Node recipe explains why.
This recipe stops at the record. Stakes, payouts and the order in which to debit, derive and credit are on Settling bets.
4. Validate Bodies with a Zod Pipe
import { BadRequestException, PipeTransform } from '@nestjs/common';
import type { ZodSchema } from 'zod';
export class ZodPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodSchema<T>) {}
transform(value: unknown): T {
const parsed = this.schema.safeParse(value);
if (!parsed.success) {
throw new BadRequestException({
message: 'validation failed',
issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
});
}
return parsed.data;
}
}
That is the project's pipe, unchanged. It isn't @Injectable() and isn't registered anywhere, because it's constructed inline with the schema it should enforce: @Body(new ZodPipe(betBody)). The handler's parameter type is then z.infer<typeof betBody>, so the schema is the single description of the body and the type follows from it.
5. Add the Controller
import { BadRequestException, Body, Controller, Get, Headers, HttpCode, Post } from '@nestjs/common';
import { inspectRecord } from '@galabet/fair';
import { z } from 'zod';
import { SeedsService } from './seeds.service';
import { ZodPipe } from './zod.pipe';
const sessionId = z.string().uuid();
const betBody = z
.object({
game: z.enum(['dice', 'limbo', 'roulette']),
params: z.object({ houseEdge: z.number().min(0).max(0.5).optional() }).strict().default({}),
})
.strict();
function requireSession(header: string | undefined): string {
const parsed = sessionId.safeParse(header);
if (!parsed.success) throw new BadRequestException('X-Session header must be a session id');
return parsed.data;
}
@Controller('fair')
export class FairController {
constructor(private readonly seeds: SeedsService) {}
@Post('session')
create() {
return this.seeds.create();
}
@Get('session')
view(@Headers('x-session') sid?: string) {
return this.seeds.view(requireSession(sid));
}
@Post('bet')
@HttpCode(200)
bet(@Headers('x-session') sid: string | undefined, @Body(new ZodPipe(betBody)) body: z.infer<typeof betBody>) {
return this.seeds.bet(requireSession(sid), body.game, body.params);
}
@Post('rotate')
@HttpCode(200)
rotate(@Headers('x-session') sid?: string) {
return this.seeds.rotate(requireSession(sid));
}
@Get('history')
history(@Headers('x-session') sid?: string) {
return this.seeds.history(requireSession(sid));
}
@Post('verify')
@HttpCode(200)
async verify(@Body() body: unknown) {
try {
return await inspectRecord(body);
} catch (error) {
throw new BadRequestException(error instanceof Error ? error.message : 'unreadable record');
}
}
}
Both objects in betBody are strict. play ignores parameters it doesn't know, so without .strict() a typo in params would be played as the default and recorded as sent. The houseEdge range is there because play doesn't check it and inspectRecord does, which means an unbounded route can issue records its own verify route will reject. Three games keep the schema short. The demo's schema covers nine, and its bounds are listed under Input Bounds.
The session travels in a header and is parsed as a UUID before anything else happens, so junk never becomes a store lookup. It's a bearer token with no account behind it, which suits a free demo. In a backend with logins the id comes from your auth guard and this header disappears. If a browser on another origin calls these routes, the header has to be allowed by name. The demo's main.ts lists X-Demo-Session in allowedHeaders when it enables CORS, and a custom header missing from that list fails the preflight.
verify takes no session and injects nothing. It hands the body to inspectRecord, which validates the shape, caps the input at 64 KB and recomputes. A record whose result doesn't match comes back as 200 with status: "mismatch", because that is an answer. What the library refuses to read becomes a 400 carrying the library's message. Exposing a verify endpoint covers what such a route is good for and what it can't prove.
Nest answers a POST with 201 unless told otherwise. @HttpCode(200) is on the three routes that don't create a resource. The demo leaves the default in place.
6. Register the Module
import { Module } from '@nestjs/common';
import { FairController } from './fair.controller';
import { MemorySeedStore, SEED_STORE } from './seed-store';
import { SeedsService } from './seeds.service';
@Module({
controllers: [FairController],
providers: [{ provide: SEED_STORE, useClass: MemorySeedStore }, SeedsService],
})
export class FairModule {}
Add FairModule to the imports array of the AppModule that nest new generated.
7. Run It
npm run start:dev
curl -X POST http://localhost:3000/fair/session
curl -X POST http://localhost:3000/fair/bet \
-H "x-session: PASTE-THE-ID" \
-H "content-type: application/json" \
-d '{"game":"dice"}'
These need the Nest app from the steps above listening on localhost:3000, and nothing else. The first answer has four keys, id, commitment, clientSeed and nonce, with nonce at 0. The second is { record, nonceNext }, and the record has no serverSeed. After POST /fair/rotate, GET /fair/history returns the same records with serverSeed attached, and posting any one of them to /fair/verify comes back with "status":"matches".
A rotation before any bet is refused with 400 and nothing to reveal: no bets under this seed yet. A body with a stray key gets the pipe's answer, which for {"game":"dice","stake":5} was:
{"message":"validation failed","issues":[{"path":"","message":"Unrecognized key(s) in object: 'stake'"}]}
Why Every Action Takes the Lock
reserveNonce is atomic, so two bets can't share a nonce. That's not the same as bet and rotate being safe to run together. Both read the session, do some awaiting, and write it back. Here are the steps of a bet written out by hand, with a whole rotation placed between the first and the second:
import { commit, createServerSeed, play } from '@galabet/fair';
const rows = new Map(), nonces = new Map(), revealed = new Map();
const store = {
get: async (id) => JSON.parse(rows.get(id)),
put: async (session) => void rows.set(session.id, JSON.stringify(session)),
reserveNonce: async (id) => { const next = (nonces.get(id) ?? 0) + 1; nonces.set(id, next); return next - 1; },
resetNonce: async (id) => void nonces.delete(id),
};
async function freshSeed() {
const serverSeed = await createServerSeed();
return { serverSeed, commitment: (await commit(serverSeed)).commitment };
}
async function rotate(id) {
const s = await store.get(id);
revealed.set(s.commitment, s.serverSeed);
await store.put({ ...s, ...(await freshSeed()), betsUnderSeed: 0 });
await store.resetNonce(id);
}
async function roll(s, nonce) {
return (await play({ game: 'dice', serverSeed: s.serverSeed, clientSeed: s.clientSeed, nonce })).result;
}
await store.put({ id: 'ana', clientSeed: 'galabet', betsUnderSeed: 0, ...(await freshSeed()) });
// An ordinary first bet.
let s = await store.get('ana');
const first = await roll(s, await store.reserveNonce('ana'));
await store.put({ ...s, betsUnderSeed: 1 });
// The second bet, one step at a time.
s = await store.get('ana'); // bet: load the session
await rotate('ana'); // another request: a complete rotation
const nonce = await store.reserveNonce('ana'); // bet: reserve
const second = await roll(s, nonce); // bet: derive, from the seed loaded earlier
await store.put({ ...s, betsUnderSeed: s.betsUnderSeed + 1 }); // bet: write the session back
const live = await store.get('ana');
console.log('nonce given to the second bet:', nonce);
console.log('second roll equals the first:', second === first);
console.log('the live seed is one the player was handed at rotation:', revealed.get(live.commitment) === live.serverSeed);
nonce given to the second bet: 0
second roll equals the first: true
the live seed is one the player was handed at rotation: true
Two separate failures. The bet was derived from the old seed with a counter that had been reset, so it repeated a roll the player has already seen. Then its write-back put the old session over the new one, and the seed that rotation had published became the live seed again. Every later bet in that session is computable by the player in advance.
Is that interleaving likely outside a contrived example? We took the lock out of the service above and fired a bet, a rotation and a second bet at the same session in one Promise.all, 30 sessions per run, three runs. A session ended with an already revealed seed live in 6, 3 and 2 of the 30. One session in the 90 also showed a repeated pair of commitment and nonce. With the lock back, the same driver found neither fault, and 34 of its requests were answered 409 instead.
So the rule the demo follows is that everything which reads and rewrites the session runs inside withSession. A refused request is cheap. The client retries.
Moving the Store to Redis
Here the provider pays for itself. The service and the controller don't change. The module stops naming MemorySeedStore and builds a Redis-backed class from a client that is itself a provider:
import Redis from 'ioredis';
import { RedisSeedStore } from './redis-seed-store';
export const REDIS = 'REDIS';
const providers = [
{ provide: REDIS, useFactory: () => new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379') },
{ provide: SEED_STORE, useFactory: (client: Redis) => new RedisSeedStore(client, Number(process.env.SESSION_TTL ?? 86400)), inject: [REDIS] },
SeedsService,
];
npm install ioredis dotenv first. RedisSeedStore is the class printed in the Redis recipe with the TTL moved into the constructor, and three methods that differ from it:
const RELEASE = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end";
export class RedisSeedStore implements SeedStore {
constructor(private readonly redis: Redis, private readonly ttl: number) {}
async reserveNonce(id: string) {
const key = this.k(id, 'nonce');
const replies = await this.redis.multi().incr(key).expire(key, this.ttl + 3600).exec();
const [error, value] = replies?.[0] ?? [new Error('no reply to INCR'), null];
if (error || typeof value !== 'number') throw error ?? new Error('no reply to INCR');
return value - 1;
}
async lock(id: string) {
const key = this.k(id, 'lock'), owner = randomUUID();
const acquired = await this.redis.set(key, owner, 'PX', 30_000, 'NX');
if (!acquired) return null;
return async () => void (await this.redis.eval(RELEASE, 1, key, owner));
}
// get, put, peekNonce, resetNonce, reveal, revealed, pushRecord and records follow the same pattern
}
reserveNonce throws when the counter is missing instead of starting again from 0, and the counter's TTL is an hour longer than the session's so it can't lapse first. The demo reaches the same place differently, by renewing every session key together in one script, as Every Session Key Shares One Expiry shows. Either works; what matters is that the counter can never vanish while its seed is live. The lock is the demo's SET NX PX with its owner-checked release, reshaped to fit the lock method.
We ran the same driver against this store on the project's Redis 7 container. The routes behaved as they had on the memory store, with no repeated pairs and no revealed seed left live. Each session left three keys behind: session, records and revealed. The nonce key was gone because the last action to succeed in every session was a rotation, and the lock key because every release found its own UUID.
The demo constructs its store inside the service, with new RedisSeedStore(redis) in the constructor. That works, and it's why the project's API tests have to imitate the Redis client itself. With the store behind a token, a test passes MemorySeedStore and needs no Redis at all.
.env Is Not Loaded by Nest
process.env.REDIS_URL in that factory reads the process environment and nothing else. Nest doesn't open a .env file for you. This project found out on its first boot, when config validation failed on variables that were sitting in a file next to package.json. The fix is one line, first in src/main.ts, above every other import:
import 'dotenv/config';
It has to be first because some code reads process.env while modules are still being imported. The project's AppModule builds the rate limiter's Redis client inside its @Module decorator, which runs at import time, long before bootstrap(). The project's API has a second entry point for its worker, and that file starts with the same line. @nestjs/config is the other route. The demo doesn't use it: it parses process.env with a zod schema at startup and throws a list of what's missing.
tsx Strips Decorator Metadata
The library runs fine under tsx. A Nest app doesn't. tsx compiles with esbuild, which doesn't emit the design:paramtypes metadata that emitDecoratorMetadata asks for, and that metadata is how Nest knows FairController wants a SeedsService.
The confusing part is when it fails. We started this recipe with tsx src/main.ts. It booted, mapped all six routes and logged Nest application successfully started. POST /fair/verify worked, since that handler uses nothing injected. POST /fair/session answered 500, and the log said:
ERROR [ExceptionsHandler] Cannot read properties of undefined (reading 'create')
this.seeds was undefined. The @Inject(SEED_STORE) parameter would have survived, because an explicit token doesn't depend on emitted types. In this project the same thing surfaced in the worker as Cannot read properties of undefined (reading 'currentChain'), and the runner was the last thing anyone suspected. Use nest start, nest start --watch or nest build, which go through the TypeScript compiler.
What the Demo Adds Around This
The demo's controller carries pieces this recipe leaves out, and each is there because of something that went wrong or could.
Rate limits come from @nestjs/throttler, with per-route overrides such as five bets a second. The counters live in Redis. They used to live in process memory, and under PM2's cluster mode that multiplied every limit by the number of workers.
An IdempotencyInterceptor is attached to the controller class with @UseInterceptors. It stores the first response under the caller's Idempotency-Key for 24 hours and replays it on retry, with an in-flight marker taken by SET NX so that two copies of a retry can't both run the handler. The marker lasts the full 24 hours, and a request that fails with a server error keeps it, because the stake may already have been debited; a retry then gets a 409 and should look at the session before trying again. The behaviour a client sees is tabled under Retried Requests.
A global exception filter gives every error the same shape and adds a requestId. You can see why in step 7: Nest's default body for a string exception has statusCode and error fields, and the pipe's object body has neither.
New sessions are capped per IP address per day, a change of client seed retires the server seed, and both rotation and seed changes are refused while a Mines board is live.
