# 01 — TS gateway (`tools/trap-sandbox/`) > Sub-plan of [trap-sandbox-gateway-spec](./trap-sandbox-gateway-spec.md). Implements the > **Protocol** and **Handshake** contracts defined there — do not redefine frame shapes or error > codes here; reference the spec. **Build first** (M1): no Lua dependency. > **Grill-refined (implemented).** This sub-plan was hardened in a follow-up grill and built. Deltas > from the text below, all in the shipped code: > - WS `type` `health` → **`probe_gateway`** (in-game command stays `sandbox health`). > - Validation uses **zod** (`protocol.ts`); `parse()` throws on malformed / unknown `event` / failed > schema (handler catches → debug-logs → drops). `payload` (`[]`/missing/non-object → `{}`) via zod. > - `resolveAccount(secret, password)` owns the password gate (signature gained `password`). > - Dispatch state machine extracted to **`connection.ts`** (pure, FakeSocket-testable); the module > `index.ts` is the thin `ws` adapter. Registry depends on a minimal `GatewaySocket { send, close }`. > - `registry.request()` rejects a typed **`GatewayError { code }`** on not_connected/timeout/ok:false, > resolves the payload on success. Newest-wins eviction rejects the old socket's pendings + closes it; > `unregister`/close are reference-identity-checked. > - Logging via Fastify's **pino**; env `LOG_LEVEL` (default `info`, `silent` in tests), `LOG_PRETTY`; > silent drops log at `debug` with `{ event, type, reason }`. **No `serve` script** — `dev` is the > pretty entry point (`LOG_PRETTY=1 tsx watch …`); `pino-pretty` devDep; `zod` dep. > - Every `computer_request` (incl. `hello`) must carry non-empty `traposSenderId` (zod-enforced). > - Open `GET /health` is liveness-only (`{ok:true}`, no auth, no probe). Opencode probe: client built > once, fresh uncached 5s-bounded `config.get()` per call, no-URL short-circuit. ## Scope The TypeScript service: Fastify app on port 4444, the `trapos-cloud-gateway` module (protocol/registry/WS), the opencode probe, the auth seam, env/config, package wiring, and TS **unit** tests. Integration/e2e tests live in [04-integration-tooling](./04-integration-tooling.md). ## Task checklist - [ ] Scaffold `tools/trap-sandbox/` (ESM, `tsc` build, `tsx` dev/test, `node:test`, typescript-eslint flat config — copy `mcp-bridge`'s `tsconfig.json`/`eslint.config.js`). - [ ] `package.json`: deps + devDeps + scripts (see below). - [ ] `src/auth.ts` — `resolveAccount(secret)`. - [ ] `src/opencode.ts` — `createOpencodeProbe(config)`. - [ ] `src/modules/trapos-cloud-gateway/protocol.ts` — envelope/error types + parse/validate. - [ ] `src/modules/trapos-cloud-gateway/registry.ts` — `GatewayRegistry`. - [ ] `src/modules/trapos-cloud-gateway/index.ts` — Fastify plugin: `GET /health` (open) + `GET /gateway` (ws). - [ ] `src/app.ts` — `createApp(config)` returning `{ app, registry }`. - [ ] `src/index.ts` — env config, listen, `EADDRINUSE` clear message + exit. - [ ] TS unit tests (`test/`): protocol, registry, auth/hello, probe. - [ ] Gate: `npm test`, `npm run lint`/`check` clean. ## Layout ESM, `tsc` build, `tsx` dev/test, `node:test`, typescript-eslint flat config (copy mcp-bridge's `tsconfig.json`/`eslint.config.js`). Fastify `^5` + `@fastify/websocket` `^11`; `engines.node>=20`. ``` src/ ├── index.ts # env config; build app; listen on SANDBOX_PORT; clear EADDRINUSE message + exit ├── app.ts # createApp(config): Fastify + GatewayRegistry; register @fastify/websocket + module; returns { app, registry } ├── auth.ts # resolveAccount(secret): "local" | null (constant seam; null = unauthorized) ├── opencode.ts # createOpencodeProbe(config): probe(): { ok, detail } via @opencode-ai/sdk └── modules/trapos-cloud-gateway/ ├── index.ts # Fastify plugin: GET /health (open) + GET /gateway (ws) ├── protocol.ts # envelope/error types + parse/validate (4 events, hello, empty-payload→{}) └── registry.ts # GatewayRegistry keyed by (accountId,traposId); newest-wins; request()+timeout; pending cleanup on close test/ # TS unit (protocol, registry, auth/hello, probe) with FakeSocket + fake fetch ``` ## Behavior - **WS `/gateway`** (`{ websocket: true }`): no upgrade auth (auth is the hello frame). `maxPayload` ~1MB. `socket.on("message")` parses a frame inside try/catch → dispatch: pre-hello only `hello` is honored; post-hello handle `computer_request` (`health`, else `unknown_type`) and `computer_response` (resolve pending `gateway_request` by messageId). `close`/`error` → unregister + reject that connection's pendings. (Handshake steps & error codes: see spec **Handshake**.) - **GatewayRegistry**: `register`/`unregister`/`has`/`count`; `request(accountId, traposId, type, payload, timeoutMs=600000)` sends `gateway_request`, races matching `computer_response` vs timeout. Keyed by `(accountId, traposId)`, newest-wins. - **opencode probe**: `createOpencodeClient({ baseUrl: OPENCODE_URL, headers:{ Authorization: basic ("opencode", OPENCODE_SERVER_PASSWORD) }, throwOnError:false })` then `config.get()` with `AbortSignal.timeout(5000)`, wrapped in try/catch. `ok = response 2xx`; `detail` = url + status or error. No `OPENCODE_URL` → `{ ok:false, detail:"OPENCODE_URL not set" }` without a call. - **auth**: `resolveAccount(secret)` → `"local" | null` (constant seam; `null` = unauthorized). ## Env (`index.ts`) `SANDBOX_HOST`(`0.0.0.0`), `SANDBOX_PORT`(`4444`), `SANDBOX_PASSWORD`(unset⇒ auth off), `SANDBOX_REQUEST_TIMEOUT_MS`(`600000`), `OPENCODE_URL`(`http://127.0.0.1:4242`), `OPENCODE_SERVER_PASSWORD`, `OPENCODE_USERNAME`(`opencode`). ## package.json - deps: `fastify`, `@fastify/websocket`, `@opencode-ai/sdk` (move from devDeps). - devDeps: copy mcp-bridge (`typescript`, `tsx`, `@eslint/js`, `typescript-eslint`, `@types/node`, `globals`, `eslint`, `@types/ws`). - scripts: copy mcp-bridge **plus `"lint":"npm run eslint"`**. ## TS unit tests (`test/`) Protocol parse/validate (4 events, hello, `ok`/`error`, empty-payload→`{}`), registry (duplicate newest-wins per account, messageId correlation, timeout, pending cleanup on close), `resolveAccount`, opencode probe (fake fetch/server: ok / 401 / timeout). Use `FakeSocket` + fake fetch. ## Verification gate `npm test` + `npm run lint`/`check` clean in `tools/trap-sandbox`; service starts and listens on 4444.