@altrsoftware/shield
v2.0.0
Published
ALTR Shield Node.js SDK — protect and restore sensitive data via the Shield data plane, with an XML token envelope and streaming detokenization.
Readme
@altrsoftware/shield
Node.js SDK for the ALTR Shield data plane. Protect sensitive data (classify + tokenize/mask per policy) before it leaves your trust boundary, and restore it — where policy allows — on the way back. Protected values are carried in-text as an XML token envelope (<altr tok="…"/>), so prompts, memories, tool arguments, and streamed model output stay structurally intact while carrying only non-sensitive surrogates.
- Protect —
POST /v1/protectclassifies your text; the SDK splices the findings back in as token tags and mask literals, byte-offset-safe for any Unicode. - Restore / detokenize — swap policy-allowed tokens back for their values; denied tokens stay as tags. Restoration is identity-aware: what detokenizes depends on the calling application and the tags on both the request and the stored token.
- Streaming — a chunk-boundary-safe
TransformStream/ async-iterable detokenizer for LLM output.
ESM-only, and runtime-agnostic — Node, Edge/Workers, Deno/Bun, and browsers (server-side; see Runtime support).
Install
npm install @altrsoftware/shield
# or: pnpm add @altrsoftware/shield / yarn add @altrsoftware/shieldGetting your credentials
Everything the constructor needs comes from your ALTR organization (ask your ALTR admin, or see the ALTR docs for your deployment):
| Option | What it is | Where it comes from |
| ---------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| baseUrl | Shield data-plane URL, e.g. https://<your-org>.shield.altr.com | Provided when Shield is enabled for your org |
| orgId | Your ALTR org id (sent as the JWT client_id claim) | ALTR organization settings |
| appId | Shield application id (JWT shield_app_id claim) | Created when you register a Shield Application |
| privateKeyPem | The application's registered RSA private key, PKCS#8 PEM | You generate the keypair; the public key is registered on the application (two rotation slots) |
| collectionName | Classifier collection that decides what counts as sensitive | Created/managed in ALTR |
Generate a keypair:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out app-private.pem
openssl pkey -in app-private.pem -pubout -out app-public.pem
# register app-public.pem on your Shield ApplicationThe SDK mints short-lived JWTs from the private key on demand — there is no long-lived token to manage.
Quickstart
import { readFileSync } from "node:fs";
import { ShieldClient } from "@altrsoftware/shield";
const shield = new ShieldClient({
baseUrl: "https://<your-org>.shield.altr.com",
orgId: "<YOUR_ALTR_ORG_ID>",
appId: "<YOUR_SHIELD_APP_ID>",
privateKeyPem: readFileSync("app-private.pem", "utf8"),
collectionName: "<YOUR_COLLECTION_NAME>",
});
const { protectedText, tokens, findings } = await shield.protect(
"Hi, I'm Jane Doe. My card is 4111111111115462.",
{ tags: ["conv:sess_123"] },
);
// protectedText → Hi, I'm <altr tok="9edc…"/>. My card is ************5462.
const { text, restored, denied } = await shield.restore(protectedText, {
tags: ["conv:sess_123"],
});
// text → policy-allowed tokens restored; denied tokens stay as tagsRequest-scope tags are stamped onto every token a protect call mints and are evaluated against restore policy — sending the same correlator tag (a conversation id, an actor id) on both protect and detokenize is what makes tag-scoped, identity-aware restore policies work.
Deterministic tokenization is scoped by an optional determinismContext (set it on the client or per protect() call): under a deterministic policy strategy, the same value in the same context always resolves to the same token, and a different context yields an unlinkable one. The context is compared byte-exact — never trimmed or case-folded ("Fruit" ≠ "fruit") — and capped at 256 UTF-8 bytes (MAX_DETERMINISM_CONTEXT_BYTES); omitted and "" are the same (default) scope.
detokenize(tokens, { tags }) is the batch primitive underneath restore(). Every token you pass gets an entry in the returned values map; a token that did not resolve (denied, unknown, or missing from the response) maps to itself — test with values[token] === token, never with truthiness.
findings is discriminated on action: switch on it to get finding.token (tokenize) or finding.masked_as (mask) as plain strings. A finding this SDK version cannot represent fails the whole protect() call with ShieldSpliceError instead of reaching your code. See docs/node-sdk.md for the full API walkthrough.
Streaming LLM output
Model output can split an <altr tok="…"/> tag across any chunk boundary; the streaming detokenizer buffers only the smallest suffix that could still be a tag and swaps complete tags as they close:
import {
createDetokenizeStream,
detokenizeIterable,
} from "@altrsoftware/shield";
// Web Streams — decode bytes first; a response body streams Uint8Array:
if (!modelResponse.body) throw new Error("no response body");
modelResponse.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(createDetokenizeStream(shield, { tags: ["conv:sess_123"] }))
.pipeTo(destination); // destination = any WritableStream (an HTTP response, a file, ...)
// Async iterable (agent loops, SSE handlers):
for await (const piece of detokenizeIterable(shield, chunks, { tags })) {
response.write(piece);
}
// A Node stream yields Buffers unless you ask for text:
nodeReadable.setEncoding("utf8");
for await (const piece of detokenizeIterable(shield, nodeReadable, { tags })) {
response.write(piece);
}Both flavors take decoded text — a Buffer/Uint8Array chunk throws ShieldError, because a byte chunk can end mid-UTF-8-sequence. Decode first with setEncoding("utf8"), a TextDecoderStream, or one TextDecoder reused with { stream: true }.
Repeated tokens cost one lookup per stream (denials cached too, up to a 10,000-unique-token per-stream cache). On a mid-stream detokenize failure the default is to error the stream; errorMode: "passthrough" emits the affected tags verbatim and keeps streaming, with an optional onDetokenizeError observer so failures aren't invisible.
Tags resolve per incoming chunk, which keeps output incremental. Streaming input is usually model output an attacker can influence directly, so bound it with a signal + timeout just like restore() — see docs/security-notes.md.
Retries
Transient failures — network errors, 429, 500, 502, 503, and 504 — retry automatically with jittered exponential backoff (base 500 ms, capped at 5 s), honoring a server Retry-After. Both endpoints are safe to repeat: detokenize is a pure read, and a deterministic vault returns the identical token on a protect retry. The default budget is 2 retries — tune with maxRetries on the constructor (credential form only; a custom transport owns its own retry policy) or per call ({ maxRetries: 0 } disables). A gateway-authorizer 401/403 additionally replays exactly once with a freshly minted JWT. Detokenize batches that trip the 422 response-size cap split themselves automatically and continue.
Cancellation & timeouts
Every network-touching call accepts an AbortSignal, combined with the transport's own per-request timeout (default 30 s, configurable via timeoutMs):
const controller = new AbortController();
const pending = shield.protect(text, { signal: controller.signal });
controller.abort(); // pending rejects with the abort reason, not a ShieldNetworkErrorrestore() has no built-in cap on how many tokens it extracts — untrusted text carrying thousands of token-shaped tags fans out into many sequential round trips. When text is untrusted (stored chat history, an uploaded document), pass a signal wired to a timeout and bound the size of text yourself (details in docs/security-notes.md):
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(new Error("restore timeout")),
5_000,
);
try {
const { text } = await shield.restore(untrustedStoredText, {
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}An abort also cuts short any in-progress retry backoff. To identify your application in server-side logs, set appInfo: { name: "my-service", version: "2.1.0" } on the client — it is appended to the SDK's user-agent header.
Runtime support
The SDK depends only on Web Platform APIs — fetch, TextEncoder/TextDecoder, and Web Crypto (via jose). It touches no Node-only globals (Buffer/process), so it runs anywhere those standards exist:
| Runtime | Supported |
| ------------------------------------------------ | ------------------------------- |
| Node.js (^20.19.0 \|\| ^22.12.0 \|\| >=23.0.0) | Yes |
| Edge runtimes / Cloudflare & Vercel Workers | Yes |
| Deno / Bun | Yes |
| Browsers & bundlers (Vite, esbuild, webpack) | Yes — but read the caveat below |
Run it server-side. The client signs requests with your RSA private key, so it belongs anywhere that key is a server secret — a Node route handler, a Server Component/Action, an edge function, a worker. Do not import it into a browser bundle or a "use client" component: that ships the private key to the client.
Next.js: works on both the Node.js runtime and the Edge runtime — route handlers, server components, server actions, and middleware are all fine. The only unsupported context is client components (see above).
The engines.node floor applies only to CommonJS require() callers (require(esm) needs Node ≥ 20.19 / 22.12 / 23); plain ESM import works on any modern runtime. CommonJS TypeScript consumers also need TypeScript ≥ 5.8 with module/moduleResolution: "nodenext" — on older TypeScript, use a dynamic import() instead.
Error handling
All SDK errors extend ShieldError:
| Error | Meaning |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ShieldError | base class; also thrown directly for client-side misconfiguration — e.g. a privateKeyPem that cannot be imported as a PKCS#8 RSA key for RS256 or cannot sign with it, with the underlying jose/WebCrypto error on cause |
| ShieldApiError | non-2xx response; carries status, errorCode, raw body, and the gateway requestId (quote it to support) |
| ShieldAuthError | gateway authorizer denied the JWT (SDK already retried once with a fresh mint) |
| ShieldPayloadTooLargeError | 413 — body over the protect (500 KB = 500,000 bytes) / detokenize (512 KiB = 524,288 bytes) caps; also thrown client-side pre-flight |
| ShieldResponseTooLargeError | 422 — the restored payload would exceed the response cap; split the batch |
| ShieldNetworkError | no HTTP response (DNS/connection/timeout); underlying error on cause |
| ShieldSpliceError | the server's findings violated invariants — the SDK fails loud rather than corrupt data |
| ShieldTagValidationError | tag rejected client-side before any network call |
import { ShieldApiError, ShieldAuthError } from "@altrsoftware/shield";
try {
await shield.protect(text);
} catch (err) {
if (err instanceof ShieldAuthError) {
// signing key not registered / rotated away — check key_registration slots
} else if (err instanceof ShieldApiError) {
console.error(err.toSafeString()); // "ShieldApiError: HTTP 400 (error_code 700400) [req abc-123]"
}
throw err;
}Catch order: ShieldAuthError, ShieldPayloadTooLargeError, and ShieldResponseTooLargeError all extend ShieldApiError, so test the specific subclasses before the ShieldApiError base (as above) — a ShieldApiError branch placed first swallows all three.
Logging caution: ShieldApiError.message and .body reproduce the server response verbatim, which for validation errors can echo fragments of the submitted (sensitive) text. Use toSafeString() in logs and telemetry.
The numeric errorCode (present on apiError-shaped bodies) uses the 700xxx family — the last three digits mirror the HTTP status; full reference in docs/error-codes.md. A 401/403 whose body is not apiError-shaped is the gateway authorizer rejecting the JWT before it reached Shield — surfaced as ShieldAuthError after one fresh-bearer replay.
Learn more
See the repository's docs/ for the token-envelope grammar, the full usage guide, and the security notes (private-key handling, TLS enforcement, untrusted-input costs), and examples/ for runnable programs.
Support
New features and fixes land on the latest major version only; the supported Node.js range is the engines field. File bugs and feature requests on the repository issue tracker; report suspected vulnerabilities privately per SECURITY.md, never as public issues. Contributions are welcome — see CONTRIBUTING.md.
License
Apache-2.0 © ALTR Solutions, Inc.
