euredact 0.5.1 → 0.7.0

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 +658 -45
  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 +131 -18
  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 +138 -48
  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 +418 -23
  19. package/dist/cjs/rules/validators.js +103 -0
  20. package/dist/cjs/sdk.js +150 -44
  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 +90 -5
  28. package/dist/esm/cloud/client.js +126 -26
  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 +138 -48
  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 +417 -22
  51. package/dist/esm/rules/validators.d.ts +14 -0
  52. package/dist/esm/rules/validators.js +100 -0
  53. package/dist/esm/sdk.d.ts +63 -19
  54. package/dist/esm/sdk.js +148 -44
  55. package/dist/esm/types.d.ts +58 -2
  56. package/dist/esm/types.js +4 -1
  57. package/package.json +12 -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
@@ -114,6 +114,7 @@ interface RedactOptions {
114
114
  allowlistDomains?: string[] | null; // domains never redacted (EMAIL/URL)
115
115
  detectDates?: boolean; // include DOB / date of death
116
116
  cache?: boolean; // reuse results for identical input
117
+ idempotencyKey?: string; // mode: "cloud" only; see the table below
117
118
  }
118
119
  ```
119
120
 
@@ -141,6 +142,7 @@ interface RedactOptions {
141
142
  | Parameter | Default | Description |
142
143
  |---|---|---|
143
144
  | `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). |
145
+ | `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
146
  | `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
147
  | `chunkOffset` | `0` | Where this chunk starts in the whole document. Only rebases spans recorded in `context`; returned detections stay relative to `text`. |
146
148
  | `cache` | `true` | Reuse the result for an identical input and configuration. |
@@ -248,10 +250,18 @@ instance, which is what makes one instance per tenant the right default.
248
250
  interface RedactResult {
249
251
  redactedText: string; // The input text with PII replaced
250
252
  detections: Detection[]; // All PII spans found
251
- source: string; // Detection backend ("rules")
253
+ source: string; // "rules" or "cloud"
252
254
  degraded: boolean; // True if the engine fell back to a simpler mode
255
+
256
+ // Country inference — see "Country-independent detection"
257
+ inferredCountries: Array<[string, number]>; // [country, confidence], strongest first
258
+ evidence: CountryEvidence[]; // every signal, with the span behind it
259
+ detectionMode: string; // "declared" if countries was passed,
260
+ // "inferred" otherwise
253
261
  tokens: Record<string, string>; // token -> original value; only with tokenize: true
254
262
  exempted: Exemption[]; // spans the allowlist kept, with the rule that matched
263
+ cloud?: CloudInfo; // mode: "cloud" only: job id, model, cost, unplaced
264
+ // findings — see "What the service said"
255
265
  }
256
266
  ```
257
267
 
@@ -265,7 +275,7 @@ interface Detection {
265
275
  text: string; // The matched substring
266
276
  source: DetectionSource; // "rules" or "cloud"
267
277
  country: string | null; // ISO code or null for shared/custom patterns
268
- confidence: string; // "high" | "medium" | "low" — see below
278
+ confidence: string | null; // "high" | "medium" | "low"; null for a cloud-model span — see below
269
279
  countryConfidence?: number; // How strongly the document supports `country`,
270
280
  // in [0, 1]. 0 means the attribution rests on
271
281
  // a checksum alone.
@@ -282,6 +292,7 @@ about whether the span is masked — every detection is, at every level.
282
292
  | `"high"` | a pattern matched and, where one exists, its checksum passed |
283
293
  | `"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
294
  | `"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 |
295
+ | `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
296
 
286
297
  Filter on it when you need only checksum-backed detections:
287
298
 
@@ -294,24 +305,6 @@ identifier: the shape and the label agree, the check digit does not. Earlier
294
305
  versions dropped these, which meant a redaction library printed in full an
295
306
  identifier it had recognised and rejected.
296
307
 
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
308
  #### `EntityType`
316
309
 
317
310
  String enum with all supported PII categories:
@@ -330,10 +323,15 @@ Cloud tier only — the rule engine never emits these, because there is no shape
330
323
  to match on. That is precisely why the model exists:
331
324
 
332
325
  ORGANISATION_NAME JOB_TITLE MEDICAL_CONDITION SENSITIVE_ATTRIBUTE
