Documentation

Install it, then earn the payload.

The public surface of @gainratio/privacy-core 0.3.2: installation, the four-step loop, every export, how the vault mapping works, the typed errors, and how to deploy the boundary without weakening it.

This page follows the repository documentation: README.md, API.md, DETECTION.md and ARCHITECTURE.md. Those files and the code are the source of truth.

Install

ES modules only. Node 22.13 or newer, or a modern browser bundler. The library is tested in Chromium and its public barrel is browser-safe.

npm install @gainratio/privacy-core
# or
pnpm add @gainratio/privacy-core
# or
yarn add @gainratio/privacy-core

There is one runtime dependency: @gainratio/avow ^0.5.3, used to sign the optional egress receipts. No account and no API key are needed to try the library.

import {
  approve,
  guardedProvider,
  NoLLMProvider,
  redactForEgress,
  rehydrate,
  Vault,
} from "@gainratio/privacy-core";
Package name change. 0.3.1 was the first release under @gainratio/privacy-core; 0.3.0 and older shipped as @edgeproc/privacy-core. If you are on the old name, change the install command and every import, including the /testing subpath.

The loop

This is the whole flow. It prints each step, including the audit log. NoLLMProvider is an offline echo, so the run needs no key and no network.

import {
  approve, guardedProvider, NoLLMProvider, redactForEgress, rehydrate, Vault,
} from "@gainratio/privacy-core";

const statement =
  "Grace Hopper, card 4242 4242 4242 4242, was charged $482.10 at Whole Foods on 01/14/2026.";

const vault = new Vault(); // holds your real values, in this process, on this machine
const audit = (e) => console.log("[audit]", e.kind, e.placeholders);

const pending = await redactForEgress(statement, vault, audit);
console.log("leaves your machine:", pending.redactedText);

const payload = approve(pending, audit);

const reply = await guardedProvider(new NoLLMProvider()).complete(payload);
console.log("model replied:", reply.redactedText);

console.log("you read:", rehydrate(reply.redactedText, vault, payload.vaultRef));
  1. Redact. redactForEgress detects values, writes them to the Vault, and returns a PendingRedaction proposal. It is not sendable.
  2. Approve. approve is the explicit review action. It requires an audit sink and mints the only capability a provider accepts.
  3. Send. A provider — the offline echo, OpenRouter, or your own — receives the approved payload and puts only placeholders on the wire.
  4. Restore. rehydrate takes the payload's vaultRef, checks it against the vault, and replaces the labels locally.
Zero detections still need approval. “The detector found nothing” is not the same as “a reviewer approved this content to leave the device”. The approval step does not disappear when the ruleset is quiet.

API

Everything src/index.ts exports, and nothing more. The brand factory mintPendingRedaction is intentionally not public, and test fixtures live only behind the /testing subpath.

Core signatures

detect(text: string): Span[]

redactForEgress(
  raw: string,
  vault: Vault,
  audit?: AuditSink,
): Promise<PendingRedaction>

approve(pending: PendingRedaction, audit: AuditSink): RedactedPayload

guardedProvider(inner: LlmProvider, governance?: EgressGovernance): LlmProvider

rehydrate(redactedText: string, vault: Vault, expectedRef: VaultRef): string

unsafeBypass(raw: string, reason: string, audit: AuditSink): RedactedPayload

makeProvider(config?: ProviderConfig): SelectedProvider

Core and audit

Exports from the public barrel
ExportKindRole
detectfunctionDeterministic identifier detection; returns spans.
redactForEgressfunctionDetect, write to the vault, and return a PendingRedaction proposal.
approvefunctionThe explicit review step. Required audit sink; mints the sendable payload.
rehydratefunctionRestore real values locally, bound to the payload's vault.
VaultclassIn-memory token-to-value map with a frozen reference id.
PendingRedactiontypeA review proposal. Not sendable.
RedactedPayloadtypeThe branded egress capability.
assertApprovedfunctionRuntime half of the guard; rejects unminted payloads by identity.
unsafeBypassfunctionThe explicit, audited escape hatch for raw text.
guardedProviderfunctionWrap a provider so the runtime guard runs at one chokepoint; optionally seal receipts.
LlmProviderinterfaceProvider contract; accepts only RedactedPayload.
MAX_REDACTION_INPUT_BYTESconstant512 KiB UTF-8 input budget.
AuditEntrytypeDiscriminated union of redact, approve and unsafe-bypass records.
RedactAuditEntryinterfaceAudit record for a redact step.
ApproveAuditEntryinterfaceAudit record for an approve step.
UnsafeBypassAuditEntryinterfaceAudit record for an explicit bypass, including the reason.
AuditSinktypeThe callback every approval and bypass must emit into.
EntityTypetypeThe category union used in placeholders and spans.
SpaninterfaceOne detected span: type, value, start, end.
VaultRefinterfaceOpaque, frozen handle to a vault's mapping.

Providers

