Skip to content
For LLMsView as Markdown·

For the operator's engineer whose PAM already has a wallet and player API, and who would rather configure the contract than write it.

The adapter ​

@onehazel/connect-adapter sits in front of your PAM and speaks the Operator Contract for it. OneHazel calls the adapter. The adapter checks the bearer, verifies X-OneHazel-Signature over the raw body, checks X-OneHazel-Env, forwards the call to your PAM's own API, and turns your PAM's errors into the contract's { code, message, retryable } envelope. Your PAM keeps its own routes and its own error format.

It covers the three modules OneHazel calls you on: wallet, player and webhook.receive.

When to use it ​

SituationUse
Your PAM already exposes bet, win, rollback, balance and player lookup over HTTPThe adapter. Write a config, not code.
Your PAM is written in NodeThe adapter as a library, mounted in your app (Express, Fastify, Hono or node:http).
Your PAM is in another languageThe adapter as a standalone proxy, run next to the PAM (npx or the container).
You want the contract implemented inside your own codebaseA generated sample for your stack.
You are porting the adapter to another languageThe envelope spec and test vectors.

Your coding agent gets the same choice: connect_activate returns a version-pinned install snippet (module adapter) whenever wallet, player or webhook.receive is still not_started: one config file, one compose service and one list of variables covering exactly the modules not started, with the base URLs you have already registered filled in; a module registered with baseUrls whose test and live differ shows both, test <url> live <url>, because conformance calls the test one. connect_samples with framework: "adapter" returns the same skeleton for all three modules, unfilled.

Install ​

sh
npm install @onehazel/connect-adapter@0.2.1

Or, for a PAM in any language, run the standalone proxy without installing anything:

sh
npx -y @onehazel/connect-adapter@0.2.1 --config onehazel-adapter.json --port 8080

If npm answers no matching version, that version is not published to npm yet: use the container image instead, it carries the same version.

The container image is ghcr.io/zsdigitalmt/onehazel-connect-adapter, tagged <version> (for example 0.2.1, the tag the install snippet names), with <version>-r<build> and sha-<commit> for one exact build. It runs the same proxy and reads its config from /etc/connect-adapter/adapter.json; mount yours over it and pass the environment variables by name.

GET /healthz answers 200 on the proxy. Startup refuses, with exit code 1, when the config is invalid or a variable it names is missing, and the message names the variable, never its value. Run --check first to find a config problem before deploying, see Check the config.

One adapter for every module ​

Each module you register with connect_register_endpoint gets its own signing secret and its own callback key. One adapter process serves every module you run: its config lists them under modules, each with its own secrets, and the adapter checks each call against the secrets of the module its route belongs to. A call to a wallet route signed with the player module's secret is refused exactly as an unknown secret is, 401 signature_invalid. Register the adapter's one public URL as the base URL of every module it serves.

json
{
  "contract": "1.4",
  "native": { "baseUrl": { "test": "https://pam-test.example.com/provider/v1", "live": "https://pam.example.com/provider/v1" } },
  "errors": { "shape": { "code": "$.error.code", "message": "$.error.message" }, "map": { "INSUFFICIENT_BALANCE": "insufficient_funds" } },
  "modules": {
    "wallet": {
      "secrets": { "signing": "ONEHAZEL_WALLET_SIGNING_SECRETS", "callbackKey": "ONEHAZEL_WALLET_CALLBACK_KEY" },
      "native": { "auth": { "header": "Authorization", "valueEnv": "PAM_WALLET_TOKEN" } },
      "testPlayerIds": ["oh-test-1"],
      "testGame": { "provider_slug": "your-studio", "game_code": "your-game" }
    },
    "player": {
      "secrets": { "signing": "ONEHAZEL_PLAYER_SIGNING_SECRETS", "callbackKey": "ONEHAZEL_PLAYER_CALLBACK_KEY" },
      "native": { "auth": { "header": "Authorization", "valueEnv": "PAM_PLAYER_TOKEN" } },
      "testPlayerIds": ["oh-test-1"],
      "route": "/players/{player_external_id}",
      "fields": { "currency": "$.currency", "country": "$.country", "status": "$.status", "restrictions": "$.limits" }
    },
    "webhook": {
      "secrets": { "signing": "ONEHAZEL_WEBHOOK_SIGNING_SECRETS", "callbackKey": "ONEHAZEL_WEBHOOK_CALLBACK_KEY" },
      "native": { "auth": { "header": "Authorization", "valueEnv": "PAM_WEBHOOK_TOKEN" } },
      "sink": { "url": "https://pam.example.com/oh/events", "authHeaderEnv": "PAM_WEBHOOK_TOKEN" },
      "dedup": "redis"
    }
  }
}

