@decionis/iso8583
v0.3.0
Published
ISO 8583 authorization-rail interception: match each authorization to a grant the bank holds, or evaluate it at the commit boundary, before it settles.
Maintainers
Readme
@decionis/iso8583
The Decionis gate on an ISO 8583 TCP wire. It sits between the switch and the issuer host, reads each authorization with the host's own profile, tokenizes the card number in the bank's process, and decides the message before the host sees it: matched to a grant the cardholder already approved, or evaluated by Decionis at the commit boundary. A refusal is answered to the client with a synthetic decline; the host never receives the message.
See it stop a transaction, in one command
npx @decionis/iso8583 demoNo account, no network, nothing to configure. It stands up a stand-in acquirer, the real interceptor and a client on loopback, then sends two authorizations, one inside the demo policy and one over it:
+ authorization 4250 minor units
verdict APPROVE
acquirer received yes
client received 0110 response code 00 (STAN 000001 echoed)
x authorization 250000 minor units
verdict REJECT
acquirer received NO, the frame never left the interceptor
client received 0110 response code 05 (STAN 000002 echoed)The refused authorization was never seen by the upstream. Not logged after the fact, not reversed: it did not arrive. Only the decision function is local; the framing, the packager, the tokenizer and the fail-closed path are the production ones.
Install
npm install @decionis/iso8583 @decionis/sdkTwo ways to decide
| Mode | Who decided, and when | What the interceptor does on the wire |
| ---------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| match | The cardholder, before the card was submitted: the bank's AgentSafe holds the grant. | Matches the authorization to the held grant by card reference, amount, currency and merchant, claims it once, and records the host's answer as the effect. No match is answered by policy.onNoMatch. |
| evaluate | Decionis, at the commit boundary: the authorization itself is the intent. | Sends the intent to enforce-and-bind. An ALLOW is claimed before the message goes to the host and finalized with the host's answer; a BLOCK is declined; an ESCALATE cannot wait for a person on this clock, so policy.onEscalate says what happens. |
| shadow | Decionis, observing. | Every authorization is forwarded and every decision is recorded as a signed dossier. The mode to start in, and the only mode replay runs in. |
Run it in front of a host
npx @decionis/iso8583 serve --config ./iso8583.json{
"mode": "evaluate",
"listen": {
"host": "0.0.0.0",
"port": 8583,
"tls": { "certFile": "/run/secrets/listen.crt", "keyFile": "/run/secrets/listen.key" }
},
"upstream": {
"host": "issuer-host.bank.internal",
"port": 8583,
"tls": { "caFile": "/run/secrets/issuer-ca.pem" }
},
"decionis": {
"baseUrl": "https://api.decionis.com",
"apiKeyFile": "/run/secrets/decionis-api-key",
"tenantId": "11111111-1111-4111-8111-111111111111"
},
"tokenizer": { "kind": "hmac", "keyFile": "/run/secrets/card-tokenizer.key" },
"policy": {
"onUnavailable": "forward",
"onEscalate": "decline",
"onNoMatch": "decline",
"onParseError": "forward",
"decisionTimeoutMs": 150
},
"binRanges": ["411111", "522222"],
"profile": { "name": "issuer-host", "lengthHeader": "u16be", "bitmap": "ascii" }
}A config holds paths to secrets, never secrets: the Decionis API key, the
AgentSafe operator token and the tokenizer key (32 or more bytes, base64) are
read from the files named. match mode takes an agentsafe section
(baseUrl, tokenFile: an operator token holding cards.authorize) in place
of decionis.
The fail policy is the bank's choice, written down. onUnavailable (the
authority or the runtime did not answer within decisionTimeoutMs),
onEscalate (policy wants a person) and onNoMatch (no held grant) each say
forward or decline. onParseError (a governed message this process cannot
read with the profile) says forward, decline or close. Only corrupt
framing closes a connection on its own.
Limits, all optional: maxFrameBytes, maxInFlightPerConnection,
maxConnections, idleTimeoutMs, upstreamConnectTimeoutMs,
responseTimeoutMs (a forwarded authorization the host never answers settles
as unanswered) and gateTimeoutMs (a gate that neither answers nor fails by
then is unavailable; twice the decision budget by default).
The profile lays a host's differences over the ISO 8583:1987 ASCII element
table: the length header (u16be, u32be, ascii4), the bitmap encoding
(binary, ascii), a TPDU, per-element layouts, which MTIs are governed,
which elements carry the merchant id and the MCC, the decline and error
response codes, and which elements a synthetic decline echoes. The elements
that carry card data are never echoed, whatever the profile says.
Try it before it is live
npx @decionis/iso8583 simulate ./burst.json
npx @decionis/iso8583 simulate ./burst.json --config ./iso8583.json
npx @decionis/iso8583 replay ./history.jsonl --config ./iso8583.json --report ./replay.jsonsimulate sends a burst through the real interceptor on loopback, decided by
the scenario's own policy (a ceiling and a per-card count) or by the configured
gate. A scenario names cards by label; the simulator makes a test card number
for each, so a scenario file never holds one:
{
"name": "one card, five merchants",
"policy": { "ceilingMinor": 100000, "maxPerCard": 3 },
"authorizations": [
{ "card": "A", "amountMinor": 4250, "merchantId": "M-1", "mcc": "5812" },
{ "card": "A", "amountMinor": 1999, "merchantId": "M-2", "afterMs": 50 }
]
}replay runs the bank's tokenized history through the gate offline, in shadow
mode only. A record is one JSON line,
{ "card_token_ref", "amount_minor", "currency", "merchant_id", "mcc"?, "at", "stan"?, "rrn"? };
a record carrying anything shaped like a card number is refused before it is
read. The report counts what policy would have done, the bursts per card
inside the velocity window, and how many authorizations a policy would have
held for the cardholder.
Embed it
import { readFileSync } from "node:fs";
import { DecionisExecutionClient } from "@decionis/sdk";
import {
ASCII_PROFILE,
AuthorityGate,
HmacCardTokenizer,
Iso8583Interceptor,
defineProfile,
} from "@decionis/iso8583";
const profile = defineProfile({ name: "issuer-host", governedMtis: ["0100", "0200"] });
const policy = {
onUnavailable: "forward",
onEscalate: "decline",
onNoMatch: "decline",
decisionTimeoutMs: 150,
} as const;
const interceptor = new Iso8583Interceptor({
profile,
upstream: { host: "issuer-host.bank.internal", port: 8583 },
tokenizer: new HmacCardTokenizer(readFileSync("/run/secrets/card-tokenizer.key")),
gate: new AuthorityGate({
client: new DecionisExecutionClient({
authorityBaseUrl: "https://api.decionis.com",
defaultHeaders: {
authorization: `Bearer ${readFileSync("/run/secrets/decionis-api-key", "utf8").trim()}`,
},
}),
tenantId: "11111111-1111-4111-8111-111111111111",
mode: "ENFORCEMENT",
policy,
declineResponseCode: profile.declineResponseCode,
}),
policy: { ...policy, onParseError: "forward" },
binRanges: ["411111"],
log: (event) => console.log(JSON.stringify(event)),
});
await interceptor.listen(8583, "0.0.0.0");MatchGate takes an AgentSafeCardClient (baseUrl, token) in place of the
Decionis client. LocalGate wraps a decision function, for a shadow policy of
the bank's own or a test. A gate never throws: an error is a verdict under the
fail policy, with its reason.
What never leaves the process
- The card number is read from the frame, turned into
tok_<bin>_<mac>by a keyed tokenizer (the MAC as letters, so the reference is never digit-shaped), and that reference is all the gate, the intent, the log and the metrics ever see. A tokenizer that returns something shaped like a card number is refused. - A synthetic decline echoes only the elements the profile names, and never the PAN, track data, PIN block or EMV data.
- The log carries ids, codes, actions and durations; the metrics are counters by action and reason, served as OpenMetrics. Nothing shaped like a card number, an amount or a merchant is written to either.
- A replay refuses a record carrying anything shaped like a card number before it reads it.
The first release's API
createIso8583InterceptorServer, extractAsciiIso8583Metadata,
buildIso8583DecisionRequest, packIso8583Frame and unpackIso8583Frames
remain for integrations built on 0.1.0 that do their own field extraction.
The changelog records what changed in them.
Package contents
- ESM and CommonJS builds in
dist/, with TypeScript declarations - the
decionis-iso8583command (demo,simulate,replay,serve)
Release
For repository-backed packaging and publish automation, use the workflow
documented in
docs/package-distribution.md.
