# @bellona/remote-gateway

Bellona remote gateway walking skeleton for Phase 180.

This walking skeleton exposes process and HTTP readiness primitives, host
WebSocket registration, a protocol-valid device registry, an internal command
dispatcher, session lifecycle manager, hash-chained audit log, approval store,
one-time pairing code manager, timeline query service, and in-memory telemetry
recorder. The default runtime remains ephemeral for tests and development, but
the gateway can opt into a first-stage atomic JSON store for devices, sessions,
command dispatches/events, pairing codes, artifact metadata, approval requests
and decisions, and audit entries.

The gateway owns a remote device registry for early host/session work. Every
registry write is validated with `@bellona/remote-protocol`
`RemoteDeviceSchema`; when first-stage persistence is configured, the same
validated snapshots are written through the gateway persistent store.

## Endpoints

- `GET /health`
- `GET /v1/health`
- `GET /ready`
- `GET /v1/ready`
- `GET /v1/artifacts`
- `POST /v1/artifacts`
- `GET /v1/approvals`
- `POST /v1/approvals/:requestId/decisions`
- `GET /v1/devices`
- `GET /v1/timeline`
- `POST /v1/pairing/exchange`
- `WS /v1/hosts/connect`

Host WebSocket clients must send a first message shaped as this abridged
example:

```json
{
  "type": "host.handshake",
  "authentication": {
    "scheme": "bellona-host-token-v1",
    "deviceKeyId": "device-key:example-development",
    "token": "bellona-host-token-v1.<payload>.<signature>"
  },
  "protocolVersion": "0.1.0",
  "supportedProtocolVersions": ["0.1.0"],
  "device": {
    "id": "device:example"
  }
}
```

The `device` payload must be a complete `RemoteDevice` report from
`@bellona/remote-protocol`. The `authentication` payload must contain a
gateway-signed host token issued during pairing. Invalid handshakes, missing
tokens, expired tokens, mismatched device ids, and invalid signatures are closed
with WebSocket policy code `1008`.

Device inventory is available through `GET /v1/devices`. The endpoint returns
the validated device registry snapshots currently known to the gateway,
including host-sourced permission diagnostics such as macOS Screen Recording and
Accessibility state. Those permission records are also the policy input used to
deny or require approval before desktop fallback commands reach a host.

Gateway-dispatched commands are sent to the connected host as `command.dispatch`
messages containing a protocol-valid `RemoteCommandEnvelope`. Duplicate
idempotency keys replay the first gateway dispatch record and do not send
another host message.

Before dispatch, the gateway evaluates the command with the shared
`@bellona/remote-protocol` permission policy. The evaluator combines the
advertised device capability scopes with inferred high-risk scopes for
observe-only streaming, project writes, desktop control, real or isolated
browser profiles, arbitrary code, shell execution, destructive commands,
publishing, and financial actions. Commands that are not advertised by the host,
target blocked adapters or capabilities, miss required permissions, have denied
or expired permissions, or require approval are rejected before any host message
is sent. Rejections emit `policy.denied` audit entries and
`remote_gateway.command.failed` telemetry.

After dispatch, connected hosts can send `command.progress`, `command.result`,
and `command.error` messages back over the same WebSocket. The gateway validates
those messages with `@bellona/remote-protocol` result schemas and routes them to
the dispatching client id recorded by the internal command dispatcher.

Session lifecycle state is managed through internal service methods on
`app.sessionLifecycle`. The in-memory manager validates every returned
`RemoteSession`, supports start, pause, resume, stop, takeover request, and
takeover grant transitions, and records protocol-valid lifecycle events for each
transition.

