Skip to content

Pre-release. v0.1 is not out yet, so there is nothing to install and no public source to clone — the quickstart builds from a checkout.

@subactid/server

@subactid/server implements what a tool server must do (section 9 of the spec):

  1. Fetch and cache the control plane’s keys.
  2. Validate the token.
  3. Enforce the route’s scope.
  4. Introspect when the route or the token asks for it.
  5. Log the human and the agent on every request.
  6. Refuse an act chain deeper than this server allows.
  • ESM only, Node 20 or later.
  • No dependencies outside the platform: WebCrypto and fetch. Express and Fastify are not dependencies; the adapters are typed to the parts of a request they use.
  • Not on npm yet.

Protect a tool server walks through it.

examples/tool-server/jira.ts, lines 9–13
export const subactid = new SubactIdToolServer({
issuer: process.env['SUBACTID_ISSUER'] ?? 'http://127.0.0.1:5100',
audience: process.env['SUBACTID_AUDIENCE'] ?? 'https://jira.internal',
requireActor: false,
});

The example sets requireActor: false, so each of its routes says whether only an agent may call it. The default is true.

Option Type Default What it is
issuer string — The control plane’s issuer URL
audience string — What this server is: the aud a token must carry
requireActor boolean true Only agents acting for a human
maxDelegationDepth number 1 Longest act chain accepted. A whole number, 1 or more
clockSkewSeconds number 60 Skew tolerated on exp, nbf and iat
timeoutMs number 10000 Longest a call to the control plane may take
realm string the audience Named in WWW-Authenticate
log (event: AccessEvent) => void one JSON line on stdout Where every request is logged
fetch, now — the globals For tests
Method Returns What it does
verifyToken(token) Promise<TaskToken> Checks the signature, header, iss, aud, lifetime and claim shape
authenticate(authorization) Promise<TaskToken> The same, from an Authorization header
authorize(claims, policy, options?) Promise<SubactIdAuthError | undefined> Applies the route’s rules. undefined means allowed. Does not log
guard(authorization, policy, route) Promise<TaskToken> Authenticates and authorizes, and logs either way. Throws a refusal
log(route, claims, denial?) void Writes one AccessEvent, when you did the checks yourself
refusal(error) Refusal The status, headers and body to answer a thrown error with

Use guard. The other methods are for a transport that is not HTTP.

The server fetches keys from <issuer>/.well-known/jwks.json and introspects at <issuer>/oauth2/introspect.

examples/tool-server/express-app.ts, lines 28–35
app.post(
'/issues/:id/comments',
subactIdExpress(subactid, { scope: 'jira:comment', highRisk: true, requireActor: true }),
(request, response) => {
const claims = claimsOf(request as SubactIdRequest);
response.status(201).json({ issue: request.params.id, by: claims?.act?.sub, for: claims?.sub });
},
);
Field Type What it does
scope string | string[] Every scope listed must be in the token. Omit it to accept any verified token
highRisk boolean Also introspect at the control plane on every request, so a revoked token is refused at once
requireActor boolean Overrides the server’s setting for this route
maxDelegationDepth number Overrides the server’s setting for this route

An empty scope (an empty string, an empty array, or an array with an empty entry) throws an error. It is not read as “no scope needed”. To require no scope, omit scope.

A token carrying introspect_required is introspected whether or not the route sets highRisk. The control plane adds that claim when the audience is in the agent’s high_risk_audiences. Set highRisk on a route that is riskier than its audience as a whole.

AuthorizeOptions.alreadyIntrospected skips the introspection, for a caller that has already introspected the token for this call.

A verified token:

packages/server/src/token.ts, lines 5–31
/** The `act` claim: who is acting, nested from the outermost actor inward. */
export interface Actor {
sub: string;
depth: number;
instance?: string;
act?: Actor;
}
/** A verified task token's claims, the ones a tool server acts on. */
export interface TaskToken {
/** The human the action is taken on behalf of. Never an agent. */
sub: string;
/** The agent, when one is acting; absent when the human called directly. */
act?: Actor;
/** Scopes the token carries. */
scopes: string[];
audience: string;
jti: string;
/** The task, when the token belongs to one. */
taskId?: string;
/** Seconds since the epoch. */
exp: number;
/** Every claim, for anything above not covered. */
claims: Record<string, unknown>;
/** The token itself, for introspection. A credential: never log it. */
token: string;
}

