lorica-node
v0.1.5
Published
Node.js SDK for the Lorica biometric verification API — prove a real human authorized it
Maintainers
Readme
lorica-node
Node.js SDK for Attest — biometric attestation for high-stakes actions.
When someone releases a pharma batch, approves a wire, or deletes production data, Attest proves a real, identified human authorized it — and hands you a signed receipt anyone can verify later, even offline, without trusting Attest's database.
- Node.js >= 20, TypeScript, ESM + CJS.
- One runtime dependency:
jose.
Install
npm install lorica-nodeQuickstart
import { LoricaClient } from 'lorica-node';
const attest = new LoricaClient({
apiKey: process.env.ATTEST_API_KEY!, // "attest_sk_..."
baseUrl: 'http://localhost:8000', // or LORICA_API_URL
});
// 1. Consent first. Written informed consent is required before any face
// is embedded (BIPA/GDPR) — the server rejects enrollment without it.
// Fetch the exact disclosure text, show it, and record the "I agree":
const disclosure = await attest.getDisclosure(); // { version, text, sha256 }
showToUser(disclosure.text); // your UI — must be shown verbatim
await attest.recordConsent({ // after an explicit "I agree"
userId: 'op-1041',
disclosureVersion: disclosure.version, // omit to auto-fetch the current one
});
// 2. Enroll the operator once (face + verified identity).
await attest.enroll({
userId: 'op-1041',
image: LoricaClient.imageFromFile('./enrollment.jpg'), // Buffer | path | base64
identity: { name: 'Zoë Müller', role: 'qa_lead', license_id: 'L-9', org: 'acme' },
});
// Or do both in one call — consent is recorded first, then enrollment:
// await attest.enroll({ userId, image, identity, consent: {} });
// 3. At the moment of the action: attest it.
const { receipt, record } = await attest.attest({
userId: 'op-1041',
image: webcamFrame, // Buffer | path | base64
actionType: 'batch_release', // letters/digits/_-.: only
payload: { batch_id: 'BATCH-2210', quantity: 5000, unit: 'vials' },
ref: 'BATCH-2210',
});
// Store `receipt` (an RS256 JWT) alongside the action it authorized.
// 4. Later — verify. Online:
const check = await attest.verify(receipt); // { valid, reason, who, what, when, chain }
// ...or offline — verify without asking Attest for a verdict. Pass a JWKS for
// a truly zero-network check (fetch the keys once and pin/persist them, or take
// them from an evidence package's `jwks` block). Omit the arg and the JWKS is
// fetched once over the network and cached (~24h) instead.
const jwks = await attest.fetchJwks(); // GET /.well-known/jwks.json (once)
const offline = await attest.verifyOffline(receipt, jwks); // zero network
if (offline.valid) {
// Optionally bind the receipt to the exact payload you're about to act on:
const bound = attest.verifyPayload(offline.claims!, {
batch_id: 'BATCH-2210', quantity: 5000, unit: 'vials',
});
console.log('authorized by', offline.claims!.identity.name, '— payload bound:', bound);
}Payload numbers must be JSON-stable. Use integers (
{ amount: 50000 }) or decimal strings ({ amount: "50000.00" }) for numeric payload fields — never floats. A whole-number float like50000.0serializes as50000.0in Python but50000in JavaScript, so the SDKs and server reject it to guarantee your receipt verifies in any language. NaN and Infinity are rejected for the same reason. (In JavaScript50000.0is50000, so there is nothing for this SDK to reject — the rule matters when a Python service mints or re-verifies the same payload.)
What a receipt proves
Each attest() call returns a signed JWT whose claims embed:
- who — the enrolled identity (
sub,identity) and a live face match (live,match_score), - what — the action type and a canonical sha256 of your payload
(
action,action_payload.{ref,hash}), - when / where in the log —
iat,seq,prev_hash, and arecord_hashbinding the whole record into a tamper-evident chain.
verifyOffline() checks the RS256 signature against a JWKS document,
recomputes the canonical record hash from the claims, and compares it to
record_hash. The server is never asked for a verdict, and the call is
zero-network by default: pass a jwks (pinned from an evidence package's
jwks block, or from a one-time fetchJwks()); omitting it throws
JWKSRequiredError rather than silently fetching. If you want the SDK to
fetch the JWKS for you on first use (cached ~24h — server-independent, but
not network-free), opt in with allowNetwork: true on the client.
API surface
| Method | Endpoint |
|---|---|
| getDisclosure() | GET /consent/disclosure (public) |
| recordConsent({userId, disclosureVersion?, agree?}) | POST /consent |
| getConsent(userId) | GET /consent/{user_id} |
| getRetention(userId) | GET /retention/{user_id} |
| setRetention(userId, {mode, windowSeconds?}) | POST /retention/{user_id} |
| enroll({userId, image, identity, consent?}) | POST /enroll |
| attest({userId, image, actionType, payload?, ref?}) | POST /attest |
| verify(receipt) | POST /verify |
| fetchJwks() | GET /.well-known/jwks.json (cached 24h) |
| verifyOffline(receipt, jwks?) | verify with no server verdict; zero-network by default (throws without jwks unless allowNetwork: true) |
| verifyPayload(claims, payload) | local |
| deleteUser(userId) | DELETE /users/{id} |
| audit(filters?) | GET /audit |
| exportEvidence(filters?) | GET /audit/export |
| getReceipt(recordId) | GET /audit/receipt/{id} |
| usage() | GET /usage |
| health() | GET /health |
audit filters: { userId, actionType, fromTs, toTs, limit, offset }.
exportEvidence filters: { userId, actionType, fromTs, toTs } only — the
server ignores limit/offset on /audit/export and always returns up to a
hard cap of 1000 records, so the SDK drops them rather than sending no-ops.
recordConsent with no disclosureVersion fetches the current one via
getDisclosure() first — only appropriate when the disclosure you displayed
IS the current one. Calling it at all asserts your app really showed the text
and received an explicit affirmative action from the person.
Retention
Each enrolled user's biometric template has a retention mode (the org-wide
schedule is public at GET /retention/policy): 'standing_credential'
(default — kept until account closure or explicit deletion) or 'ephemeral'
(destroyed once windowSeconds elapse after last use; 0 destroys it right
after the action it was captured for).
await attest.getRetention('op-1041'); // { user_id, mode, window_seconds }
await attest.setRetention('op-1041', { mode: 'ephemeral', windowSeconds: 0 });Errors
All HTTP errors are typed subclasses of LoricaError carrying
statusCode, errorCode, and the raw envelope: AuthenticationError (401),
MatchFailedError (403 match_failed — the action was not attested),
ConsentRequiredError (403 consent_required — enroll attempted with no
consent on file), NotFoundError (404), ValidationError (422 —
no_face_detected, multiple_faces, image_too_large, invalid_base64,
consent_not_affirmative, unknown_disclosure_version,
invalid_retention_mode, ...),
RateLimitError (429, with rateLimit.retryAfterMs), ServerError (5xx),
plus NetworkError / TimeoutError for transport failures.
verifyOffline() throws JWKSRequiredError when called without a JWKS
document (the zero-network default); configure the client with
allowNetwork: true to let it fetch the JWKS once instead.
Idempotent requests (GETs, DELETE, verify(), setRetention()) automatically
retry on 429/5xx and transport drops with exponential backoff, honoring
Retry-After. attest() also retries: each call mints one Idempotency-Key
and reuses it across every attempt, so the server replays the original receipt
instead of creating (and billing) a second attestation. (Reusing a key with a
different body is rejected server-side with 409 idempotency_conflict; the SDK
never does this because the key is minted with the body it is sent with.)
enroll() and recordConsent() are never auto-retried — the server does
not deduplicate them, so a transient failure there surfaces immediately for you
to handle.
Canonical JSON & the float caveat
Payload hashing uses canonical JSON: keys sorted recursively by Unicode code
point (matching Python's sort_keys=True, including keys that mix astral and
upper-BMP characters), no whitespace, UTF-8, non-ASCII unescaped —
byte-identical to Python's
json.dumps(obj, sort_keys=True, separators=(",",":"), ensure_ascii=False).
Use integers and strings in payloads, not floats. JavaScript and Python
disagree on rendering whole-number floats (1.0 → "1" vs "1.0"), which
would break cross-language hash equality. The Python SDK and the Attest
server therefore reject whole-number floats (and NaN/Infinity) in payloads
at the boundary, so an ambiguous receipt is never minted; this SDK needs no
such check because JavaScript cannot distinguish 1.0 from 1 in the first
place. Represent money as integer cents or a decimal string. Fractional
floats like 0.97 are fine.
Development
npm install
npm test # vitest, fully offline (mocked fetch, in-test RSA keypairs)
npm run build # dual ESM/CJS via esbuild + tsc declarations → dist/