Authentication and authorization
Both are off by default, so an unconfigured server accepts any peer and allows any exposed call. The management surface is not off by default in the same sense — it is simply never published unless asked for. See below.
Authenticating peers
authenticate receives whatever the client sent as credentials and returns an identity to accept the peer, or undefined to reject it. Rejected peers never reach the RPC layer — the check runs as socket.io middleware, before the connection is established at all.
const server = new RpcServer({
transports: [{ port: 7843 }],
authenticate: async (credentials) => {
const user = await lookUpToken((credentials as { token?: string }).token)
return user && { name: user.id, roles: user.roles }
}
})
const client = new RpcClient('http://localhost:7843', {
name: 'operator-17', // must equal the identity's name, see below
credentials: { token: 'a-token' }
})RpcClientOptions.name must match RpcIdentity.name. The source field of a message is written by the sender, so it is a claim, not evidence. An authenticating transport pins each connection to the name it authenticated as and drops frames claiming any other source. Without that, an authenticated peer could address its calls as another peer and inherit its rights.
It pins the peer registry to the same rule. A frame's source is normally learned as it is parsed, which is how discovery works over MQTT — the broker is the authority there and there is no connection to check. Where there is one, a name is registered only once the connection has been checked, so a rejected frame cannot leave a peer that does not exist in the routing table.
Tokens, without writing the authenticator
createTokenAuthenticator is the common case packaged: a map from bearer token to the peer it admits.
import { createTokenAuthenticator, defaultWebSocketPort, RpcServer } from '@source-repo/rpc'
const server = new RpcServer({
transports: [{ port: defaultWebSocketPort }],
authenticate: createTokenAuthenticator({
[process.env.PLANT_TOKEN!]: 'plantServer',
[process.env.HMI_TOKEN!]: { name: 'hmi', roles: ['operator'] }
})
})One token per peer, not one token for the bus. A token that maps to a name is evidence of who is calling, and the rule above then does the rest: a holder that connects under any other name gets a socket and nothing else — its announcement is refused, so it is never listed, and its frames are dropped. A single token shared by everyone proves only that the caller is inside the fence, and any holder could then claim to be the peer whose commands matter. There is deliberately no single-secret form.
Blank tokens, grants with no name and an empty map all throw rather than construct, because each one would quietly admit more than it looks like it does.
Authorizing calls
authorize runs for every call and every event subscription. Return false to reject with a Forbidden error.
const server = new RpcServer({
transports: [{ port: 7843 }],
authenticate,
authorize: ({ identity, instanceName, method, subscription }) => {
if (subscription) return identity?.roles?.includes('observer') ?? false
if (instanceName === 'plant' && method.startsWith('write')) return identity?.roles?.includes('engineer') ?? false
return true
}
})An authorizer that throws denies the call. Failing open would turn a bug in the authorizer into an access-control bypass.
requireAuthenticatedPeers defaults to true when authenticate is set, rejecting calls from peers no transport can vouch for with an Unauthorized error.
The management surface
manageRpc is not exposed by default. Enabling it publishes exactly one method, createRpcInstance, which constructs an instance of a class already passed to exposeClass():
const server = new RpcServer({ transports: [{ port: 7843 }], exposeManagement: true })It is still subject to authorize, so you can restrict who may create instances. The expose* methods are never remotely reachable.
Versions before 2.0.0 published all of
ManageRpcundermanageRpcwith no authentication, so any peer that could reach the transport could construct anyexposeClass'd class with chosen arguments, or overwrite an exposed name and deny service to every other client. If you are upgrading, treat both as having been reachable.
A peer says what it can currently do that is dangerous
Before there is any mechanism for granting elevated access, there should be one for seeing it — because a gate whose state nobody can observe is a gate nobody can audit. Today the question "is anything on this network unlocked right now" should have an answer without calling anything.
describe() carries elevated, and a console shows it above everything else on the peer:
elevated docker.create — may create containers from postgres, emqx/emqx · until someone closes itIt announces and nothing more. authorize(), the AI grants document and the capability's own allow-list decide what may happen, and would decide exactly the same with this field removed. What it buys is that the posture travels.
It is asked of the instance, not remembered by the host. A component that is an elevation implements elevation(), so composing it into a host is what makes the host announce it — the way dataResources() works. That matters because the failure this exists to catch is somebody forgetting, and an announcement you have to remember to make is one that will be missed exactly when it counts.
For a capability that is not an object — a mounted socket, a debug endpoint, a flag somebody passed — the host declares it directly:
const held = server.elevate({ capability: 'debug.endpoint', reason: 'diagnosing', until: Date.now() + 2 * 3600_000, grantedBy: 'anders' })
// …later, or by itself when `until` passes
held.lower()The most important field is until, and the most important case is its absence. An elevation nothing will close is one somebody has to remember to close — the taped-over key, opened for a reason that passed while nobody came back. A viewer draws that as worse than a bounded one rather than the same, and where an until is given it is enforced as well as announced, so the announcement cannot outlive the thing.
A lapsed elevation is not announced at all. Posture is what is true now; history belongs in the audit trail.