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.
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";
@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));
- Redact.
redactForEgressdetects values, writes them to theVault, and returns aPendingRedactionproposal. It is not sendable. - Approve.
approveis the explicit review action. It requires an audit sink and mints the only capability a provider accepts. - Send. A provider — the offline echo, OpenRouter, or your own — receives the approved payload and puts only placeholders on the wire.
- Restore.
rehydratetakes the payload'svaultRef, checks it against the vault, and replaces the labels locally.
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
| Export | Kind | Role |
|---|---|---|
detect | function | Deterministic identifier detection; returns spans. |
redactForEgress | function | Detect, write to the vault, and return a PendingRedaction proposal. |
approve | function | The explicit review step. Required audit sink; mints the sendable payload. |
rehydrate | function | Restore real values locally, bound to the payload's vault. |
Vault | class | In-memory token-to-value map with a frozen reference id. |
PendingRedaction | type | A review proposal. Not sendable. |
RedactedPayload | type | The branded egress capability. |
assertApproved | function | Runtime half of the guard; rejects unminted payloads by identity. |
unsafeBypass | function | The explicit, audited escape hatch for raw text. |
guardedProvider | function | Wrap a provider so the runtime guard runs at one chokepoint; optionally seal receipts. |
LlmProvider | interface | Provider contract; accepts only RedactedPayload. |
MAX_REDACTION_INPUT_BYTES | constant | 512 KiB UTF-8 input budget. |
AuditEntry | type | Discriminated union of redact, approve and unsafe-bypass records. |
RedactAuditEntry | interface | Audit record for a redact step. |
ApproveAuditEntry | interface | Audit record for an approve step. |
UnsafeBypassAuditEntry | interface | Audit record for an explicit bypass, including the reason. |
AuditSink | type | The callback every approval and bypass must emit into. |
EntityType | type | The category union used in placeholders and spans. |
Span | interface | One detected span: type, value, start, end. |
VaultRef | interface | Opaque, frozen handle to a vault's mapping. |
Providers
| Export | Kind | Role |
|---|---|---|
NoLLMProvider | class | Offline echo provider; no network, no key. |
OpenRouterProvider | class | OpenAI-compatible chat/completions provider. |
OpenRouterConfig | interface | Key, model, endpoint, timeout and response budget. |
makeProvider | function | Config-driven selector, returning a provider and a UI label. |
ProviderConfig | interface | Host-supplied config for makeProvider. |
SelectedProvider | interface | The selected provider plus its label. |
DEFAULT_OPENROUTER_TIMEOUT_MS | constant | 30 seconds. |
DEFAULT_OPENROUTER_MAX_RESPONSE_BYTES | constant | 1 MiB of UTF-8. |
RedactedResponse | interface | A provider reply, still in placeholder form. |
Receipts
| Export | Kind | Role |
|---|---|---|
EgressGovernance | interface | Provider name, signing seed and receipt sink for a guarded provider. |
EgressDecision | type | The guard's verdict: allow or deny. |
buildEgressSubject | function | Build the hash-only signed subject for one decision. |
contentHash | function | The canonical hash a verifier recomputes. |
DETECTOR_VERSION | constant | Ruleset version recorded in each receipt. |
sealEgressReceipt | function | Sign one decision into a receipt. |
verifyEgressReceipt | function | Verify a stored receipt against a pinned key, including 0.2.x receipts. |
StoredEgressReceipt | type | A receipt as stored; schema is optional for legacy records. |
EgressReceiptVerification | type | Verification result, with an advisory legacy flag. |
EgressSubject | type | The signed, hash-only record of one egress decision. |
EgressSubjectInput | interface | What the caller supplies to build the subject. |
Configuration
The library reads no environment variables; your code passes every setting in.
| Setting | Where | Default | What it changes |
|---|---|---|---|
apiKey | OpenRouterConfig, ProviderConfig | Required for OpenRouter | The key sent to the provider. Keep it server-side. |
model | OpenRouterConfig, ProviderConfig | openai/gpt-4o-mini via makeProvider | Which model answers. |
endpoint | OpenRouterConfig, ProviderConfig | https://openrouter.ai/api/v1/chat/completions | Any OpenAI-compatible chat/completions URL. |
timeoutMs | OpenRouterConfig, ProviderConfig | 30000 | End-to-end request deadline. |
maxResponseBytes | OpenRouterConfig, ProviderConfig | 1048576 (1 MiB) | Largest reply accepted. |
allowOffline | ProviderConfig | false | Opt in to the offline echo when no key is set. |
MAX_REDACTION_INPUT_BYTES | constant | 512 KiB | Input 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]
- Stable within a vault. The same
(type, value)always receives the same token. Each entity type has its own counter, so[CARD_1]and[EMAIL_1]are separate. - The reference is frozen. The payload carries
payload.vaultRef, an opaque handle whoseididentifies the vault.rehydratechecks that id first; a different vault fails withVaultMismatchError. - Every label must resolve. Under the bound vault, an unknown
[TYPE_n]token fails withUnresolvedPlaceholderError. The function does not leave a lookalike token in the reply. - A new vault starts over.
new Vault()gets a new id and counters start at 1. Hold the vault for the length of the conversation that the payload belongs to. - Placeholder-shaped input is refused. If the raw text already contains something like
[CARD_1], redaction fails withPlaceholderCollisionError, because restore would be ambiguous. - Nothing is persisted. The vault is ordinary process or tab memory and clears on reload. An encrypted IndexedDB vault is planned, not shipped. Do not share a vault between users.
- The map is only as complete as detection. A value the ruleset missed is never written to the vault, so it cannot be restored, and it may still leave in the reviewed text.
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.
| Type | Recognized | Not 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.
| Error | Raised when |
|---|---|
PrivacyCoreError | The common base class. Catch it to handle any boundary failure. |
InputTooLargeError | Input exceeds 512 KiB of UTF-8, before detection, vault writes or audit callbacks. |
PlaceholderCollisionError | The input already contains placeholder-shaped text such as [CARD_1]. |
ResidualValueError | A recognized value would still appear verbatim as a standalone token in the redacted output. |
ForgedPayloadError | approve() receives an object the redaction pipeline did not mint. |
UnapprovedPayloadError | A provider receives a payload the guard never approved, including a structurally identical look-alike. |
VaultMismatchError | rehydrate() receives a vault whose id does not match the payload's vaultRef. |
UnresolvedPlaceholderError | A token in the reply is not in the bound vault — for example, a model-invented token. |
MissingApiKeyError | makeProvider gets no usable key and allowOffline is not true. |
ProviderTimeoutError | An OpenRouter request exceeds its deadline (30 seconds by default). |
ProviderResponseTooLargeError | A reply exceeds the response cap (1 MiB by default), before JSON parsing. |
MalformedProviderResponseError | A 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
- Only the formats in the detection table are found. General names, addresses and free text are not detected.
- Redaction is not anonymisation; context can still re-identify a person.
- The vault is in-memory and clears on reload. There is no shipped encrypted persistence.
- A compromised device, extension, XSS bug or dependency can read the vault and a browser-held signing key.
- The library does not control what the AI provider does with the labelled text it receives.
- Input is capped at 512 KiB per call; split larger documents into reviewed sections.
The security page covers the threat model, the test evidence and private reporting.