@trustsig/server
Node.js and Edge SDK for TrustSig bot protection. Handles token verification and local decryption. Requires Node.js 18+.
Installation
npm install @trustsig/server
Usage
import { TrustSig } from '@trustsig/server';
const ts = new TrustSig({ secretKey: process.env.TRUSTSIG_SECRET_KEY });
const token = request.headers.get('X-TrustSig-Response'); // string | null
const result = await ts.verifyRemote(token);
if (result.action !== 'ALLOW') {
throw new Error(`Access denied (${result.error ?? result.action})`);
}
Result contract
verifyLocal and verifyRemote return the same shape and never throw. They
always resolve to a decision:
| Field | Type | Notes |
|---|---|---|
action |
'ALLOW' | 'CHALLENGE' | 'BLOCK' |
All failures normalize to BLOCK. |
blocked |
boolean |
Equals action !== 'ALLOW'. |
error |
TrustSigErrorCode | null |
null on success; otherwise TOKEN_MISSING, TOKEN_EXPIRED, TOKEN_REUSED, CRYPTO_FAIL, API_FAIL, or MALFORMED_RESPONSE. |
is_bot |
boolean |
|
score |
number |
0-100; higher is more suspicious. |
request_id |
string |
Correlation id; usable for an app-side replay cache. |
factors |
string[] |
Contributing signals. |
evidence |
Record<string, unknown> |
Raw signal detail. |
site_key |
string |
Gate access with action !== 'ALLOW' or blocked. Never match the old
BLOCK_CRYPTO_FAIL / BLOCK_API_FAIL strings; they no longer exist.
Verification methods
verifyLocal(token)decrypts locally with ChaCha20-Poly1305. Zero latency, no network. Checks decryptability, age, and (by default) per-token reuse via an in-process cache. See Replay protection for the cap and how to tune or disable it.verifyRemote(token)validates against the TrustSig Edge API. Replay-resistant via the server-side nonce, full telemetry, configurable timeout. Use it for authentication, payments, and account changes.
Replay protection
verifyLocal keeps a small in-process cache that hashes every accepted token
(SHA-256) and caps how many times the same token may be verified. By default
the same token is accepted up to 4 times; the 5th call resolves to
action: 'BLOCK' with error: 'TOKEN_REUSED'. Entries auto-evict at the
token's own expiry (issued_at + maxTokenAgeSeconds, default 300s), so
memory growth is bounded by the in-flight token set.
// Defaults: replayProtection on, 4 uses per token.
const ts = new TrustSig({ secretKey });
// Single-use tokens (server-rendered checkout, OTP confirmation).
const oneShot = new TrustSig({ secretKey, maxUsesPerToken: 1 });
// Keep tracking but raise the cap.
const generous = new TrustSig({ secretKey, maxUsesPerToken: 50 });
// Accept the same token unlimited times within its age window.
const noReplay = new TrustSig({ secretKey, replayProtection: false });
// Tests / manual operator reset.
ts.clearReplayCache();
The cache lives in process memory, so it only enforces the cap inside a
long-lived server: a Node process, a container, a fly.io machine, a Cloud Run
instance with min-instances >= 1, a worker on a queue. It is not preserved
across cold starts or shared between horizontally scaled instances. In
per-request serverless runtimes, where each invocation spawns a fresh
process, the cache always starts empty and the cap cannot be enforced. There,
set replayProtection: false to drop the overhead, or call verifyRemote(),
which validates the nonce server-side.
Options
| Option | Type | Default | Description |
|---|---|---|---|
secretKey |
string | required | Secret key. Server-side only; never expose to the browser. |
env |
PROD | DEMO | DEV |
PROD |
Selects the default endpoint host. |
endpoint |
string | env-derived | Override the verification endpoint. |
timeoutMs |
number | 5000 |
verifyRemote network timeout; abort fails closed. |
maxTokenAgeSeconds |
number | 300 |
Max accepted token age for verifyLocal. Also the eviction TTL for replay-cache entries. |
clockSkewSeconds |
number | 30 |
Tolerance for future-dated tokens. |
replayProtection |
boolean | true |
Track token hashes in process memory and cap reuse via verifyLocal. Set false in serverless runtimes. |
maxUsesPerToken |
number | 4 |
Maximum times the same token may pass verifyLocal before TOKEN_REUSED. Ignored when replayProtection is false. |
TrustSig Pro
TrustSigPro is the backend client for TrustSig Pro: account-level fraud
intelligence (account takeover, signup abuse, account sharing, bot
classification), reputation lists, an identity graph, and a synchronous
Events API. It is a separate, opt-in class. The TrustSig class above is
unchanged, and the two can be used side by side. Import it from the main
entry or from @trustsig/server/pro.
It authenticates with a scoped API credential (pro_sk_...). The project is
implicit from the credential, so no project id appears anywhere in the API.
import { TrustSigPro } from '@trustsig/server'; // or '@trustsig/server/pro'
const pro = new TrustSigPro({ apiKey: process.env.TRUSTSIG_PRO_KEY }); // pro_sk_...
// Verify the device token and bind it to your user in one call.
const v = await pro.verify(token, { user_id: user.id, traits: { email: user.email } });
if (v.blocked) return v.decision === 'REVIEW' ? stepUp() : deny();
// Submit a business event for a synchronous fraud verdict.
const e = await pro.submitEvent({
kind: 'login',
user_id: user.id,
request_id: v.request_id, // joins the device verdict from the verify call
ip: req.ip,
});
if (e.decision === 'BLOCK') return deny();
Decisions
Pro verdicts use ALLOW | REVIEW | BLOCK. REVIEW means step up (MFA,
captcha, manual review queue), not deny, and it never overrides BLOCK. Gate
on decision !== 'ALLOW' or the blocked boolean:
if (v.decision === 'BLOCK') deny();
else if (v.decision === 'REVIEW') stepUp();
else proceed();
Error handling
| Methods | On failure |
|---|---|
verify, submitEvent |
Fail-closed. Any transport, auth, quota, or validation failure resolves to decision: 'BLOCK' with error set; never throws. Mirrors the Vanilla TrustSig posture so an allow-list check stays fail-safe. |
| everything else | Throws TrustSigProError with { code, status, retryAfter } so reads and list-writes surface errors normally. |
error / code values: TOKEN_MISSING, UNAUTHORIZED, FORBIDDEN,
NOT_FOUND, RATE_LIMITED, QUOTA_EXCEEDED, CONFLICT, VALIDATION,
API_FAIL, MALFORMED_RESPONSE. On a 429, TrustSigProError.retryAfter
carries the server's Retry-After seconds when present.
import { TrustSigProError } from '@trustsig/server';
try {
const account = await pro.getAccount(userId);
} catch (err) {
if (err instanceof TrustSigProError && err.code === 'NOT_FOUND') {
// no such account
} else if (err instanceof TrustSigProError && err.code === 'RATE_LIMITED') {
await sleep((err.retryAfter ?? 1) * 1000);
}
}
Methods
Each method's required credential scope is noted. An empty scope list (or
*) grants everything.
| Area | Methods | Scope |
|---|---|---|
| Verify | verify(token, { user_id?, traits?, claimed_entity_id?, telegram?, idempotencyKey?, wait_for_complete_max_ms? }) |
verify |
| Events | submitEvent(event, { idempotencyKey? }), listEvents(filters) |
events / read |
| Requests | getRequest(requestId) |
read |
| Accounts | getAccount(userId), getAccountSharing(userId), getAccountAlts(userId) |
read |
| Entities | resolveEntity(stableId), getEntity(id), getEntityLinks(id), getEntityCluster(id), getEntityAlts(id), searchEntities(filters) |
read |
| Reputation | getReputation(kind, value), setReputationStatus(kind, value, { status, note?, value_raw? }) |
read / lists |
| Rules | getRules(), replaceRules(rules, description?), activateRules() |
read / rules |
| Config | getDetectionCatalog(), getActiveConfig() |
read |
| Analytics | getAnalytics(name, query), getAudit(query) |
read |
| Export | exportObservations(query), returns a raw NDJSON/CSV string |
export |
| Feedback | submitLabel(body), listLabels(filters) |
feedback / read |
| Webhooks | registerWebhook(body), listWebhooks() |
webhooks / read |
| Usage / meta | getUsage(), getReasonCodes(), getWebhookEvents() |
any valid credential |
verify and submitEvent each count as one monthly check against your
plan. Both honor an Idempotency-Key: same body replays the cached
response, different body returns CONFLICT. The webhook secret from
registerWebhook is returned once; store it to verify inbound deliveries
(below).
Verify extras:
claimed_entity_idis the entity id your frontend surfaced; the response'sentity_matchsays whether it equals the server-authoritative id (spoof detection).telegram({ init_data, bot_id }) attests a Telegram Mini App user. The platform validates the signed initData against Telegram's public key (no bot token involved); the verdict'stelegramblock carriesverified, the signeduser_id, anduser_id_match. Also accepted onsubmitEvent.- Responses include
bot_score(inverse oftrust_score), evidenceddetections, andrule_matches(ids of custom rules that fired).
getRequest(requestId) returns the full analysis record for a request id:
bot score, every rule and which matched, evidenced detections, flags,
geo/agent/velocity context, and the redacted device fingerprint. Use it to
debug a verdict or build an internal review tool.
Rules: replaceRules(rules) replaces the whole rule array and writes a
Draft config version; nothing is enforced until activateRules() promotes
it. Each rule is { id, name, description?, action, state, expr } where
action is block | review | allow | label, state is
active | shadow | disabled, and expr chains condition blocks,
all/any/not combinators, and raw {signal, operator, value} leaves.
Verifying inbound webhooks
Pro signs each webhook delivery with HMAC-SHA256 over "<t>.<body>" and
sends X-TrustSig-Signature: t=<unix>,v1=<hex>. Verify the raw request body
before parsing it:
import { verifyWebhookSignature } from '@trustsig/server';
// also available as the static TrustSigPro.verifyWebhookSignature
app.post('/hooks/trustsig', express.raw({ type: '*/*' }), (req, res) => {
const ok = verifyWebhookSignature(
endpointSecret,
req.header('X-TrustSig-Signature'),
req.body.toString('utf8'),
);
if (!ok) return res.status(400).end();
const event = JSON.parse(req.body.toString('utf8'));
// handle event
res.status(200).end();
});
It returns true only for a well-formed, fresh, valid signature. A
timestamp outside the tolerance window (default 300s) is rejected to blunt
replay. Pass { toleranceSeconds } to tune, { toleranceSeconds: 0 } to
disable the freshness check, and { nowMs } in tests.
TrustSigPro options
| Option | Type | Default | Description |
|---|---|---|---|
apiKey |
string | required | Scoped API credential (pro_sk_...). Server-side only. |
env |
PROD | DEMO | DEV |
PROD |
Selects the default host (PROD is cpro.trustsig.eu, DEV is localhost:9100). |
endpoint |
string | env-derived | Override the Pro base URL. |
timeoutMs |
number | 5000 |
Per-request network timeout. |