ExportKindRole
NoLLMProviderclassOffline echo provider; no network, no key.
OpenRouterProviderclassOpenAI-compatible chat/completions provider.
OpenRouterConfiginterfaceKey, model, endpoint, timeout and response budget.
makeProviderfunctionConfig-driven selector, returning a provider and a UI label.
ProviderConfiginterfaceHost-supplied config for makeProvider.
SelectedProviderinterfaceThe selected provider plus its label.
DEFAULT_OPENROUTER_TIMEOUT_MSconstant30 seconds.
DEFAULT_OPENROUTER_MAX_RESPONSE_BYTESconstant1 MiB of UTF-8.
RedactedResponseinterfaceA provider reply, still in placeholder form.

Receipts

ExportKindRole
EgressGovernanceinterfaceProvider name, signing seed and receipt sink for a guarded provider.
EgressDecisiontypeThe guard's verdict: allow or deny.
buildEgressSubjectfunctionBuild the hash-only signed subject for one decision.
contentHashfunctionThe canonical hash a verifier recomputes.
DETECTOR_VERSIONconstantRuleset version recorded in each receipt.
sealEgressReceiptfunctionSign one decision into a receipt.
verifyEgressReceiptfunctionVerify a stored receipt against a pinned key, including 0.2.x receipts.
StoredEgressReceipttypeA receipt as stored; schema is optional for legacy records.
EgressReceiptVerificationtypeVerification result, with an advisory legacy flag.
EgressSubjecttypeThe signed, hash-only record of one egress decision.
EgressSubjectInputinterfaceWhat the caller supplies to build the subject.

Configuration

The library reads no environment variables; your code passes every setting in.

SettingWhereDefaultWhat it changes
apiKeyOpenRouterConfig, ProviderConfigRequired for OpenRouterThe key sent to the provider. Keep it server-side.
modelOpenRouterConfig, ProviderConfigopenai/gpt-4o-mini via makeProviderWhich model answers.
endpointOpenRouterConfig, ProviderConfighttps://openrouter.ai/api/v1/chat/completionsAny OpenAI-compatible chat/completions URL.
timeoutMsOpenRouterConfig, ProviderConfig30000End-to-end request deadline.
maxResponseBytesOpenRouterConfig, ProviderConfig1048576 (1 MiB)Largest reply accepted.
allowOfflineProviderConfigfalseOpt in to the offline echo when no key is set.
MAX_REDACTION_INPUT_BYTESconstant512 KiBInput budget; larger input is refused.

makeProvider fails closed with MissingApiKeyError when there is no usable key and allowOffline is not true. It is env-agnostic: it never reads a key for you.

Placeholders and the vault mapping

A placeholder is a typed label, not a hash: [CARD_1], [EMAIL_2]. The mapping is the Vault, and it lives only in memory.

const vault = new Vault();

await redactForEgress("Card 4242 4242 4242 4242", vault);
// text:  Card [CARD_1]
// vault: [CARD_1] ↔ 4242 4242 4242 4242

await redactForEgress("Again: 4242 4242 4242 4242", vault);
// same value, same vault → [CARD_1] again

await redactForEgress("ada@example.com and grace@example.com", vault);
// two different values → [EMAIL_1] and [EMAIL_2]
Rehydration is local. The provider's reply contains placeholders; the real values are substituted on the device after the reply arrives. The real values never need to be in the provider request.

Detection coverage

Detection is a fixed ruleset, so the honest version of “it finds the private bits” is a list. This is the whole of it. If a format is not in the left column, it is not detected, and the review step is what catches it.

The complete recognized set. The repository proves each recognized format at the network boundary.
TypeRecognizedNot recognized
EMAIL Any script, on both sides of the @: ada@example.com, josé.álvarez@example.com, kontakt@münchen-bank.example. A domain with no dot (user@localhost).
SSN 123-45-6789, 123 45 6789, and the same shape with any one consistent separator — dot, any whitespace (NBSP included) or any Unicode dash — with any digits, so ITINs and never-issued values are redacted too. Unseparated 123456789 only when it passes the SSA issuance rules (area not 000/666/9xx, group not 00, serial not 0000). An unseparated 9-digit run that could never have been issued; mixed separators (123-45.6789).
PHONE US/NANP with -, . or space separators, optional parentheses, optional +1: (415) 555-0132, +1(415) 555-0132, 415-555-0132, 212.555.0187, +1 646 555 0143; also when glued to an extension. A parenthesized area code takes any digits; without parentheses, area and exchange must start 2–9. An unformatted 4155550132, a 7-digit local number, non-NANP international, an unparenthesized number whose area or exchange starts 0/1 (123-456-7890).
CARD 13–19 digits with an optional space or hyphen between any two, Luhn-valid: 4111 1111 1111 1111, 4111-1111-1111-1111, unseparated, Amex 3782 822463 10005, mixed separators and the documented irregular layouts. Printed layouts are also found after another digit group and before more digits, via a shorter retry. Runs that fail Luhn; a card fused into a longer digit run with no separator; other separators (dot, NBSP).
IBAN Grouped or compact, mod-97-valid, including when an uppercase word follows. Groups may be separated by any whitespace. Other bank identifiers (SWIFT/BIC, UK sort codes).
ROUTING Routing number: 021000021. The English label is required. A bare routing number, which is not distinguishable from any other 9-digit run.
ACCOUNT Account number: 000123456789. 6 to 17 digits; the English label is required. A bare account number; a labelled one of 5 digits or fewer.
AMOUNT $1,482.10. Other currencies.
DATE 01/14/2026. Every other date format.
NAME Three demo names: Ada Lovelace, Grace Hopper, Alan Turing. Every other name. General name detection is not shipped.
MERCHANT Five demo merchants: Whole Foods, Starbucks, Amazon, Walmart, Costco. Every other merchant.

