euredact 0.3.9 → 0.5.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.
package/dist/esm/sdk.d.ts CHANGED
@@ -1,5 +1,69 @@
1
1
  import { DocumentContext } from "./rules/context.js";
2
- import { type RedactResult } from "./types.js";
2
+ import { type Detection, type RedactResult } from "./types.js";
3
+ /** Returns the label that replaces `det`; `slice` is the exact text it covers. */
4
+ type LabelFor = (det: Detection, slice: string) => string;
5
+ /**
6
+ * Splice a label over every detection and return the masked text.
7
+ *
8
+ * `detections` must be sorted by `(start, -end)`.
9
+ *
10
+ * Labels are resolved right-to-left because the referential mapper numbers
11
+ * each entity type in call order, and that order is part of the output
12
+ * contract. The string itself is then assembled in a single forward pass:
13
+ * rebuilding it per detection copied the whole document each time, which is
14
+ * O(document x detections) — 1.68 s of pure copying on a 1 MB document with
15
+ * 15,000 detections, versus 2 ms here.
16
+ *
17
+ * Spans from the rule engine are deduplicated and non-overlapping. Spans from
18
+ * elsewhere (the cloud service) are only sorted, so a span may start behind
19
+ * the cursor. Its uncovered tail is still masked rather than dropped: dropping
20
+ * the span would leave those characters in the clear, and splicing it whole
21
+ * would corrupt the label already emitted over its head.
22
+ */
23
+ export declare function applyReplacements(text: string, detections: Detection[], labelFor: LabelFor): string;
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.
28
+ */
29
+ export declare const TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
30
+ export declare const TOKEN_SUFFIX_LENGTH = 4;
31
+ /**
32
+ * Maps each distinct PII value in one call to a reversible token.
33
+ *
34
+ * A token is `TYPE_XXXX`: the entity type, then `TOKEN_SUFFIX_LENGTH` random
35
+ * characters from `TOKEN_ALPHABET`. The same value gets the same token within
36
+ * the call, so relationships survive; across calls it gets a different one, so
37
+ * two tokenized documents never reveal a shared value.
38
+ *
39
+ * Nothing is kept beyond the call. The mapping goes to the caller in
40
+ * `RedactResult.tokens`, and only the caller can turn it back into text with
41
+ * `restore()`.
42
+ *
43
+ * Tokens are also kept clear of any token-shaped string already in the
44
+ * document. A caller that redacts an LLM's reply to a tokenized prompt has
45
+ * exactly such a document, and a fresh token colliding with an old one would
46
+ * make `restore()` put the wrong value back.
47
+ */
48
+ export declare class TokenMapper {
49
+ private byValue;
50
+ private taken;
51
+ /** Token -> original value, for `RedactResult.tokens`. */
52
+ readonly tokens: Record<string, string>;
53
+ constructor(text: string, detections: Detection[]);
54
+ /** Return the token for `value`, minting one on first sight. */
55
+ getToken: (det: Detection, value: string) => string;
56
+ }
57
+ /**
58
+ * Put the original values back into `text`.
59
+ *
60
+ * `tokens` is `RedactResult.tokens` from the `redact(text, { tokenize: true })`
61
+ * call that produced the text this one derives from — typically the reply an
62
+ * LLM wrote to the tokenized prompt. Every occurrence of every token is
63
+ * replaced; a token an LLM glued to other characters (`EMAIL_K7Q2s`) is still
64
+ * restored, since leaving a token behind is the worse failure.
65
+ */
66
+ export declare function restore(text: string, tokens: Record<string, string>): string;
3
67
  export interface RedactOptions {
4
68
  /**
5
69
  * Scope. Detections attributed elsewhere are flagged `outOfScope`, never
@@ -24,6 +88,22 @@ export interface RedactOptions {
24
88
  chunkOffset?: number;
25
89
  mode?: string;
26
90
  referentialIntegrity?: boolean;
91
+ /**
92
+ * Replace each value with a reversible token (`EMAIL_K7Q2`) and return the
93
+ * token -> value mapping in `RedactResult.tokens`; see `restore()`. Tokens
94
+ * are unique to the call. Cannot be combined with `referentialIntegrity`.
95
+ */
96
+ tokenize?: boolean;
97
+ /**
98
+ * Values never to redact, matched whole and case-insensitively against each
99
+ * detection. Merged with the instance's allowlist.
100
+ */
101
+ allowlist?: string[] | null;
102
+ /**
103
+ * Domains whose addresses are never redacted, e.g. `["acme.be"]`. Applies to
104
+ * EMAIL and URL only, and covers subdomains. Merged with the instance list.
105
+ */
106
+ allowlistDomains?: string[] | null;
27
107
  detectDates?: boolean;
28
108
  cache?: boolean;
29
109
  }