In this shape modules is an object, and each module's own keys sit in its entry; contract, native, errors, ackOnSinkFailure and allowInsecureNative stay at the top and are shared. Every key keeps the meaning it has in the config reference below:

Key in the modules shapeRequiredMeaning
modules.walletoptionalThe wallet module: secrets, native, testPlayerIds, testGame. At least one module is REQUIRED.
modules.playeroptionalThe player module: secrets, native, testPlayerIds, route, resolve, fields (the keys under player in the single-module shape).
modules.webhookoptionalThe webhook.receive module: secrets, native, sink, dedup, allowMemoryDedup (the keys under webhook in the single-module shape).
modules.<module>.secretsREQUIRED in every moduleThat module's signing and callbackKey variables, the ones connect_register_endpoint showed for it.
modules.<module>.nativeoptionalMerged over the top-level native key by key, so a module can name its own auth variable and share baseUrl. Every module needs a native.baseUrl and a native.auth, from its entry or from the top.

Each module carries its own native.auth in the example, so give each one a PAM credential allowed to call only that module's routes: PAM_WALLET_TOKEN for the wallet routes, PAM_PLAYER_TOKEN for your player route, PAM_WEBHOOK_TOKEN for your event sink. One token is enough when a single PAM service credential may call every wallet route, the player route and the event sink: set PAM_WALLET_TOKEN, PAM_PLAYER_TOKEN and PAM_WEBHOOK_TOKEN to the same value.

--check validates the whole file and names a problem at the path you wrote, such as modules.player.route. The startup player route probe runs for the player module. Each module rotates on its own: two comma-separated values in ONEHAZEL_WALLET_SIGNING_SECRETS during a wallet rotation hour leave the player and webhook secrets untouched. In Node, createAdapter(config) takes the same file and returns one handler for every route.

One process per module ​

The single-module shape still works, unchanged: modules is a list, secrets sits at the top, and one adapter holds one signing secret (two during a rotation hour) and one callback key. Run one adapter per module, three proxies on three ports or three createAdapter calls mounted on three paths, and register each adapter's public URL as that module's base URL. A config in this shape lists one module: listing several under one secrets pair does not match what connect_register_endpoint issues, since every module gets its own pair. The wallet config below is this shape; the player and webhook files follow the same pattern with "modules": ["player"] plus player, and "modules": ["webhook.receive"] plus webhook.

