# 3pm-backend route `nodeForward`

Paths are relative to the backend's origin (https://fleet.edgecr4ft.net, or your own 3pm-backend), not to this site. Index: https://3pm.edgecr4ft.net/docs/backend/api.md

---

tokens:
  claude: 4956
  o200k: 3133
# POST /api/buckets/{clusterId}/nodes/{nodeId}/forward (nodeForward)

Open one TCP connection from the node to `host:port` (`3pm fleet ssh -L`, one call per local connection): the answer is the call, whose `input.blob` you `PUT` the bytes to send and `output.blob` you `GET` the bytes that come back; it replies `exited` once both ways are closed. Never from a page on another site.

- auth: an account — Authorization: Bearer 3pmu_<token>, or the dashboard's session cookie
- node: also open to a node's own bearer (3pma_…) when its row is granted fleet:invoke; that node's bucket only, and for the calls it placed itself
- body: NodeForwardBody (JSON)
- answer: CallView (JSON)

## Details

The body: `host` (1 to 255 bytes, no space nor control character) and `port` (1 to 65535), else 422 `ERR_UNPROCESSABLE` — or, for `3pm fleet ssh -R`, `accepted` (`{listen, conn}`: a connection one of the caller's `nodeListen` calls accepted, which the node takes over instead of dialing) with neither and `e2e: true`, refused 404 `ERR_NOT_FOUND` unless `listen` is a running listen this caller placed on this node (one answer for every case, so a guessed id says nothing). Any destination the node can reach, itself included. Who may: the bucket's account always; a node's own bearer with `fleet:invoke` (else 403 `ERR_FORBIDDEN`) when the target's `callers` (`setNodeAccess`) is empty or holds a rule naming the action `3pm:forward` with `app` `-` for it — a wildcard action never reaches it — else 403 `ERR_FORWARD_NOT_ALLOWED`. A `Bearer` from a page on another site is refused (403). The answer and the legs are `nodeShell`'s (the same states, quotas, quiet minute and day), except what the legs carry: `live` frames whose payload is raw bytes (64 KiB at most each), an empty frame flagged `END` closing that way (a half-close: the node shuts its socket's write side, the caller its own). The node connects within 5 s or the call settles with the agent's `err` (`unavailable`, `connect <host>:<port>: <why>`); the agent refuses it `forbidden` without the `node:forward` capability (on by default) or when too old to know `forward`. Audit (`eventsQuery`, `kind: shell`): `open_requested` (or `refused`, with why) carrying `forward` = `host:port` (`listen` and `conn` for a connection taken over), `ended` when it settles, and the agent's `close` with `bytes_in` (to the destination) and `bytes_out` (back).

## Types