@@ -32,13 +112,60 @@ export declare class EuRedact {
32
112
  private cache;
33
113
  private referentialMapper;
34
114
  private maxInputLength;
115
+ private allowlist;
116
+ private allowlistDomains;
117
+ /**
118
+ * @param options.maxInputLength Longest document `redact` accepts, in characters.
119
+ * @param options.allowlist Values never to redact, for every call on this
120
+ * instance — an organisation's own name, its own addresses. Merged with
121
+ * the per-call `allowlist`. Matched whole, case-insensitively.
122
+ */
35
123
  constructor(options?: {
36
124
  maxInputLength?: number;
125
+ allowlist?: string[] | null;
126
+ allowlistDomains?: string[] | null;
37
127
  });
38
128
  addCustomPattern(name: string, pattern: string): void;
39
129
  /** Clear the result cache and referential integrity mappings.
40
130
  * Call this in long-running processes to free PII from memory. */
41
131
  clear(): void;
132
+ /** The instance allowlist merged with a call's, in comparison form. */
133
+ private allowedFor;
134
+ /** The instance domain list merged with a call's. */
135
+ private domainsFor;
136
+ /** The label function for one call, given its output options. */
137
+ private labelFor;
138
+ /**
139
+ * Send a document to the cloud tier.
140
+ *
141
+ * Options the service cannot honour throw rather than being ignored. Silently
142
+ * dropping one would mean returning a result that does not match what was
143
+ * asked for — which, for anything that changes which spans come back, is
144
+ * under-redaction wearing a plausible face.
145
+ */
146
+ 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;
42
161
  redact(text: string, options?: RedactOptions): RedactResult;
162
+ /**
163
+ * Redact, awaiting the cloud tier when `mode: "cloud"` is asked for.
164
+ *
165
+ * `mode: "rules"` resolves immediately with exactly what `redact()` returns,
166
+ * so callers that may or may not use the cloud tier can hold one code path.
167
+ */
168
+ redactAsync(text: string, options?: RedactOptions): Promise<RedactResult>;
43
169
  redactBatch(texts: string[], options?: RedactOptions): RedactResult[];
44
170
  }
171
+ export {};
package/dist/esm/sdk.js CHANGED
@@ -1,9 +1,271 @@
1
+ import { randomInt } from "node:crypto";
1
2
  import { ResultCache } from "./cache.js";
2
3
  import { normalize, mapOffsetToOriginal } from "./normalizer.js";
3
4
  import { RuleEngine } from "./rules/engine.js";
4
5
  import { weightsToRanking } from "./rules/evidence.js";
5
6
  import { EntityType } from "./types.js";
6
7
  const DATE_TYPES = new Set([EntityType.DOB, EntityType.DATE_OF_DEATH]);