333
- BIOMETRIC_REF FINANCIAL_AMOUNT QUASI_IDENTIFIER CREDENTIAL
334
- URL
326
+ BIOMETRIC_REF FINANCIAL_AMOUNT QUASI_IDENTIFIER URL
335
327
  ```
336
328
 
329
+ `SECRET` is the one type for credentials, whichever layer finds them: the rules
330
+ engine's shaped keys and tokens, and the cloud model's prose passwords and
331
+ connection-string secrets, which the service labels `CREDENTIAL`. The SDK
332
+ reports those as `SECRET`, and `CREDENTIAL` remains only as a legacy alias of
333
+ it (rules-engine#111).
334
+
337
335
  `INTERNAL_ID` — an employee, badge or customer number tied to a person — is
338
336
  emitted **only** when an explicit label names it (`medarbejdernummer:`,
339
337
  `Personalnummer:`, `Employee No:`, `Betriebsstättennr.`, `Badge`). There is no
@@ -370,6 +368,87 @@ masked is unchanged either way.
370
368
 
371
369
  For custom patterns, `entityType` is a plain string (e.g. `"EMPLOYEE_ID"`).
372
370
 
371
+ ## Batch processing and concurrency
372
+
373
+ `redactBatch` loads the country configurations once instead of per document,
374
+ which is the whole reason to prefer it to a loop:
375
+
376
+ ```ts
377
+ import { redactBatch } from "euredact";
378
+
379
+ const docs = ["Mijn BSN is 111222333.", "IBAN NL91ABNA0417164300."];
380
+
381
+ redactBatch(docs, { countries: ["NL"] }).map((r) => r.redactedText);
382
+ // [ 'Mijn BSN is [NATIONAL_ID].', 'IBAN [BANK_ACCOUNT].' ]
383
+ ```
384
+
385
+ `redactBatch` is **synchronous**, and deliberately so: the rules engine is
386
+ CPU-bound, so wrapping it in a promise would add scheduling without adding
387
+ parallelism. There is no `redactBatchAsync`. Where the Python SDK offers
388
+ `aredact_batch(..., max_concurrency=n)` — it can offload to a thread pool —
389
+ this SDK has one thread, so the honest equivalent is either `redactBatch` on the
390
+ main thread or real workers:
391
+
392
+ ```ts
393
+ // Keep the event loop responsive across a large batch.
394
+ import { redactBatch } from "euredact";
395
+
396
+ async function* redactInChunks(docs: string[], size = 200) {
397
+ for (let i = 0; i < docs.length; i += size) {
398
+ yield redactBatch(docs.slice(i, i + size), { countries: ["NL"] });
399
+ await new Promise((resolve) => setImmediate(resolve));
400
+ }
401
+ }
402
+ ```
403
+
404
+ For genuine parallelism use `node:worker_threads` and give each worker its own
405
+ `EuRedact`; for true throughput at scale that is the only thing that helps,
406
+ because the cost is regex matching rather than waiting.
407
+
408
+ `redactAsync` is for the **cloud tier**, not for parallelism — it exists because
409
+ a network call cannot be synchronous. With `mode: "rules"` it resolves
410
+ immediately with exactly what `redact()` returns. Cloud calls *are* I/O, so
411
+ those parallelise properly:
412
+
413
+ ```ts
414
+ import { redactAsync } from "euredact";
415
+
416
+ const results = await Promise.all(
417
+ docs.map((d) => redactAsync(d, { countries: ["BE"], mode: "cloud" })),
418
+ );
419
+ ```
420
+
421
+ Bound that yourself if the batch is large. The service's rate limit answers
422
+ `429`, which the client retries, honouring `Retry-After`, before throwing
423
+ `RateLimitedError`. A `429` for the daily quota throws `QuotaExceededError`
424
+ at once: it will not clear before the day does.
425
+
426
+ ### Reuse one instance
427
+
428
+ The module-level functions share a single hidden instance, which is usually what
429
+ you want. Construct your own when the configuration must be per-tenant: custom
430
+ patterns, an allowlist and the result cache all live on the instance.
431
+
432
+ ```ts
433
+ import { EuRedact } from "euredact";
434
+
435
+ const engine = new EuRedact({ allowlist: ["ACME Corporation"] });
436
+ engine.addCustomPattern("CASE_REF", String.raw`CASE-\d{8}`);
437
+
438
+ engine.redactBatch(
439
+ ["ACME Corporation, ref CASE-20260401, BSN 111222333."],
440
+ { countries: ["NL"] },
441
+ )[0].redactedText;
442
+ // 'ACME Corporation, ref [CASE_REF], BSN [NATIONAL_ID].'
443
+ ```
444
+
445
+ ### Tokens and referential labels do not span a batch
446
+
447
+ `tokenize: true` mints tokens **per call**, so item 3 of a batch does not share a
448
+ token with item 1 even for the same value. If two documents must agree on a
449
+ label, redact them as one text, or use `referentialIntegrity: true`, whose
450
+ `TYPE_n` numbering is per instance rather than per call.
451
+
373
452
  ## Cloud tier
374
453
 
375
454
  > **Status: private alpha.** The cloud tier is in closed testing — it is **not**
@@ -394,6 +473,11 @@ result.detections.map(d => [d.entityType, d.text]);
394
473
  // [["PERSON_NAME", "Bas Verhoeven"], ["PHONE", "+32 ..."]]
395
474
  ```
396
475
 
476
+ The endpoint defaults to `https://api.euredact.dev`; `baseUrl` (or
477
+ `EUREDACT_BASE_URL`) overrides it. It must be `https://`: plain `http://` is
478
+ refused except to `localhost`, `127.0.0.1` or `::1`, so the key and the text
479
+ never travel unencrypted.
480
+
397
481
  **`redact()` is synchronous and cannot serve the cloud tier**, so
398
482
  `redact(text, { mode: "cloud" })` throws and names `redactAsync`. It never
399
483
  falls back to rules-only output: a caller who believes names and diagnoses were
@@ -412,13 +496,439 @@ seen a chunk boundary.
412
496
 
413
497
  Options the service cannot honour reject rather than being ignored: multiple
414
498
  `countries`, `countryHint`, `context`/`chunkOffset` and `referentialIntegrity`.
415
- `tokenize`, `allowlist` and `allowlistDomains` are honoured: the SDK applies them to the spans the
416
- service returns and rebuilds the text from those.
499
+ `tokenize`, `allowlist` and `allowlistDomains` are honoured: the SDK applies
500
+ them locally, after the response, to its own spans and the service's.
501
+
502
+ `CloudClient` is the transport underneath. It sends exactly the text it is
503
+ given and returns spans relative to it: the local masking happens in
504
+ `redactAsync()`, not in the client. Use it directly only with text you have
505
+ already masked.
417
506
 
418
507
  The package stays **zero-dependency** — the client uses the platform's own
419
508
  `fetch`. Node 18+ provides one; on Node 16 the rules engine is unaffected and a
420
509
  `fetchImpl` can be supplied.
421
510
 
