euredact 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/NOTICE +11 -0
  2. package/README.md +455 -44
  3. package/dist/cjs/cache.js +2 -8
  4. package/dist/cjs/cloud/account.js +180 -0
  5. package/dist/cjs/cloud/batches.js +469 -0
  6. package/dist/cjs/cloud/client.js +124 -14
  7. package/dist/cjs/cloud/config.js +28 -2
  8. package/dist/cjs/cloud/errors.js +39 -2
  9. package/dist/cjs/cloud/index.js +16 -1
  10. package/dist/cjs/index.js +9 -1
  11. package/dist/cjs/platform.browser.js +98 -0
  12. package/dist/cjs/platform.js +140 -0
  13. package/dist/cjs/rules/bicGleif.js +57 -0
  14. package/dist/cjs/rules/bicRegistry.js +37 -11
  15. package/dist/cjs/rules/countries/index.js +83 -13
  16. package/dist/cjs/rules/cues.js +29 -9
  17. package/dist/cjs/rules/engine.js +14 -6
  18. package/dist/cjs/rules/suppressors.js +479 -26
  19. package/dist/cjs/rules/validators.js +50 -0
  20. package/dist/cjs/sdk.js +108 -14
  21. package/dist/cjs/types.js +4 -1
  22. package/dist/esm/cache.js +2 -8
  23. package/dist/esm/cloud/account.d.ts +155 -0
  24. package/dist/esm/cloud/account.js +175 -0
  25. package/dist/esm/cloud/batches.d.ts +166 -0
  26. package/dist/esm/cloud/batches.js +464 -0
  27. package/dist/esm/cloud/client.d.ts +83 -1
  28. package/dist/esm/cloud/client.js +119 -22
  29. package/dist/esm/cloud/config.d.ts +6 -0
  30. package/dist/esm/cloud/config.js +27 -2
  31. package/dist/esm/cloud/errors.d.ts +26 -1
  32. package/dist/esm/cloud/errors.js +35 -1
  33. package/dist/esm/cloud/index.d.ts +4 -2
  34. package/dist/esm/cloud/index.js +4 -2
  35. package/dist/esm/index.d.ts +2 -2
  36. package/dist/esm/index.js +1 -1
  37. package/dist/esm/platform.browser.d.ts +27 -0
  38. package/dist/esm/platform.browser.js +92 -0
  39. package/dist/esm/platform.d.ts +20 -0
  40. package/dist/esm/platform.js +101 -0
  41. package/dist/esm/rules/bicGleif.d.ts +4 -0
  42. package/dist/esm/rules/bicGleif.js +53 -0
  43. package/dist/esm/rules/bicRegistry.d.ts +25 -11
  44. package/dist/esm/rules/bicRegistry.js +36 -11
  45. package/dist/esm/rules/countries/index.js +83 -13
  46. package/dist/esm/rules/cues.d.ts +1 -1
  47. package/dist/esm/rules/cues.js +29 -9
  48. package/dist/esm/rules/engine.js +14 -6
  49. package/dist/esm/rules/suppressors.d.ts +1 -0
  50. package/dist/esm/rules/suppressors.js +478 -25
  51. package/dist/esm/rules/validators.d.ts +14 -0
  52. package/dist/esm/rules/validators.js +47 -0
  53. package/dist/esm/sdk.d.ts +77 -4
  54. package/dist/esm/sdk.js +108 -17
  55. package/dist/esm/types.d.ts +64 -6
  56. package/dist/esm/types.js +4 -1
  57. package/package.json +13 -4
package/README.md CHANGED
@@ -34,7 +34,7 @@ console.log(result.detections);
34
34
  ## Features
35
35
 
36
36
  - **31 European countries** with country-specific patterns
