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
@@ -65,7 +65,21 @@ export declare function validateSpanishDni(candidate: string): boolean;
65
65
  export declare function validateSpanishNie(candidate: string): boolean;
66
66
  export declare function validatePortugueseNif(candidate: string): boolean;
67
67
  export declare function validatePolishPesel(candidate: string): boolean;
68
+ /** Polish domestic account number (NRB): the PL IBAN without its "PL", so the IBAN's mod-97 decides it (rules-engine#93). */
69
+ export declare function validatePolishNrb(candidate: string): boolean;
68
70
  export declare function validatePolishNip(candidate: string): boolean;
71
+ /**
72
+ * Polish identity card (dowód osobisty): 3 letters + 6 digits, the first digit
73
+ * a check digit. Letters A=10 … Z=35, weights 7,3,1,9,7,3,1,7,3: the weighted
74
+ * sum of all nine is 0 mod 10 exactly when the check digit is right (rules-engine#75).
75
+ */
76
+ export declare function validatePolishIdCard(candidate: string): boolean;
77
+ /**
78
+ * Polish REGON: 9 digits, or 14 for a local unit, each with a mod-11 check (a
79
+ * remainder of 10 counts as 0). A 14-digit REGON begins with its parent's valid
80
+ * 9-digit REGON (rules-engine#76).
81
+ */
82
+ export declare function validatePolishRegon(candidate: string): boolean;
69
83
  export declare function validateCzechBirthNumber(candidate: string): boolean;
70
84
  export declare function validateRomanianCnp(candidate: string): boolean;
71
85
  export declare function validateHungarianTaj(candidate: string): boolean;
@@ -467,10 +467,24 @@ export function validatePortugueseNif(candidate) {
467
467
  const check = remainder < 2 ? 0 : 11 - remainder;
468
468
  return parseInt(c[8]) === check;
469
469
  }
470
+ // PESEL carries the century in the month field: +0 is the 1900s, +20 the 2000s,
471
+ // +40 the 2100s, +60 the 2200s, +80 the 1800s. Without a date check this
472
+ // accepted 80666603210 -- day 66 -- as NATIONAL_ID at high confidence
473
+ // (rules-engine#43).
470
474
  export function validatePolishPesel(candidate) {
471
475
  const c = candidate.replace(/[\s\-]/g, "");
472
476
  if (c.length !== 11 || !/^\d{11}$/.test(c))
473
477
  return false;
478
+ const rawMonth = parseInt(c.slice(2, 4), 10);
479
+ let month = null;
480
+ for (const off of [0, 20, 40, 60, 80]) {
481
+ if (rawMonth - off >= 1 && rawMonth - off <= 12) {
482
+ month = rawMonth - off;
483
+ break;
484
+ }
485
+ }
486
+ if (month === null || !isRealDay(month, parseInt(c.slice(4, 6), 10)))
487
+ return false;
474
488
  const d = c.split("").map(Number);
475
489
  const weights = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3];
476
490
  let total = 0;
@@ -478,6 +492,13 @@ export function validatePolishPesel(candidate) {
478
492
  total += d[i] * weights[i];
479
493
  return d[10] === (10 - (total % 10)) % 10;
480
494
  }
