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):
- Fetch and cache the control plane’s keys.
- Validate the token.
- Enforce the route’s scope.
- Introspect when the route or the token asks for it.
- Log the human and the agent on every request.
- Refuse an
actchain 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.
SubactIdToolServer
Section titled “SubactIdToolServer”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.
RoutePolicy
Section titled “RoutePolicy”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.
TaskToken
Section titled “TaskToken”A verified token:
/** 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.
Refusals
Section titled “Refusals”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_activealso covers an introspection answer whosesuborjtidoes not match the token. The message includes therevocation_reasonwhen the control plane gave one.- A
503means this server could not reach the control plane. The request is refused, not served. A503carries noWWW-Authenticate. - A
401or403carriesWWW-Authenticatewith the realm and, except formissing_token, the error and its description. - When the control plane sent
Retry-After, the error carriesretryAfterSecondsand the response repeats the header. - The body’s
errorisinvalid_tokenfor a401,insufficient_scopefor a403, andtemporarily_unavailablefor a503. - An error that is not an
SubactIdAuthErroris answered500with no detail.
The log line
Section titled “The log line”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.
Adapters
Section titled “Adapters”| 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.
Building blocks
Section titled “Building blocks”For a caller that does the rest itself:
verifyTaskToken(token, options)validates a token (step 2).JwksCachefetches and caches the keys (step 1). It serves a fetched set for ten minutes. A token with an unknownkidtriggers 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.SubactIdToolServerbuilds its own cache and does not take one.
Verifying audit records
Section titled “Verifying audit records”@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.
© 2026 Nikola Živković PR Agencija za programerske usluge Novi Sad. Subact ID is its product.