Before you write the config ​

  • native.baseUrl is the FULL route prefix the adapter appends contract paths to (e.g. https://pam.example.com/provider/v1, not the bare host).
  • The sample JSONPaths are placeholders: fetch ONE real response for the test player and write every path against it; a wrapped response needs $.data.* paths.
  • You never hold the PAM token, so never call the PAM yourself: ask your human to run one request for the test player on the adapter's host, with the token from onehazel.env, and paste the response body with tokens and personal values removed. The adapter's --check with the token set only proves the player route answers: it prints the status, not the body.
  • Call your player route with the real testPlayerId first; it must answer 200.
  • Before registering, confirm your player read route accepts the same player id your wallet uses. If it does not, put {player_external_id} in a query parameter ("route": "/players?externalId={player_external_id}"), or add a search step that finds your internal id ("resolve": { "route": "/players/search?ref={player_external_id}", "idPath": "$.items[0].id" } with "route": "/players/{player_internal_id}"). The adapter reads the first testPlayerId through that route once at startup, and in --check when the PAM token is set; a 4xx on that read stops it.
  • If you have one PAM environment, register baseUrls with test and live both at the adapter URL and set native.baseUrl.test and .live to the same PAM prefix.

The one real response comes from the person who holds the PAM token, never from the agent. Quote every value in onehazel.env that contains spaces, for example PAM_PLAYER_TOKEN="Bearer ...": the shell line below cannot load an unquoted one, and Docker Compose reads the quoted form. A value that contains a $ goes in single quotes instead, for example PAM_PLAYER_TOKEN='Bearer ab$cd', because bash and Docker Compose both expand $cd inside double quotes. On the adapter's host, with the real PAM_PLAYER_TOKEN in onehazel.env, they run one request for the test player against the player route, using the header your PAM expects:

sh
set -a; . ./onehazel.env; set +a
curl -s -H "Authorization: $PAM_PLAYER_TOKEN" "https://pam.example.com/provider/v1/players/<test player id>"

They remove every token, name, email and other personal value from the output and paste the rest to the agent. --check with the token set reads the first test player through the player route too, but it prints only the PAM's status, never the body, so it proves the route answers and cannot replace the sample.

Config reference ​

Every secret is the NAME of an environment variable, never a value. The validator rejects a pasted value without echoing it.

Every key marked REQUIRED must be present or the adapter refuses to start. errors.shape (both code and message) is REQUIRED for every module, wallet, player and webhook.receive alike. native.auth means both header and valueEnv; player.fields means all four fields. Check each config with --check before you deploy it, see Check the config.

KeyRequiredMeaning
contractREQUIREDContract minor implemented, "1.<minor>", for example "1.4".
modulesREQUIREDSubset of wallet, player, webhook.receive, one per adapter in this shape; or an object keyed by wallet, player, webhook for one adapter serving several modules, see One adapter for every module.
secrets.signingREQUIREDVariable holding 1 or 2 comma-separated whsec_ values. Two only during a rotation hour.
secrets.callbackKeyREQUIREDVariable holding the callback key OneHazel sends as Authorization: Bearer.
native.baseUrlREQUIREDYour PAM's base URL: a string, or { test, live }. https, unless the host is loopback, private or cluster-internal by name.
native.authREQUIRED{ header, valueEnv }. valueEnv holds the FULL header value, for example Bearer ....
native.timeoutMsoptionalDefault 8000.
testPlayerIdsREQUIRED with wallet or playertestPlayerIds is required whenever modules includes wallet or player: list your test player ids, because X-OneHazel-Env: test is refused for any other player with player_unknown and the message "env_refused: player not in testPlayerIds". live is refused for these.
testGameREQUIRED with wallet{ provider_slug, game_code }: the game in your own catalogue that conformance and connect_simulate start every test session with, the same pair you register as testGame. Requests pass through unchanged.
errors.shapeREQUIRED{ code, message } JSONPaths into your PAM's error body. code may be a list of JSONPaths tried in order; the first non-empty value wins. The message is never echoed.
errors.mapoptionalYour error code to a contract error code. Needed for every code your PAM sends that is not already a contract code: see How your errors are mapped.
errors.byStatusoptionalYour HTTP status ("402", 400 to 599) to a contract error code, for errors whose body carries no code: see How your errors are mapped.
player.routeREQUIRED with playerYour GET route relative to native.baseUrl. It contains {player_external_id} exactly once, as a path segment or in the query string, or {player_internal_id} exactly once when player.resolve is set. See Player route forms.
player.resolveoptional{ route, idPath }: your GET search route containing {player_external_id} exactly once, and the JSONPath (REQUIRED with resolve) to your internal player id in its response.
player.fieldsREQUIRED with playerJSONPaths for currency, country, status and restrictions. restrictions may instead be an object that builds the array from flags, see below.
webhook.sinkREQUIRED with webhook.receive{ url, authHeaderEnv? }: where verified deliveries are forwarded.
webhook.dedupREQUIRED with webhook.receivememory, redis or url. The proxy supports redis (URL in ONEHAZEL_ADAPTER_REDIS_URL); memory is refused in production.
ackOnSinkFailureoptionalDefault false: a sink failure answers 503 so OneHazel retries.
allowInsecureNativeoptionalDefault false.

JSONPaths use a small subset: $, then .name, ['name'] or [index] segments.

A wallet config:

json
{
  "contract": "1.4",
  "modules": ["wallet"],
  "secrets": { "signing": "ONEHAZEL_WALLET_SIGNING_SECRETS", "callbackKey": "ONEHAZEL_WALLET_CALLBACK_KEY" },
  "native": {
    "baseUrl": { "test": "https://pam-test.example.com/provider/v1", "live": "https://pam.example.com/provider/v1" },
    "auth": { "header": "Authorization", "valueEnv": "PAM_WALLET_TOKEN" }
  },
  "testPlayerIds": ["oh-test-1"],
  "testGame": { "provider_slug": "your-studio", "game_code": "your-game" },
  "errors": { "shape": { "code": "$.error.code", "message": "$.error.message" }, "map": { "INSUFFICIENT_BALANCE": "insufficient_funds" } }
}

Name one active game in your catalogue that may be used for test rounds (provider slug and game code as your PAM knows them) and register it with the wallet as testGame: { provider_slug, game_code }. Put the same pair in testGame: conformance runs and connect_simulate start every test session with it, and nothing else.

How your errors are mapped ​

When your PAM answers a non-2xx, the adapter reads your code at errors.shape.code (with a list, the first path that holds a non-empty value) and picks the contract code in this order:

  1. Your code is in errors.map: the mapped contract code.
  2. Your code is already a contract code: it passes through, except internal_error, which only an explicit errors.map or errors.byStatus entry emits (it is OneHazel's retry trigger).
  3. Your HTTP status is in errors.byStatus: the mapped contract code.
  4. Otherwise by status: a 4xx is invalid_request; a 5xx, a network failure or a timeout is unavailable.

Rule 4 is the fallback, so a code your PAM sends that is neither mapped nor a contract code becomes invalid_request on a 4xx, whatever it meant. Example: a PAM that answers 404 {"error":{"code":"unknown_provider"}} for an unknown game gets 400 invalid_request until you add:

json
"errors": {
  "shape": { "code": "$.error.code", "message": "$.error.message" },
  "map": { "unknown_provider": "game_unknown" }
}

after which the same answer is 404 game_unknown.

If your PAM's error body has no code at all, map by status instead. Example: a PAM that answers 402 {"status":"insufficient_funds","balance":"1.00"} has nothing at $.error.code, so it gets 400 invalid_request until you add:

json
"errors": {
  "shape": { "code": "$.error.code", "message": "$.error.message" },
  "byStatus": { "402": "insufficient_funds" }
}

after which the same answer is 402 insufficient_funds, with native_code: null in details. Alternatively give errors.shape.code a list, ["$.error.code", "$.status"]: the adapter reads insufficient_funds from $.status, and because that is already a contract code it passes through.

Every error your PAM produced also carries details: { "native_status", "native_code" } (Operator Contract 1.4.1): your PAM's own status and code, secret-redacted and capped at 64 characters, null when it sent no code. OneHazel shows it in conformance failures and never acts on it, so a failed scenario tells you what your PAM actually said. Your error message and body are never echoed. The optional options.log events carry the same two fields.

Player route forms ​

The adapter reads one player in one of three forms. The id OneHazel sends is your wallet's player_external_id.

FormConfigRequests per lookup
Path segment"route": "/players/{player_external_id}"One GET.
Query parameter"route": "/players?externalId={player_external_id}" (the placeholder may sit anywhere in the route)One GET.
Search, then read"resolve": { "route": "/players/search?ref={player_external_id}", "idPath": "$.items[0].id" } and "route": "/players/{player_internal_id}"A search GET, then a read GET with the internal id found at idPath.

The id is URL-encoded wherever it is placed. With resolve, a 2xx search with nothing at idPath answers 404 player_unknown and the read is not sent; a value there that is not a string or a number, or that holds /, .. or a control character, answers 502 unavailable. A route with neither {player_external_id} nor a resolve step is refused at startup and by --check, with a message naming both options.

Startup, and --check when the native auth value is set, read the first testPlayerIds entry through these routes once against native.baseUrl.test. A 4xx on that read (or a search with no usable id) stops both with "your player read route rejected a test player id, check whether it expects the same id your wallet uses, a query parameter, or a search step", followed by the native status and native code. An unreachable PAM or a 5xx is printed and does not stop either. --no-probe (or CONNECT_ADAPTER_PROBE=0) skips the read.

Restrictions from flags ​

The contract's restrictions is an array of { kind, until?, detail? }. If your player record already has one, player.fields.restrictions is its JSONPath, as above. If your PAM keeps restrictions as flags instead, for example a boolean is_self_excluded beside the lifecycle status, give an object:

json
"restrictions": {
  "fromFlags": [
    { "path": "$.is_self_excluded", "kind": "self_exclusion" },
    { "path": "$.cooling_off_until", "kind": "cooling_off", "when": "nonEmpty", "until": "$.cooling_off_until" }
  ],
  "passthrough": "$.limits"
}
  • Each flag that is set adds one entry, in the order listed.
  • when is "true" (the default: the value is boolean true) or "nonEmpty" (the value is a non-empty string, such as a date).
  • until is an optional JSONPath to a date-time copied into that entry. It is left out when the value is absent or empty.
  • passthrough is an optional JSONPath to an array you already have. Its entries are appended after the flag entries, keeping only kind, until and detail.
  • kind must be one of the kinds the contract lists: deposit_limit, loss_limit, session_limit, cooling_off or self_exclusion. Any other kind is refused at startup. Kinds your PAM already returns in an array reach OneHazel through passthrough.
  • A flag that is absent or null is off. A flag of the wrong type, such as the string "true" or the number 1, answers 502 unavailable: a value the adapter cannot read is never reported as "not restricted".
  • A mistyped path therefore reads as off. Conformance catches it: lookup.restricted_flag fails when the restricted test player comes back with no restrictions.
  • nonEmpty does not compare dates. Clear the field when the restriction ends, or the lookup keeps reporting it.

Check the config ​

Before you deploy or register anything, check the config file (every file, with one process per module):

sh
npx -y @onehazel/connect-adapter@0.2.1 --check --config onehazel-adapter.json

If npm answers no matching version, that version is not published to npm yet: use the container image instead, it carries the same version. The container form of the check is below.

The check runs the same validator the adapter runs at startup, with environment variable NAMES only. The values may be unset; none is read beyond whether it is set, except the native auth value: when it is set, the check also reads your first test player through the player route once, as startup does (see Player route forms), and --no-probe skips that read. It prints every rule failure with its JSON path and rule, lists the variables the config needs, exits 0 when the config is valid and 1 when it is not, and never starts a listener. A config it refuses is a config the adapter refuses to start with. It validates as NODE_ENV=production, the image's value, unless NODE_ENV is set.

Without Node, the image runs the same check when CONNECT_ADAPTER_CHECK=1 is set:

sh
docker run --rm -e CONNECT_ADAPTER_CHECK=1 \
  -v "$PWD/onehazel-adapter.json:/etc/connect-adapter/adapter.json:ro" \
  ghcr.io/zsdigitalmt/onehazel-connect-adapter:0.2.1

A failing check, for a config with no errors block:

text
connect-adapter --check: onehazel-adapter.json is INVALID (1 rule failure, NODE_ENV=production):
  - errors.shape: must be { code, message } JSONPaths into the native error body (rule: required)

What it cannot do for you ​

  1. Apply webhook events to your PAM. The adapter verifies each delivery, deduplicates it on deliveryId and forwards the raw body to your sink. What the event changes in your PAM is your code.
  2. Provide what webhook.receive needs. webhook.receive still needs a PAM-side sink endpoint and Redis for dedup in production, or the "url" dedup form with a shared store you provide in code; the in-memory store is refused in production unless webhook.allowMemoryDedup is true. A sink that does not exist yet fails every delivery with 503.
  3. Choose the test game. Conformance and connect_simulate start every test session with the testGame you registered. It must be an active game your PAM accepts under X-OneHazel-Env: test; when your PAM refuses it, the run stops at its first session/start with "The game you declared was rejected by your casino, check provider_slug and game_code." and your PAM's status and code.

Then ​

Register the adapter's URL for each module, run conformance (connect_run_conformance) until every scenario passes, and read the result with connect_conformance_evidence. Go-live stays a click by a person in the OneHazel app.

Registering a module that is already registered keeps its signing secret and callback key (the result says secretUnchanged: true and carries no secret), so fixing an address, a test player or the test game this way changes only OneHazel's registration and needs no adapter restart; only connect_rotate_secret, or a different callbackKey, replaces them.

The adapter reads its config and its variables only when it starts, so after a change to either, restart it before the next conformance run. With the container, run the start line again with --force-recreate:

sh
docker compose --env-file onehazel.env up -d --force-recreate

Without the container, stop the process (Ctrl+C in its terminal, or SIGTERM) and start it again with the new values in its environment. The values in onehazel.env must be quoted when they contain spaces:

sh
set -a; . ./onehazel.env; set +a
npx -y @onehazel/connect-adapter@0.2.1 --config onehazel-adapter.json --port 8080