--- title: "Connect: the adapter" description: "@onehazel/connect-adapter puts the Operator Contract envelope in front of a stock PAM: what it does, when to use it, its config, and what it cannot do for you." --- 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 | Situation | Use | |---|---| | Your PAM already exposes bet, win, rollback, balance and player lookup over HTTP | The adapter. Write a config, not code. | | Your PAM is written in Node | The adapter as a library, mounted in your app (Express, Fastify, Hono or node:http). | | Your PAM is in another language | The adapter as a standalone proxy, run next to the PAM (`npx` or the container). | | You want the contract implemented inside your own codebase | A [generated sample](/connect/samples) for your stack. | | You are porting the adapter to another language | The [envelope spec and test vectors](/connect/adapter-envelope). | 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 live `, 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 `` (for example `0.2.1`, the tag the install snippet names), with `-r` and `sha-` 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](#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](#config-reference) below: | Key in the modules shape | Required | Meaning | |---|---|---| | `modules.wallet` | optional | The wallet module: `secrets`, `native`, `testPlayerIds`, `testGame`. At least one module is REQUIRED. | | `modules.player` | optional | The player module: `secrets`, `native`, `testPlayerIds`, `route`, `resolve`, `fields` (the keys under `player` in the single-module shape). | | `modules.webhook` | optional | The webhook.receive module: `secrets`, `native`, `sink`, `dedup`, `allowMemoryDedup` (the keys under `webhook` in the single-module shape). | | `modules..secrets` | **REQUIRED** in every module | That module's `signing` and `callbackKey` variables, the ones `connect_register_endpoint` showed for it. | | `modules..native` | optional | Merged 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](#config-reference) 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/" ``` 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](#check-the-config). | Key | Required | Meaning | |---|---|---| | `contract` | **REQUIRED** | Contract minor implemented, `"1."`, for example `"1.4"`. | | `modules` | **REQUIRED** | Subset 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](#one-adapter-for-every-module). | | `secrets.signing` | **REQUIRED** | Variable holding 1 or 2 comma-separated `whsec_` values. Two only during a rotation hour. | | `secrets.callbackKey` | **REQUIRED** | Variable holding the callback key OneHazel sends as `Authorization: Bearer`. | | `native.baseUrl` | **REQUIRED** | Your PAM's base URL: a string, or `{ test, live }`. https, unless the host is loopback, private or cluster-internal by name. | | `native.auth` | **REQUIRED** | `{ header, valueEnv }`. `valueEnv` holds the FULL header value, for example `Bearer ...`. | | `native.timeoutMs` | optional | Default 8000. | | `testPlayerIds` | **REQUIRED** with `wallet` or `player` | testPlayerIds 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. | | `testGame` | **REQUIRED** 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.shape` | **REQUIRED** | `{ 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.map` | optional | Your 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](#how-your-errors-are-mapped). | | `errors.byStatus` | optional | Your HTTP status (`"402"`, 400 to 599) to a contract error code, for errors whose body carries no code: see [How your errors are mapped](#how-your-errors-are-mapped). | | `player.route` | **REQUIRED** with `player` | Your 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-route-forms). | | `player.resolve` | optional | `{ 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.fields` | **REQUIRED** with `player` | JSONPaths for `currency`, `country`, `status` and `restrictions`. `restrictions` may instead be an object that builds the array from flags, see below. | | `webhook.sink` | **REQUIRED** with `webhook.receive` | `{ url, authHeaderEnv? }`: where verified deliveries are forwarded. | | `webhook.dedup` | **REQUIRED** with `webhook.receive` | `memory`, `redis` or `url`. The proxy supports `redis` (URL in `ONEHAZEL_ADAPTER_REDIS_URL`); `memory` is refused in production. | | `ackOnSinkFailure` | optional | Default false: a sink failure answers 503 so OneHazel retries. | | `allowInsecureNative` | optional | Default 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`. | Form | Config | Requests 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](#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 ```