The repository proves the recognized column at the wire, with test/detector-completeness.test.ts, and pins a recall floor against v0.2.2. The limits that are easiest to widen by accident are pinned as tests too. Read the source table.

Errors

Every typed failure extends PrivacyCoreError. They are fail-closed signals: the safe reaction is to stop the send or restore, not to continue past it.

ErrorRaised when
PrivacyCoreErrorThe common base class. Catch it to handle any boundary failure.
InputTooLargeErrorInput exceeds 512 KiB of UTF-8, before detection, vault writes or audit callbacks.
PlaceholderCollisionErrorThe input already contains placeholder-shaped text such as [CARD_1].
ResidualValueErrorA recognized value would still appear verbatim as a standalone token in the redacted output.
ForgedPayloadErrorapprove() receives an object the redaction pipeline did not mint.
UnapprovedPayloadErrorA provider receives a payload the guard never approved, including a structurally identical look-alike.
VaultMismatchErrorrehydrate() receives a vault whose id does not match the payload's vaultRef.
UnresolvedPlaceholderErrorA token in the reply is not in the bound vault — for example, a model-invented token.
MissingApiKeyErrormakeProvider gets no usable key and allowOffline is not true.
ProviderTimeoutErrorAn OpenRouter request exceeds its deadline (30 seconds by default).
ProviderResponseTooLargeErrorA reply exceeds the response cap (1 MiB by default), before JSON parsing.
MalformedProviderResponseErrorA reply has no string assistant message at choices[0].message.content.

Non-2xx HTTP responses from the OpenRouter adapter reject with an Error carrying the status and body; the typed errors above cover the boundary, timeout, size and malformed-reply paths.

Deployment guidance

privacy-core is a library, not a service. It ships no server component: you bring the provider, the review screen and, if you need one, the proxy that holds the model key.

Run the boundary where the text is

The promise is that real values stay on the user's device. That holds when redaction runs in the client before the provider call. The package also runs on Node 22.13+, but if you run it on a server, the vault is server memory and the “on-device” framing no longer describes your architecture.

Keep the provider key server-side

The library is environment-agnostic: you pass the key in, and it never reads one from the environment for you. The bundled demo keeps OPENROUTER_API_KEY server-side and injects it through a same-origin dev proxy; the key never reaches the browser bundle. A real key shipped to the browser is a bug.

import { makeProvider } from "@gainratio/privacy-core";

const { provider, label } = makeProvider({
  apiKey: process.env.OPENROUTER_API_KEY, // server-side only
  model: "openai/gpt-4o-mini",
});

A browser path can point the endpoint at your own same-origin proxy. The sentinel key only selects the OpenRouter code path; it is not a secret.

const { provider } = makeProvider({
  apiKey: "via-proxy", // non-secret sentinel, not the real key
  endpoint: "/api/chat",
});

Hold one vault per conversation

Create a Vault for the interaction and keep it until you have rehydrated the reply. Pass payload.vaultRef to rehydrate; never share a vault between users. The vault clears on reload, so a refresh starts a new one.

Put the preview in front of approve

The review step is part of the security boundary. Render pending.redactedText exactly as it will be sent, let the person read it, and call approve only after that. The detector catches the listed formats; the person catches the rest.

Treat the origin as part of the boundary

The vault and any browser-held signing key are readable by anything that can run code on your origin: a malicious extension, an XSS bug or a compromised dependency. Keep third-party scripts off the origin, keep the app on HTTPS, and do not treat the library as a substitute for a clean client.

Respect the budgets

Input is capped at 512 KiB of UTF-8. OpenRouter requests default to a 30-second deadline and a 1 MiB response cap. Override timeoutMs or maxResponseBytes only with a deliberate, tested budget; timeout and overflow failures are typed and fail closed.

Decide whether you need receipts

Receipts are opt-in. Give guardedProvider a governance context and every allow or deny is signed into a receipt containing a SHA-256 hash of the text, the decision and the provider name — never the text itself. Persisting those receipts is your job; the sinks are wired, durable storage is planned. Receipts are tamper-evident, not a statement that the signing host was uncompromised.

Ship with the checks green

The repository's gate is pnpm gate: lint, type checks, unit tests with 100% coverage thresholds, the Playwright wire proof and the build. The e2e test drives the real demo in Chromium, intercepts the outbound request and fails if any real value appears in it. Run it before you change how your app builds its provider request.

Key limits

The security page covers the threat model, the test evidence and private reporting.

Next