Contents

HTTP service

The HTTP service: submit operations, receive provider webhooks, and health/readiness endpoints.

Source src/server.ts#L185createServerscripts/main.ts#L404runServetest/server.test.ts

The request-path counterpart to the Economy

Most of these docs talk about the Economy as an in-process object: you hold a reference and call submit against it directly. But a deployed service has to take requests off a socket. That’s what the HTTP service is: a thin wrapper that turns a Fetch Request into one submit call and a Response.

createServer takes one options object — the Economy, the narrow ports pick the routes read (config, secrets, clock, meter, logger; a full Ports is assignable), and an authenticate posture — and returns a handler with the signature (request: Request) => Promise<Response>:

import { createServer } from '@pwngh/economy-lab';

const handler = createServer({ economy, ports, authenticate });
const response = await handler(new Request('http://service/healthz'));

It uses only Fetch globals (Request, Response, URL, crypto.subtle) and no Node APIs, so the same handler runs on Node, Bun, Deno, and Cloudflare Workers. The wiring in scripts/main.ts (covered below) is what bridges it onto a concrete runtime.

What it exposes

The handler routes five paths and answers 404 to everything else. Three move money or report state; two are for your orchestrator.

Method and pathWhat it does
POST /submitDecode one operation from the JSON body, run it through submit, return the Outcome.
POST /instances/:scope/purchaseHand an in-world purchase to the scope’s fast lane. Routed only when the server is built with instances; 404 otherwise.
POST /webhooks/:providerVerify an inbound provider callback, then hand it to the injected handler.
GET /healthzReport liveness without touching storage.
GET /readyzReport readiness via one cheap store-touching read.

Submitting an operation

POST /submit is the request-path version of a submit call. You send one operation as a JSON object; you get back its Outcome. The body’s kind selects the operation: topUp, spend, refund, and the rest. Every operation page shows both forms of its example: the HTTP tab is the same call as a curl against this route.

One detail matters on the wire: money never travels as a JSON number. A JSON number can’t safely hold the integer minor-units an Amount carries, so money fields arrive as decimal strings like CREDIT:10.00 and the server decodes them back into Amount values. Here’s a topUp body:

{
  "kind": "topUp",
  "idempotencyKey": "idem_buyer_10",
  "actor": { "kind": "system", "service": "checkout" },
  "userId": "usr_buyer",
  "source": "card",
  "amount": "CREDIT:10.00"
}

A committed operation comes back 200 with its transaction, each leg’s amount written as the same decimal string:

{
  "status": "committed",
  "transaction": { "legs": [{ "account": "...", "amount": "CREDIT:10.00" }] }
}

A rejected outcome is not an error. When the economy declines a valid request for a business reason (say a spend the buyer can’t cover) the response is still 200, carrying the decline:

{
  "status": "rejected",
  "reason": "INSUFFICIENT_FUNDS",
  "detail": {
    "reason": "INSUFFICIENT_FUNDS",
    "account": "usr_buyer:spendable",
    "need": "CREDIT:4.00",
    "have": "CREDIT:0.00"
  }
}

The wire shape is { status, reason, detail }: the top-level reason is a convenience copy derived from detail.reason, which is the rejection’s sole TypeScript discriminant, and every money figure in detail travels as the same decimal string as any other wire amount.

In the status mapping below, a decline is a normal answer and a fault is an HTTP error.

How thrown faults map to status codes

When submit throws an EconomyError, statusForError maps it to a status code. The response carries the caller-safe problem fields (title, code, retryable) and never detail, cause, or stack — those stay server-side.

The mapping is by the error’s stable code:

ConditionStatus
Missing permission (UNAUTHORIZED) or bad webhook signature (INVALID_SIGNATURE)401
Caller’s request was wrong: malformed operation, invalid amount, currency mismatch400
Retryable fault, like a transient storage failure503
Anything else500

An unexpected throw that isn’t an EconomyError is normalized into a retryable storage fault, so it goes out as a 503 with a generic message. The internals never reach the client.

The instance purchase lane

POST /instances/:scope/purchase is the transport an unprivileged game server calls to buy through the instance economy. It exists only when ServerOptions.instances carries the lane manager — an InstanceEconomies built over the same ports — and answers 404 otherwise. The route hands the decoded purchase to laneFor(scope) and returns the PurchaseOutcome; the money settles later, on the lane’s own schedule.

const outcome = await lanes.laneFor('wrld_1043').purchase({
  buyerId: 'usr_buyer',
  price: toAmount('CREDIT', 30_000n),
  recipients: [{ sellerId: 'usr_creator', shareBps: 10_000 }],
  product: { sku: 'wrld_1043:jetpack', kind: 'permanent' },
});
// → { status: "accepted", orderId: "sess_…", seq: 0 }
curl -s https://economy.example/instances/wrld_1043/purchase \
  -H 'content-type: application/json' \
  -d '{
    "buyerId": "usr_buyer",
    "price": "CREDIT:300.00",
    "recipients": [{ "sellerId": "usr_creator", "shareBps": 10000 }],
    "product": { "sku": "wrld_1043:jetpack", "kind": "permanent" }
  }'

