npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

lorica-node

v0.1.7

Published

Node.js SDK for the Lorica biometric verification API — prove a real human authorized it

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-node

Quickstart

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. NOTE: an authenticated verify() defaults to
//    mode 'redeem' — SINGLE-USE. It consumes the receipt's nonce, so a second
//    verify() of the same receipt returns { valid: false, reason:
//    'nonce_replay' } by design. Use { mode: 'evidence' } for a repeatable,
//    non-consuming check of stored evidence.
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). Omitting the arg resolves
// against keys cached by a prior fetchJwks() — with nothing cached it throws
// JWKSRequiredError unless the client opted in with `allowNetwork: true`.
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 like 50000.0 serializes as 50000.0 in Python but 50000 in 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 JavaScript 50000.0 is 50000, so there is no whole-number float for this SDK to reject — that rule matters when a Python service mints or re-verifies the same payload.) Integers must stay within ±(2^53-1): JavaScript parses every JSON number as a double and silently rounds anything larger, so no independent verifier could re-hash the receipt. attest() rejects such integers client-side before any network call (the server enforces the same rule with a 422); use a decimal string for identifiers or amounts that large.

What a receipt proves

Each attest() call returns a signed JWT whose claims embed:

  • who — the enrolled identity (sub, identity) and the face match (match_score, with input_kind and an advisory liveness block),
  • what — the action type and a canonical sha256 of your payload (action, action_payload.{ref,hash}),
  • when / where in the logiat, seq, prev_hash, and a record_hash binding the whole record into a tamper-evident chain.

Every receipt also carries an assurance claim: for each signal (match, liveness, anti_spoof, injection, challenge, active_verdict), the operator's configured posture (required) beside what the flow actually did (achieved) — e.g. claims.assurance.liveness records the deployment's liveness mode next to the method, score, and verified flag that ran. A signal that did not run says "not_evaluated" outright. It is signed but rides outside record_hash (like liveness and chal), so it is authenticated by the receipt signature, not the chain hash — and it records posture, it does not upgrade what any signal proves.

verifyOffline() checks the RS256 signature against a JWKS document, recomputes the canonical record hash from the claims, and compares it to record_hash. The hashed field set is version-dispatched on the receipt's ver claim (current receipts are ver: 2; both versions verify forever):

  • ver absent or 1 — the frozen v1 ten: record_id, sub, identity, action, action_payload, live, match_score, iat, seq, prev_hash (live is hash-covered but no longer a top-level claim; it is recomputed from input_kind === 'image', its only meaning).
  • ver: 2 — the v1 ten plus ver, env, key_mode, auth_id, conf_hash, action_schema_version, enrollment_version in the same flat dict.
  • ver > 2 — fails closed (reason: 'unsupported_receipt_version').

v2 receipts can additionally be pinned to a deployment via verifyOffline(receipt, jwks, { expectedEnv: 'production', expectedKeyMode: 'live' }) — enforced only when supplied and the receipt is v2. 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, {mode?}) | POST /verify — authenticated default is 'redeem' (single-use; a repeat returns nonce_replay); 'evidence' is repeatable and consumes nothing | | 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.

The server also exposes GET /verify?receipt=... (+ optional mode, expected_key_mode) — identical semantics and response shape to POST /verify, so a verifier page can link straight to a result. The SDK always uses the POST form.

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).

Flipping an enrolled user into 'ephemeral' (or shortening an existing ephemeral window) is destructive — it puts a previously-permanent credential on a self-destruct timer — so the server requires a fresh consent recorded after enrollment (or an admin override). Without one the call fails with 403 retention_flip_requires_consent (a ConsentRequiredError). Record consent again, then flip:

await attest.getRetention('op-1041');   // { user_id, mode, window_seconds }

// Fresh consent AFTER enrollment authorizes the destructive flip:
await attest.recordConsent({ userId: 'op-1041' });
await attest.setRetention('op-1041', { mode: 'ephemeral', windowSeconds: 0 });

(Loosening a window or flipping back to 'standing_credential' is never destructive and needs no fresh consent.)

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; retention_flip_requires_consent / reenroll_requires_fresh_consent: a destructive change needs a fresh consent), NotFoundError (404), ValidationError (422 — no_face_detected, multiple_faces, image_too_large, invalid_base64, 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, 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.) verify() auto-retries only in 'evidence' mode: the authenticated default is a single-use redemption, so a blind replay after a lost response would read back nonce_replay on a receipt that was genuinely just redeemed — after a transport error on a redeem, treat the redemption as unknown and check with { mode: 'evidence' } before redeeming again. 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 whole-float check because JavaScript cannot distinguish 1.0 from 1 in the first place. Integers beyond ±(2^53-1) are the same bug from the other direction — JS parses every JSON number as a double and silently rounds them, so the server (422), the Python SDK, and this SDK (attest() and canonicalJson both throw) all reject them with the same message. Represent money as integer cents or a decimal string, and identifiers or amounts beyond ±(2^53-1) as decimal strings. 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/