Appearance
player: TypeScript + Express
The player lookup OneHazel calls for currency, country, status and active restrictions.
Generated from Operator Contract v1.4.1. Key typescript/express/player; the connect_samples MCP tool returns this page for that key.
What it implements
| Operation | Purpose | Idempotency key |
|---|---|---|
POST /player/lookup | Read a player's currency, country, status and active restrictions. | no |
Every request is checked in this order: the Authorization: Bearer callback key, then X-OneHazel-Signature (t=<unix seconds>,v1=<hex>, HMAC-SHA256 of "<t>." + raw body, 300 s tolerance, constant-time compare) over the RAW body before it is parsed, then X-OneHazel-Env. test traffic goes to a separate test ledger and never touches the live one. Errors use the flat { code, message, retryable } envelope; money is integer minor units.
Run it
bash
npm install express
npm install --save-dev tsx @types/express @types/node
ONEHAZEL_CALLBACK_KEY=<callback key> ONEHAZEL_SIGNING_SECRET=<signing secret> npx tsx onehazel-player.ts
# base URL to register: https://<your host>/onehazel/v1 (ONEHAZEL_BASE_PATH changes the path)Environment: ONEHAZEL_CALLBACK_KEY and ONEHAZEL_SIGNING_SECRET are the values connect_register_endpoint showed you once. During a secret rotation set ONEHAZEL_SIGNING_SECRET_PREVIOUS to the old secret for the overlap hour. ONEHAZEL_TEST_PLAYER_ID (default test-player-1) is the funded player on the test ledger.
Code
onehazel-player.ts
ts
import express from "express";
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
// Generated from the OneHazel Operator Contract v1.4.1: operations, request fields,
// error codes with their HTTP status, and the signature tolerance. Do not hand-edit; regenerate.
const CONTRACT = {
"version": "1.4.1",
"auth": {
"scheme": "timestamped",
"bearer": true,
"envHeader": true
},
"toleranceSec": 300,
"errorStatus": {
"unauthorized": 401,
"signature_invalid": 401,
"idempotency_key_conflict": 409,
"launch_token_consumed": 409,
"round_closed": 409,
"currency_unsupported": 422,
"currency_mismatch": 422,
"player_blocked": 422,
"rollback_amount_mismatch": 422,
"insufficient_funds": 402,
"internal_error": 500,
"unavailable": 500,
"invalid_request": 400,
"session_unknown": 404,
"session_expired": 404,
"launch_token_unknown": 404,
"launch_token_expired": 404,
"player_unknown": 404,
"game_unknown": 404,
"transaction_unknown": 404,
"freeround_unknown": 404
},
"retryable": [
"internal_error",
"unavailable"
],
"ops": [
{
"path": "/player/lookup",
"operationId": "playerLookup",
"summary": "Read a player's currency, country, status and active restrictions.",
"idempotent": false,
"fields": [
{
"name": "player_external_id",
"type": "string",
"required": true,
"minLength": 1
}
],
"closed": true
}
]
} as const;
// ───────────────────────────── configuration ─────────────────────────────
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
// Never print the value of a secret, only its name.
console.error(`${name} is not set`);
process.exit(1);
}
return value;
}
// During a rotation OneHazel signs with the new secret; the old one stays valid for 1 hour.
const SIGNING_SECRETS = [requiredEnv("ONEHAZEL_SIGNING_SECRET"), process.env.ONEHAZEL_SIGNING_SECRET_PREVIOUS]
.filter((s): s is string => Boolean(s));
const PORT = Number(process.env.PORT ?? "3000");
const BASE_PATH = (process.env.ONEHAZEL_BASE_PATH ?? "/onehazel/v1").replace(/\/+$/, "");
// ───────────────────────────── errors ─────────────────────────────
type ErrorCode = keyof typeof CONTRACT.errorStatus;
interface Reply {
status: number;
body: unknown;
}
/** Thrown anywhere in a handler; becomes the flat `{ code, message, retryable }` envelope. */
class ContractError extends Error {
constructor(public readonly code: ErrorCode, message: string) {
super(message);
}
}
function errorReply(code: ErrorCode, message: string): Reply {
return {
status: CONTRACT.errorStatus[code],
body: { code, message, retryable: (CONTRACT.retryable as readonly string[]).includes(code) },
};
}
// ───────────────────────────── request authentication ─────────────────────────────
type HeaderGetter = (name: string) => string | undefined;
function constantTimeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
// Compare against itself on a length mismatch so the timing does not reveal the length.
return ab.length === bb.length ? timingSafeEqual(ab, bb) : (timingSafeEqual(ab, ab), false);
}
// ───────────────────────────── request validation ─────────────────────────────
type Body = Record<string, unknown>;
interface Field {
readonly name: string;
readonly type: string;
readonly required: boolean;
readonly minimum?: number;
readonly exclusiveMinimum?: number;
readonly minLength?: number;
readonly maxLength?: number;
readonly pattern?: string;
readonly enum?: readonly string[];
readonly fields?: readonly Field[];
}
function checkFields(value: unknown, fields: readonly Field[], closed: boolean, where: string): Body {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new ContractError("invalid_request", `${where || "body"} must be a JSON object`);
}
const obj = value as Body;
if (closed) {
for (const key of Object.keys(obj)) {
if (!fields.some((f) => f.name === key)) throw new ContractError("invalid_request", `unknown field ${where}${key}`);
}
}
for (const f of fields) {
const v = obj[f.name];
const name = `${where}${f.name}`;
if (v === undefined || v === null) {
if (f.required) throw new ContractError("invalid_request", `${name} is required`);
continue;
}
const ok =
f.type === "integer" ? Number.isSafeInteger(v)
: f.type === "array" ? Array.isArray(v)
: f.type === "object" ? typeof v === "object" && !Array.isArray(v)
: typeof v === f.type;
if (!ok) throw new ContractError("invalid_request", `${name} must be ${f.type}`);
if (typeof v === "number") {
if (f.minimum !== undefined && v < f.minimum) throw new ContractError("invalid_request", `${name} is below ${f.minimum}`);
if (f.exclusiveMinimum !== undefined && v <= f.exclusiveMinimum) {
throw new ContractError("invalid_request", `${name} must be greater than ${f.exclusiveMinimum}`);
}
}
if (typeof v === "string") {
if (f.minLength !== undefined && v.length < f.minLength) throw new ContractError("invalid_request", `${name} is too short`);
if (f.maxLength !== undefined && v.length > f.maxLength) throw new ContractError("invalid_request", `${name} is too long`);
if (f.pattern !== undefined && !new RegExp(f.pattern).test(v)) throw new ContractError("invalid_request", `${name} is malformed`);
if (f.enum !== undefined && !f.enum.includes(v)) throw new ContractError("invalid_request", `${name} is not an allowed value`);
}
if (f.fields) checkFields(v, f.fields, false, `${name}.`);
}
return obj;
}
function parseBody(rawBody: Buffer): unknown {
try {
return JSON.parse(rawBody.toString("utf8"));
} catch {
throw new ContractError("invalid_request", "body is not valid JSON");
}
}
// ───────────────────────────── OneHazel-to-operator call authentication ─────────────────────────────
const CALLBACK_KEY = requiredEnv("ONEHAZEL_CALLBACK_KEY");
const TEST_PLAYER_ID = process.env.ONEHAZEL_TEST_PLAYER_ID ?? "test-player-1";
const TEST_PLAYER_BALANCE = Number(process.env.ONEHAZEL_TEST_PLAYER_BALANCE ?? "100000");
const TEST_PLAYER_CURRENCY = process.env.ONEHAZEL_TEST_PLAYER_CURRENCY ?? "EUR";
function checkBearer(authorization: string | undefined): void {
if (!authorization || !constantTimeEqual(authorization, `Bearer ${CALLBACK_KEY}`)) {
throw new ContractError("unauthorized", "missing or wrong callback key");
}
}
/**
* `X-OneHazel-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>." + raw body)>`.
* Runs over the RAW bytes, before the body is parsed.
*/
function verifySignature(rawBody: Buffer, header: string | undefined, secrets: string[], nowSec: number): boolean {
if (!header) return false;
let t = NaN;
const macs: string[] = [];
for (const part of header.split(",")) {
const [k, v] = part.trim().split("=", 2);
if (k === "t") t = /^\d+$/.test(v ?? "") ? Number(v) : NaN;
if (k === "v1" && /^[0-9a-f]{64}$/.test(v ?? "")) macs.push(v);
}
if (!Number.isFinite(t) || Math.abs(nowSec - t) > CONTRACT.toleranceSec || macs.length === 0) return false;
for (const secret of secrets) {
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
if (macs.some((mac) => constantTimeEqual(mac, expected))) return true;
}
return false;
}
type Env = "test" | "live";
/** Bearer, then signature over the raw body, then the environment header. */
function authenticate(header: HeaderGetter, rawBody: Buffer): Env {
checkBearer(header("authorization"));
if (!verifySignature(rawBody, header("x-onehazel-signature"), SIGNING_SECRETS, Math.floor(Date.now() / 1000))) {
throw new ContractError("signature_invalid", "bad or stale X-OneHazel-Signature");
}
const env = header("x-onehazel-env");
if (env !== "test" && env !== "live") throw new ContractError("invalid_request", "X-OneHazel-Env must be test or live");
return env;
}
// ───────────────────────────── players ─────────────────────────────
//
// One directory per X-OneHazel-Env. Conformance runs always send `test`. Replace
// `live` with a read from your player database; return only these fields.
interface Restriction {
kind: string;
until?: string;
detail?: string;
}
interface PlayerFacts {
player_external_id: string;
currency: string;
country: string;
status: "active" | "suspended" | "self_excluded" | "closed";
restrictions: Restriction[];
}
const RESTRICTED_TEST_PLAYER_ID = process.env.ONEHAZEL_RESTRICTED_TEST_PLAYER_ID ?? "test-player-restricted";
const DIRECTORIES: Record<Env, Map<string, PlayerFacts>> = {
test: new Map([
[TEST_PLAYER_ID, { player_external_id: TEST_PLAYER_ID, currency: TEST_PLAYER_CURRENCY, country: "MT", status: "active", restrictions: [] }],
[
RESTRICTED_TEST_PLAYER_ID,
{
player_external_id: RESTRICTED_TEST_PLAYER_ID,
currency: TEST_PLAYER_CURRENCY,
country: "MT",
status: "active",
restrictions: [{ kind: "deposit_limit", detail: "Test restriction: 100.00 per day" }],
},
],
]),
// Replace with your player database.
live: new Map(),
};
const HANDLERS: Record<string, (directory: Map<string, PlayerFacts>, body: Body) => Reply> = {
"/player/lookup": (directory, b) => {
const player = directory.get(b.player_external_id as string);
// Unknown is a 404, never a 200 with empty fields.
if (!player) throw new ContractError("player_unknown", "unknown player");
return { status: 200, body: player };
},
};
/** The whole module: one call per request, framework-agnostic. */
async function handle(path: string, header: HeaderGetter, rawBody: Buffer): Promise<Reply> {
try {
const op = CONTRACT.ops.find((o) => o.path === path);
if (!op) return { status: 404, body: { code: "invalid_request", message: "unknown operation", retryable: false } };
const env = authenticate(header, rawBody);
const body = checkFields(parseBody(rawBody), op.fields, op.closed, "");
return HANDLERS[path](DIRECTORIES[env], body);
} catch (e) {
if (e instanceof ContractError) return errorReply(e.code, e.message);
console.error("player handler failed", e instanceof Error ? e.message : "unknown error");
return errorReply("internal_error", "internal error");
}
}
// ───────────────────────────── Express wiring ─────────────────────────────
const app = express();
// Keep the body RAW: the signature is computed over the exact bytes OneHazel sent.
app.use(BASE_PATH, express.raw({ type: () => true, limit: "1mb" }), async (req, res) => {
if (req.method !== "POST") {
res.status(405).json({ code: "invalid_request", message: "POST only", retryable: false });
return;
}
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
const header: HeaderGetter = (name) => {
const v = req.headers[name];
return Array.isArray(v) ? v[0] : v;
};
const reply = await handle(req.path, header, raw);
res.status(reply.status).json(reply.body);
});
app.listen(PORT, () => console.log(`OneHazel module listening on :${PORT}${BASE_PATH}`));Before you go live
- Replace the in-memory (or file) stores with your database. The idempotency store must survive restarts.
- Point the
liveledger at your real wallet; keeptesttraffic on the test ledger. - Run conformance (
connect_run_conformance) until every scenario passes.