The route shares /submit’s authentication posture, and a user principal may only purchase with their own wallet — a body naming someone else’s buyerId is refused with AUTH.UNAUTHORIZED before the lane sees it.

There is deliberately no batch route: Economy.submitBatch and the submit coalescer are in-process surface. An HTTP edge that wants burst-coalescing wraps its own economy handle; the wire protocol stays one operation, one outcome.

Authenticating the submit path

ServerOptions.authenticate is a required posture, not a default: createServer refuses to construct without it. Passing authenticate: false declares that the server trusts the actor object in the body, which is only safe when the handler runs in-process and every caller is your own code. The moment the handler sits on a network socket, the actor must come from a credential, not from the caller’s say-so — otherwise any client can claim to be an operator.

For that, you supply a function from the raw Request to a Principal — typically by checking a bearer token or session cookie — and submitRoute runs it before it reads the body:

const handler = createServer({
  economy,
  ports,
  authenticate: async (request) => {
    const session = await sessions.lookup(request.headers.get('authorization'));
    return session === null ? null : { kind: 'user', userId: session.userId };
  },
});

Three rules follow once the hook is configured:

  • The returned principal is stamped onto the operation as its actor. The body must omit actor entirely; a body that carries its own is rejected 400 rather than silently overridden, so a caller can never believe a claimed actor was honored.
  • Returning null refuses the request with a 401 problem response carrying the stable UNAUTHORIZED code.
  • The hook runs before the body is read, so an unauthenticated caller costs no buffering.

Authorization — what the stamped principal may then do — is unchanged and stays inside the economy: a user principal still can’t spend from someone else’s wallet regardless of how it authenticated.

Body limits and cross-origin calls

Every body the server accepts — /submit and /webhooks/:provider alike — is read under two bounds, both settable in ServerOptions:

  • A byte ceiling (maxBodyBytes, default 64 KiB). A declared content-length past it is refused before any byte is read; a lying or absent declaration is caught while the chunks arrive. Past the ceiling the reply is a 413 problem. Every legitimate operation fits in a small fraction of the default.
  • A read deadline (readTimeoutMs, default 10 seconds). A body that trickles past the deadline is answered with a 408 problem, so a slow client cannot hold the handler open.

The bounded read lives at readBounded. The Node bridge in scripts/main.ts buffers the body before the Fetch handler ever sees it, so the bridge itself enforces the same two limits while the bytes stream in.

Cross-origin resource sharing (CORS) — the browser mechanism that decides whether a page on another origin may call this service — stays off unless you list origins:

const handler = createServer({
  economy,
  ports,
  authenticate,
  cors: { origins: ['https://app.example'] },
});

Origins match exactly. With the option absent the server sets no CORS headers at all, and with it present an unlisted origin gets none either — the deny is the absence of a grant. A denied preflight is a bare 204 that leaks nothing about the allowlist (preflightResponse).

Rate limiting the submit path

/submit takes admission control through the RateLimiter port: one allow call per request, keyed by caller, answering a verdict. The limit and window are the adapter’s policy, so the server never sees the numbers:

import { createServer } from '@pwngh/economy-lab';
import { memoryRateLimiter } from '@pwngh/economy-lab/adapters';

const handler = createServer({
  economy,
  ports,
  authenticate,
  rateLimit: { limiter: memoryRateLimiter({ limit: 100, windowMs: 60_000 }) },
});

A denial answers 429, with a retry-after header when the limiter knows how long the window has left. The default key is the authenticated principal (user:usr_42, system:checkout); without an authenticate hook it falls back to the client address the Node bridge reads from the socket and stamps into the x-economy-client-ip header, overwriting anything inbound so a caller can’t spoof it. Hosts on other runtimes supply rateLimit.keyFor.

Two adapters ship: memoryRateLimiter (on the adapters subpath) counts fixed windows in-process, and redisRateLimiter (on the adapters/redis subpath) does the same over Redis so several instances share one budget. A throwing limiter fails open — a down limiter backend should degrade protection, not availability — and each such failure counts economy.ratelimit.degraded on the wired meter.

Correlating a request end to end

Every /submit reply carries an x-request-id header, problem responses included. The server picks the id in a fixed order: the trace id of a W3C traceparent header wins, so a caller already running distributed tracing gets one id across both systems; an explicit x-request-id header is next; otherwise the server mints one (req_…).

The id doesn’t stop at the response. Submit stamps it onto the outbound event envelope, and because the envelope is persisted in the outbox, the id survives into the relay worker running in another process — its delivery-failure and dead-letter logs name the originating request. One id, quoted from any error report, follows a money movement from the HTTP edge to its event leaving the building.

Two boundaries on purpose: worker-born events (payout reversals, subscription renewals) carry a null id because no request caused them, and the health probes carry no id at all — there is nothing to trace.

Receiving provider webhooks