Basic audit emission is available through `app.auditLog`. The gateway emits
hash-chained `RemoteAuditEntry` records for pairing completion, device
revocation, command receipt, command dispatch with policy decision metadata,
policy denial, approval request/decision, host command results or errors, and
session stop events. Command-related audit metadata is normalized with
`correlationId`, `sessionId`, `commandId`, `actorParticipantId`, `deviceId`,
stable `commandHash` and `resultHash` values, and policy decision fields where
available. Every audit entry must carry a protocol-valid `RemoteActorChain`;
audit metadata also records the chain id, root actor id, terminal actor id,
actor ids, stable actor ids, and actor kinds so delegated human, orchestrator,
specialist, and adapter attribution remains queryable after export. For
dispatched commands, the gateway reserves the target host connection and writes
the `command.dispatched` audit entry before sending the host WebSocket message,
so a dispatch-audit store failure blocks mutating adapter work before it reaches
the host.

Audit-store outages are fail-closed for command execution. If the gateway cannot
write the initial `command.received` audit entry, it blocks every command except
explicitly safe host diagnostics advertised through the diagnostic adapter:
`agent.readiness` and `state.snapshot`. Those diagnostics must remain read-only,
require no approval or dry-run, use only `device.read`, `adapter.inspect`, and
`command.execute.readonly` scopes, and be backed by an available diagnostic
adapter/capability. Repeated audit write failures during that degraded
diagnostic path are logged and counted, but do not prevent the diagnostic result
from routing back to the client.

Ordered timeline reads are available through `app.timeline.queryTimeline()` and
`GET /v1/timeline`. The query service merges session lifecycle events, command
progress/result/error client events, approval requests and decisions, registered
artifact metadata, and audit-backed stream events into a single
timestamp-ordered result. The HTTP endpoint accepts `sessionId`, `commandId`,
`deviceId`, `actorParticipantId`, `streamId`, `artifactId`, `approvalRequestId`,
repeated or comma-separated `kind` and `type` filters, `after`, `before`,
`limit`, and `offset` query parameters. Timeline events are returned with
`kind`, `type`, `source`, `occurredAt`, correlation identifiers,
evidence/artifact references, and normalized metadata. Until a dedicated stream
store is added in later tasks, stream events are derived from
`evidence.captured` audit entries that carry `streamId` metadata.

Artifact metadata registration is available through `app.artifactRegistry` and
`POST /v1/artifacts`. The registry validates `RemoteArtifact` metadata for
screenshots, renders, logs, browser traces, exports, and diagnostic bundles,
stores the artifact metadata in the gateway persistent store, and emits a
hash-chained `artifact.registered` audit entry. Export registrations currently
map to the protocol `audit-export` artifact kind; diagnostic bundles map to the
protocol `file` kind with `artifactPurpose: "diagnostic-bundle"` metadata.
`GET /v1/artifacts` lists registered artifacts and accepts `sessionId`,
`commandId`, `deviceId`, `kind`, and `redactionStatus` filters.

Host command results can also register artifacts without a separate HTTP call.
When a `browser.screenshot` or `desktop.screenshot` result includes
`artifact`/`artifacts` plus `artifactIds` and `evidenceIds`, the command
dispatcher stores those screenshot artifacts through the same registry and makes
them visible in audit and timeline queries.

Desktop window fallback commands do not register artifacts by themselves.
`desktop.window.list` and `desktop.window.focus` return structured command
outputs with selected-window identity, app/title, bounds, display metadata, and
focus verification so later screenshot/click commands can reference the same
window id through the audited command timeline.

The operator runbook for reading session timelines and exporting the current
store-backed audit bundle is
[`docs/domains/bellona/extras/remote-control/session-timeline-and-audit-bundle.md`](../../../docs/domains/bellona/extras/remote-control/session-timeline-and-audit-bundle.md).
The current bundle path uses `GET /v1/timeline`, the first-stage JSON store,
local checksums, and `POST /v1/artifacts` registration; a dedicated public audit
export endpoint remains a later Phase 180 task.