A chain whose depths do not go down by one at each level is malformed_token.

claimsOf(request) returns the claims from an Express request after the middleware ran.

Every refusal is an SubactIdAuthError with a status, a stable reason, and a message that never contains the token. refusal(error) turns it into the status, headers and JSON body to send.

Reason Status What happened
missing_token 401 No bearer token
malformed_token 401 Not a compact JWS, no jti, or a malformed act claim
unsupported_algorithm 401 Not signed with ES256
wrong_type 401 The header’s typ is not at+jwt
unknown_key 401 No such kid in the control plane’s keys
invalid_signature 401 The signature does not verify
wrong_issuer 401 From a different control plane
wrong_audience 401 For a different server
expired, not_yet_valid 401 Outside its lifetime, after clock skew
no_subject 401 No sub
subject_is_agent 401 An agent in sub
no_actor 401 No act, on a route that requires one
not_active 401 Introspection did not say active: true for this token
delegation_too_deep 403 The act chain is longer than this route accepts
insufficient_scope 403 The token lacks a scope the route requires
unknown_route 403 The Fastify plugin found no policy for the route
unknown_tool 403 @subactid/mcp found no policy for the tool
introspection_unavailable 503 The control plane could not be asked, or answered with an error
keys_unavailable 503 The keys could not be fetched and none are cached
  • not_active also covers an introspection answer whose sub or jti does not match the token. The message includes the revocation_reason when the control plane gave one.
  • A 503 means this server could not reach the control plane. The request is refused, not served. A 503 carries no WWW-Authenticate.
  • A 401 or 403 carries WWW-Authenticate with the realm and, except for missing_token, the error and its description.
  • When the control plane sent Retry-After, the error carries retryAfterSeconds and the response repeats the header.
  • The body’s error is invalid_token for a 401, insufficient_scope for a 403, and temporarily_unavailable for a 503.
  • An error that is not an SubactIdAuthError is answered 500 with no detail.

One AccessEvent per request, allowed or refused. By default it is one JSON line on stdout:

{
"event": "request",
"at": "2026-09-12T14:31:05.412Z",
"route": "POST /issues/:id/comments",
"decision": "deny",
"reason": "insufficient_scope",
"sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"act": "agent:jira-triage",
"depth": 1,
"task_id": "task_01HQZX9K4M",
"jti": "tok_01HQZX9K5P"
}

instance is added when the token has act.instance. A line never contains a token, a query string or a key. Pass log to send lines elsewhere.

Export What it is
subactIdExpress(subactid, policy) Express middleware for one route. Claims go on req.subactid
subactIdFastify(subactid, policy) A Fastify onRequest hook for one route. Claims go on request.subactid
subactIdFastifyPlugin(subactid, { unguarded? }) A Fastify plugin that guards every route in the scope it is registered on

The plugin takes each route’s policy from the route’s config.subactid. A route with no policy is refused with unknown_route. unguarded lists routes that need no token, as /healthz (every method) or GET /healthz (one method). A route that has a policy is guarded even if unguarded names it.

For a caller that does the rest itself:

  • verifyTaskToken(token, options) validates a token (step 2).
  • JwksCache fetches and caches the keys (step 1). It serves a fetched set for ten minutes. A token with an unknown kid triggers a new fetch, at most every thirty seconds; a request that arrives during a fetch waits for it. If a fetch fails, it keeps the last good set. SubactIdToolServer builds its own cache and does not take one.

@subactid/server/audit checks the control plane’s audit ledger from outside it. It does no I/O: you pass it the documents from GET /audit, GET /audit/checkpoints, GET /audit/records/{seq}/proof and the JWKS.

Function What it checks
verifyAuditRecord(record, proof, checkpoint, keys) The record is inside the checkpoint, and the checkpoint is signed
verifyCheckpoint(checkpoint, keys) The checkpoint’s signature
verifyCheckpointChain(checkpoints, keys) Each checkpoint links to the one before it, and each is signed. Pass 'links-only' to check links alone
verifyInclusion(leaf, path, index, size, root) An audit path folds a leaf to a root

Each returns { ok: true } or { ok: false, fault, detail }. keys is a JWKS document or a JwksCache. The audit ledger explains the seal.

Subact ID Pre-release. v0.1 is not out yet.

© 2026 Nikola Živković PR Agencija za programerske usluge Novi Sad. Subact ID is its product.

LegalTermsPrivacy