8
+ /**
9
+ * Splice a label over every detection and return the masked text.
10
+ *
11
+ * `detections` must be sorted by `(start, -end)`.
12
+ *
13
+ * Labels are resolved right-to-left because the referential mapper numbers
14
+ * each entity type in call order, and that order is part of the output
15
+ * contract. The string itself is then assembled in a single forward pass:
16
+ * rebuilding it per detection copied the whole document each time, which is
17
+ * O(document x detections) — 1.68 s of pure copying on a 1 MB document with
18
+ * 15,000 detections, versus 2 ms here.
19
+ *
20
+ * Spans from the rule engine are deduplicated and non-overlapping. Spans from
21
+ * elsewhere (the cloud service) are only sorted, so a span may start behind
22
+ * the cursor. Its uncovered tail is still masked rather than dropped: dropping
23
+ * the span would leave those characters in the clear, and splicing it whole
24
+ * would corrupt the label already emitted over its head.
25
+ */
26
+ export function applyReplacements(text, detections, labelFor) {
27
+ const kept = [];
28
+ let pos = 0;
29
+ for (const det of detections) {
30
+ const start = Math.max(det.start, pos);
31
+ if (det.end <= start)
32
+ continue;
33
+ kept.push([det, start, det.end]);
34
+ pos = det.end;
35
+ }
36
+ const labels = new Array(kept.length);
37
+ for (let i = kept.length - 1; i >= 0; i--) {
38
+ const [det, start, end] = kept[i];
39
+ labels[i] = labelFor(det, text.slice(start, end));
40
+ }
41
+ const parts = [];
42
+ pos = 0;
43
+ for (let i = 0; i < kept.length; i++) {
44
+ const [, start, end] = kept[i];
45
+ parts.push(text.slice(pos, start), labels[i]);
46
+ pos = end;
47
+ }
48
+ parts.push(text.slice(pos));
49
+ return parts.join("");
50
+ }
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.
55
+ */
56
+ export const TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
57
+ export const TOKEN_SUFFIX_LENGTH = 4;
58
+ const TOKEN_MAX_DRAWS = 100;
59
+ function escapeRegExp(s) {
60
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
+ }
62
+ /**
63
+ * Maps each distinct PII value in one call to a reversible token.
64
+ *
65
+ * A token is `TYPE_XXXX`: the entity type, then `TOKEN_SUFFIX_LENGTH` random
66
+ * characters from `TOKEN_ALPHABET`. The same value gets the same token within
67
+ * the call, so relationships survive; across calls it gets a different one, so
68
+ * two tokenized documents never reveal a shared value.
69
+ *
70
+ * Nothing is kept beyond the call. The mapping goes to the caller in
71
+ * `RedactResult.tokens`, and only the caller can turn it back into text with
72
+ * `restore()`.
73
+ *
74
+ * Tokens are also kept clear of any token-shaped string already in the
75
+ * document. A caller that redacts an LLM's reply to a tokenized prompt has
76
+ * exactly such a document, and a fresh token colliding with an old one would
77
+ * make `restore()` put the wrong value back.
78
+ */
79
+ export class TokenMapper {
80
+ byValue = new Map();
81
+ taken = new Set();
82
+ /** Token -> original value, for `RedactResult.tokens`. */
83
+ tokens = {};
84
+ constructor(text, detections) {
85
+ const types = [...new Set(detections.map(d => String(d.entityType)))].sort();
86
+ if (types.length === 0)
87
+ return;
88
+ const shaped = new RegExp(`(?:${types.map(escapeRegExp).join("|")})_[${TOKEN_ALPHABET}]{${TOKEN_SUFFIX_LENGTH}}`, "g");
89
+ for (const m of text.matchAll(shaped))
90
+ this.taken.add(m[0]);
91
+ }
92
+ /** Return the token for `value`, minting one on first sight. */
93
+ getToken = (det, value) => {
94
+ let token = this.byValue.get(value);
95
+ if (token === undefined) {
96
+ const prefix = `${det.entityType}_`;
97
+ let draws = 0;
98
+ do {
99
+ if (draws++ >= TOKEN_MAX_DRAWS) {
100
+ throw new Error(`could not mint a unique token with prefix ${JSON.stringify(prefix)} after ${TOKEN_MAX_DRAWS} draws`);
101
+ }
102
+ let suffix = "";
103
+ for (let i = 0; i < TOKEN_SUFFIX_LENGTH; i++) {
104
+ suffix += TOKEN_ALPHABET[randomInt(TOKEN_ALPHABET.length)];
105
+ }
106
+ token = prefix + suffix;
107
+ } while (this.taken.has(token));
108
+ this.taken.add(token);
109
+ this.byValue.set(value, token);
110
+ this.tokens[token] = value;
111
+ }
112
+ return token;
113
+ };
114
+ }
115
+ /**
116
+ * Put the original values back into `text`.
117
+ *
118
+ * `tokens` is `RedactResult.tokens` from the `redact(text, { tokenize: true })`
119
+ * call that produced the text this one derives from — typically the reply an
120
+ * LLM wrote to the tokenized prompt. Every occurrence of every token is
121
+ * replaced; a token an LLM glued to other characters (`EMAIL_K7Q2s`) is still
122
+ * restored, since leaving a token behind is the worse failure.
123
+ */
124
+ export function restore(text, tokens) {
125
+ const keys = Object.keys(tokens);
126
+ if (keys.length === 0)
127
+ return text;
128
+ // Longest first so a token that is a prefix of another (custom pattern names
129
+ // allow it) cannot be matched short. The replacement is a function so `$&`
130
+ // and friends in the original values are literal.
131
+ keys.sort((a, b) => b.length - a.length);
132
+ const pattern = new RegExp(keys.map(escapeRegExp).join("|"), "g");
133
+ return text.replace(pattern, m => tokens[m]);
134
+ }
135
+ /**
136
+ * Types whose separators are presentational: the same identifier stays the
137
+ * same value however it is spaced, hyphenated or dotted, so an allowlisted
138
+ * IBAN is exempted whether the document writes NL91ABNA0417164300 or
139
+ * NL91 ABNA 0417 1643 00 (issue rules-engine#15).
140
+ *
141
+ * Free-text types are deliberately absent: folding separators in an address
142
+ * would make `jan.devries@acme.be` exempt `jandevries@acme.be`, a different
143
+ * mailbox at most providers.
144
+ */
145
+ const SEPARATOR_INSENSITIVE = new Set([
146
+ EntityType.BANK_ACCOUNT, EntityType.BIC, EntityType.CREDIT_CARD,
147
+ EntityType.PHONE, EntityType.VAT, EntityType.NATIONAL_ID, EntityType.SSN,
148
+ EntityType.TAX_ID, EntityType.PASSPORT, EntityType.DRIVERS_LICENSE,
149
+ EntityType.RESIDENCE_PERMIT, EntityType.HEALTH_INSURANCE,
150
+ EntityType.CHAMBER_OF_COMMERCE, EntityType.IMEI, EntityType.VIN,
151
+ ]);
152
+ /** Types carrying a domain an owner may want exempted wholesale. */
153
+ const DOMAIN_BEARING = new Set([EntityType.EMAIL, EntityType.URL]);
154
+ const SEPARATORS = /[\s.\-/()]+/g;
155
+ /**
156
+ * Reject a bare string where a list of allowlisted values is expected.
157
+ *
158
+ * A string is iterable, so `allowlist: "ACME NV"` would become the
159
+ * one-character entries "A", "C", ... — none of which is a whole detection, so
160
+ * nothing is exempted and the caller's own name is redacted after all.
161
+ */
162
+ function checkAllowlistArg(value, param = "allowlist") {
163
+ if (typeof value === "string") {
164
+ throw new TypeError(`${param} must be an array of values, not a bare string. ` +
165
+ `Pass ${param}: ["${value}"] rather than ${param}: "${value}".`);
166
+ }
167
+ }
168
+ /**
169
+ * The form an allowlist entry and a detected value are compared in. NFC
170
+ * because the rule engine matches on NFC-normalised text while the document
171
+ * may be NFD; lower-cased because an org name in a heading is the same org.
172
+ */
173
+ function allowlistKey(value) {
174
+ return value.normalize("NFC").trim().toLowerCase();
175
+ }
176
+ /** `allowlistKey` with presentational separators removed. */
177
+ function foldedKey(value) {
178
+ return allowlistKey(value).replace(SEPARATORS, "");
179
+ }
180
+ /** Comparison key -> the entry as the caller wrote it (for reporting). */
181
+ function normalizeAllowlist(values) {
182
+ checkAllowlistArg(values);
183
+ const out = new Map();
184
+ for (const v of values ?? []) {
185
+ if (!v || !v.trim())
186
+ continue;
187
+ if (!out.has(allowlistKey(v)))
188
+ out.set(allowlistKey(v), v);
189
+ if (!out.has(foldedKey(v)))
190
+ out.set(foldedKey(v), v);
191
+ }
192
+ return out;
193
+ }
194
+ /** Domain key -> the entry as written. A leading `@` or `.` is ignored. */
195
+ function normalizeDomains(values) {
196
+ checkAllowlistArg(values, "allowlistDomains");
197
+ const out = new Map();
198
+ for (const v of values ?? []) {
199
+ if (!v || !v.trim())
200
+ continue;
201
+ const k = allowlistKey(v).replace(/^[@.]+/, "");
202
+ if (k && !out.has(k))
203
+ out.set(k, v);
204
+ }
205
+ return out;
206
+ }
207
+ /** The domain an exemption rule would apply to, or null. */
208
+ function domainOf(entityType, value) {
209
+ if (!DOMAIN_BEARING.has(entityType))
210
+ return null;
211
+ let v = allowlistKey(value);
212
+ if (v.includes("@"))
213
+ return v.slice(v.lastIndexOf("@") + 1).replace(/^[<[(]+|[>\])(),;:"']+$/g, "") || null;
214
+ v = v.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
215
+ return v.split("/")[0].split(":")[0].replace(/^[<[(]+|[>\])(),;:"']+$/g, "") || null;
216
+ }
217
+ /** The [rule, kind] exempting this detection, or null. */
218
+ function matchingRule(det, slice, allowed, domains) {
219
+ for (const candidate of [slice, det.text]) {
220
+ if (!candidate)
221
+ continue;
222
+ const exact = allowed.get(allowlistKey(candidate));
223
+ if (exact !== undefined)
224
+ return [exact, "value"];
225
+ if (SEPARATOR_INSENSITIVE.has(det.entityType)) {
226
+ const folded = allowed.get(foldedKey(candidate));
227
+ if (folded !== undefined)
228
+ return [folded, "value"];
229
+ }
230
+ const host = domainOf(det.entityType, candidate);
231
+ if (host) {
232
+ for (const [owned, entry] of domains) {
233
+ if (host === owned || host.endsWith("." + owned))
234
+ return [entry, "domain"];
235
+ }
236
+ }
237
+ }
238
+ return null;
239
+ }
240
+ /**
241
+ * Split detections into those to redact and those the caller exempted.
242
+ *
243
+ * Whole span only: `euredact.be` as a *value* does not exempt
244
+ * `joren@euredact.be`; that is what `allowlistDomains` is for.
245
+ */
246
+ function applyAllowlist(text, detections, allowed, domains) {
247
+ if (allowed.size === 0 && domains.size === 0)
248
+ return [detections, []];
249
+ const kept = [];
250
+ const exempted = [];
251
+ for (const d of detections) {
252
+ const hit = matchingRule(d, text.slice(d.start, d.end), allowed, domains);
253
+ if (hit === null)
254
+ kept.push(d);
255
+ else
256
+ exempted.push({
257
+ entityType: d.entityType, start: d.start, end: d.end,
258
+ text: text.slice(d.start, d.end), rule: hit[0], ruleKind: hit[1],
259
+ });
260
+ }
261
+ return [kept, exempted];
262
+ }
263
+ /** Two label schemes for the same spans cannot both apply. */
264
+ function checkLabelOptions(options) {
265
+ if (options.tokenize && options.referentialIntegrity) {
266
+ throw new Error("tokenize and referentialIntegrity are two label schemes for the same spans; pass one of them");
267
+ }
268
+ }
7
269
  /** Entry count at which a one-time warning is emitted. */
8
270
  const MAPPING_WARN_THRESHOLD = 100_000;
9
271
  /**
@@ -76,8 +338,18 @@ export class EuRedact {
76
338
  cache = new ResultCache();
77
339
  referentialMapper = new ReferentialMapper();
78
340
  maxInputLength;
341
+ allowlist;
342
+ allowlistDomains;
343
+ /**
344
+ * @param options.maxInputLength Longest document `redact` accepts, in characters.
345
+ * @param options.allowlist Values never to redact, for every call on this
346
+ * instance — an organisation's own name, its own addresses. Merged with
347
+ * the per-call `allowlist`. Matched whole, case-insensitively.
348
+ */
79
349
  constructor(options) {
80
350
  this.maxInputLength = options?.maxInputLength ?? DEFAULT_MAX_INPUT_LENGTH;
351
+ this.allowlist = normalizeAllowlist(options?.allowlist);
352
+ this.allowlistDomains = normalizeDomains(options?.allowlistDomains);
81
353
  }
82
354
  addCustomPattern(name, pattern) {
83
355
  this.engine.addCustomPattern(name, pattern);
@@ -89,15 +361,120 @@ export class EuRedact {
89
361
  this.cache.clear();
90
362
  this.referentialMapper.clear();
91
363
  }
364
+ /** The instance allowlist merged with a call's, in comparison form. */
365
+ allowedFor(options) {
366
+ const allowed = new Map(this.allowlist);
367
+ for (const [k, v] of normalizeAllowlist(options.allowlist))
368
+ allowed.set(k, v);
369
+ return allowed;
370
+ }
371
+ /** The instance domain list merged with a call's. */
372
+ domainsFor(options) {
373
+ const domains = new Map(this.allowlistDomains);
374
+ for (const [k, v] of normalizeDomains(options.allowlistDomains))
375
+ domains.set(k, v);
376
+ return domains;
377
+ }
378
+ /** The label function for one call, given its output options. */
379
+ labelFor(referentialIntegrity, tokenMapper) {
380
+ if (tokenMapper !== null)
381
+ return tokenMapper.getToken;
382
+ if (referentialIntegrity) {
383
+ const mapper = this.referentialMapper;
384
+ return (det) => mapper.getLabel(det.text, det.entityType);
385
+ }
386
+ return (det) => `[${det.entityType}]`;
387
+ }
388
+ /**
389
+ * Send a document to the cloud tier.
390
+ *
391
+ * Options the service cannot honour throw rather than being ignored. Silently
392
+ * dropping one would mean returning a result that does not match what was
393
+ * asked for — which, for anything that changes which spans come back, is
394
+ * under-redaction wearing a plausible face.
395
+ */
396
+ async redactViaCloud(text, options) {
397
+ const { CloudClient } = await import("./cloud/client.js");
398
+ const countries = options.countries ?? null;
399
+ if (!countries || countries.length !== 1) {
400
+ throw new Error('cloud mode needs exactly one country, e.g. { countries: ["BE"] }. The ' +
401
+ "model is trained and evaluated per country, so a multi-country request " +
402
+ "has no defined behaviour.");
403
+ }
404
+ if (options.countryHint) {
405
+ throw new Error("countryHint is not supported in cloud mode");
406
+ }
407
+ if (options.context || options.chunkOffset) {
408
+ throw new Error("context/chunkOffset are not supported in cloud mode: the model has never " +
409
+ "seen a chunk boundary, so the service rejects oversized input rather " +
410
+ "than splitting it");
411
+ }
412
+ if (options.referentialIntegrity) {
413
+ throw new Error("referentialIntegrity is not supported in cloud mode");
414
+ }
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
+ const allowed = this.allowedFor(options);
421
+ 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 : {};
452
+ }
92
453
  redact(text, options = {}) {
93
454
  checkCountryArg(options.countries, "countries");
94
455
  checkCountryArg(options.countryHint, "countryHint");
456
+ checkLabelOptions(options);
457
+ const allowed = this.allowedFor(options);
458
+ const domains = this.domainsFor(options);
459
+ const requestedMode = options.mode ?? "rules";
460
+ if (requestedMode === "cloud") {
461
+ // Cannot be served synchronously: the cloud tier is a network call, and
462
+ // returning rules-only output instead would be the silent under-redaction
463
+ // this guard exists to prevent. Point at the async entry point rather
464
+ // than inventing a Promise-or-value union return type.
465
+ throw new Error('mode: "cloud" is asynchronous — use redactAsync(text, { mode: "cloud" }) ' +
466
+ "(or EuRedact#redactAsync). redact() is synchronous and can only serve " +
467
+ 'mode: "rules".');
468
+ }
469
+ if (requestedMode !== "rules") {
470
+ throw new Error(`unknown mode ${JSON.stringify(requestedMode)}: expected "rules" or "cloud"`);
471
+ }
95
472
  if (text.length > this.maxInputLength) {
96
473
  throw new Error(`Input text length (${text.length.toLocaleString()} chars) exceeds the maximum ` +
97
474
  `(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
98
475
  `increase maxInputLength when constructing EuRedact.`);
99
476
  }
100
- const { countries = null, countryHint = null, context = null, chunkOffset = 0, mode = "rules", referentialIntegrity = false, detectDates = false, } = options;
477
+ const { countries = null, countryHint = null, context = null, chunkOffset = 0, mode = "rules", referentialIntegrity = false, tokenize = false, detectDates = false, } = options;
101
478
  // A context makes the result depend on evidence from other chunks, so the
102
479
  // text no longer identifies the result. Caching is disabled rather than
103
480
  // keyed on the context, whose contents change as chunks arrive.
@@ -108,7 +485,14 @@ export class EuRedact {
108
485
  : ["ALL"];
109
486
  // countryHint changes attribution, so it must key the cache too.
110
487
  const hintKey = countryHint ? countryHint.map(c => c.toUpperCase()).sort().join(",") : "";
111
- const cacheMode = `${mode}|dates=${detectDates}|hint=${hintKey}`;
488
+ // referentialIntegrity changes the labels, not the spans, so a cached
489
+ // bracketed result is the wrong answer for a labelled call on the same
490
+ // text — it has to key the cache too.
491
+ // JSON rather than a joined string: an entry may itself contain the
492
+ // separator, and two different lists must never share a key.
493
+ const allowKey = (allowed.size ? JSON.stringify([...allowed.keys()].sort()) : "")
494
+ + (domains.size ? "|dom=" + JSON.stringify([...domains.keys()].sort()) : "");
495
+ const cacheMode = `${mode}|dates=${detectDates}|hint=${hintKey}|ri=${referentialIntegrity}|tok=${tokenize}|allow=${allowKey}`;
112
496
  let cacheKey;
113
497
  if (cache) {
114
498
  cacheKey = this.cache.key(normalizedText, countriesTuple, cacheMode);
@@ -130,33 +514,11 @@ export class EuRedact {
130
514
  if (!detectDates) {
131
515
  detections = detections.filter(d => !DATE_TYPES.has(d.entityType));
132
516
  }
517
+ let exempted;
518
+ [detections, exempted] = applyAllowlist(text, detections, allowed, domains);
133
519
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
134
- // Labels are resolved right-to-left because the referential mapper numbers
135
- // each entity type in call order, and that order is part of the output
136
- // contract. The string itself is then assembled in a single forward pass:
137
- // rebuilding it per detection copied the whole document each time, which is
138
- // O(document x detections) — 1.68 s of pure copying on a 1 MB document with
139
- // 15,000 detections, versus 2 ms here.
140
- const replacements = new Array(detections.length);
141
- for (let i = detections.length - 1; i >= 0; i--) {
142
- const det = detections[i];
143
- replacements[i] = referentialIntegrity
144
- ? this.referentialMapper.getLabel(det.text, det.entityType)
145
- : `[${det.entityType}]`;
146
- }
147
- const parts = [];
148
- let pos = 0;
149
- for (let i = 0; i < detections.length; i++) {
150
- const det = detections[i];
151
- // Spans reach here deduplicated and non-overlapping; one starting behind
152
- // the cursor would splice into the middle of a label, so drop it.
153
- if (det.start < pos)
154
- continue;
155
- parts.push(text.slice(pos, det.start), replacements[i]);
156
- pos = det.end;
157
- }
158
- parts.push(text.slice(pos));
159
- const redacted = parts.join("");
520
+ const tokenMapper = tokenize ? new TokenMapper(text, detections) : null;
521
+ const redacted = applyReplacements(text, detections, this.labelFor(referentialIntegrity, tokenMapper));
160
522
  // Report the inference so it can be audited. Spans in `evidence` are
161
523
  // offsets into the normalised text, matching `detections`.
162
524
  const inferredCountries = [...weightsToRanking(scores).entries()]
@@ -169,12 +531,27 @@ export class EuRedact {
169
531
  inferredCountries,
170
532
  evidence,
171
533
  detectionMode: countries && countries.length ? "declared" : "inferred",
534
+ tokens: tokenMapper ? tokenMapper.tokens : {},
535
+ exempted,
172
536
  };
173
537
  if (cache && cacheKey) {
174
538
  this.cache.put(cacheKey, result);
175
539
  }
176
540
  return result;
177
541
  }
542
+ /**
543
+ * Redact, awaiting the cloud tier when `mode: "cloud"` is asked for.
544
+ *
545
+ * `mode: "rules"` resolves immediately with exactly what `redact()` returns,
546
+ * so callers that may or may not use the cloud tier can hold one code path.
547
+ */
548
+ async redactAsync(text, options = {}) {
549
+ const mode = options.mode ?? "rules";
550
+ if (mode !== "cloud")
551
+ return this.redact(text, options);
552
+ checkLabelOptions(options);
553
+ return this.redactViaCloud(text, options);
554
+ }
178
555
  redactBatch(texts, options = {}) {
179
556
  // Generation is country-blind, so every country's patterns are loaded
180
557
  // regardless of what the caller declared.