Redaction utilities are exported from `app` package entrypoints for gateway and
host call sites that need to scrub retained metadata, logs, traces, window
titles, or future screenshot OCR text before audit/artifact retention. The
utilities redact bearer/JWT/gateway tokens, API keys, passwords, browser cookie
headers or cookie-valued metadata, macOS/Linux/Windows personal file paths, and
operator-denied window titles. They return both the redacted value and
structured findings so later audit/export code can preserve redaction evidence
without retaining the sensitive source text.

Gateway runtime logs use the shared Bellona remote structured logging fields
from `@bellona/remote-protocol`. Command, host-connection, HTTP, and lifecycle
logs include `serviceRole`, `eventType`, and the available correlation fields:
`correlationId`, `sessionId`, `commandId`, `deviceId`, `connectionId`,
`actorParticipantId`, `idempotencyKey`, and command target fields. These logs
are intended for operational triage and metrics correlation; audit remains the
append-only security record.

Basic gateway metrics are available through `app.telemetry`. The in-memory
recorder emits typed counter/timer/state metric events for command count,
command duration, command failures, host online/offline state, host reconnects,
approval decision latency, and audit write failures. Metrics use the same
correlation fields as structured logs so local tests, future exporters, and
diagnostic bundles can join them with timeline and audit records.

Approval request and decision storage is available through `app.approvalStore`.
Approval request creation, decision handling, timeout, deny-by-default behavior,
and approved command continuation are available through `app.approvalService`.
When command policy returns `requires-approval`, the dispatcher stores a pending
`RemoteApprovalRequest`, emits `approval.requested`, and withholds the host
message. An approved response records `RemoteApprovalDecision`, emits
`approval.decided`, and automatically continues the original command. Denied,
cancelled, and expired requests clear their pending continuation without
dispatch. Expiry is deny-by-default and records an `expired` approval decision
from `participant:gateway-policy`; the running gateway sweeps pending approvals
on a bounded interval derived from
`BELLONA_REMOTE_GATEWAY_APPROVAL_REQUEST_TTL_MS`. The Control Room-facing HTTP
surface can list approval requests with `GET /v1/approvals?status=pending` and
can record operator decisions with `POST /v1/approvals/:requestId/decisions`.
Denied decisions require a reason, approved decisions continue the withheld
command through the same gateway approval continuation handler, and closed or
missing requests fail without dispatching.

One-time pairing code creation is available through `app.pairingCodes`. Pairing
codes are generated with Node CSPRNG bytes, normalized to an eight-character
operator-friendly code, stored only as SHA-256 hashes, expire after a bounded
TTL, and can be redeemed once.

Pairing exchange is available at `POST /v1/pairing/exchange`. The request
contains a one-time pairing code and a complete `RemoteDevice` report. The
gateway redeems the code, stores the device as an offline paired device, and
returns gateway-signed host credentials. The Remote Host persists those
credentials in macOS Keychain by default; mTLS remains later transport hardening
work.

Device revocation is available through `app.deviceRevocation`. Revocation marks
the device `blocked`, records revocation metadata on the device snapshot,
disconnects any active host connection with WebSocket policy code `1008`,
rejects future handshakes for that device id, and prevents command dispatch to
the blocked device.

Metrics and structured command/session logs include the same correlation fields:
`correlationId`, `sessionId`, `deviceId`, `commandId`, `actorId`, and
`adapterTarget` when command context is available. The current walking skeleton
stores metric events in `app.telemetry`.

## Configuration

- `BELLONA_REMOTE_GATEWAY_HOST` or `HOST`, default `127.0.0.1`
- `BELLONA_REMOTE_GATEWAY_APPROVAL_REQUEST_TTL_MS`, default `60000`
- `BELLONA_REMOTE_GATEWAY_HOST_HEARTBEAT_INTERVAL_MS`, default `30000`
- `BELLONA_REMOTE_GATEWAY_HOST_HEARTBEAT_TIMEOUT_MS`, default `90000`
- `BELLONA_REMOTE_GATEWAY_HOST_TOKEN_SIGNING_SECRET`, default development-only
  local secret; production deployments must override this