The other money-moving path is inbound. A payment provider calls POST /webhooks/:provider to report a real-world event: a user’s purchase cleared, a payout settled or failed, a charge was disputed. The :provider segment names who’s calling (steam, billing); it comes from the route, not the body, so it can’t be spoofed.

The server doesn’t apply these itself. It verifies the callback, then hands the trusted bytes to a WebhookHandler you inject through ServerOptions. With no handler wired, the path answers 404. The handler is where the callback becomes a ledger operation: a cleared purchase maps to a topUp, a settled payout to a settlePayout, a failed payout to a reversePayout, a dispute to a clawback. Those mappers (toTopUp, toSettlePayout, toReversePayout, toClawback) and the event shapes they consume belong to the processor port, so see that page for the dispatch. The economy-edge bridge injects exactly such a handler for the Tilia rail’s callbacks (the packages).

The verification gate

When ports.secrets carries a webhook secret, the server gates every callback before the handler runs. A forged request never reaches code that changes balances. The checks run in order:

  1. Signature. The hex x-signature header must be an HMAC-SHA256 of the raw body, keyed with the configured secret. A mismatch is 401 INVALID_SIGNATURE and nothing downstream runs. The check uses crypto.subtle.verify, which compares in constant time.
  2. Freshness. The x-timestamp header must be finite and within ports.config.replayWindowMs of ports.clock. A stale or missing timestamp is answered 200 with { "status": "duplicate" }, not a 5xx that would invite a retry storm.
  3. Replay. When you also wire a ReplayStore, the server claims the provider’s eventId last, after signature and freshness. A repeat eventId is answered 200 and the handler never runs, so its work happens once. Claiming last means a forged or stale delivery can never burn a real eventId and block a later genuine one.

This is the request-path half of idempotency: the replay store drops most redeliveries here, at the edge, before they reach the handler.

When no secret is configured, the webhook path is a bare pass-through: the server forwards straight to the handler and the host owns verification. The wiring below fails to start without a secret.

Health and readiness

The two GET probes are for your orchestrator, and they answer different questions.

GET /healthz is liveness: the process is up and can serve a response. It does no I/O, so it answers even when a downstream dependency is down. The Dockerfile health check targets this path.

{ "status": "ok" }

GET /readyz is readiness: a dependency is reachable, so the orchestrator can route traffic here. It does one cheap store-touching read through the economy: the balance of a known SYSTEM account. Any throw means the store is unreachable, reported as 503 with no detail:

{ "status": "ready" }     // 200, store reachable
{ "status": "unavailable" } // 503, store read threw

The probe goes through the economy’s public read surface, not the ledger directly, since createServer only ever receives the Economy and the narrow ports pick, so it can’t reach past that boundary.

Wiring it onto a runtime

createServer is runtime-agnostic on purpose, which means something has to mount it on a real listener. That’s scripts/main.ts: the app entry point, and the one place environment variables are read.

It runs in three modes, selected by argv[2]:

ModeWhat it starts
serveThe HTTP API on $PORT (default 3000); store, cache, and dispatcher come from the environment.
devThe same API, forced to in-memory adapters with dev secrets, no infrastructure, for make dev.
workerThe background worker loop, not the HTTP service.

In serve and dev, runServe builds the economy from the environment and mounts the handler. It passes the purchase-webhook handler and the ports bag into createServer. The bag’s secrets and config are what activate the verification gate, so a genuine callback is persisted and a forged or stale one is rejected before it changes anything.

The handler itself is yours to write, and its body is two calls exported from the server subpath. decodeWebhookEvent turns the parsed JSON body into a typed PurchaseEvent; a wrong-shape body or bad amount throws a fault the service maps to a 400 problem response before anything reaches the ledger. handlePurchaseWebhook persists the decoded event to the inbox, deduplicated on the provider’s event id, for the apply worker to settle off the request path:

import { createServer } from '@pwngh/economy-lab';
import { decodeWebhookEvent, handlePurchaseWebhook } from '@pwngh/economy-lab/server';

const handler = createServer({
  economy,
  ports,
  authenticate,
  webhook: async (provider, request) => {
    const event = decodeWebhookEvent(provider, await request.json());
    const receipt = await handlePurchaseWebhook(store, { ids, clock }, event);
    return Response.json({ status: receipt.status });
  },
});

On Bun and Deno, the Fetch handler is served directly. On Node, the entry bridges node:http requests into web Request/Response objects so the same Fetch-only handler runs unchanged. This translation lives in scripts/, and the rest of src/ stays runtime-agnostic.

What’s stubbed versus production-grade

The HTTP edge (the routing, the wire codec, the webhook gate, the status mapping) is what the tests exercise: test/server.test.ts covers /submit, the HMAC and freshness checks, the leak-proof error body, and both probes.

The bridge in scripts/main.ts is dev plumbing, not a hardened gateway. It reads the whole request body into memory before handing it over — capped at the same byte ceiling and read deadline the server enforces — and wires no rate limiter by default. The Node path is a minimal node:http translation. In a real deployment you’d typically sit this behind a reverse proxy that owns TLS and connection limits.

See also