```ts
/** `nodeForward`'s body: where the node connects (`3pm fleet ssh -L`), or the connection one of the caller's listens accepted (`-R`). */
interface NodeForwardBody {
  /** 1 to 255 bytes, no space nor control character: a name the node resolves, or an address. Absent with `accepted`. */
  host?: string;
  /** 1 to 65535. Absent with `accepted`. */
  port?: number;
  /** `-R`: take over connection `conn` of the listen call `listen` (a live `nodeListen` of this caller on this node) instead of dialing; requires `e2e`. */
  accepted?: ForwardAccepted;
  /** As `NodeShellBody.direct`; a forward is never recorded. */
  direct?: boolean;
  /** As `NodeShellBody.e2e`. */
  e2e?: boolean;
}

/** One remote command, from `sent` to its end. */
interface CallView {
  call_id: string;
  node_id: NodeId;
  /** The `AgentRequest` sent. */
  request: CallRequest;
  state: CallState;
  /** Who placed it: `user:<id>`, or `node:<3pmn_…>/<app>` (`-` when the application was not named). */
  placed_by: string;
  /**
   * Once `replied`: for an action, `{ ok: true, res: "triggered", results: ActionOutcomeView[] }`;
   * for the rest, the agent's `AgentResponse` as is (`{ res: "pong", … }`, `{ res: "ok", affected }`, `{ res: "err", code, message }`).
   * `ok` says the action ran (`res === "triggered"`), never whether every instance succeeded — each outcome carries its own `ok`;
   * for a shell (`res: "exited"`, `code?`, `signal?`, `recording?` — the artifact the node uploaded its recording as, for an `e2e` session), that it exited 0.
   * A refusal (`res: "err"`) keeps the agent's own `code`, finer than `err.code`: `e2e_required` (the node serves no session in clear; `forbidden` in `err`), `key_not_authorized` (the caller's `cli.key` is not in the node's own `authorized_keys`, the message names the fingerprint; `forbidden` in `err`) and `e2e_broken` (the encrypted channel failed; `failed` in `err`).
   * `err` is set when the agent refused the call as a whole — `busy` (it already runs its most commands: retry), `timeout`, `forbidden`, `failed` — the same error the placing node's `call_result` carries.
   */
  result?:
    | { ok: boolean; res: "triggered"; results: ActionOutcomeView[] }
    | { ok: boolean; res: string; err?: { code: ActionErrCode; message: string }; [k: string]: unknown };
  sent_at: EpochMs;
  deadline_at?: EpochMs;
  acked_at?: EpochMs;
  replied_at?: EpochMs;
  /**
   * The blob to `PUT` the call's bytes to, when it carries any — the same thing `request.input` holds.
   * `bytes` is absent on a stream (`live: true`): a session has no length to declare.
   */
  input?: { blob: string; bytes?: number; sha256?: string; ext?: string; live: boolean };
  /**
   * The blob the target writes its answer to, when one was asked for; `GET` it once the call is replied.
   * `bytes`, `sha256` and `ext` are absent until an outcome has claimed it: check the bytes you pull against `sha256`.
   */
  output?: { blob: string; bytes?: number; sha256?: string; ext?: string };
  /**
   * A stream's counters while it is relayed, and frozen at their last value once it has settled;
   * absent for a call that carries no stream, and gone once the relay has forgotten its blobs.
   */
  live?: LiveView;
  /** The files the call's actions left, once replied. */
  artifacts: ArtifactView[];
  /** The other end, when the two nodes are LAN neighbours: in the answer that placed the call only, for the caller's agent to move the bytes itself. */
  peer?: Peer;
  /** Which way the call's blob went, once its writer said: `direct` (agent to agent) or `relay`; absent, the relay. */
  path?: "direct" | "relay" | (string & {});
}

/** A connection a listen accepted, as its `accepted` message numbered it. */
interface ForwardAccepted {
  /** The listen's call id. */
  listen: string;
  conn: number;
}

/** `3pmn_…` — a node. */
type NodeId = string;

/** The `AgentRequest` a call sent: a generic command, or the action `nodeAction` built. */
type CallRequest =
  | NodeCallBody
  | {
      op: "action"; target: NodeTarget; instance?: number; source?: string; action: string;
      params?: unknown; timeout_ms?: number;
      /** The input blob and what it holds, as the target's agent reads it. */
      input?: { blob: string; bytes?: number; sha256?: string; ext?: string; live: boolean };
      /** The output blob the target writes its answer to. */
      output?: string;
      /** A session: `input` and `output` are its two `live` legs. */
      stream?: boolean;
    }
  | {
      /** `nodeShell`: `input` is the keyboard leg, `output` the screen leg (both `pipe`). */
      op: "shell"; term: string; cols: number; rows: number; input: string; output: string;
      /** Who placed it (`placed_by`), and that node's hostname. */
      from: string; from_hostname?: string;
      /** The legs are sealed end to end between the caller and the node: the backend relays bytes it cannot read. */
      e2e?: boolean;
      /** The node records the session's screen itself (an `e2e` session; `record_shells` on). */
      record?: boolean;
    }
  | {
      /** `nodeForward`: `host:port`, or (`accepted`) a connection a listen accepted, `host` empty and `port` 0. */
      op: "forward"; host: string; port: number; input: string; output: string;
      from: string; from_hostname?: string; e2e?: boolean; accepted?: ForwardAccepted;
    }
  | {
      /** `nodeListen`: `bind` empty is the node's loopback, `port` 0 one it picks. */
      op: "listen"; bind: string; port: number; input: string; output: string;
      from: string; from_hostname?: string; e2e?: boolean;
    };

type CallState = "sent" | "acked" | "replied" | "unacked" | "refused" | "lost" | (string & {});

/** What one instance answered to an action. */
interface ActionOutcomeView {
  proc_id: number;
  name: string;
  instance: number;
  source: string;
  action: string;
  duration_ms: number;
  ok: boolean;
  err?: { code: ActionErrCode; message: string };
  /** The handler's JSON (≤ 256 KiB); bigger, `data_artifact_id` names an artifact holding it. */
  data?: unknown;
  data_artifact_id?: string;
  /** The file the handler left, as an artifact. */
  artifact_id?: string;
  /** The output the handler pushed to the call's blob: where it is, how long, and its digest (the target's claim, never checked here: check it when you pull the blob). */
  output?: { blob: string; bytes: number; ext: string; sha256: string };
}

type ActionErrCode = "failed" | "timeout" | "busy" | "gone" | "refused" | "too_large" | (string & {});

/** Milliseconds since the Unix epoch. */
type EpochMs = number;

/** What a `live` session has relayed, counted in memory — never stored. */
interface LiveView {
  /** Bytes the caller has sent into the session (the input blob). */
  bytes_in: number;
  /** Bytes the target has sent back (the output blob). */
  bytes_out: number;
  /** Whole messages the window threw away with a reader still behind them, both ways together. */
  dropped_out: number;
  /** When the session's blobs were opened. */
  since: EpochMs;
  /** The answer leg goes agent to agent between LAN neighbours (both nodes `direct: "all"`): there is no preview to watch. A leg gone direct counts what its writer reports every 5 s. */
  direct?: boolean;
}

/** A file an action left: what `listArtifacts` lists and a call carries. */
interface ArtifactView {
  artifact_id: string;
  node_id: NodeId;
  call_id: string;
  app: string;
  instance: number;
  source: string;
  action: string;
  /** `cpuprofile`, `heapprofile`, `json`, …: what the file is; `artifactFile` serves gzip of it. */
  ext: string;
  bytes: number;
  /** The size of the stored file; equal to `bytes` when `encoding` is `identity`. */
  gz_bytes: number;
  sha256: string;
  /** `gzip` (what the agent sent) or `identity` (a call's output kept raw, by a backend that still copied them). */
  encoding: "gzip" | "identity" | (string & {});
  /** Who placed the call it came out of; empty once that call's row is past retention. */
  placed_by: string;
  /** A profile's hot frames, when it parsed. */
  summary?: ProfileSummary;
  created_at: EpochMs;
}

/**
 * The other end of a call whose two nodes are LAN neighbours: its node id, its Ed25519 public key
 * (base64url, the only key its TLS handshake may present) and the private addresses of its listener.
 */
interface Peer {
  node_id: NodeId;
  pubkey: string;
  addrs: string[];
}

/** `nodeCall`'s body: one of the link's generic `AgentRequest`s (an action goes through `nodeAction`). */
type NodeCallBody =
  | { op: "ping" }
  | { op: "restart"; target: NodeTarget }
  | { op: "stop"; target: NodeTarget };

type NodeTarget = { id: number } | { name: string };

/** The hot frames of a sampling profile (`threepm_core::profile_summary`). */
interface ProfileSummary {
  kind: "cpu" | "heap";
  /** Microseconds (cpu) or bytes (heap) over every frame. */
  total: number;
  samples: number;
  duration_ms?: number;
  top: { function: string; location: string; self_value: number; percent: number }[];
}
```