- `BELLONA_REMOTE_GATEWAY_HOST_TOKEN_TTL_MS`, default `7776000000` (90 days)
- `BELLONA_REMOTE_GATEWAY_PORT` or `PORT`, default `4070`
- `BELLONA_REMOTE_GATEWAY_LOG_LEVEL`, default `info`
- `BELLONA_REMOTE_GATEWAY_PAIRING_CODE_TTL_MS`, default `600000`
- `BELLONA_REMOTE_GATEWAY_STORE_FILE`, optional path to the first-stage gateway
  JSON store
- `BELLONA_REMOTE_GATEWAY_SHUTDOWN_GRACE_MS`, default `5000`
- `NODE_ENV`, default `development`

## First-Stage Store

Set `BELLONA_REMOTE_GATEWAY_STORE_FILE` to enable durable gateway state:

```bash
BELLONA_REMOTE_GATEWAY_STORE_FILE=.local/bellona/remote-gateway.store.json pnpm nx start @bellona/remote-gateway
```

The file is an explicitly documented first-stage store, not the final production
database. It uses schema version `3`, kind `gateway-first-stage-json-file`, and
atomic temp-file rename writes. On load, the gateway validates every stored
`RemoteDevice`, `RemoteSession`, `RemoteCommandEnvelope`,
`RemoteApprovalRequest`, `RemoteApprovalDecision`, and `RemoteAuditEntry`; it
also rejects duplicate primary ids and broken audit sequence/hash links before
the server starts using that state.

Legacy schema version `1` and `2` snapshots are forward-migrated on load and
rewritten to the current schema after validation. Current snapshots include
version stamps for device, session, command, approval, and audit record sets,
plus migration history entries that capture schema and protocol transitions. The
exported `migrateRemoteGatewayPersistentStoreSnapshot` helper can also downgrade
current snapshots to schema version `2` or `1` for rollback fixtures and
compatibility tests.

The store currently persists:

- device snapshots from host handshake and disconnect updates
- session snapshots and lifecycle events
- command dispatch records keyed by command id and idempotency key
- routed command progress, result, and error events
- artifact metadata records and redaction status
- hashed one-time pairing code records
- approval requests and approval decisions
- hash-chained audit entries

## Pairing Codes

Pairing code methods are currently internal service APIs. A caller creates a
code with `app.pairingCodes.createPairingCode()`, shows the returned `code` to
the operator or host setup flow once, and only stores the returned
`record.codeHash`.

Important behavior:

- default code TTL is ten minutes and can be overridden by config or create
  input within the bounded 30-second to 60-minute range
- redemption accepts hyphenated or lowercase input after normalization
- expired, cancelled, and already redeemed codes cannot be reused
- `expirePairingCodes()` marks stale pending records as `expired`
- `redeemPairingCode()` currently records the redeemed device id when supplied;
  issuing/storing device credentials is the next checklist item
- `POST /v1/pairing/exchange` is the development host-facing exchange path for
  `180.C.07.03`

## Verification

- `pnpm nx build @bellona/remote-gateway`
- `pnpm nx typecheck @bellona/remote-gateway`
- `pnpm nx lint @bellona/remote-gateway`
- `pnpm nx test @bellona/remote-gateway`

## Local Startup

```bash
BELLONA_REMOTE_GATEWAY_PORT=4097 BELLONA_REMOTE_GATEWAY_LOG_LEVEL=debug pnpm nx start @bellona/remote-gateway
curl -fsS http://127.0.0.1:4097/health
curl -fsS http://127.0.0.1:4097/ready
```

Expected health output is a JSON payload with `status: "healthy"`. Expected
readiness output is a JSON payload with `status: "ready"` and `checks.config`,
`checks.deviceRegistry`, and `checks.shuttingDown`.

## Fake Host Connection