511
+ ### What leaves your machine
512
+
513
+ Be precise about this, because it is the question a security review asks first.
514
+
515
+ In `mode: "cloud"` the rules engine runs **on your machine first**, and only
516
+ the text it leaves behind is sent to the service over TLS. Every value it finds
517
+ is already a `[TYPE]` placeholder by the time the request is built:
518
+
519
+ ```
520
+ your text IBAN NL91 ABNA 0417 1643 00 belongs to Nick Bols, tel +31 6 12345678
521
+ │ rules engine, on your machine
522
+ what is sent IBAN [BANK_ACCOUNT] belongs to Nick Bols, tel [PHONE]
523
+ │ TLS ──▶ service (model) ──▶ spans relative to the masked text
524
+ what you get IBAN [BANK_ACCOUNT] belongs to [PERSON_NAME], tel [PHONE]
525
+ ```
526
+
527
+ The request body is that one `text` field plus the country. No list of types,
528
+ no offsets and no original values travel beside it. The service answers with
529
+ spans that index the masked text, and the SDK maps them back onto your
530
+ original, so `detections` always index the document you passed in. Service offsets count
531
+ code points and JavaScript slices UTF-16 units; the client converts.
532
+
533
+ **This is minimisation, not an exemption.** Three things still leave:
534
+
535
+ - what the model is there to find — names, employers, job titles, diagnoses;
536
+ - anything the rules engine missed, such as an identifier in a format it does
537
+ not know;
538
+ - the surrounding prose.
539
+
540
+ So the service still processes personal data on your behalf, and a DPIA should
541
+ say so. What it no longer receives is every IBAN, national ID, phone number and
542
+ email address the local engine could place.
543
+
544
+ Three things behave differently here than in rules mode:
545
+
546
+ - `detectDates` has no effect. The local pass always runs with dates on,
547
+ because that is the rules output the model was trained against. It can only
548
+ cause more to be masked, never less.
549
+ - `tokenize` and the allowlists are applied locally, after the response, and do
550
+ not change what is sent: placeholders on the wire are always `[TYPE]`, and an
551
+ allowlisted value is masked in the request and put back in your result.
552
+ - [Custom patterns](#custom-patterns) run in the local pass, so a value only
553
+ you know how to recognise is masked before the request too.
554
+
555
+ Every span the service returns is checked against the text that was sent. One
556
+ that does not match raises `CloudError` rather than being placed by guesswork.
557
+
558
+ ### Keeping identifiers local
559
+
560
+ `mode: "cloud"` does this for you with our model. The same pattern works with
561
+ any other model — your own, or a general-purpose LLM — and that is what
562
+ `tokenize` is for:
563
+
564
+ ```ts
565
+ import { redact, restore } from "euredact";
566
+
567
+ const prompt = "Stuur een mail naar Bas Verhoeven (bas@example.nl) over NL91ABNA0417164300.";
568
+
569
+ // 1. Mask locally. Nothing has left the process.
570
+ const local = redact(prompt, { countries: ["NL"], tokenize: true });
571
+ local.redactedText;
572
+ // 'Stuur een mail naar Bas Verhoeven (EMAIL_S5SH) over BANK_ACCOUNT_5XXR.'
573
+ local.tokens;
574
+ // { BANK_ACCOUNT_5XXR: 'NL91ABNA0417164300', EMAIL_S5SH: 'bas@example.nl' }
575
+
576
+ // 2. Send only the masked text to any model, ours or someone else's.
577
+ const answer = await callYourModel(local.redactedText);
578
+
579
+ // 3. Put the real values back locally.
580
+ restore(answer, local.tokens);
581
+ ```
582
+
583
+ The token suffixes are random per call, so yours will differ.
584
+
585
+ **Read that output carefully: `Bas Verhoeven` is still there.** That is the whole
586
+ trade-off and it is why the cloud tier exists. The local engine masks what has a
587
+ shape — the IBAN and the address — and cannot mask a name, an employer or a
588
+ diagnosis, because it cannot find them. So this pattern keeps every identifier
589
+ the engine detects inside your process and sends the prose, names included.
590
+ `mode: "cloud"` sends that same masked prose to a model trained to find the
591
+ names in it.
592
+
593
+ Either way the prose leaves. If names must not leave your infrastructure
594
+ either, no hosted model can help; run one yourself against
595
+ `local.redactedText`.
596
+
597
+
598
+ ### What the service said: `result.cloud`
599
+
600
+ A cloud result carries `cloud`, what the service reported beside the spans;
601
+ it is absent on a rules-only result.
602
+
603
+ ```ts
604
+ const result = await redactAsync(text, {
605
+ countries: ["BE"], mode: "cloud", idempotencyKey: "invoice-2291-v1",
606
+ });
607
+ result.cloud?.jobId; // support, Jobs.retrieve, replays
608
+ result.cloud?.modelVersion; // the model that answered; null when only rules ran
609
+ for (const item of result.cloud?.unlocated ?? []) console.log(item.entityType, item.text);
610
+ const usage = result.cloud?.usage;
611
+ if (usage) {
612
+ const { tokens, billingRate, credits, factors } = usage;
613
+ for (const f of factors) console.log(f.code, f.types ?? "", f.detail);
614
+ }
615
+ ```
616
+
617
+ - **`idempotencyKey`** is sent as the request's `Idempotency-Key`: the same key
618
+ returns the same job instead of running and billing the document again. One
619
+ is generated per call when omitted. It applies to `mode: "cloud"` only.
620
+ - **`usage`** is the total the request was charged and the reasons for it, never
621
+ a cost per step. `credits` is `round(tokens × billingRate)`, the formula the
622
+ service debits by. The factor codes are `rules_only`, `document_length`,
623
+ `instructions`, `dense_document`, `special_category_check` (with the `types`
624
+ that triggered it), `long_document` and `batch_rate`; a code added later
625
+ still arrives, as its string. Absent when the service does not report it or
626
+ reported it malformed: a cost report never fails a redaction.
627
+ - **`unlocated`** lists what the model found but could not match to a position
628
+ in the text it was sent. Nothing was masked for these.
629
+ - **Batch documents** carry `modelVersion` and `unlocated`; no `jobId` or
630
+ `usage`, which belong to the batch (`batch.tokens`, `batch.creditsCharged`,
631
+ `batch.billingRate`).
632
+
633
+ All four types are exported from `euredact`:
634
+
635
+ ```ts
636
+ interface CloudInfo {
637
+ jobId: string | null; // null for a batch document
638
+ modelVersion: string | null; // null when only rules ran
639
+ usage?: Usage; // absent from older services, batch documents, or a malformed block
640
+ unlocated: Unlocated[];
641
+ }
642
+
643
+ interface Unlocated {
644
+ text: string; // what the model reported
645
+ entityType: EntityType | string; // e.g. "PERSON_NAME"; unknown types stay strings
646
+ }
647
+
648
+ interface Usage {
649
+ tokens: number; // every model call the document triggered
650
+ billingRate: number; // 1, or e.g. 0.75 for a batch
651
+ credits: number; // round(tokens * billingRate): what was debited
652
+ factors: UsageFactor[];
653
+ }
654
+
655
+ interface UsageFactor {
656
+ code: string; // see the list above; new codes arrive as strings
657
+ detail: string; // one sentence from the service ("" if none)
658
+ types?: string[]; // special_category_check only
659
+ }
660
+ ```
661
+
662
+ **Retrying safely.** Pass your own `idempotencyKey` when your code may send the
663
+ same document twice: after a timeout of your own, from a retried queue message
664
+ or a restarted worker. Derive it from something stable (an invoice number plus
665
+ a version), not from a random value per attempt. A replay returns the same
666
+ `result.cloud.jobId` and is not billed again.
667
+
668
+ ### Account, past jobs and the batch list
669
+
670
+ Everything an API key may do on the service is reachable from the SDK. What
671
+ needs a signed-in person (logging in, minting keys, settings, members, terms)
672
+ is not, because a key cannot do it. Every call returns a `Promise`.
673
+
674
+ | Call | Service endpoint | Resolves to |
675
+ |---|---|---|
676
+ | `new Account().summary()` | `GET /v1/account` | `AccountSummary` |
677
+ | `new Account().credits()` | `GET /v1/account/credits` | `Credits` |
678
+ | `new Account().creditHistory(limit = 50)` | `GET /v1/account/credits/history` | `CreditEntry[]`, newest first; `limit` 1–500 |
679
+ | `new Account().usage(days = 30)` | `GET /v1/account/usage` | `UsageDay[]` |
680
+ | `new Account().usageByKey(days = 30)` | `GET /v1/account/usage/by-key` | `KeyUsage[]` |
681
+ | `new Account().keys()` | `GET /v1/account/keys` | `ApiKey[]` |
682
+ | `new Account().revokeKey(keyId)` | `POST /v1/account/keys/{id}/revoke` | `KeyRevocation` |
683
+ | `new Jobs().retrieve(jobId)` | `GET /v1/jobs/{id}` | `Job` |
684
+ | `new Batches().list(limit = 20)` | `GET /v1/batches` | `Batch[]`, newest first; `limit` 1–100 |
685
+
686
+ ```ts
687
+ import { Account, Batches, Jobs } from "euredact";
688
+
689
+ const account = new Account();
690
+ const summary = await account.summary();
691
+ if (summary.quotaRemaining !== null && summary.quotaRemaining < 100) {
692
+ console.log(`${summary.quotaRemaining} documents left today`);
693
+ }
694
+ console.log((await account.credits()).balance);
695
+ for (const day of await account.usage(7)) console.log(day.day, day.documents, day.tokens);
696
+ for (const key of await account.keys()) console.log(key.id, key.name, key.active, key.lastUsedAt);
697
+
698
+ const job = await new Jobs().retrieve("job-7f3a…");
699
+ if (job.result) console.log(job.result.redactedText);
700
+
701
+ for (const batch of await new Batches().list(20)) {
702
+ console.log(batch.id, batch.status, batch.documents, batch.creditsCharged);
703
+ }
704
+ ```
705
+
706
+ #### Creating the clients
707
+
708
+ `Account` and `Jobs` read the configuration `configure()` set (or
709
+ `EUREDACT_API_KEY`). Both take two optional arguments:
710
+ `new Account(config?, { fetchImpl? })`. `config` is a `CloudConfig` of its own,
711
+ for a second key or another endpoint: a plain object with `apiKey`, `baseUrl`,
712
+ `timeoutMs`, `pollTimeoutMs`, `maxRetries` and `headers`, checked for an
713
+ `https://` base URL like `configure()` does; `fetchImpl` replaces the platform `fetch`,
714
+ for a proxy or a mock in your tests. (`Batches` takes the same two inside its
715
+ options object: `new Batches({ config, fetchImpl })`.) There is nothing to close.
716
+ They need a global `fetch`: Node 18+, or a browser, or pass `fetchImpl`.
717
+
718
+ #### What each call resolves to
719
+
720
+ Each value has `raw`, the service's JSON as received, so a field the SDK does
721
+ not type yet is still reachable; every field is `null` when the service left
722
+ it out or sent the wrong type, rather than the call failing.
723
+
724
+ ```ts
725
+ interface AccountSummary {
726
+ tenantId: string | null; tenantName: string | null; active: boolean | null;
727
+ memberSince: string | null; // ISO date
728
+ role: string | null;
729
+ retentionMode: string | null; // "ttl", or "none": text discarded on delivery
730
+ payloadTtlHours: number | null; // 0 under "none"
731
+ documentsToday: number | null; documentsThisMonth: number | null;
732
+ tokensThisMonth: number | null; failures24h: number | null;
733
+ dailyQuota: number | null; // null: no daily quota on this account
734
+ quotaRemaining: number | null;
735
+ activeKeys: number | null;
736
+ batchesAvailable: boolean | null; // batches need retention ("ttl")
737
+ raw: Record<string, unknown>;
738
+ }
739
+ interface Credits { balance: number | null; granted: number | null; spent: number | null;
740
+ unit: string | null /* "tokens" */; raw: Record<string, unknown> }
741
+ interface CreditEntry { // rolled up per minute, reason and direction
742
+ at: string | null; delta: number | null /* negative for a debit */;
743
+ reason: string | null; count: number | null; jobId: string | null; sourceRef: string | null;
744
+ raw: Record<string, unknown>;
745
+ }
746
+ interface UsageDay { day: string | null; documents: number | null; tokens: number | null;
747
+ failures: number | null; rulesOnly: number | null; raw: Record<string, unknown> }
748
+ interface KeyUsage { // the busiest keys one by one; the rest in one entry, keyId null
749
+ keyId: number | null; name: string | null;
750
+ documents: number | null; tokens: number | null; failures: number | null;
751
+ series: UsageDay[]; raw: Record<string, unknown>;
752
+ }
753
+ interface ApiKey { // never the secret: shown once, when minted
754
+ id: number | null; name: string | null; createdBy: string | null;
755
+ createdAt: string | null; lastUsedAt: string | null; revokedAt: string | null;
756
+ active: boolean | null; raw: Record<string, unknown>;
757
+ }
758
+ interface KeyRevocation {
759
+ id: number | null; keysRemaining: number | null;
760
+ lockedOut: boolean | null; // true: no active key left; mint one in the console
761
+ selfRevoked: boolean | null; // true: you revoked the key this client uses
762
+ raw: Record<string, unknown>;
763
+ }
764
+ interface Job {
765
+ jobId: string;
766
+ status: string; // "queued", "running" or "succeeded"
767
+ createdAt: string | null;
768
+ result: RedactResult | null; // set once succeeded
769
+ raw: Record<string, unknown>;
770
+ }
771
+ ```
772
+
773
+ `Batch` gained four fields beside `id`, `status`, `counts` and the timestamps:
774
+ `documents`, `tokens` (`{ prompt, completion }`), `creditsCharged` and
775
+ `billingRate` (e.g. `0.75`).
776
+
777
+ #### Retrieving a job
778
+
779
+ `redactAsync(text, { mode: "cloud" })` already waits for its job, polling past
780
+ the service's synchronous window, so most code never needs `Jobs`. It is for
781
+ support and audits (look up `result.cloud.jobId` later) and for a job the
782
+ client gave up polling on (`pollTimeoutMs`), which may still finish.
783
+
784
+ A failed or rejected job throws (`CloudError` with status 502, `TooLargeError`)
785
+ rather than resolving with a status. The result is over the text the service
786
+ received: under `mode: "cloud"` that is the locally masked text, so its offsets
787
+ and `redactedText` are not your original's. Results are kept for the account's
788
+ retention period (`AccountSummary.payloadTtlHours`), and not at all under
789
+ `retentionMode === "none"`; after that, `ResultExpiredError`.
790
+
791
+ #### Revoking a key
792
+
793
+ `revokeKey` is the one write an API key may make, for a key that leaked. It is
794
+ sent once and never retried. Revoking the key the client itself uses succeeds
795
+ and reports `selfRevoked: true`; every later call with it fails with status
796
+ 401. When `lockedOut` is `true`, no active key is left and only a person in the
797
+ console can mint one.
798
+
799
+ #### Handling errors
800
+
801
+ All errors extend `CloudError`, which carries `status` (the HTTP status, or
802
+ `undefined` for a network failure) and `detail` (the service's JSON detail).
803
+ Test the specific ones first: `RateLimitedError` extends `QuotaExceededError`.
804
+
805
+ ```ts
806
+ import {
807
+ CloudError, QuotaExceededError, RateLimitedError, TooLargeError, redactAsync,
808
+ } from "euredact";
809
+
810
+ try {
811
+ const result = await redactAsync(text, {
812
+ countries: ["BE"], mode: "cloud", idempotencyKey: `invoice-${invoice.id}-v${invoice.version}`,
813
+ });
814
+ } catch (err) {
815
+ if (err instanceof RateLimitedError) {
816
+ // rate limit still hit after every retry: back off, then retry with the same key
817
+ } else if (err instanceof QuotaExceededError) {
818
+ // daily quota used up (err.detail.used, err.detail.limit): wait for tomorrow
819
+ } else if (err instanceof TooLargeError) {
820
+ // over the input cap: split the document and send the parts
821
+ } else if (err instanceof CloudError) {
822
+ // anything else; err.status says what
823
+ } else {
824
+ throw err;
825
+ }
826
+ }
827
+ ```
828
+
829
+ | Error | Status | When | Retried by the SDK |
830
+ |---|---|---|---|
831
+ | `NotConfiguredError` | — | no API key configured, or no global `fetch` | no |
832
+ | `CloudError` | 401 | the key is wrong or revoked | no |
833
+ | `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 |
834
+ | `CloudError` | 403 | `Batches.create` on a workspace without retention: `detail.code === "retention_required"` (see [Batches](#batches)) | no |
835
+ | `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 |
836
+ | `CloudError` | 409 | `revokeKey` on a key already revoked | no |
837
+ | `ResultExpiredError` | 410 | the job's result is past retention | no |
838
+ | `TooLargeError` | 413 | the document is over the input cap; also a rejected job | no |
839
+ | `QuotaExceededError` | 429 | the daily document quota is used up | **no**: it will not clear before the day does |
840
+ | `RateLimitedError` | 429 | the edge's rate limit, still hit after every retry | yes, `maxRetries` times with backoff |
841
+ | `CloudError` | 5xx, `undefined` | the service or the network failed | yes for reads and `redactAsync`; never for `revokeKey` |
842
+
843
+ `NotFoundError`, `ResultExpiredError` and `RateLimitedError` are exported from
844
+ `euredact` alongside the others.
845
+
846
+ **Availability:** like the rest of the cloud tier, these calls are in private
847
+ alpha, and the response shapes may still change between releases. The batch
848
+ calls, `list()` included, answer `NotFoundError` until batches are enabled on
849
+ the service (see [Batches](#batches)).
850
+
851
+ ### Batches
852
+
853
+ > **Availability:** private alpha, like the rest of the cloud tier. Batches
854
+ > need **retention** on the workspace (see below); while batches are not
855
+ > enabled on the service, every batch call answers `NotFoundError`
856
+ > (`batches_not_enabled`).
857
+
858
+ A batch hands up to 5,000 documents to the cloud tier and collects the answers
859
+ within 24 hours, at a lower price and without your plan's concurrency limit.
860
+ It keeps the same promise as `mode: "cloud"`: each document is masked **on
861
+ your machine**, and only the masked text is uploaded. What is needed to map the
862
+ answers back stays in a **local batch file**, `~/.euredact/batches/<batchId>.json`
863
+ (directory `0700`, file `0600`): the originals, their local detections and a
864
+ SHA-256 of the masked text sent. The file format is shared with the Python SDK,
865
+ so a batch created by one can be resolved by the other.
866
+
867
+ ```ts
868
+ import { configure, Batches } from "euredact";
869
+
870
+ configure({ apiKey: "erk_..." });
871
+ const batches = new Batches();
872
+ const batch = await batches.create([
873
+ { customId: "invoice-2291", text: invoiceText, countries: ["BE"] },
874
+ { customId: "memo-17", text: memoText, countries: ["NL"] },
875
+ ]);
876
+
877
+ // Later, possibly in another process:
878
+ const outcome = await batches.results(batch.id);
879
+ if (outcome.status === "resolved") {
880
+ for (const [customId, doc] of Object.entries(outcome.documents)) {
881
+ console.log(customId, doc.ok ? doc.result!.redactedText : `${doc.error}: ${doc.message}`);
882
+ }
883
+ }
884
+ ```
885
+
886
+ **Batches need retention.** The service holds each document until it is
887
+ processed and each result until you fetch it, so a workspace that erases
888
+ documents on delivery (`retentionMode === "none"`, or 0 hours of retention)
889
+ cannot create one. `create()` then throws `CloudError` with status 403 and
890
+ `detail.code === "retention_required"`, before anything is uploaded or written
891
+ locally. The workspace owner turns retention on in the console, on the
892
+ **Account** tab. To check first:
893
+
894
+ ```ts
895
+ import { Account, CloudError } from "euredact";
896
+
897
+ if (!(await new Account().summary()).batchesAvailable) {
898
+ // no retention on this workspace: send the documents with mode: "cloud"
899
+ }
900
+
901
+ try {
902
+ await batches.create(documents);
903
+ } catch (err) {
904
+ if (err instanceof CloudError && err.status === 403 && err.detail?.code === "retention_required") {
905
+ // ask the workspace owner to turn retention on
906
+ }
907
+ throw err;
908
+ }
909
+ ```
910
+
911
+ - **`results()`** returns `not_ended` while the batch runs and changes nothing.
912
+ Once it has ended it maps every document, wipes the originals and leaves a
913
+ text-free receipt; a second call returns `already_resolved`.
914
+ - **Integrity.** Each masked text is rebuilt from the file and must match the
915
+ hash the gateway reports before a span is placed. An edited file, a missing
916
+ entry or no file is an error for that document (`local_mismatch`,
917
+ `missing_local_entry`), never the masked text passed off as a result.
918
+ - **`pending()`**, **`retrieve(id)`**, **`cancel(id)`** (the file stays until
919
+ the finished documents are mapped) and **`purge(id)`**. Receipts and
920
+ unmapped files are cleaned up once the gateway's results expire.
921
+ - **In a browser** there is no filesystem: pass `store`, any object with async
922
+ `read`, `write`, `delete` and `list`. In Node, `batchDir` moves the
923
+ directory. `cipher` (`encrypt`/`decrypt`) encrypts the file at rest with a
924
+ key you hold.
925
+ - **Limits** checked before anything is masked or uploaded: 5,000 documents,
926
+ unique `customId`s of 1-64 characters from `A-Z a-z 0-9 _ . : -` (the
927
+ gateway's rule, exported as `CUSTOM_ID`), one country each, a 128 MB body. The 5,000-token limit
928
+ per document is **not** checked by the SDK: it is counted by the model's
929
+ tokenizer on the gateway. A document over it comes back as `too_long`, whose
930
+ message says so; split it and submit the parts.
931
+
422
932
  ## `NAME` is now `PERSON_NAME`
423
933
 
424
934
  The canonical type name is `PERSON_NAME`; `NAME` is a legacy alias, exactly as
@@ -431,6 +941,82 @@ matching the *string* `"NAME"` should be updated; `LEGACY_TYPE_ALIASES`
431
941
  publishes the mapping, and `STREET_ADDRESS` → `ADDRESS` and
432
942
  `NATIONALITY_ETHNICITY` → `SENSITIVE_ATTRIBUTE` are recognised the same way.
433
943
 
944
+ ## What `countries` actually controls
945
+
946
+ This is the parameter most often misread, and reading it wrongly leads to
947
+ under-redaction in exactly the case you care about — a foreign identifier in a
948
+ domestic document. So, precisely:
949
+
950
+ > `countries` decides how a detection is **attributed and scored**.
951
+ > It does **not** decide what gets **found**.
952
+
953
+ Country-specific patterns for every supported country run on every document,
954
+ whatever you declare. Declaring `countries: ["NL"]` does not switch the Belgian
955
+ patterns off; it says "this document is Dutch", and anything Belgian that turns
956
+ up is still detected and still masked — it is just flagged as sitting outside
957
+ what you declared.
958
+
959
+ The same Belgian national number, under four different calls:
960
+
961
+ ```ts
962
+ import { redact } from "euredact";
963
+
964
+ const text = "Rijksregisternummer 85.07.30-033.61 van onze klant.";
965
+
966
+ for (const options of [{ countries: ["NL"] },
967
+ { countries: ["BE"] },
968
+ {},
969
+ { countries: ["NL"], countryHint: ["BE"] }]) {
970
+ const result = redact(text, options);
971
+ const [detection] = result.detections;
972
+ console.log(result.redactedText, "|", result.detectionMode,
973
+ "| country:", detection.country,
974
+ "| countryConfidence:", detection.countryConfidence,
975
+ "| outOfScope:", detection.outOfScope);
976
+ }
977
+ ```
978
+
979
+ ```
980
+ Rijksregisternummer [NATIONAL_ID] van onze klant. | declared | country: BE | countryConfidence: 0 | outOfScope: true
981
+ Rijksregisternummer [NATIONAL_ID] van onze klant. | declared | country: BE | countryConfidence: 0.8807970779778823 | outOfScope: false
982
+ Rijksregisternummer [NATIONAL_ID] van onze klant. | inferred | country: BE | countryConfidence: 0 | outOfScope: false
983
+ Rijksregisternummer [NATIONAL_ID] van onze klant. | declared | country: BE | countryConfidence: 0.8807970779778823 | outOfScope: true
984
+ ```
985
+
986
+ **The masked output is byte-identical in all four.** What moved is the metadata.
987
+
988
+ | You pass | Effect on what is found | Effect on labelling |
989
+ |---|---|---|
990
+ | `countries: ["NL"]` | None — every country's patterns still run | Attributed normally; a non-Dutch hit gets `outOfScope: true` and `countryConfidence: 0` |
991
+ | `countries: ["BE"]` | None | The Belgian hit is corroborated: `countryConfidence` ~0.88, `outOfScope: false` |
992
+ | `countries` omitted | None | `detectionMode: "inferred"`; the country is inferred from the document and nothing is out of scope, because nothing was declared |
993
+ | `countryHint: ["BE"]` | None | A *prior*: it corroborates Belgian attribution **without** widening or narrowing scope, so `out_of_scope` still reflects `countries` alone |
994
+
995
+ ### How to use each one
996
+
997
+ - **`countries: [...]`** — you know where the document is from. Use it. It
998
+ improves attribution and makes `out_of_scope` meaningful, and it is the
999
+ cheapest path because country inference is skipped.
1000
+ - **`countries` omitted** — you do not know. The engine infers from the document
1001
+ and reports `inferredCountries` with confidences.
1002
+ - **`countryHint: [...]`** — you have a weak signal (the customer's billing
1003
+ country, the mailbox a document arrived in) that should resolve ambiguity
1004
+ without declaring scope. Added in 0.3.2 precisely so that a hint could not be
1005
+ mistaken for a scope restriction.
1006
+
1007
+ ### Why it works this way
1008
+
1009
+ Until 0.3.2, `countries` *did* gate detection, and 0.3.3 fixed the remaining
1010
+ case where it "could change which spans were found, not just how they were
1011
+ labelled". Both were changed deliberately: a redaction library that hides a
1012
+ Belgian national number because you told it the document was Dutch has failed
1013
+ at the only job it has. Scoping is a reporting concern; masking is not.
1014
+
1015
+ The practical consequence: **do not use `countries` as a filter.** If you only
1016
+ want Dutch detections in your output, filter on
1017
+ `detection.country === "NL"` or on `detection.outOfScope` after the call —
1018
+ the value was still masked in the text either way.
1019
+
434
1020
  ## Country codes
435
1021
 
436
1022
  `countries` accepts **ISO 3166-1 alpha-2** codes. The two EU/VAT spellings are
@@ -555,14 +1141,23 @@ BIC is the only bank identifier here with **no check digit**, and characters
555
1141
  | Stage | Condition | Result |
556
1142
  |---|---|---|
557
1143
  | Gate 0 | the token also occurs as an ordinary lowercase word in the same document | never emitted |
558
- | Tier 1 | registry hit on the BIC6 institution+country prefix | emitted |
1144
+ | Tier 1 | registry hit on the BIC6 institution+country prefix (your registry, or the bundled seed of major banks) | emitted |
559
1145
  | Gate 2 | heading / shouted-word shape | never emitted |
560
- | Tier 2 | `BIC`/`SWIFT` keyword, an IBAN, or a bank block in the enclosing line, record or paragraph | emitted |
1146
+ | 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 |
561
1147
  | — | none of the above | never emitted |
562
1148
 
563
- The package bundles **no licensed BIC data** — only a small seed list of BIC6
564
- prefixes compiled from publicly published bank data. Deployments holding a
565
- licensed directory install it at startup:
1149
+ The package bundles about **10,400 BIC6 prefixes** for the 31 supported countries,
1150
+ derived from the [GLEIF BIC-to-LEI mapping](https://www.gleif.org/en/lei-data/lei-mapping/download-bic-to-lei-relationship-files),
1151
+ which SWIFT develops and licenses for redistribution under the BIC/LEI Mapping
1152
+ Table License Agreement; its required notice is in `NOTICE`. They tell a real
1153
+ bank from an ordinary word next to an IBAN, but do not by themselves license a
1154
+ code in bare prose: with that many real prefixes, some words begin with one
1155
+ (`DERNIERS` → `DERN`+`IE`, an Irish institution). `scripts/refresh_bic_registry.py`
1156
+ regenerates the list; GLEIF publishes the mapping monthly.
1157
+
1158
+ The complete SWIFTRef BIC Directory is a commercial product and is not
1159
+ bundled. Deployments holding it install it at startup, where it works like the
1160
+ seed — a hit is emitted without context:
566
1161
 
567
1162
  ```ts
568
1163
  import { setBicRegistry } from "euredact";
@@ -572,10 +1167,10 @@ setBicRegistry(bic => myDirectory.has(bic)); // membership callable
572
1167
  setBicRegistry(null); // remove
573
1168
  ```
574
1169
 
575
- The registry is an **accept** signal, never a filter: a code missing from it
576
- falls through to the context gate and is still detected when banking context is
577
- present. A stale list costs a little recall on bare, contextless BICs — it
578
- never causes a leak. Annual review is sufficient.
1170
+ The registry is mostly an **accept** signal: a code missing from it falls
1171
+ through to the context gate and is still detected when banking context is
1172
+ present — except a letters-only code, the only shape an ordinary word can take,
1173
+ which then also needs a `BIC`/`SWIFT` label touching it (rules-engine#57).
579
1174
 
580
1175
  ## `IBAN` is now `BANK_ACCOUNT`
581
1176
 
@@ -749,16 +1344,16 @@ and two tokenized documents never reveal that they share a value. That is the
749
1344
  opposite retention model from `referentialIntegrity`, which is why the two
750
1345
  cannot be combined. `redactBatch` tokenizes each text on its own.
751
1346
 
752
- A token is `TYPE_` plus four characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`
753
- (no vowels, no `0`/`1`/`I`/`O`). Tokens are kept clear of any token-shaped
1347
+ A token is `TYPE_` plus four characters from `BCDFGHJKLMNPQRSTVWXZ23456789`
1348
+ (no vowels or `Y`, no `0`/`1`/`I`/`O`), about 615,000 suffixes. Tokens are kept clear of any token-shaped
754
1349
  string already in the document, so an LLM's reply to a tokenized prompt can
755
1350
  itself be redacted without `restore()` putting the wrong value back.
756
1351
  `restore()` replaces every occurrence, including a token an LLM glued to other
757
1352
  characters (`EMAIL_P4RTs`) — leaving a token behind is the worse failure.
758
1353
 
759
- Works in cloud mode via `redactAsync`: the SDK rebuilds the text from the spans
760
- the service returns, which is exactly what the service built its own output
761
- from.
1354
+ Works in cloud mode via `redactAsync`: tokens are minted locally, after the
1355
+ response, over the local spans and the service's. What is sent to the service
1356
+ always carries `[TYPE]` placeholders, never tokens.
762
1357
 
763
1358
  ## Allowlist
764
1359
 
@@ -926,19 +1521,37 @@ Measured on the same 611 documents, this SDK runs about 6–7× faster than the
926
1521
  accelerated Python path, so adding a native addon — and with it the loss of
927
1522
  bundler, edge-runtime and Deno compatibility — would buy nothing.
928
1523
 
1524
+ | Package | |
1525
+ |---|---:|
1526
+ | Tarball | 232 kB |
1527
+ | Unpacked | 817 kB |
1528
+ | Runtime dependencies | **0** |
1529
+
1530
+ Measured with `npm pack --dry-run` with the bundled BIC prefix list, which
1531
+ accounts for about 60 kB of the tarball (shipped in both the ESM and CJS builds). A stale duplicate of this section
1532
+ previously quoted 0.02 ms and 86 KB; both were wrong, and the figures above are
1533
+ the measured ones.
1534
+
929
1535
  ## CommonJS
930
1536
 
931
1537
  ```js
932
1538
  const { redact } = require("euredact");
933
1539
  ```
934
1540
 
935
- ## Performance
1541
+ ## Browsers
936
1542
 
937
- | Metric | Value |
938
- |---|---|
939
- | Latency per redaction | ~0.02 ms |
940
- | Package size | ~86 KB |
941
- | Runtime dependencies | 0 |
1543
+ The package bundles for a page as published. Its only platform dependency is a
1544
+ SHA-256 digest (the result-cache key) and a random index (token suffixes); on
1545
+ Node both come from `node:crypto`, and a bundler targeting the browser (esbuild,
1546
+ webpack, Rollup, Vite) follows the `browser` field in `package.json` to a build
1547
+ that uses `crypto.getRandomValues` and a synchronous SHA-256 of its own, checked
1548
+ against `node:crypto` byte for byte. `redact` runs entirely in the page, and so
1549
+ does the local half of `redactAsync({ mode: "cloud" })`: only the masked text
1550
+ leaves it.
1551
+
1552
+ ```js
1553
+ import { redact } from "euredact"; // esbuild --platform=browser, webpack, Vite
1554
+ ```
942
1555
 
943
1556
  ## License
944
1557