37
- - **41 PII entity types** (30 from the rules engine, 11 from the
37
+ - **40 PII entity types** (30 from the rules engine, 10 from the
38
38
  [cloud tier](#cloud-tier)): national IDs, IBANs, phone numbers, email, VAT
39
39
  numbers, license plates, credit cards, BIC/SWIFT, VIN, IMEI, GPS coordinates,
40
40
  UUIDs, social handles, IP/IPv6, MAC addresses, secrets/API keys, and more
@@ -82,7 +82,8 @@ console.log(result.detections);
82
82
  | keep relationships visible across a whole session | `{ referentialIntegrity: true }` |
83
83
  | never redact my own company name or addresses | `{ allowlist: [...] }`, or `new EuRedact({ allowlist })` |
84
84
  | never redact anything at my own domain | `{ allowlistDomains: ["acme.be"] }` |
85
- | audit what the allowlist kept in the document | `result.exempted` |
85
+ | leave whole types in the text (job titles, amounts) | `{ excludeTypes: ["JOB_TITLE", "FINANCIAL_AMOUNT"] }`, or `new EuRedact({ excludeTypes })` — see [Excluding entity types](#excluding-entity-types) |
86
+ | audit what the allowlist or `excludeTypes` kept in the document | `result.exempted` |
86
87
  | catch person names, employers, job titles, diagnoses | `redactAsync(text, { mode: "cloud" })` — see [Cloud tier](#cloud-tier) |
87
88
  | include dates of birth | `{ detectDates: true }` |
88
89
  | redact a document too large for one call | `context` + `chunkOffset` — see [Chunked documents](#chunked-documents) |
@@ -112,8 +113,10 @@ interface RedactOptions {
112
113
  tokenize?: boolean; // reversible tokens, EMAIL_K7Q2
113
114
  allowlist?: string[] | null; // values never redacted
114
115
  allowlistDomains?: string[] | null; // domains never redacted (EMAIL/URL)
116
+ excludeTypes?: string[] | null; // entity types neither reported nor masked
115
117
  detectDates?: boolean; // include DOB / date of death
116
118
  cache?: boolean; // reuse results for identical input
119
+ idempotencyKey?: string; // mode: "cloud" only; see the table below
117
120
  }
118
121
  ```
119
122
 
@@ -135,12 +138,14 @@ interface RedactOptions {
135
138
  | `tokenize` | `false` | Reversible token per value (`EMAIL_K7Q2`), with the mapping in `result.tokens` for [`restore()`](#restoretext-tokens). Unique to the **call**. Cannot be combined with `referentialIntegrity`. See [Reversible tokenization](#reversible-tokenization). |
136
139
  | `allowlist` | `null` | Values never to redact, whole-span and case-insensitive, merged with the instance's list. Structured identifiers (IBAN, phone, VAT, …) also match across spacing and hyphenation. Applies to cloud-tier types too. See [Allowlist](#allowlist). |
137
140
  | `allowlistDomains` | `null` | Domains whose addresses are never redacted, e.g. `["acme.be"]`. Applies to `EMAIL` and `URL` only, and covers subdomains. See [Allowlist](#allowlist). |
141
+ | `excludeTypes` | `null` | Entity types neither reported nor masked, e.g. `["JOB_TITLE"]`; merged with the instance's list. Unknown names throw. See [Excluding entity types](#excluding-entity-types). |
138
142
 
139
143
  **Long documents and tiers**
140
144
 
141
145
  | Parameter | Default | Description |
142
146
  |---|---|---|
143
147
  | `mode` | `"rules"` | `"rules"` runs locally and synchronously. `"cloud"` must go through [`redactAsync()`](#redactasynctext-options) — `redact()` is synchronous and a network call cannot be. See [Cloud tier](#cloud-tier). |
148
+ | `idempotencyKey` | generated | `mode: "cloud"` only (through `redactAsync`): sent as the request's `Idempotency-Key`, so the same key returns the same job instead of running and billing the document again. Throws in rules mode. See [What the service said](#what-the-service-said-resultcloud). |
144
149
  | `context` | `null` | Share country evidence across the chunks of one document. Pass the same `DocumentContext` to every chunk. Disables the cache. See [Chunked documents](#chunked-documents). |
145
150
  | `chunkOffset` | `0` | Where this chunk starts in the whole document. Only rebases spans recorded in `context`; returned detections stay relative to `text`. |
146
151
  | `cache` | `true` | Reuse the result for an identical input and configuration. |
@@ -227,6 +232,8 @@ The `EuRedact` class exposes `redact()`, `redactAsync()`, `redactBatch()`,
227
232
  new EuRedact({
228
233
  maxInputLength?: number, // default 10_485_760 (~10 MB)
229
234
  allowlist?: string[] | null,
235
+ allowlistDomains?: string[] | null,
236
+ excludeTypes?: string[] | null,
230
237
  })
231
238
  ```
232
239
 
@@ -235,6 +242,7 @@ new EuRedact({
235
242
  | `maxInputLength` | `10_485_760` | Longest document `redact()` accepts, in characters. Above it, it throws — split the input or raise the ceiling. |
236
243
  | `allowlist` | `null` | Values never to redact, for every call on this instance. Merged with the per-call `allowlist`. See [Allowlist](#allowlist). |
237
244
  | `allowlistDomains` | `null` | Domains never to redact, for every call on this instance. |
245
+ | `excludeTypes` | `null` | Entity types never reported or masked, for every call on this instance. See [Excluding entity types](#excluding-entity-types). |
238
246
 
239
247
  The result cache, referential-integrity labels and custom patterns are all per
240
248
  instance, which is what makes one instance per tenant the right default.
@@ -248,10 +256,18 @@ instance, which is what makes one instance per tenant the right default.
248
256
  interface RedactResult {
249
257
  redactedText: string; // The input text with PII replaced
250
258
  detections: Detection[]; // All PII spans found
251
- source: string; // Detection backend ("rules")
259
+ source: string; // "rules" or "cloud"
252
260
  degraded: boolean; // True if the engine fell back to a simpler mode
261
+
262
+ // Country inference — see "Country-independent detection"
263
+ inferredCountries: Array<[string, number]>; // [country, confidence], strongest first
264
+ evidence: CountryEvidence[]; // every signal, with the span behind it
265
+ detectionMode: string; // "declared" if countries was passed,
266
+ // "inferred" otherwise
253
267
  tokens: Record<string, string>; // token -> original value; only with tokenize: true
254
- exempted: Exemption[]; // spans the allowlist kept, with the rule that matched
268
+ exempted: Exemption[]; // spans the allowlist or excludeTypes kept, with the rule
269
+ cloud?: CloudInfo; // mode: "cloud" only: job id, model, cost, unplaced
270
+ // findings — see "What the service said"
255
271
  }
256
272
  ```
257
273
 
@@ -265,7 +281,7 @@ interface Detection {
265
281
  text: string; // The matched substring
266
282
  source: DetectionSource; // "rules" or "cloud"
267
283
  country: string | null; // ISO code or null for shared/custom patterns
268
- confidence: string; // "high" | "medium" | "low" — see below
284
+ confidence: string | null; // "high" | "medium" | "low"; null for a cloud-model span — see below
269
285
  countryConfidence?: number; // How strongly the document supports `country`,
270
286
  // in [0, 1]. 0 means the attribution rests on
271
287
  // a checksum alone.
@@ -282,6 +298,7 @@ about whether the span is masked — every detection is, at every level.
282
298
  | `"high"` | a pattern matched and, where one exists, its checksum passed |
283
299
  | `"medium"` | the type comes from a label touching the span, because no pattern of that type claimed it — `Αρ. Ταυτότητας: 00892341` is a `NATIONAL_ID` although nothing can checksum it |
284
300
  | `"low"` | a pattern matched, its checksum *failed*, and the document labels the span as that very type — `Rijksregisternummer: 85.03.19-284.73` is a national number with a bad check digit |
301
+ | `null` | a span the cloud model found (`source` is `cloud`). The model reports no confidence, so none is invented: a model span is not checksum-backed, and filtering on `"high"` keeps rules detections only *(rules-engine#108)* |
285
302
 
286
303
  Filter on it when you need only checksum-backed detections:
287
304
 
@@ -294,24 +311,6 @@ identifier: the shape and the label agree, the check digit does not. Earlier
294
311
  versions dropped these, which meant a redaction library printed in full an
295
312
  identifier it had recognised and rejected.
296
313
 
297
- #### `RedactResult`
298
-
299
- ```ts
300
- interface RedactResult {
301
- redactedText: string;
302
- detections: Detection[];
303
- source: string;
304
- degraded: boolean;
305
-
306
- // Country inference — see "Country-independent detection"
307
- inferredCountries: Array<[string, number]>; // [country, confidence], strongest first
308
- evidence: CountryEvidence[]; // every signal, with the span behind it
309
- detectionMode: string; // "declared" if countries was passed,
310
- // "inferred" otherwise
311
- tokens: Record<string, string>; // token -> original value; only with tokenize: true
312
- }
313
- ```
314
-
315
314
  #### `EntityType`
316
315
 
317
316
  String enum with all supported PII categories:
@@ -330,10 +329,15 @@ Cloud tier only — the rule engine never emits these, because there is no shape
330
329
  to match on. That is precisely why the model exists:
331
330
 
332
331
  ORGANISATION_NAME JOB_TITLE MEDICAL_CONDITION SENSITIVE_ATTRIBUTE
333
- BIOMETRIC_REF FINANCIAL_AMOUNT QUASI_IDENTIFIER CREDENTIAL
334
- URL
332
+ BIOMETRIC_REF FINANCIAL_AMOUNT QUASI_IDENTIFIER URL
335
333
  ```
336
334
 
335
+ `SECRET` is the one type for credentials, whichever layer finds them: the rules
336
+ engine's shaped keys and tokens, and the cloud model's prose passwords and
337
+ connection-string secrets, which the service labels `CREDENTIAL`. The SDK
338
+ reports those as `SECRET`, and `CREDENTIAL` remains only as a legacy alias of
339
+ it (rules-engine#111).
340
+
337
341
  `INTERNAL_ID` — an employee, badge or customer number tied to a person — is
338
342
  emitted **only** when an explicit label names it (`medarbejdernummer:`,
339
343
  `Personalnummer:`, `Employee No:`, `Betriebsstättennr.`, `Badge`). There is no
@@ -420,8 +424,10 @@ const results = await Promise.all(
420
424
  );
421
425
  ```
422
426
 
423
- Bound that yourself if the batch is large — the service enforces quotas and will
424
- answer `429`, which the client honours via `Retry-After`.
427
+ Bound that yourself if the batch is large. The service's rate limit answers
428
+ `429`, which the client retries, honouring `Retry-After`, before throwing
429
+ `RateLimitedError`. A `429` for the daily quota throws `QuotaExceededError`
430
+ at once: it will not clear before the day does.
425
431
 
426
432
  ### Reuse one instance
427
433
 
@@ -473,6 +479,11 @@ result.detections.map(d => [d.entityType, d.text]);
473
479
  // [["PERSON_NAME", "Bas Verhoeven"], ["PHONE", "+32 ..."]]
474
480
  ```
475
481
 
482
+ The endpoint defaults to `https://api.euredact.dev`; `baseUrl` (or
483
+ `EUREDACT_BASE_URL`) overrides it. It must be `https://`: plain `http://` is
484
+ refused except to `localhost`, `127.0.0.1` or `::1`, so the key and the text
485
+ never travel unencrypted.
486
+
476
487
  **`redact()` is synchronous and cannot serve the cloud tier**, so
477
488
  `redact(text, { mode: "cloud" })` throws and names `redactAsync`. It never
478
489
  falls back to rules-only output: a caller who believes names and diagnoses were
@@ -590,6 +601,340 @@ either, no hosted model can help; run one yourself against
590
601
  `local.redactedText`.
591
602
 
592
603
 
604
+ ### What the service said: `result.cloud`
605
+
606
+ A cloud result carries `cloud`, what the service reported beside the spans;
607
+ it is absent on a rules-only result.
608
+
609
+ ```ts
610
+ const result = await redactAsync(text, {
611
+ countries: ["BE"], mode: "cloud", idempotencyKey: "invoice-2291-v1",
612
+ });
613
+ result.cloud?.jobId; // support, Jobs.retrieve, replays
614
+ result.cloud?.modelVersion; // the model that answered; null when only rules ran
615
+ for (const item of result.cloud?.unlocated ?? []) console.log(item.entityType, item.text);
616
+ const usage = result.cloud?.usage;
617
+ if (usage) {
618
+ const { tokens, billingRate, credits, factors } = usage;
619
+ for (const f of factors) console.log(f.code, f.types ?? "", f.detail);
620
+ }
621
+ ```
622
+
623
+ - **`idempotencyKey`** is sent as the request's `Idempotency-Key`: the same key
624
+ returns the same job instead of running and billing the document again. One
625
+ is generated per call when omitted. It applies to `mode: "cloud"` only.
626
+ - **`usage`** is the total the request was charged and the reasons for it, never
627
+ a cost per step. `credits` is `round(tokens × billingRate)`, the formula the
628
+ service debits by. The factor codes are `rules_only`, `document_length`,
629
+ `instructions`, `dense_document`, `special_category_check` (with the `types`
630
+ that triggered it), `long_document` and `batch_rate`; a code added later
631
+ still arrives, as its string. Absent when the service does not report it or
632
+ reported it malformed: a cost report never fails a redaction.
633
+ - **`unlocated`** lists what the model found but could not match to a position
634
+ in the text it was sent. Nothing was masked for these.
635
+ - **Batch documents** carry `modelVersion` and `unlocated`; no `jobId` or
636
+ `usage`, which belong to the batch (`batch.tokens`, `batch.creditsCharged`,
637
+ `batch.billingRate`).
638
+
639
+ All four types are exported from `euredact`:
640
+
641
+ ```ts
642
+ interface CloudInfo {
643
+ jobId: string | null; // null for a batch document
644
+ modelVersion: string | null; // null when only rules ran
645
+ usage?: Usage; // absent from older services, batch documents, or a malformed block
646
+ unlocated: Unlocated[];
647
+ }
648
+
649
+ interface Unlocated {
650
+ text: string; // what the model reported
651
+ entityType: EntityType | string; // e.g. "PERSON_NAME"; unknown types stay strings
652
+ }
653
+
654
+ interface Usage {
655
+ tokens: number; // every model call the document triggered
656
+ billingRate: number; // 1, or e.g. 0.75 for a batch
657
+ credits: number; // round(tokens * billingRate): what was debited
658
+ factors: UsageFactor[];
659
+ }
660
+
661
+ interface UsageFactor {
662
+ code: string; // see the list above; new codes arrive as strings
663
+ detail: string; // one sentence from the service ("" if none)
664
+ types?: string[]; // special_category_check only
665
+ }
666
+ ```
667
+
668
+ **Retrying safely.** Pass your own `idempotencyKey` when your code may send the
669
+ same document twice: after a timeout of your own, from a retried queue message
670
+ or a restarted worker. Derive it from something stable (an invoice number plus
671
+ a version), not from a random value per attempt. A replay returns the same
672
+ `result.cloud.jobId` and is not billed again.
673
+
674
+ ### Account, past jobs and the batch list
675
+
676
+ Everything an API key may do on the service is reachable from the SDK. What
677
+ needs a signed-in person (logging in, minting keys, settings, members, terms)
678
+ is not, because a key cannot do it. Every call returns a `Promise`.
679
+
680
+ | Call | Service endpoint | Resolves to |
681
+ |---|---|---|
682
+ | `new Account().summary()` | `GET /v1/account` | `AccountSummary` |
683
+ | `new Account().credits()` | `GET /v1/account/credits` | `Credits` |
684
+ | `new Account().creditHistory(limit = 50)` | `GET /v1/account/credits/history` | `CreditEntry[]`, newest first; `limit` 1–500 |
685
+ | `new Account().usage(days = 30)` | `GET /v1/account/usage` | `UsageDay[]` |
686
+ | `new Account().usageByKey(days = 30)` | `GET /v1/account/usage/by-key` | `KeyUsage[]` |
687
+ | `new Account().keys()` | `GET /v1/account/keys` | `ApiKey[]` |
688
+ | `new Account().revokeKey(keyId)` | `POST /v1/account/keys/{id}/revoke` | `KeyRevocation` |
689
+ | `new Jobs().retrieve(jobId)` | `GET /v1/jobs/{id}` | `Job` |
690
+ | `new Batches().list(limit = 20)` | `GET /v1/batches` | `Batch[]`, newest first; `limit` 1–100 |
691
+
692
+ ```ts
693
+ import { Account, Batches, Jobs } from "euredact";
694
+
695
+ const account = new Account();
696
+ const summary = await account.summary();
697
+ if (summary.quotaRemaining !== null && summary.quotaRemaining < 100) {
698
+ console.log(`${summary.quotaRemaining} documents left today`);
699
+ }
700
+ console.log((await account.credits()).balance);
701
+ for (const day of await account.usage(7)) console.log(day.day, day.documents, day.tokens);
702
+ for (const key of await account.keys()) console.log(key.id, key.name, key.active, key.lastUsedAt);
703
+
704
+ const job = await new Jobs().retrieve("job-7f3a…");
705
+ if (job.result) console.log(job.result.redactedText);
706
+
707
+ for (const batch of await new Batches().list(20)) {
708
+ console.log(batch.id, batch.status, batch.documents, batch.creditsCharged);
709
+ }
710
+ ```
711
+
712
+ #### Creating the clients
713
+
714
+ `Account` and `Jobs` read the configuration `configure()` set (or
715
+ `EUREDACT_API_KEY`). Both take two optional arguments:
716
+ `new Account(config?, { fetchImpl? })`. `config` is a `CloudConfig` of its own,
717
+ for a second key or another endpoint: a plain object with `apiKey`, `baseUrl`,
718
+ `timeoutMs`, `pollTimeoutMs`, `maxRetries` and `headers`, checked for an
719
+ `https://` base URL like `configure()` does; `fetchImpl` replaces the platform `fetch`,
720
+ for a proxy or a mock in your tests. (`Batches` takes the same two inside its
721
+ options object: `new Batches({ config, fetchImpl })`.) There is nothing to close.
722
+ They need a global `fetch`: Node 18+, or a browser, or pass `fetchImpl`.
723
+
724
+ #### What each call resolves to
725
+
726
+ Each value has `raw`, the service's JSON as received, so a field the SDK does
727
+ not type yet is still reachable; every field is `null` when the service left
728
+ it out or sent the wrong type, rather than the call failing.
729
+
730
+ ```ts
731
+ interface AccountSummary {
732
+ tenantId: string | null; tenantName: string | null; active: boolean | null;
733
+ memberSince: string | null; // ISO date
734
+ role: string | null;
735
+ retentionMode: string | null; // "ttl", or "none": text discarded on delivery
736
+ payloadTtlHours: number | null; // 0 under "none"
737
+ documentsToday: number | null; documentsThisMonth: number | null;
738
+ tokensThisMonth: number | null; failures24h: number | null;
739
+ dailyQuota: number | null; // null: no daily quota on this account
740
+ quotaRemaining: number | null;
741
+ activeKeys: number | null;
742
+ batchesAvailable: boolean | null; // batches need retention ("ttl")
743
+ raw: Record<string, unknown>;
744
+ }
745
+ interface Credits { balance: number | null; granted: number | null; spent: number | null;
746
+ unit: string | null /* "tokens" */; raw: Record<string, unknown> }
747
+ interface CreditEntry { // rolled up per minute, reason and direction
748
+ at: string | null; delta: number | null /* negative for a debit */;
749
+ reason: string | null; count: number | null; jobId: string | null; sourceRef: string | null;
750
+ raw: Record<string, unknown>;
751
+ }
752
+ interface UsageDay { day: string | null; documents: number | null; tokens: number | null;
753
+ failures: number | null; rulesOnly: number | null; raw: Record<string, unknown> }
754
+ interface KeyUsage { // the busiest keys one by one; the rest in one entry, keyId null
755
+ keyId: number | null; name: string | null;
756
+ documents: number | null; tokens: number | null; failures: number | null;
757
+ series: UsageDay[]; raw: Record<string, unknown>;
758
+ }
759
+ interface ApiKey { // never the secret: shown once, when minted
760
+ id: number | null; name: string | null; createdBy: string | null;
761
+ createdAt: string | null; lastUsedAt: string | null; revokedAt: string | null;
762
+ active: boolean | null; raw: Record<string, unknown>;
763
+ }
764
+ interface KeyRevocation {
765
+ id: number | null; keysRemaining: number | null;
766
+ lockedOut: boolean | null; // true: no active key left; mint one in the console
767
+ selfRevoked: boolean | null; // true: you revoked the key this client uses
768
+ raw: Record<string, unknown>;
769
+ }
770
+ interface Job {
771
+ jobId: string;
772
+ status: string; // "queued", "running" or "succeeded"
773
+ createdAt: string | null;
774
+ result: RedactResult | null; // set once succeeded
775
+ raw: Record<string, unknown>;
776
+ }
777
+ ```
778
+
779
+ `Batch` gained four fields beside `id`, `status`, `counts` and the timestamps:
780
+ `documents`, `tokens` (`{ prompt, completion }`), `creditsCharged` and
781
+ `billingRate` (e.g. `0.75`).
782
+
783
+ #### Retrieving a job
784
+
785
+ `redactAsync(text, { mode: "cloud" })` already waits for its job, polling past
786
+ the service's synchronous window, so most code never needs `Jobs`. It is for
787
+ support and audits (look up `result.cloud.jobId` later) and for a job the
788
+ client gave up polling on (`pollTimeoutMs`), which may still finish.
789
+
790
+ A failed or rejected job throws (`CloudError` with status 502, `TooLargeError`)
791
+ rather than resolving with a status. The result is over the text the service
792
+ received: under `mode: "cloud"` that is the locally masked text, so its offsets
793
+ and `redactedText` are not your original's. Results are kept for the account's
794
+ retention period (`AccountSummary.payloadTtlHours`), and not at all under
795
+ `retentionMode === "none"`; after that, `ResultExpiredError`.
796
+
797
+ #### Revoking a key
798
+
799
+ `revokeKey` is the one write an API key may make, for a key that leaked. It is
800
+ sent once and never retried. Revoking the key the client itself uses succeeds
801
+ and reports `selfRevoked: true`; every later call with it fails with status
802
+ 401. When `lockedOut` is `true`, no active key is left and only a person in the
803
+ console can mint one.
804
+
805
+ #### Handling errors
806
+
807
+ All errors extend `CloudError`, which carries `status` (the HTTP status, or
808
+ `undefined` for a network failure) and `detail` (the service's JSON detail).
809
+ Test the specific ones first: `RateLimitedError` extends `QuotaExceededError`.
810
+
811
+ ```ts
812
+ import {
813
+ CloudError, QuotaExceededError, RateLimitedError, TooLargeError, redactAsync,
814
+ } from "euredact";
815
+
816
+ try {
817
+ const result = await redactAsync(text, {
818
+ countries: ["BE"], mode: "cloud", idempotencyKey: `invoice-${invoice.id}-v${invoice.version}`,
819
+ });
820
+ } catch (err) {
821
+ if (err instanceof RateLimitedError) {
822
+ // rate limit still hit after every retry: back off, then retry with the same key
823
+ } else if (err instanceof QuotaExceededError) {
824
+ // daily quota used up (err.detail.used, err.detail.limit): wait for tomorrow
825
+ } else if (err instanceof TooLargeError) {
826
+ // over the input cap: split the document and send the parts
827
+ } else if (err instanceof CloudError) {
828
+ // anything else; err.status says what
829
+ } else {
830
+ throw err;
831
+ }
832
+ }
833
+ ```
834
+
835
+ | Error | Status | When | Retried by the SDK |
836
+ |---|---|---|---|
837
+ | `NotConfiguredError` | — | no API key configured, or no global `fetch` | no |
838
+ | `CloudError` | 401 | the key is wrong or revoked | no |
839
+ | `CloudError` | 403 | the service refused the call. Account calls are accepted with an API key while the service does not require a browser `Origin`; if that changes they answer 403 | no |
840
+ | `CloudError` | 403 | `Batches.create` on a workspace without retention: `detail.code === "retention_required"` (see [Batches](#batches)) | no |
841
+ | `NotFoundError` | 404 | no such job, batch or key — or another account's; also every batch call, `list()` included, while batches are not enabled on the service (`batches_not_enabled`) | no |
842
+ | `CloudError` | 409 | `revokeKey` on a key already revoked | no |
843
+ | `ResultExpiredError` | 410 | the job's result is past retention | no |
844
+ | `TooLargeError` | 413 | the document is over the input cap; also a rejected job | no |
845
+ | `QuotaExceededError` | 429 | the daily document quota is used up | **no**: it will not clear before the day does |
846
+ | `RateLimitedError` | 429 | the edge's rate limit, still hit after every retry | yes, `maxRetries` times with backoff |
847
+ | `CloudError` | 5xx, `undefined` | the service or the network failed | yes for reads and `redactAsync`; never for `revokeKey` |
848
+
849
+ `NotFoundError`, `ResultExpiredError` and `RateLimitedError` are exported from
850
+ `euredact` alongside the others.
851
+
852
+ **Availability:** like the rest of the cloud tier, these calls are in private
853
+ alpha, and the response shapes may still change between releases. The batch
854
+ calls, `list()` included, answer `NotFoundError` until batches are enabled on
855
+ the service (see [Batches](#batches)).
856
+
857
+ ### Batches
858
+
859
+ > **Availability:** private alpha, like the rest of the cloud tier. Batches
860
+ > need **retention** on the workspace (see below); while batches are not
861
+ > enabled on the service, every batch call answers `NotFoundError`
862
+ > (`batches_not_enabled`).
863
+
864
+ A batch hands up to 5,000 documents to the cloud tier and collects the answers
865
+ within 24 hours, at a lower price and without your plan's concurrency limit.
866
+ It keeps the same promise as `mode: "cloud"`: each document is masked **on
867
+ your machine**, and only the masked text is uploaded. What is needed to map the
868
+ answers back stays in a **local batch file**, `~/.euredact/batches/<batchId>.json`
869
+ (directory `0700`, file `0600`): the originals, their local detections and a
870
+ SHA-256 of the masked text sent. The file format is shared with the Python SDK,
871
+ so a batch created by one can be resolved by the other.
872
+
873
+ ```ts
874
+ import { configure, Batches } from "euredact";
875
+
876
+ configure({ apiKey: "erk_..." });
877
+ const batches = new Batches();
878
+ const batch = await batches.create([
879
+ { customId: "invoice-2291", text: invoiceText, countries: ["BE"] },
880
+ { customId: "memo-17", text: memoText, countries: ["NL"] },
881
+ ]);
882
+
883
+ // Later, possibly in another process:
884
+ const outcome = await batches.results(batch.id);
885
+ if (outcome.status === "resolved") {
886
+ for (const [customId, doc] of Object.entries(outcome.documents)) {
887
+ console.log(customId, doc.ok ? doc.result!.redactedText : `${doc.error}: ${doc.message}`);
888
+ }
889
+ }
890
+ ```
891
+
892
+ **Batches need retention.** The service holds each document until it is
893
+ processed and each result until you fetch it, so a workspace that erases
894
+ documents on delivery (`retentionMode === "none"`, or 0 hours of retention)
895
+ cannot create one. `create()` then throws `CloudError` with status 403 and
896
+ `detail.code === "retention_required"`, before anything is uploaded or written
897
+ locally. The workspace owner turns retention on in the console, on the
898
+ **Account** tab. To check first:
899
+
900
+ ```ts
901
+ import { Account, CloudError } from "euredact";
902
+
903
+ if (!(await new Account().summary()).batchesAvailable) {
904
+ // no retention on this workspace: send the documents with mode: "cloud"
905
+ }
906
+
907
+ try {
908
+ await batches.create(documents);
909
+ } catch (err) {
910
+ if (err instanceof CloudError && err.status === 403 && err.detail?.code === "retention_required") {
911
+ // ask the workspace owner to turn retention on
912
+ }
913
+ throw err;
914
+ }
915
+ ```
916
+
917
+ - **`results()`** returns `not_ended` while the batch runs and changes nothing.
918
+ Once it has ended it maps every document, wipes the originals and leaves a
919
+ text-free receipt; a second call returns `already_resolved`.
920
+ - **Integrity.** Each masked text is rebuilt from the file and must match the
921
+ hash the gateway reports before a span is placed. An edited file, a missing
922
+ entry or no file is an error for that document (`local_mismatch`,
923
+ `missing_local_entry`), never the masked text passed off as a result.
924
+ - **`pending()`**, **`retrieve(id)`**, **`cancel(id)`** (the file stays until
925
+ the finished documents are mapped) and **`purge(id)`**. Receipts and
926
+ unmapped files are cleaned up once the gateway's results expire.
927
+ - **In a browser** there is no filesystem: pass `store`, any object with async
928
+ `read`, `write`, `delete` and `list`. In Node, `batchDir` moves the
929
+ directory. `cipher` (`encrypt`/`decrypt`) encrypts the file at rest with a
930
+ key you hold.
931
+ - **Limits** checked before anything is masked or uploaded: 5,000 documents,
932
+ unique `customId`s of 1-64 characters from `A-Z a-z 0-9 _ . : -` (the
933
+ gateway's rule, exported as `CUSTOM_ID`), one country each, a 128 MB body. The 5,000-token limit
934
+ per document is **not** checked by the SDK: it is counted by the model's
935
+ tokenizer on the gateway. A document over it comes back as `too_long`, whose
936
+ message says so; split it and submit the parts.
937
+
593
938
  ## `NAME` is now `PERSON_NAME`
594
939
 
595
940
  The canonical type name is `PERSON_NAME`; `NAME` is a legacy alias, exactly as
@@ -802,14 +1147,23 @@ BIC is the only bank identifier here with **no check digit**, and characters
802
1147
  | Stage | Condition | Result |
803
1148
  |---|---|---|
804
1149
  | Gate 0 | the token also occurs as an ordinary lowercase word in the same document | never emitted |
805
- | Tier 1 | registry hit on the BIC6 institution+country prefix | emitted |
1150
+ | Tier 1 | registry hit on the BIC6 institution+country prefix (your registry, or the bundled seed of major banks) | emitted |
806
1151
  | Gate 2 | heading / shouted-word shape | never emitted |
807
- | Tier 2 | `BIC`/`SWIFT` keyword, an IBAN, or a bank block in the enclosing line, record or paragraph | emitted |
1152
+ | Tier 2 | `BIC`/`SWIFT` keyword, an IBAN, or a bank block in the enclosing line, record or paragraph | emitted — but a **letters-only** code (`BETALING`, `HOFFMANN`), unless the bundled GLEIF mapping knows the institution, also needs a `BIC`/`SWIFT` label touching it |
808
1153
  | — | none of the above | never emitted |
809
1154
 
810
- The package bundles **no licensed BIC data** — only a small seed list of BIC6
811
- prefixes compiled from publicly published bank data. Deployments holding a
812
- licensed directory install it at startup:
1155
+ The package bundles about **10,400 BIC6 prefixes** for the 31 supported countries,
1156
+ derived from the [GLEIF BIC-to-LEI mapping](https://www.gleif.org/en/lei-data/lei-mapping/download-bic-to-lei-relationship-files),
1157
+ which SWIFT develops and licenses for redistribution under the BIC/LEI Mapping
1158
+ Table License Agreement; its required notice is in `NOTICE`. They tell a real
1159
+ bank from an ordinary word next to an IBAN, but do not by themselves license a
1160
+ code in bare prose: with that many real prefixes, some words begin with one
1161
+ (`DERNIERS` → `DERN`+`IE`, an Irish institution). `scripts/refresh_bic_registry.py`
1162
+ regenerates the list; GLEIF publishes the mapping monthly.
1163
+
1164
+ The complete SWIFTRef BIC Directory is a commercial product and is not
1165
+ bundled. Deployments holding it install it at startup, where it works like the
1166
+ seed — a hit is emitted without context:
813
1167
 
814
1168
  ```ts
815
1169
  import { setBicRegistry } from "euredact";
@@ -819,10 +1173,10 @@ setBicRegistry(bic => myDirectory.has(bic)); // membership callable
819
1173
  setBicRegistry(null); // remove
820
1174
  ```
821
1175
 
822
- The registry is an **accept** signal, never a filter: a code missing from it
823
- falls through to the context gate and is still detected when banking context is
824
- present. A stale list costs a little recall on bare, contextless BICs — it
825
- never causes a leak. Annual review is sufficient.
1176
+ The registry is mostly an **accept** signal: a code missing from it falls
1177
+ through to the context gate and is still detected when banking context is
1178
+ present — except a letters-only code, the only shape an ordinary word can take,
1179
+ which then also needs a `BIC`/`SWIFT` label touching it (rules-engine#57).
826
1180
 
827
1181
  ## `IBAN` is now `BANK_ACCOUNT`
828
1182
 
@@ -996,8 +1350,8 @@ and two tokenized documents never reveal that they share a value. That is the
996
1350
  opposite retention model from `referentialIntegrity`, which is why the two
997
1351
  cannot be combined. `redactBatch` tokenizes each text on its own.
998
1352
 
999
- A token is `TYPE_` plus four characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`
1000
- (no vowels, no `0`/`1`/`I`/`O`). Tokens are kept clear of any token-shaped
1353
+ A token is `TYPE_` plus four characters from `BCDFGHJKLMNPQRSTVWXZ23456789`
1354
+ (no vowels or `Y`, no `0`/`1`/`I`/`O`), about 615,000 suffixes. Tokens are kept clear of any token-shaped
1001
1355
  string already in the document, so an LLM's reply to a tokenized prompt can
1002
1356
  itself be redacted without `restore()` putting the wrong value back.
1003
1357
  `restore()` replaces every occurrence, including a token an LLM glued to other
@@ -1061,8 +1415,8 @@ sdk.redact("Mail jan@acme.be or piet@acme.be", { countries: ["NL"] }).redactedTe
1061
1415
  label boundary, so `acme.be` does **not** exempt `evilacme.be`. Entries may be
1062
1416
  written `acme.be`, `@acme.be` or `.acme.be`.
1063
1417
 
1064
- There are deliberately **no wildcards**. The allowlist is the only option that
1065
- turns redaction *off*, so an over-broad entry fails toward under-redaction and
1418
+ There are deliberately **no wildcards**. The allowlist and `excludeTypes` are
1419
+ the only options that turn redaction *off*, so an over-broad entry fails toward under-redaction and
1066
1420
  does so silently.
1067
1421
 
1068
1422
  ### What was exempted
@@ -1074,10 +1428,51 @@ for (const e of r.exempted) console.log(e.entityType, e.text, e.rule, e.ruleKind
1074
1428
  ```
1075
1429
 
1076
1430
  `Exemption` carries `entityType`, `start`, `end`, `text`, the `rule` that
1077
- matched as you wrote it, and `ruleKind` (`"value"` or `"domain"`). An exempted
1431
+ matched as you wrote it, and `ruleKind` (`"value"`, `"domain"` or `"type"`). An exempted
1078
1432
  span is absent from `detections`, so `exempted` is the only record that it was
1079
1433
  found at all.
1080
1434
 
1435
+ ## Excluding entity types
1436
+
1437
+ A deployment whose taxonomy is narrower than euRedact's can switch whole types
1438
+ off instead of filtering every result itself. Excluded types are neither in
1439
+ `detections` nor masked:
1440
+
1441
+ ```ts
1442
+ const r = redact("Mail anna@acme.be or call +32 470 12 34 56.", {
1443
+ countries: ["BE"], excludeTypes: ["PHONE"],
1444
+ });
1445
+ r.redactedText;
1446
+ // 'Mail [EMAIL] or call +32 470 12 34 56.'
1447
+ for (const e of r.exempted) console.log(e.entityType, e.text, e.rule, e.ruleKind);
1448
+ // PHONE +32 470 12 34 56 PHONE type
1449
+ ```
1450
+
1451
+ Set it once for a deployment with `new EuRedact({ excludeTypes: [...] })`; a per-call
1452
+ list is merged with it.
1453
+
1454
+ - **Nothing is excluded by default.** Every type is detected and redacted
1455
+ unless you name it.
1456
+ - **Names are checked.** They are case-insensitive, and legacy names resolve
1457
+ (`IBAN` is `BANK_ACCOUNT`, `CREDENTIAL` is `SECRET`). A registered custom
1458
+ pattern's name works too. An unknown name throws, so a typo
1459
+ cannot silently switch nothing off.
1460
+ - **It applies to each span's final type.** In
1461
+ `NHS Number: 5556953627` the digits also pass the Austrian SVNr check.
1462
+ Excluding `HEALTH_INSURANCE` leaves them in the text; it does not let them
1463
+ be masked as an `SSN` instead.
1464
+ - **Every excluded detection is recorded** in `result.exempted`, with
1465
+ `ruleKind: "type"` and the type's name as `rule`, so an audit can see what
1466
+ was deliberately left in.
1467
+ - **In `mode: "cloud"`** an excluded rules type is still masked in the text
1468
+ sent to the service. The option decides what comes back, not what may leave
1469
+ your machine. Model types (`JOB_TITLE`, `FINANCIAL_AMOUNT`, …) are filtered
1470
+ from the merged result.
1471
+ - **There is no include-list.** A list of types to keep would leave every type
1472
+ a later release adds unmasked for you, without notice.
1473
+ - **A type is one switch.** `INTERNAL_ID` covers employee and customer numbers
1474
+ as well as ticket and case numbers; excluding it leaves all of them in.
1475
+
1081
1476
  ## Architecture
1082
1477
 
1083
1478
  ```
@@ -1175,11 +1570,12 @@ bundler, edge-runtime and Deno compatibility — would buy nothing.
1175
1570
 
1176
1571
  | Package | |
1177
1572
  |---|---:|
1178
- | Tarball | 150 kB |
1179
- | Unpacked | 629 kB |
1573
+ | Tarball | 232 kB |
1574
+ | Unpacked | 817 kB |
1180
1575
  | Runtime dependencies | **0** |
1181
1576
 
1182
- Measured with `npm pack --dry-run` at 0.5.1. A stale duplicate of this section
1577
+ Measured with `npm pack --dry-run` with the bundled BIC prefix list, which
1578
+ accounts for about 60 kB of the tarball (shipped in both the ESM and CJS builds). A stale duplicate of this section
1183
1579
  previously quoted 0.02 ms and 86 KB; both were wrong, and the figures above are
1184
1580
  the measured ones.
1185
1581
 
@@ -1189,6 +1585,21 @@ the measured ones.
1189
1585
  const { redact } = require("euredact");
1190
1586
  ```
1191
1587
 
1588
+ ## Browsers
1589
+
1590
+ The package bundles for a page as published. Its only platform dependency is a
1591
+ SHA-256 digest (the result-cache key) and a random index (token suffixes); on
1592
+ Node both come from `node:crypto`, and a bundler targeting the browser (esbuild,
1593
+ webpack, Rollup, Vite) follows the `browser` field in `package.json` to a build
1594
+ that uses `crypto.getRandomValues` and a synchronous SHA-256 of its own, checked
1595
+ against `node:crypto` byte for byte. `redact` runs entirely in the page, and so
1596
+ does the local half of `redactAsync({ mode: "cloud" })`: only the masked text
1597
+ leaves it.
1598
+
1599
+ ```js
1600
+ import { redact } from "euredact"; // esbuild --platform=browser, webpack, Vite
1601
+ ```
1602
+
1192
1603
  ## License
1193
1604
 
1194
1605
  Apache-2.0