The host connection flow is WebSocket-first. A fake host connects to
`ws://127.0.0.1:4097/v1/hosts/connect` and sends the handshake before any
command messages:

```json
{
  "type": "host.handshake",
  "protocolVersion": "0.1.0",
  "supportedProtocolVersions": ["0.1.0"],
  "device": {
    "id": "device:studio-macbook-air",
    "displayName": "Studio MacBook Air"
  }
}
```

The `device` object above is abridged for readability. In tests and real hosts
it must be a complete `RemoteDevice` report. A valid fake host receives:

```json
{
  "type": "host.handshake.accepted",
  "connectionId": "host-connection:000001",
  "heartbeatIntervalMs": 30000,
  "selectedProtocolVersion": "0.1.0"
}
```

Invalid handshakes close with WebSocket policy code `1008`.

## Fake Command Dispatch

The walking skeleton does not expose the client command API over HTTP yet.
Dispatch is intentionally internal through `app.commandDispatcher`, which lets
tests exercise the gateway-to-host route without adding premature public API
surface.

The executable fake-host flow is covered in `src/command-dispatcher.test.ts`:

1. Start `createRemoteGatewayApp()` on an ephemeral port.
2. Connect a local `ws` client to `/v1/hosts/connect`.
3. Send a protocol-valid host handshake using `remoteDeviceFixture`.
4. Call
   `app.commandDispatcher.dispatchCommand(remoteCommandEnvelopeFixtures[0])`.
5. Assert the fake host receives this abridged message:

```json
{
  "type": "command.dispatch",
  "command": {
    "id": "command:blender-scene-query-001"
  },
  "sentAt": "2026-04-22T00:00:00.000Z"
}
```

The fake host can then respond on the same WebSocket with this abridged message:

```json
{
  "type": "command.result",
  "result": {
    "id": "result:blender-scene-query-001",
    "commandId": "command:blender-scene-query-001",
    "status": "succeeded"
  }
}
```

The full `command` and `result` payloads must satisfy `RemoteCommandEnvelope`
and `RemoteCommandResult`. Progress and error responses use the same route with
`command.progress` and `command.error` message types. The gateway validates all
host responses against `@bellona/remote-protocol`, routes them to the
dispatching client id, emits audit entries, and records telemetry.

`180.C.15.06` extends the same fake-host coverage to the Unreal read-only
commands `unreal.project.info` and `unreal.world.query`. The tests register an
Unreal-capable device snapshot, dispatch both commands through the generic
gateway command dispatcher, return protocol-valid Unreal outputs, and assert
that `command.received`, `command.dispatched`, `command.progress`,
`command.result`, and `command.completed` are visible through audit and timeline
queries.

`180.C.15.07` extends the route to `unreal.actor.spawn`. The gateway fake-host
test registers an Unreal capability with `command.execute.safe-mutation` and
`project.write`, dispatches the command only when policy allows those scopes,
rejects it before host dispatch when `project.write` is denied, and validates
the returned actor-spawn result object, undo-stack transaction metadata, and
screenshot evidence placeholder through the normal client/audit/timeline path.

## Expected Logs And Tests

With `BELLONA_REMOTE_GATEWAY_LOG_LEVEL=debug`, the local flow should include
structured log entries named:

- `remote gateway listening`
- `remote host handshake accepted`
- `remote gateway command received`
- `remote gateway command dispatched`
- `remote gateway command result received`

Command log and metric records include `correlationId`, `sessionId`, `deviceId`,
`commandId`, `actorId`, and `adapterTarget`. Session stop records include
`correlationId`, `sessionId`, `deviceId`, and `actorId`.

Run the gateway checks before relying on the fake flow:

```bash
pnpm nx test @bellona/remote-gateway --verbose
pnpm nx typecheck @bellona/remote-gateway --verbose
pnpm nx lint @bellona/remote-gateway --verbose
pnpm nx build @bellona/remote-gateway --skip-nx-cache --verbose
```