495
+ /** Polish domestic account number (NRB): the PL IBAN without its "PL", so the IBAN's mod-97 decides it (rules-engine#93). */
496
+ export function validatePolishNrb(candidate) {
497
+ const c = candidate.replace(/[\s\-]/g, "");
498
+ if (!/^\d{26}$/.test(c))
499
+ return false;
500
+ return validateIban("PL" + c);
501
+ }
481
502
  export function validatePolishNip(candidate) {
482
503
  const c = candidate.replace(/[\s\-]/g, "");
483
504
  if (c.length !== 10 || !/^\d{10}$/.test(c))
@@ -489,18 +510,90 @@ export function validatePolishNip(candidate) {
489
510
  total += d[i] * weights[i];
490
511
  return total % 11 === d[9];
491
512
  }
513
+ /**
514
+ * Polish identity card (dowód osobisty): 3 letters + 6 digits, the first digit
515
+ * a check digit. Letters A=10 … Z=35, weights 7,3,1,9,7,3,1,7,3: the weighted
516
+ * sum of all nine is 0 mod 10 exactly when the check digit is right (rules-engine#75).
517
+ */
518
+ export function validatePolishIdCard(candidate) {
519
+ const c = candidate.replace(/\s/g, "").toUpperCase();
520
+ if (!/^[A-Z]{3}\d{6}$/.test(c))
521
+ return false;
522
+ const weights = [7, 3, 1, 9, 7, 3, 1, 7, 3];
523
+ let total = 0;
524
+ for (let i = 0; i < 9; i++) {
525
+ const ch = c[i];
526
+ total += (/[A-Z]/.test(ch) ? ch.charCodeAt(0) - 55 : Number(ch)) * weights[i];
527
+ }
528
+ return total % 10 === 0;
529
+ }
530
+ /**
531
+ * Polish REGON: 9 digits, or 14 for a local unit, each with a mod-11 check (a
532
+ * remainder of 10 counts as 0). A 14-digit REGON begins with its parent's valid
533
+ * 9-digit REGON (rules-engine#76).
534
+ */
535
+ export function validatePolishRegon(candidate) {
536
+ const c = candidate.replace(/[\s\-]/g, "");
537
+ if (!/^\d+$/.test(c) || (c.length !== 9 && c.length !== 14))
538
+ return false;
539
+ const check = (digits, weights) => {
540
+ let total = 0;
541
+ for (let i = 0; i < weights.length; i++)
542
+ total += Number(digits[i]) * weights[i];
543
+ const rest = total % 11;
544
+ return (rest === 10 ? 0 : rest) === Number(digits[weights.length]);
545
+ };
546
+ if (!check(c.slice(0, 9), [8, 9, 2, 3, 4, 5, 6, 7]))
547
+ return false;
548
+ return c.length === 9 || check(c, [2, 4, 8, 5, 0, 9, 7, 3, 6, 1, 2, 4, 8]);
549
+ }
550
+ // Days per month for the rodné číslo date check. February is 29 because the
551
+ // century is not recoverable from a two-digit year, so a leap year cannot be
552
+ // ruled out -- being permissive by one day is the right direction here.
553
+ const RC_DAYS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
554
+ // The calendar month a rodné číslo month field encodes, or null. Four ranges
555
+ // are legal: 1-12 is a man, +50 a woman, and since 2004 +20 marks a birth on a
556
+ // day whose sequence numbers were exhausted (so +70 is a woman on such a day).
557
+ // Whether `day` exists in `month`, February permissive at 29 because a
558
+ // two-digit year does not reveal the century.
559
+ function isRealDay(month, day) {
560
+ return month >= 1 && month <= 12 && day >= 1 && day <= RC_DAYS[month - 1];
561
+ }
562
+ function czechMonth(raw) {
563
+ for (const offset of [0, 20, 50, 70]) {
564
+ if (raw - offset >= 1 && raw - offset <= 12)
565
+ return raw - offset;
566
+ }
567
+ return null;
568
+ }
569
+ // The date is checked, not only the checksum. Without it the validator accepted
570
+ // an impossible month, and a Czech mobile number is the same shape -- nine
571
+ // digits opening 6 or 7 -- so any mobile that happened to pass mod 11 was typed
572
+ // NATIONAL_ID at high confidence: 606666032 (MM=66), 778836400 (MM=88),
573
+ // 728990603 (MM=89). 284 per corpus pass, the largest single false-positive
574
+ // bucket in the evaluation (rules-engine#37).
492
575
  export function validateCzechBirthNumber(candidate) {
493
576
  const c = candidate.replace(/[/\s]/g, "");
494
577
  if (!/^\d+$/.test(c) || (c.length !== 9 && c.length !== 10))
495
578
  return false;
579
+ const month = czechMonth(parseInt(c.slice(2, 4), 10));
580
+ if (month === null)
581
+ return false;
582
+ const day = parseInt(c.slice(4, 6), 10);
583
+ if (day < 1 || day > RC_DAYS[month - 1])
584
+ return false;
496
585
  if (c.length === 10)
497
586
  return parseInt(c) % 11 === 0;
498
587
  return true;
499
588
  }
589
+ // S YY MM DD JJ NNN C -- the leading S carries the century, so the month needs
590
+ // no offset convention here (rules-engine#43).
500
591
  export function validateRomanianCnp(candidate) {
501
592
  const c = candidate.replace(/[\s\-]/g, "");
502
593
  if (c.length !== 13 || !/^\d{13}$/.test(c) || !"12345678".includes(c[0]))
503
594
  return false;
595
+ if (!isRealDay(parseInt(c.slice(3, 5), 10), parseInt(c.slice(5, 7), 10)))
596
+ return false;
504
597
  const key = "279146358279";
505
598
  let total = 0;
506
599
  for (let i = 0; i < 12; i++)
@@ -547,10 +640,14 @@ export function validateCroatianOib(candidate) {
547
640
  const check = (11 - product) % 10;
548
641
  return check === parseInt(c[10]);
549
642
  }
643
+ // DD MM YYY RR BBB K -- note EMSO opens with the *day*, where the Czech, Polish
644
+ // and Romanian forms open with the year or a century digit (rules-engine#43).
550
645
  export function validateSlovenianEmso(candidate) {
551
646
  const c = candidate.replace(/[\s\-]/g, "");
552
647
  if (c.length !== 13 || !/^\d{13}$/.test(c))
553
648
  return false;
649
+ if (!isRealDay(parseInt(c.slice(2, 4), 10), parseInt(c.slice(0, 2), 10)))
650
+ return false;
554
651
  const d = c.split("").map(Number);
555
652
  const weights = [7, 6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
556
653
  let total = 0;
@@ -700,6 +797,9 @@ export const VALIDATORS = {
700
797
  portuguese_nif: validatePortugueseNif,
701
798
  polish_pesel: validatePolishPesel,
702
799
  polish_nip: validatePolishNip,
800
+ polish_nrb: validatePolishNrb,
801
+ polish_id_card: validatePolishIdCard,
802
+ polish_regon: validatePolishRegon,
703
803
  czech_birth_number: validateCzechBirthNumber,
704
804
  romanian_cnp: validateRomanianCnp,
705
805
  hungarian_taj: validateHungarianTaj,
package/dist/esm/sdk.d.ts CHANGED
@@ -22,11 +22,51 @@ type LabelFor = (det: Detection, slice: string) => string;
22
22
  */
23
23
  export declare function applyReplacements(text: string, detections: Detection[], labelFor: LabelFor): string;
24
24
  /**
25
- * Characters a token suffix is drawn from. No vowels, so a suffix never spells
26
- * a word; no 0/1/I/O, so it survives being read back by a person; no
27
- * underscore, so the type prefix stays unambiguous.
25
+ * One label in the text sent to the cloud tier: where it sits in the masked
26
+ * text, and the extent of the original it replaced —
27
+ * `[maskedStart, maskedEnd, start, end]`.
28
28
  */
29
- export declare const TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
29
+ export type WireLabel = [number, number, number, number];
30
+ /**
31
+ * Bracket-mask `text` and record where each label landed.
32
+ *
33
+ * Returns what is sent to the cloud tier, plus one `WireLabel` per label.
34
+ * Everything between labels is copied unchanged, so the labels are all it
35
+ * takes to carry an offset from the masked text back to the original.
36
+ *
37
+ * Always `[TYPE]`, whatever label scheme the caller asked for. That is the
38
+ * syntax the model was trained on, where a placeholder means "already handled,
39
+ * do not list it". A token or a numbered label would be read as ordinary text,
40
+ * so the format on the wire is a model contract and not an output option.
41
+ */
42
+ /** @internal Shared with cloud/batches.ts. */
43
+ export declare function maskForCloud(text: string, detections: Detection[]): [string, WireLabel[]];
44
+ /**
45
+ * Carry the service's spans from the masked text onto the original.
46
+ *
47
+ * The service only ever saw `masked`, so its offsets index that. An offset in
48
+ * copied text shifts by whatever the labels before it added or removed. An
49
+ * offset inside a label has no counterpart in the original, so it snaps
50
+ * outward to the edge of the value the label replaced: an address the model
51
+ * reports around `[POSTAL_CODE]` covers the postal code, never half of it.
52
+ * Over-masking is the safe direction.
53
+ *
54
+ * A span lying wholly inside one label describes the label, not the document.
55
+ * It is dropped; the local detection behind the label stands.
56
+ *
57
+ * Throws `Unplaceable` when a span does not match the text that was sent. Such
58
+ * a span cannot be placed, and masking where it points would cover the wrong
59
+ * characters and leave the right ones in the clear.
60
+ */
61
+ /** @internal Shared with cloud/batches.ts. */
62
+ export declare function ontoOriginal(spans: Detection[], masked: string, text: string, labels: WireLabel[], Unplaceable: new (message: string) => Error): Detection[];
63
+ /**
64
+ * Characters a token suffix is drawn from. No vowels (and no Y), so a suffix
65
+ * never spells a word; no 0/1/I/O, so it survives being read back by a person;
66
+ * no underscore, so the type prefix stays unambiguous. A, E, U and Y were in it
67
+ * until rules-engine#55, against this comment.
68
+ */
69
+ export declare const TOKEN_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ23456789";
30
70
  export declare const TOKEN_SUFFIX_LENGTH = 4;
31
71
  /**
32
72
  * Maps each distinct PII value in one call to a reversible token.
@@ -106,6 +146,13 @@ export interface RedactOptions {
106
146
  allowlistDomains?: string[] | null;
107
147
  detectDates?: boolean;
108
148
  cache?: boolean;
149
+ /**
150
+ * `mode: "cloud"` only. Sent as the request's `Idempotency-Key`: the same key
151
+ * returns the same job instead of running (and billing) the document again,
152
+ * which is what a caller retrying after its own timeout needs. One is
153
+ * generated per call when omitted.
154
+ */
155
+ idempotencyKey?: string;
109
156
  }
110
157
  export declare class EuRedact {
111
158
  private engine;
@@ -136,7 +183,16 @@ export declare class EuRedact {
136
183
  /** The label function for one call, given its output options. */
137
184
  private labelFor;
138
185
  /**
139
- * Send a document to the cloud tier.
186
+ * Run the rules here, then send what they left to the cloud tier.
187
+ *
188
+ * Local-first: the rules engine masks everything it can find on the caller's
189
+ * machine, and only that masked text is sent. The service looks for what has
190
+ * no shape to match on — names, employers, diagnoses — and answers with
191
+ * spans relative to the text it received, which are mapped back onto the
192
+ * original here.
193
+ *
194
+ * This is minimisation, not an exemption: what the rules miss, and the names
195
+ * and diagnoses the model is there to find, still travel.
140
196
  *
141
197
  * Options the service cannot honour throw rather than being ignored. Silently
142
198
  * dropping one would mean returning a result that does not match what was
@@ -144,21 +200,9 @@ export declare class EuRedact {
144
200
  * under-redaction wearing a plausible face.
145
201
  */
146
202
  private redactViaCloud;
147
- /**
148
- * Rebuild the masked text from the service's spans, in place.
149
- *
150
- * The service masks with bracketed labels only. Any other output option is
151
- * applied here, from its spans: the service builds its `redacted_text` from
152
- * exactly the spans it returns (entities it reports but cannot place are
153
- * listed separately and never applied), so nothing it masked is lost by
154
- * masking again from the same spans.
155
- *
156
- * The service reports offsets in code points and this SDK slices UTF-16
157
- * units, so the slice check is what stands between an emoji in the document
158
- * and a rebuilt text with the wrong characters masked.
159
- */
160
- private remaskCloudResult;
161
203
  redact(text: string, options?: RedactOptions): RedactResult;
204
+ /** The local pipeline: normalise, detect, exempt, mask. */
205
+ private redactRules;
162
206
  /**
163
207
  * Redact, awaiting the cloud tier when `mode: "cloud"` is asked for.
164
208
  *
package/dist/esm/sdk.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomInt } from "node:crypto";
1
+ import { randomIndex } from "./platform.js";
2
2
  import { ResultCache } from "./cache.js";
3
3
  import { normalize, mapOffsetToOriginal } from "./normalizer.js";
4
4
  import { RuleEngine } from "./rules/engine.js";
@@ -49,11 +49,104 @@ export function applyReplacements(text, detections, labelFor) {
49
49
  return parts.join("");
50
50
  }
51
51
  /**
52
- * Characters a token suffix is drawn from. No vowels, so a suffix never spells
53
- * a word; no 0/1/I/O, so it survives being read back by a person; no
54
- * underscore, so the type prefix stays unambiguous.
52
+ * Bracket-mask `text` and record where each label landed.
53
+ *
54
+ * Returns what is sent to the cloud tier, plus one `WireLabel` per label.
55
+ * Everything between labels is copied unchanged, so the labels are all it
56
+ * takes to carry an offset from the masked text back to the original.
57
+ *
58
+ * Always `[TYPE]`, whatever label scheme the caller asked for. That is the
59
+ * syntax the model was trained on, where a placeholder means "already handled,
60
+ * do not list it". A token or a numbered label would be read as ordinary text,
61
+ * so the format on the wire is a model contract and not an output option.
62
+ */
63
+ /** @internal Shared with cloud/batches.ts. */
64
+ export function maskForCloud(text, detections) {
65
+ const parts = [];
66
+ const labels = [];
67
+ let pos = 0;
68
+ let out = 0;
69
+ for (const det of detections) {
70
+ const start = Math.max(det.start, pos);
71
+ if (det.end <= start)
72
+ continue;
73
+ const label = `[${det.entityType}]`;
74
+ out += start - pos;
75
+ parts.push(text.slice(pos, start), label);
76
+ labels.push([out, out + label.length, start, det.end]);
77
+ out += label.length;
78
+ pos = det.end;
79
+ }
80
+ parts.push(text.slice(pos));
81
+ return [parts.join(""), labels];
82
+ }
83
+ /** Index of the last label starting at or before `offset` — strictly before
84
+ * when `strict` — or -1. */
85
+ function labelBefore(labels, offset, strict) {
86
+ let lo = 0;
87
+ let hi = labels.length;
88
+ while (lo < hi) {
89
+ const mid = (lo + hi) >>> 1;
90
+ if (strict ? labels[mid][0] < offset : labels[mid][0] <= offset)
91
+ lo = mid + 1;
92
+ else
93
+ hi = mid;
94
+ }
95
+ return lo - 1;
96
+ }
97
+ /**
98
+ * Carry the service's spans from the masked text onto the original.
99
+ *
100
+ * The service only ever saw `masked`, so its offsets index that. An offset in
101
+ * copied text shifts by whatever the labels before it added or removed. An
102
+ * offset inside a label has no counterpart in the original, so it snaps
103
+ * outward to the edge of the value the label replaced: an address the model
104
+ * reports around `[POSTAL_CODE]` covers the postal code, never half of it.
105
+ * Over-masking is the safe direction.
106
+ *
107
+ * A span lying wholly inside one label describes the label, not the document.
108
+ * It is dropped; the local detection behind the label stands.
109
+ *
110
+ * Throws `Unplaceable` when a span does not match the text that was sent. Such
111
+ * a span cannot be placed, and masking where it points would cover the wrong
112
+ * characters and leave the right ones in the clear.
113
+ */
114
+ /** @internal Shared with cloud/batches.ts. */
115
+ export function ontoOriginal(spans, masked, text, labels, Unplaceable) {
116
+ const placed = [];
117
+ for (const span of spans) {
118
+ if (!(0 <= span.start && span.start <= span.end && span.end <= masked.length) ||
119
+ masked.slice(span.start, span.end) !== span.text) {
120
+ throw new Unplaceable("span offsets do not match the text that was sent; cannot place the " +
121
+ "service's detections in the document");
122
+ }
123
+ if (span.start === span.end)
124
+ continue;
125
+ let start = span.start;
126
+ let at = labelBefore(labels, span.start, false);
127
+ if (at >= 0) {
128
+ const [, maskedEnd, origStart, origEnd] = labels[at];
129
+ if (span.end <= maskedEnd)
130
+ continue;
131
+ start = span.start < maskedEnd ? origStart : origEnd + span.start - maskedEnd;
132
+ }
133
+ let end = span.end;
134
+ at = labelBefore(labels, span.end, true);
135
+ if (at >= 0) {
136
+ const [, maskedEnd, , origEnd] = labels[at];
137
+ end = origEnd + Math.max(0, span.end - maskedEnd);
138
+ }
139
+ placed.push({ ...span, start, end, text: text.slice(start, end) });
140
+ }
141
+ return placed;
142
+ }
143
+ /**
144
+ * Characters a token suffix is drawn from. No vowels (and no Y), so a suffix
145
+ * never spells a word; no 0/1/I/O, so it survives being read back by a person;
146
+ * no underscore, so the type prefix stays unambiguous. A, E, U and Y were in it
147
+ * until rules-engine#55, against this comment.
55
148
  */
56
- export const TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
149
+ export const TOKEN_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ23456789";
57
150
  export const TOKEN_SUFFIX_LENGTH = 4;
58
151
  const TOKEN_MAX_DRAWS = 100;
59
152
  function escapeRegExp(s) {
@@ -101,7 +194,7 @@ export class TokenMapper {
101
194
  }
102
195
  let suffix = "";
103
196
  for (let i = 0; i < TOKEN_SUFFIX_LENGTH; i++) {
104
- suffix += TOKEN_ALPHABET[randomInt(TOKEN_ALPHABET.length)];
197
+ suffix += TOKEN_ALPHABET[randomIndex(TOKEN_ALPHABET.length)];
105
198
  }
106
199
  token = prefix + suffix;
107
200
  } while (this.taken.has(token));
@@ -386,7 +479,16 @@ export class EuRedact {
386
479
  return (det) => `[${det.entityType}]`;
387
480
  }
388
481
  /**
389
- * Send a document to the cloud tier.
482
+ * Run the rules here, then send what they left to the cloud tier.
483
+ *
484
+ * Local-first: the rules engine masks everything it can find on the caller's
485
+ * machine, and only that masked text is sent. The service looks for what has
486
+ * no shape to match on — names, employers, diagnoses — and answers with
487
+ * spans relative to the text it received, which are mapped back onto the
488
+ * original here.
489
+ *
490
+ * This is minimisation, not an exemption: what the rules miss, and the names
491
+ * and diagnoses the model is there to find, still travel.
390
492
  *
391
493
  * Options the service cannot honour throw rather than being ignored. Silently
392
494
  * dropping one would mean returning a result that does not match what was
@@ -395,6 +497,7 @@ export class EuRedact {
395
497
  */
396
498
  async redactViaCloud(text, options) {
397
499
  const { CloudClient } = await import("./cloud/client.js");
500
+ const { CloudError } = await import("./cloud/errors.js");
398
501
  const countries = options.countries ?? null;
399
502
  if (!countries || countries.length !== 1) {
400
503
  throw new Error('cloud mode needs exactly one country, e.g. { countries: ["BE"] }. The ' +
@@ -412,43 +515,37 @@ export class EuRedact {
412
515
  if (options.referentialIntegrity) {
413
516
  throw new Error("referentialIntegrity is not supported in cloud mode");
414
517
  }
415
- // detectDates is deliberately NOT forwarded. The service always runs its
416
- // rules engine with dates on, because that is what the model was trained
417
- // against; the caller's value cannot change that. It is the one ignored
418
- // option that is safe to ignore — it can only cause MORE to be detected,
419
- // never less, so it cannot produce under-redaction.
420
518
  const allowed = this.allowedFor(options);
421
519
  const domains = this.domainsFor(options);
422
- const result = await new CloudClient().redact(text, { country: countries[0] });
423
- if (options.tokenize || allowed.size > 0 || domains.size > 0) {
424
- await this.remaskCloudResult(result, text, { tokenize: options.tokenize ?? false, allowed, domains });
425
- }
426
- return result;
427
- }
428
- /**
429
- * Rebuild the masked text from the service's spans, in place.
430
- *
431
- * The service masks with bracketed labels only. Any other output option is
432
- * applied here, from its spans: the service builds its `redacted_text` from
433
- * exactly the spans it returns (entities it reports but cannot place are
434
- * listed separately and never applied), so nothing it masked is lost by
435
- * masking again from the same spans.
436
- *
437
- * The service reports offsets in code points and this SDK slices UTF-16
438
- * units, so the slice check is what stands between an emoji in the document
439
- * and a rebuilt text with the wrong characters masked.
440
- */
441
- async remaskCloudResult(result, text, opts) {
442
- const { CloudError } = await import("./cloud/errors.js");
443
- for (const det of result.detections) {
444
- if (text.slice(det.start, det.end) !== det.text) {
445
- throw new CloudError("span offsets do not match the document; cannot apply tokenize/allowlist locally");
446
- }
447
- }
448
- [result.detections, result.exempted] = applyAllowlist(text, result.detections, opts.allowed, opts.domains);
449
- const tokenMapper = opts.tokenize ? new TokenMapper(text, result.detections) : null;
450
- result.redactedText = applyReplacements(text, result.detections, this.labelFor(false, tokenMapper));
451
- result.tokens = tokenMapper ? tokenMapper.tokens : {};
520
+ const client = new CloudClient();
521
+ // Dates are always on, whatever detectDates says: that is the rules output
522
+ // the model was trained against, and a date of birth the rules can place
523
+ // has no reason to travel. It is the one ignored option that is safe to
524
+ // ignore — it can only cause MORE to be detected, never less.
525
+ //
526
+ // No allowlist and no tokens on this pass. An exemption says what the
527
+ // caller wants back, not what may leave; both are applied below, after the
528
+ // response, to the merged spans.
529
+ const local = this.redactRules(text, { countries, detectDates: true, cache: options.cache }, new Map(), new Map());
530
+ const [masked, labels] = maskForCloud(text, local.detections);
531
+ // The client converts the service's code-point offsets to UTF-16 units of
532
+ // the text it sent, so everything from here on is in one unit.
533
+ const remote = await client.redact(masked, {
534
+ country: countries[0], idempotencyKey: options.idempotencyKey,
535
+ });
536
+ // A new array throughout: `local` may be the cached rules result.
537
+ const [detections, exempted] = applyAllowlist(text, [...local.detections, ...ontoOriginal(remote.detections, masked, text, labels, CloudError)], allowed, domains);
538
+ detections.sort((a, b) => a.start - b.start || b.end - a.end);
539
+ const tokenMapper = options.tokenize ? new TokenMapper(text, detections) : null;
540
+ return {
541
+ ...local,
542
+ redactedText: applyReplacements(text, detections, this.labelFor(false, tokenMapper)),
543
+ detections,
544
+ source: "cloud",
545
+ tokens: tokenMapper ? tokenMapper.tokens : {},
546
+ exempted,
547
+ ...(remote.cloud ? { cloud: remote.cloud } : {}),
548
+ };
452
549
  }
453
550
  redact(text, options = {}) {
454
551
  checkCountryArg(options.countries, "countries");
@@ -469,12 +566,19 @@ export class EuRedact {
469
566
  if (requestedMode !== "rules") {
470
567
  throw new Error(`unknown mode ${JSON.stringify(requestedMode)}: expected "rules" or "cloud"`);
471
568
  }
569
+ if (options.idempotencyKey !== undefined) {
570
+ throw new Error('idempotencyKey applies to mode: "cloud" only: a rules-only redaction makes no request to deduplicate');
571
+ }
572
+ return this.redactRules(text, options, allowed, domains);
573
+ }
574
+ /** The local pipeline: normalise, detect, exempt, mask. */
575
+ redactRules(text, options, allowed, domains) {
472
576
  if (text.length > this.maxInputLength) {
473
577
  throw new Error(`Input text length (${text.length.toLocaleString()} chars) exceeds the maximum ` +
474
578
  `(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
475
579
  `increase maxInputLength when constructing EuRedact.`);
476
580
  }
477
- const { countries = null, countryHint = null, context = null, chunkOffset = 0, mode = "rules", referentialIntegrity = false, tokenize = false, detectDates = false, } = options;
581
+ const { countries = null, countryHint = null, context = null, chunkOffset = 0, referentialIntegrity = false, tokenize = false, detectDates = false, } = options;
478
582
  // A context makes the result depend on evidence from other chunks, so the
479
583
  // text no longer identifies the result. Caching is disabled rather than
480
584
  // keyed on the context, whose contents change as chunks arrive.
@@ -492,7 +596,7 @@ export class EuRedact {
492
596
  // separator, and two different lists must never share a key.
493
597
  const allowKey = (allowed.size ? JSON.stringify([...allowed.keys()].sort()) : "")
494
598
  + (domains.size ? "|dom=" + JSON.stringify([...domains.keys()].sort()) : "");
495
- const cacheMode = `${mode}|dates=${detectDates}|hint=${hintKey}|ri=${referentialIntegrity}|tok=${tokenize}|allow=${allowKey}`;
599
+ const cacheMode = `rules|dates=${detectDates}|hint=${hintKey}|ri=${referentialIntegrity}|tok=${tokenize}|allow=${allowKey}`;
496
600
  let cacheKey;
497
601
  if (cache) {
498
602
  cacheKey = this.cache.key(normalizedText, countriesTuple, cacheMode);
@@ -69,7 +69,8 @@ export declare enum EntityType {
69
69
  BIOMETRIC_REF = "BIOMETRIC_REF",
70
70
  FINANCIAL_AMOUNT = "FINANCIAL_AMOUNT",
71
71
  QUASI_IDENTIFIER = "QUASI_IDENTIFIER",
72
- CREDENTIAL = "CREDENTIAL",
72
+ /** Legacy alias of SECRET: every credential, the model's included, is SECRET (rules-engine#111). */
73
+ CREDENTIAL = "SECRET",
73
74
  URL = "URL",
74
75
  OTHER = "OTHER"
75
76
  }
@@ -116,7 +117,13 @@ export interface Detection {
116
117
  text: string;
117
118
  source: DetectionSource;
118
119
  country: string | null;
119
- confidence: string;
120
+ /**
121
+ * How the type was arrived at: "high", "medium" or "low" for a rules
122
+ * detection. `null` for a span the cloud model found and reported no
123
+ * confidence for: the model emits none today, and "high" would claim the
124
+ * checksum-backed evidence a rules detection has (rules-engine#108).
125
+ */
126
+ confidence: string | null;
120
127
  /**
121
128
  * How strongly the document supports {@link Detection.country}, in [0, 1].
122
129
  *
@@ -151,6 +158,52 @@ export interface Exemption {
151
158
  /** `"value"` for the literal allowlist, `"domain"` for allowlistDomains. */
152
159
  ruleKind: "value" | "domain";
153
160
  }
161
+ /**
162
+ * One reason a cloud request cost what it did. `code` is one of the service's
163
+ * factor codes (`rules_only`, `document_length`, `instructions`,
164
+ * `dense_document`, `special_category_check`, `long_document`, `batch_rate`),
165
+ * kept as a string so a code added later on the service still arrives.
166
+ */
167
+ export interface UsageFactor {
168
+ code: string;
169
+ /** One sentence explaining the factor, written by the service. */
170
+ detail: string;
171
+ /** `special_category_check` only: the kinds of data that triggered it. */
172
+ types?: string[];
173
+ }
174
+ /**
175
+ * What a cloud request cost, and why (rules-engine#89). A total and its
176
+ * reasons, never a cost per step; `credits` is `round(tokens * billingRate)`,
177
+ * the formula the service debits by.
178
+ */
179
+ export interface Usage {
180
+ tokens: number;
181
+ billingRate: number;
182
+ credits: number;
183
+ factors: UsageFactor[];
184
+ }
185
+ /**
186
+ * Something the model found that could not be placed in the document: the
187
+ * service could not match it to a position in what it was sent, so nothing was
188
+ * masked for it. Reported so a caller can decide, rather than never being told.
189
+ */
190
+ export interface Unlocated {
191
+ text: string;
192
+ entityType: EntityType | string;
193
+ }
194
+ /** What the cloud service said about a request, beside its spans (rules-engine#89). */
195
+ export interface CloudInfo {
196
+ /** The service's job id: for support, for `Jobs.retrieve`, and to recognise an
197
+ * idempotent replay. `null` for a batch document. */
198
+ jobId: string | null;
199
+ /** The public name of the model that answered, `null` when only rules ran. */
200
+ modelVersion: string | null;
201
+ /** What the request cost and why. Absent when the service does not report it
202
+ * (older versions, batch documents) or reported it malformed. */
203
+ usage?: Usage;
204
+ /** What the model found but could not place in the text it was sent. */
205
+ unlocated: Unlocated[];
206
+ }
154
207
  export interface RedactResult {
155
208
  redactedText: string;
156
209
  detections: Detection[];
@@ -188,6 +241,9 @@ export interface RedactResult {
188
241
  /** Detections the allowlist kept out of the output, with the rule that
189
242
  * matched. Empty unless an allowlist was in force. */
190
243
  exempted: Exemption[];
244
+ /** What the cloud service reported beside the spans: job id, model, cost and
245
+ * anything it could not place. Absent on a rules-only result. */
246
+ cloud?: CloudInfo;
191
247
  }
192
248
  export interface PatternDef {
193
249
  entityType: EntityType | string;
package/dist/esm/types.js CHANGED
@@ -75,7 +75,8 @@ export var EntityType;
75
75
  EntityType["BIOMETRIC_REF"] = "BIOMETRIC_REF";
76
76
  EntityType["FINANCIAL_AMOUNT"] = "FINANCIAL_AMOUNT";
77
77
  EntityType["QUASI_IDENTIFIER"] = "QUASI_IDENTIFIER";
78
- EntityType["CREDENTIAL"] = "CREDENTIAL";
78
+ /** Legacy alias of SECRET: every credential, the model's included, is SECRET (rules-engine#111). */
79
+ EntityType["CREDENTIAL"] = "SECRET";
79
80
  EntityType["URL"] = "URL";
80
81
  EntityType["OTHER"] = "OTHER";
81
82
  })(EntityType || (EntityType = {}));
@@ -89,6 +90,8 @@ export const LEGACY_TYPE_ALIASES = {
89
90
  NAME: "PERSON_NAME",
90
91
  STREET_ADDRESS: "ADDRESS",
91
92
  NATIONALITY_ETHNICITY: "SENSITIVE_ATTRIBUTE",
93
+ // The model labels prose credentials CREDENTIAL; the SDK reports every credential as SECRET (rules-engine#111).
94
+ CREDENTIAL: "SECRET",
92
95
  };
93
96
  /**
94
97
  * Resolve a possibly-legacy type name to its canonical form.