# 3pm-backend route `setNodeAccess`

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: 3105
  o200k: 1871
# POST /api/buckets/{clusterId}/nodes/{nodeId}/access (setNodeAccess)

What this node may ask of the bucket with its own bearer (`scopes`), who may place a call on it (`callers`), and what it lets go agent to agent with a LAN neighbour (`direct`); no scope by default, and an empty `callers` accepts every node holding `fleet:invoke`. Replaces each list it names, and takes effect on the node's next request.

- auth: an account — Authorization: Bearer 3pmu_<token>, or the dashboard's session cookie
- body: SetNodeAccess (JSON)
- answer: NodeView (JSON)

## Details

The body is `{ scopes?, callers?, direct? }`; a list left out is unchanged, an empty one clears it (no scope; `callers` open again). `direct` (`off`, `files`, `all` — the default) is what the node lets go agent to agent when it and the other end of a call sit behind the same public address on one LAN: a call's files, or its live legs too (`all`, on both ends — the relay then never sees them, and a viewer has nothing to watch: set `files` on a node whose live legs must stay watchable); left out, unchanged. `--direct off` on this backend turns it off for everyone. `scopes` is what the node's own bearer (`3pma_…`, minted in every `welcome`) may do here: `fleet:list` (the bucket, its nodes, the feed, the per-server and per-app metric inventories), `fleet:read` (metrics, host, process trees, the catalog of actions — the fleet's attack map, granted separately), `fleet:invoke` (place calls, and read back what its own calls produced). An unknown name is a 400. `callers` narrows who may place a call on **this** node — empty, any node of the bucket holding `fleet:invoke` may, every action included; with rules, only what a rule admits: `[{ from, from_app?, app?, action? }]`, `from` a node id (`3pmn_…`) or `"*"`, the other three a name or `"*"` (absent means `"*"`); a rule whose `from` is neither is a 400, and an action of the `3pm:` prefix (`3pm:profile`, built into every Node application) must be named in full in a list with rules — a wildcard never reaches it. To close a node to every other node, write a rule no call can match, such as `{ "from": "*", "app": "-" }` (`-` is no application's name). `from_app` is an operational guard rail, not a boundary: the calling application's name is declared by the calling daemon and this backend cannot check it, so the trust boundary is the node. The answer is the node's `NodeView`, which also goes out on the feed as a `node` event. Every grant is read again on every request, so both directions are felt on the node's very next request: what you take away stops working at once, what you grant works at once, with nothing to reconnect. The node's bearer is untouched (only a revoke drops it, together with its live session).

## Types

```ts
/** `setNodeAccess`'s body: a list left out is unchanged, an empty one clears it (no scope; `callers` open again); each replaces, never merges. */
interface SetNodeAccess {
  scopes?: NodeScope[];
  callers?: CallerRule[];
  /** What the node lets go agent to agent with a LAN neighbour; left out = unchanged. `all` sends a call's live legs direct too, and the relay then has nothing to show a viewer. */
  direct?: DirectMode;
}

interface NodeView {
  node_id: NodeId;
  /** What to display: enrollment first, presence for an active node. */
  state: DisplayState;
  /** The database state alone. */
  enrollment: NodeState;
  hostname: string;
  os: string;
  arch: string;
  agent_version: string;
  /** Which token admitted the host. */
  token_id?: string;
  /**
   * What the host owner lets be asked of this node, exactly as its last
   * `hello` declared it (`link.toml` `caps`). Empty means the node
   * accepts nothing — or, for a node that has not linked since it
   * enrolled, that it has not said yet.
   */
  caps: Capability[];
  /** What this node may ask of the bucket with its own bearer; empty by default. */
  scopes: NodeScope[];
  /** Who may place a call on this node: empty, every node holding `fleet:invoke`; with rules, what a rule admits. */
  callers: CallerRule[];
  /** What the node lets go agent to agent with a LAN neighbour: nothing, a call's files (the default), or its live legs too. */
  direct: DirectMode;
  enrolled_at: EpochMs;
  approved_at?: EpochMs;
  revoked_at?: EpochMs;
  last_seen_at?: EpochMs;
  last_auth_error?: AuthError;
  /** Bytes received over the node's link per second, averaged over the last 5s. */
  rx_bps: number;
  /** Bytes received since the backend started. */
  rx_bytes: number;
  /** Where the node's last link came from, as this backend saw it (behind `--trust-proxy`, the forwarded client); absent until the node linked since the backend started. */
  peer_ip?: string;
  /** The private addresses of the node's interfaces (RFC 1918, CGNAT, IPv6 ULA; container bridges left out), from its last complete host snapshot. */
  private_ips?: string[];
  /** The account the node's daemon runs as, from its last complete host snapshot; absent until it sent one. */
  user?: string;
  /** The accounts with an open login session on the node, from its last complete host snapshot. Sent to the bucket's owner and to a node holding `fleet:read`; absent otherwise, which is not the same as nobody being logged in. */
  sessions?: string[];
}

/**
 * What a node may ask of its bucket with its own bearer, granted by the
 * owner (`setNodeAccess`): `fleet:list` the fleet as a list, `fleet:read`
 * its metrics, hosts, trees and action catalogue, `fleet:invoke` placing
 * calls. Nothing by default.
 */
type NodeScope = "fleet:list" | "fleet:read" | "fleet:invoke";

/**
 * One line of a node's `callers`: who may place a call on it once the
 * list holds a rule (an empty list accepts every node holding
 * `fleet:invoke`). `"*"` is the wildcard, and a missing `from_app` /
 * `app` / `action` means `"*"` — except an action of the `3pm:` prefix,
 * which a wildcard never reaches.
 *
 * `from_app` is an operational guard rail, not a boundary: the calling
 * application's name is declared by the calling daemon and the backend
 * cannot check it. The trust boundary is the node.
 */
interface CallerRule {
  /** The calling node: a `3pmn_…` id, or `"*"`. */
  from: string;
  from_app?: string;
  app?: string;
  action?: string;
}

/** `off`, `files` (a call's files go direct between LAN neighbours) or `all` (the default: its live legs too). */
type DirectMode = "off" | "files" | "all" | (string & {});

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

/** `NodeView.state`: the enrollment state, or the presence of an active node. */
type DisplayState = NodeState | "online" | "stale" | "offline";

/** Enrollment state in the database, presence aside. */
type NodeState = "active" | "pending" | "revoked" | "unknown";

/** What the host owner lets the backend ask of a node, as its `hello` declared it. */
type Capability = "metrics:read" | "proc:restart" | "proc:stop" | "unknown";

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

/** The last time a node was turned away at `/api/v1/link`. */
interface AuthError {
  /** `ERR_NODE_PENDING`, `ERR_NODE_REVOKED`, … */
  code: string;
  at: EpochMs;
}
```
