@abhishekvardanbotta/mask 0.0.0-stage → 0.1.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.
@@ -0,0 +1,596 @@
1
+ import { deriveKeys, fingerprint, normalizeMasterKey, open, seal } from "./crypto.js";
2
+ import { runRecognizer } from "./detectors/recognizers.js";
3
+ import { asNode, inferFieldType, itemSchema, matchFieldRule, nodeAnnotation, propertySchema, typeFromKey } from "./fields.js";
4
+ import { compilePolicy, deniedReplacement, isGranted, mergePolicyConfig } from "./policy.js";
5
+ import { PrivacyError, } from "./types.js";
6
+ import { InMemoryVaultStore } from "./vault.js";
7
+ /** The token marker the model sees: `[EMAIL_1]` (sequential) or `[EMAIL_3f9a1c0b7e]` (opaque). */
8
+ const TOKEN_ID_LENGTH = 10;
9
+ const TOKEN_MARKER = /\[(?:[A-Z][A-Z0-9_]*_(?:[0-9a-f]{10}|[1-9]\d{0,5})|BLOCKED:[A-Z][A-Z0-9_]*|REDACTED:[A-Z][A-Z0-9_]*)\]/g;
10
+ /** Strict `[TYPE_id]` plus a bare `TYPE_id` (models sometimes drop the brackets); only tokens in this scope's vault are ever resolved. */
11
+ const TOKEN_REFERENCE = /(?<![A-Za-z0-9_])\[?([A-Z][A-Z0-9_]*_(?:[0-9a-f]{10}|[1-9]\d{0,5}))\]?(?![A-Za-z0-9_])/g;
12
+ const MAX_AUDIT_EVENTS = 5000;
13
+ const MAX_MEMO_ENTRIES = 2000;
14
+ /** Everything one scope knows: shared by every policy view of that scope. */
15
+ class ScopeState {
16
+ scope;
17
+ byToken = new Map();
18
+ byFingerprint = new Map();
19
+ /** Exact plaintexts already tokenized in this scope -> token, so later free-text mentions are caught too. */
20
+ known = new Map();
21
+ knownVersion = 0;
22
+ /** Last sequential id issued per type. */
23
+ sequence = new Map();
24
+ knownRegexCache;
25
+ pending = [];
26
+ audit = [];
27
+ blocked = 0;
28
+ restored = 0;
29
+ denied = 0;
30
+ lastUsed = Date.now();
31
+ constructor(scope) {
32
+ this.scope = scope;
33
+ }
34
+ record(event) {
35
+ if (this.audit.length >= MAX_AUDIT_EVENTS)
36
+ this.audit.shift();
37
+ this.audit.push({ at: new Date().toISOString(), ...event });
38
+ }
39
+ /** Two alternations over the known plaintexts: case-sensitive, and case-folded for emails. */
40
+ knownRegexes() {
41
+ if (this.knownRegexCache?.version === this.knownVersion)
42
+ return this.knownRegexCache;
43
+ const exact = [];
44
+ const folded = [];
45
+ const foldedMap = new Map();
46
+ for (const [value, token] of this.known) {
47
+ if (this.byToken.get(token)?.type === "EMAIL") {
48
+ folded.push(value);
49
+ foldedMap.set(value.toLowerCase(), token);
50
+ }
51
+ else
52
+ exact.push(value);
53
+ }
54
+ const build = (values, flags) => values.length
55
+ ? new RegExp(`(?<![\\p{L}\\p{N}_])(?:${values.sort((a, b) => b.length - a.length).map(escapeRegex).join("|")})(?![\\p{L}\\p{N}_])`, flags)
56
+ : undefined;
57
+ this.knownRegexCache = { version: this.knownVersion, exact: build(exact, "gu"), folded: build(folded, "giu"), foldedMap };
58
+ return this.knownRegexCache;
59
+ }
60
+ }
61
+ function escapeRegex(value) {
62
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
63
+ }
64
+ function normalizeForType(type, value) {
65
+ const trimmed = value.trim();
66
+ switch (type) {
67
+ case "EMAIL": return trimmed.toLowerCase();
68
+ case "PHONE": return (trimmed.startsWith("+") ? "+" : "") + trimmed.replace(/\D/g, "");
69
+ case "CREDIT_CARD":
70
+ case "US_SSN":
71
+ case "IN_AADHAAR":
72
+ case "CARD_SECURITY_CODE": return trimmed.replace(/\D/g, "");
73
+ case "IBAN":
74
+ case "IN_PAN": return trimmed.replace(/\s+/g, "").toUpperCase();
75
+ default: return trimmed.replace(/\s+/g, " ").toLowerCase();
76
+ }
77
+ }
78
+ /** Plaintexts shorter than this are not used for free-text matching: "Al" or "42" would match everywhere. */
79
+ function knownEligible(value) {
80
+ if (/^[\d\s().+-]+$/.test(value))
81
+ return value.replace(/\D/g, "").length >= 4;
82
+ return value.trim().length >= 3;
83
+ }
84
+ function isPlainObject(value) {
85
+ if (!value || typeof value !== "object")
86
+ return false;
87
+ const proto = Object.getPrototypeOf(value);
88
+ return proto === Object.prototype || proto === null;
89
+ }
90
+ /**
91
+ * The privacy engine: holds the keys, the vault store and the per-scope state. Create one per process (or per
92
+ * tenant, if tenants need separate keys) and open a session per conversation, run or request.
93
+ */
94
+ export class Privacy {
95
+ keys;
96
+ store;
97
+ retentionMs;
98
+ tokenStyle;
99
+ baseConfig;
100
+ basePolicy;
101
+ scopes = new Map();
102
+ hydrating = new Map();
103
+ constructor(options = {}) {
104
+ const { key, store, retentionMs, tokenStyle, ...policy } = options;
105
+ this.keys = deriveKeys(normalizeMasterKey(key));
106
+ this.store = store ?? new InMemoryVaultStore();
107
+ this.retentionMs = retentionMs ?? 60 * 60 * 1000;
108
+ if (tokenStyle !== undefined && tokenStyle !== "sequential" && tokenStyle !== "opaque")
109
+ throw new PrivacyError(`mask: tokenStyle must be "sequential" or "opaque".`, "config");
110
+ this.tokenStyle = tokenStyle ?? "sequential";
111
+ this.baseConfig = policy;
112
+ this.basePolicy = compilePolicy(policy);
113
+ }
114
+ /** Compiles a policy layered over this engine's own. Compile once and reuse it across sessions. */
115
+ policy(overrides) {
116
+ return overrides ? compilePolicy(mergePolicyConfig(this.baseConfig, overrides)) : this.basePolicy;
117
+ }
118
+ /**
119
+ * A session over `scope`, without loading previously persisted records. Tokens are deterministic within a scope
120
+ * and unlinkable across scopes. Use `open()` instead when the scope may have records from an earlier process.
121
+ */
122
+ session(scope, policy) {
123
+ this.sweep();
124
+ let state = this.scopes.get(scope);
125
+ if (!state) {
126
+ state = new ScopeState(scope);
127
+ this.scopes.set(scope, state);
128
+ }
129
+ return new PrivacySession(this, state, this.resolvePolicy(policy));
130
+ }
131
+ /** Like `session()`, but first loads and decrypts the scope's persisted records from the store. */
132
+ async open(scope, policy) {
133
+ this.sweep();
134
+ let state = this.scopes.get(scope);
135
+ if (!state) {
136
+ let loading = this.hydrating.get(scope);
137
+ if (!loading) {
138
+ loading = this.hydrate(scope).finally(() => this.hydrating.delete(scope));
139
+ this.hydrating.set(scope, loading);
140
+ }
141
+ state = await loading;
142
+ }
143
+ return new PrivacySession(this, state, this.resolvePolicy(policy));
144
+ }
145
+ /** Forgets a scope: drops its in-memory state and deletes its records from the store. Its tokens become unrestorable. */
146
+ async destroy(scope) {
147
+ const state = this.scopes.get(scope);
148
+ if (state)
149
+ await Promise.allSettled(state.pending);
150
+ this.scopes.delete(scope);
151
+ await this.store.deleteScope(scope);
152
+ }
153
+ /** Runs detection only — what would be found in `text`, with confidences — without tokenizing anything. */
154
+ detect(text, policy) {
155
+ return detectSync(text, this.resolvePolicy(policy), []).map(({ type, start, end, score }) => ({ type, start, end, score }));
156
+ }
157
+ /** @internal */
158
+ tokenFor(state, type, value, classification, kind) {
159
+ const fp = fingerprint(this.keys, state.scope, type, normalizeForType(type, value));
160
+ const existing = state.byFingerprint.get(fp);
161
+ if (existing) {
162
+ const entry = state.byToken.get(existing);
163
+ // The stricter classification wins: once any field marked this value SENSITIVE, it stays SENSITIVE.
164
+ if (classification === "SENSITIVE" && entry.classification === "PII") {
165
+ entry.classification = "SENSITIVE";
166
+ this.persist(state, entry);
167
+ }
168
+ this.remember(state, value, existing, type);
169
+ return existing;
170
+ }
171
+ let token;
172
+ if (this.tokenStyle === "sequential") {
173
+ let n = state.sequence.get(type) ?? 0;
174
+ do {
175
+ n += 1;
176
+ token = `${type}_${n}`;
177
+ } while (state.byToken.has(token));
178
+ state.sequence.set(type, n);
179
+ }
180
+ else {
181
+ let offset = 0;
182
+ token = `${type}_${fp.slice(0, TOKEN_ID_LENGTH)}`;
183
+ while (state.byToken.has(token)) {
184
+ offset += 1;
185
+ token = `${type}_${fp.slice(offset, offset + TOKEN_ID_LENGTH)}`;
186
+ }
187
+ }
188
+ const entry = { token, type, classification, value, kind, fp };
189
+ state.byToken.set(token, entry);
190
+ state.byFingerprint.set(fp, token);
191
+ state.record({ action: "tokenize", type, classification, token });
192
+ this.persist(state, entry);
193
+ this.remember(state, value, token, type);
194
+ if (type === "PERSON" && /\s/.test(value.trim())) {
195
+ // "Jane Doe" also makes "Jane" and "Doe" recognizable later on their own.
196
+ for (const part of value.trim().split(/\s+/))
197
+ if (part.length >= 3 && /^\p{Lu}/u.test(part))
198
+ this.tokenFor(state, "PERSON", part, classification, "string");
199
+ }
200
+ return token;
201
+ }
202
+ remember(state, value, token, type) {
203
+ const key = type === "EMAIL" ? value.trim().toLowerCase() : value.trim();
204
+ if (!knownEligible(key) || state.known.get(key) === token)
205
+ return;
206
+ state.known.set(key, token);
207
+ state.knownVersion += 1;
208
+ }
209
+ persist(state, entry) {
210
+ const sealed = seal(this.keys, JSON.stringify({ v: entry.value }), `${state.scope}\u0000${entry.token}`);
211
+ const now = Date.now();
212
+ const record = { token: entry.token, type: entry.type, classification: entry.classification, kind: entry.kind, fp: entry.fp, createdAt: now, expiresAt: now + this.retentionMs, ...sealed };
213
+ try {
214
+ const result = this.store.put(state.scope, record);
215
+ if (result)
216
+ state.pending.push(result.catch((error) => { throw new PrivacyError(`mask: vault write failed: ${error?.message ?? error}`, "vault"); }));
217
+ }
218
+ catch (error) {
219
+ state.pending.push(Promise.reject(new PrivacyError(`mask: vault write failed: ${error?.message ?? error}`, "vault")));
220
+ }
221
+ }
222
+ async hydrate(scope) {
223
+ const state = new ScopeState(scope);
224
+ for (const record of await this.store.list(scope)) {
225
+ const { v } = JSON.parse(open(this.keys, record, `${scope}\u0000${record.token}`));
226
+ const entry = { token: record.token, type: record.type, classification: record.classification, value: v, kind: record.kind, fp: record.fp };
227
+ state.byToken.set(entry.token, entry);
228
+ state.byFingerprint.set(entry.fp, entry.token);
229
+ const seq = /_([1-9]\d{0,5})$/.exec(entry.token);
230
+ if (seq && entry.token === `${entry.type}_${seq[1]}`)
231
+ state.sequence.set(entry.type, Math.max(state.sequence.get(entry.type) ?? 0, Number(seq[1])));
232
+ this.remember(state, v, entry.token, entry.type);
233
+ }
234
+ this.scopes.set(scope, state);
235
+ return state;
236
+ }
237
+ resolvePolicy(policy) {
238
+ if (!policy)
239
+ return this.basePolicy;
240
+ return typeof policy.classify === "function" ? policy : this.policy(policy);
241
+ }
242
+ /** Drops in-memory scopes idle for longer than the retention period. Their store records expire on the same clock. */
243
+ sweep() {
244
+ const cutoff = Date.now() - this.retentionMs;
245
+ for (const [scope, state] of this.scopes)
246
+ if (state.lastUsed < cutoff)
247
+ this.scopes.delete(scope);
248
+ }
249
+ }
250
+ function detectSync(text, policy, extra, state) {
251
+ const protectedSpans = [];
252
+ for (const match of text.matchAll(TOKEN_MARKER))
253
+ protectedSpans.push([match.index, match.index + match[0].length]);
254
+ const candidates = [...extra];
255
+ for (const recognizer of policy.recognizers)
256
+ candidates.push(...runRecognizer(recognizer, text));
257
+ for (const detector of policy.detectors) {
258
+ const result = detector.detect(text);
259
+ if (Array.isArray(result))
260
+ candidates.push(...result);
261
+ // A promise means an asynchronous detector: it ran in protect()'s pre-pass and arrives via `extra`.
262
+ }
263
+ if (state?.known.size) {
264
+ const { exact, folded, foldedMap } = state.knownRegexes();
265
+ if (exact)
266
+ for (const m of text.matchAll(exact)) {
267
+ const token = state.known.get(m[0]);
268
+ if (token)
269
+ candidates.push({ type: state.byToken.get(token).type, start: m.index, end: m.index + m[0].length, score: 1, token });
270
+ }
271
+ if (folded)
272
+ for (const m of text.matchAll(folded)) {
273
+ const token = foldedMap.get(m[0].toLowerCase());
274
+ if (token)
275
+ candidates.push({ type: "EMAIL", start: m.index, end: m.index + m[0].length, score: 1, token });
276
+ }
277
+ }
278
+ // One occupancy map for existing tokens and chosen spans: overlap checks cost O(span length), not O(detections),
279
+ // which kept large inputs (thousands of detections) from going quadratic.
280
+ const occupied = new Uint8Array(text.length);
281
+ for (const [s, e] of protectedSpans)
282
+ occupied.fill(1, s, e);
283
+ const free = (d) => { for (let i = d.start; i < d.end; i++)
284
+ if (occupied[i])
285
+ return false; return true; };
286
+ const eligible = candidates.filter((d) => d.end > d.start && d.end <= text.length && d.score >= policy.threshold &&
287
+ (d.token !== undefined || policy.classify(d.type) !== "PUBLIC") &&
288
+ !policy.allowlisted(text.slice(d.start, d.end)));
289
+ // Highest confidence first, then the longest span; keep each one that overlaps neither a token nor a chosen span.
290
+ eligible.sort((a, b) => b.score - a.score || (b.end - b.start) - (a.end - a.start) || a.start - b.start);
291
+ const chosen = [];
292
+ for (const d of eligible)
293
+ if (free(d)) {
294
+ chosen.push(d);
295
+ occupied.fill(1, d.start, d.end);
296
+ }
297
+ return chosen.sort((a, b) => a.start - b.start);
298
+ }
299
+ /**
300
+ * A view over one scope, under one policy. `protect` replaces personal data with tokens before data leaves your
301
+ * trust boundary; `restore` puts it back, for exactly the types the principal is granted.
302
+ */
303
+ export class PrivacySession {
304
+ engine;
305
+ state;
306
+ policy;
307
+ memo = new Map();
308
+ /** @internal — use `Privacy.session()` / `Privacy.open()`. */
309
+ constructor(engine, state, policy) {
310
+ this.engine = engine;
311
+ this.state = state;
312
+ this.policy = policy;
313
+ }
314
+ get scope() {
315
+ return this.state.scope;
316
+ }
317
+ /**
318
+ * Replaces personal data in `value` (a string, or any JSON-shaped value) with vault tokens. Field classifications
319
+ * come from `options.schema`, the policy's `fields`, and key names; free text runs through every detector,
320
+ * including asynchronous ones. Idempotent: protecting already-protected data changes nothing.
321
+ */
322
+ async protect(value, options = {}) {
323
+ const asyncDetectors = this.policy.detectors;
324
+ let extras;
325
+ if (asyncDetectors.length) {
326
+ const strings = new Set();
327
+ collectStrings(value, strings);
328
+ extras = new Map();
329
+ await Promise.all([...strings].map(async (text) => {
330
+ const found = [];
331
+ for (const detector of asyncDetectors) {
332
+ const result = detector.detect(text);
333
+ if (!Array.isArray(result))
334
+ found.push(...(await result));
335
+ }
336
+ if (found.length)
337
+ extras.set(text, found);
338
+ }));
339
+ }
340
+ return this.walk(value, [], asNode(options.schema), asNode(options.schema), undefined, undefined, extras);
341
+ }
342
+ /** `protect()` without asynchronous detectors — for hot paths such as log lines. Values they found earlier in this scope are still caught. */
343
+ protectSync(value, options = {}) {
344
+ return this.walk(value, [], asNode(options.schema), asNode(options.schema), undefined, undefined, undefined);
345
+ }
346
+ /**
347
+ * Replaces tokens in `value` with the original values the principal may see. Unknown tokens (another scope's, or
348
+ * made up by a model) are left exactly as they are. BLOCKed values were never stored and cannot come back.
349
+ */
350
+ restore(value, options = {}) {
351
+ this.state.lastUsed = Date.now();
352
+ const seen = new Set();
353
+ const result = this.restoreValue(value, options, seen);
354
+ for (const token of seen) {
355
+ const entry = this.state.byToken.get(token);
356
+ const allowed = options.trusted === true || isGranted(options.allow, entry.type, entry.classification);
357
+ if (allowed)
358
+ this.state.restored += 1;
359
+ else
360
+ this.state.denied += 1;
361
+ this.state.record({ action: allowed ? "restore" : "deny", type: entry.type, classification: entry.classification, principal: options.principal, token });
362
+ }
363
+ return result;
364
+ }
365
+ /** Whether `text` contains a token issued in this scope. */
366
+ hasTokens(text) {
367
+ for (const match of text.matchAll(TOKEN_REFERENCE))
368
+ if (this.state.byToken.has(match[1]))
369
+ return true;
370
+ return false;
371
+ }
372
+ /** Restores a streamed response chunk by chunk, holding back a possible partial token at each chunk's end. */
373
+ streamRestorer(options = {}) {
374
+ return new StreamRestorer((text) => this.restore(text, options));
375
+ }
376
+ /** Waits for vault writes to reach the store; rejects if any failed. */
377
+ async flush() {
378
+ const pending = this.state.pending.splice(0);
379
+ const results = await Promise.allSettled(pending);
380
+ const failed = results.find((r) => r.status === "rejected");
381
+ if (failed)
382
+ throw failed.reason;
383
+ }
384
+ summary() {
385
+ const types = new Set();
386
+ for (const entry of this.state.byToken.values())
387
+ types.add(entry.type);
388
+ for (const event of this.state.audit)
389
+ if (event.action === "block" && event.type)
390
+ types.add(event.type);
391
+ return { tokenized: this.state.byToken.size, blocked: this.state.blocked, restored: this.state.restored, denied: this.state.denied, types: [...types].sort() };
392
+ }
393
+ /** What happened in this scope — tokenizations, blocks, restorations, denials. Holds tokens and types, never plaintext. */
394
+ audit() {
395
+ return [...this.state.audit];
396
+ }
397
+ // ---------------------------------------------------------------------------------------------------------------
398
+ walk(value, path, root, node, inherited, parentKey, extras) {
399
+ this.state.lastUsed = Date.now();
400
+ if (typeof value === "string")
401
+ return inherited ? this.whole(value, inherited, "string") : this.protectText(value, extras?.get(value));
402
+ if (typeof value === "number") {
403
+ if (inherited && Number.isFinite(value))
404
+ return this.whole(String(value), inherited, "number");
405
+ if (Number.isInteger(value) && Math.abs(value) >= 1_000_000) {
406
+ // Long integers can be card numbers, phone numbers or IDs stored numerically: only a detection that covers
407
+ // the whole number counts.
408
+ const text = String(value);
409
+ const hit = detectSync(text, this.policy, [], this.state).find((d) => d.start === 0 && d.end === text.length);
410
+ if (hit)
411
+ return this.whole(text, { classification: this.classificationOf(hit), type: hit.type }, "number");
412
+ }
413
+ return value;
414
+ }
415
+ if (value === null || typeof value !== "object")
416
+ return value;
417
+ if (value instanceof Date)
418
+ return value;
419
+ if (Array.isArray(value))
420
+ return value.map((item, index) => this.walk(item, path, root, root && itemSchema(root, node, index), inherited, parentKey, extras));
421
+ const source = isPlainObject(value) ? value : (typeof value.toJSON === "function" ? value.toJSON() : { ...value });
422
+ if (!isPlainObject(source))
423
+ return this.walk(source, path, root, node, inherited, parentKey, extras);
424
+ const out = {};
425
+ for (const [key, child] of Object.entries(source)) {
426
+ const childPath = [...path, key];
427
+ const childNode = root ? propertySchema(root, node, key) : undefined;
428
+ const fieldClass = inherited ?? this.fieldClass(key, childPath, root, childNode, path[path.length - 1] ?? parentKey);
429
+ const outKey = inherited || fieldClass ? key : this.protectText(key, extras?.get(key));
430
+ out[outKey] = fieldClass?.classification === "PUBLIC" ? child : this.walk(child, childPath, root, childNode, fieldClass, key, extras);
431
+ }
432
+ return out;
433
+ }
434
+ fieldClass(key, path, root, node, parentKey) {
435
+ const inferred = () => inferFieldType(key, parentKey);
436
+ const rule = matchFieldRule(this.policy.fieldRules, path);
437
+ if (rule)
438
+ return { classification: rule.classification, type: rule.type ?? inferred() ?? typeFromKey(key) };
439
+ const annotation = root ? nodeAnnotation(root, node) : undefined;
440
+ if (annotation?.classification)
441
+ return { classification: annotation.classification, type: annotation.type ?? inferred() ?? typeFromKey(key) };
442
+ if (annotation?.type)
443
+ return { classification: this.policy.classify(annotation.type), type: annotation.type };
444
+ if (this.policy.inferFromFieldNames) {
445
+ const type = inferred();
446
+ if (type)
447
+ return { classification: this.policy.classify(type), type };
448
+ }
449
+ return undefined;
450
+ }
451
+ classificationOf(detection) {
452
+ return detection.token ? this.state.byToken.get(detection.token).classification : this.policy.classify(detection.type);
453
+ }
454
+ /** Tokenizes a field's entire value as one entity. */
455
+ whole(value, fieldClass, kind) {
456
+ const type = fieldClass.type ?? "FIELD";
457
+ if (fieldClass.classification === "PUBLIC" || value.trim() === "")
458
+ return kind === "number" ? Number(value) : value;
459
+ if (kind === "string" && isMarker(value))
460
+ return value;
461
+ if (this.policy.allowlisted(value))
462
+ return kind === "number" ? Number(value) : value;
463
+ if (fieldClass.classification === "BLOCK")
464
+ return this.block(type);
465
+ return `[${this.engine.tokenFor(this.state, type, value, fieldClass.classification, kind)}]`;
466
+ }
467
+ block(type) {
468
+ if (this.policy.onBlock === "throw")
469
+ throw new PrivacyError(`mask: input contains ${type}, which the policy blocks.`, "blocked");
470
+ this.state.blocked += 1;
471
+ this.state.record({ action: "block", type, classification: "BLOCK" });
472
+ return `[BLOCKED:${type}]`;
473
+ }
474
+ protectText(text, extra) {
475
+ if (text.length < 3)
476
+ return text;
477
+ if (!extra) {
478
+ const cached = this.memo.get(text);
479
+ if (cached && cached.version === this.state.knownVersion)
480
+ return cached.output;
481
+ }
482
+ const detections = detectSync(text, this.policy, extra ?? [], this.state);
483
+ let output = "";
484
+ let cursor = 0;
485
+ for (const d of detections) {
486
+ output += text.slice(cursor, d.start);
487
+ const original = text.slice(d.start, d.end);
488
+ if (d.token)
489
+ output += `[${d.token}]`;
490
+ else {
491
+ const classification = this.policy.classify(d.type);
492
+ output += classification === "BLOCK" ? this.block(d.type) : `[${this.engine.tokenFor(this.state, d.type, original, classification, "string")}]`;
493
+ }
494
+ cursor = d.end;
495
+ }
496
+ output += text.slice(cursor);
497
+ if (!extra) {
498
+ if (this.memo.size >= MAX_MEMO_ENTRIES)
499
+ this.memo.clear();
500
+ this.memo.set(text, { version: this.state.knownVersion, output });
501
+ }
502
+ return output;
503
+ }
504
+ restoreValue(value, options, seen) {
505
+ if (typeof value === "string")
506
+ return this.restoreText(value, options, seen);
507
+ if (value === null || typeof value !== "object" || value instanceof Date)
508
+ return value;
509
+ if (Array.isArray(value))
510
+ return value.map((item) => this.restoreValue(item, options, seen));
511
+ if (!isPlainObject(value))
512
+ return value;
513
+ const out = {};
514
+ for (const [key, child] of Object.entries(value))
515
+ out[String(this.restoreText(key, options, seen))] = this.restoreValue(child, options, seen);
516
+ return out;
517
+ }
518
+ restoreText(text, options, seen) {
519
+ if (!text.includes("_"))
520
+ return text;
521
+ const mode = options.onDenied ?? "redact";
522
+ let wholeNumber;
523
+ const restored = text.replace(TOKEN_REFERENCE, (match, token) => {
524
+ const entry = this.state.byToken.get(token);
525
+ if (!entry)
526
+ return match;
527
+ seen.add(token);
528
+ const allowed = options.trusted === true || isGranted(options.allow, entry.type, entry.classification);
529
+ if (!allowed)
530
+ return deniedReplacement(mode, entry.type, entry.value, entry.token);
531
+ if (entry.kind === "number" && match.length === text.length)
532
+ wholeNumber = Number(entry.value);
533
+ return entry.value;
534
+ });
535
+ return wholeNumber ?? restored;
536
+ }
537
+ }
538
+ function isMarker(value) {
539
+ const match = value.match(TOKEN_MARKER);
540
+ return match?.length === 1 && match[0] === value;
541
+ }
542
+ function collectStrings(value, into, depth = 0) {
543
+ if (depth > 64)
544
+ return;
545
+ if (typeof value === "string") {
546
+ if (value.length >= 3)
547
+ into.add(value);
548
+ return;
549
+ }
550
+ if (!value || typeof value !== "object")
551
+ return;
552
+ if (Array.isArray(value)) {
553
+ for (const item of value)
554
+ collectStrings(item, into, depth + 1);
555
+ return;
556
+ }
557
+ for (const [key, child] of Object.entries(value)) {
558
+ into.add(key);
559
+ collectStrings(child, into, depth + 1);
560
+ }
561
+ }
562
+ /** Longest possible token reference, `[` + type + `_` + id + `]`, with headroom for long custom type names. */
563
+ const MAX_TOKEN_LENGTH = 72;
564
+ /**
565
+ * Restores a stream without ever emitting half a token: text after an unclosed `[`, or a trailing run of token
566
+ * characters, is held back until the next chunk (or `flush()`) decides it.
567
+ */
568
+ export class StreamRestorer {
569
+ restoreText;
570
+ buffer = "";
571
+ constructor(restoreText) {
572
+ this.restoreText = restoreText;
573
+ }
574
+ push(delta) {
575
+ this.buffer += delta;
576
+ let cut = this.buffer.length;
577
+ const open = this.buffer.lastIndexOf("[");
578
+ if (open !== -1 && !this.buffer.includes("]", open) && this.buffer.length - open <= MAX_TOKEN_LENGTH)
579
+ cut = open;
580
+ const tail = /[A-Za-z0-9_]{1,64}$/.exec(this.buffer.slice(0, cut));
581
+ if (tail)
582
+ cut = tail.index;
583
+ const ready = this.buffer.slice(0, cut);
584
+ this.buffer = this.buffer.slice(cut);
585
+ return ready ? this.restoreText(ready) : "";
586
+ }
587
+ flush() {
588
+ const rest = this.buffer;
589
+ this.buffer = "";
590
+ return rest ? this.restoreText(rest) : "";
591
+ }
592
+ }
593
+ /** Creates a privacy engine. See `PrivacyOptions`. */
594
+ export function createPrivacy(options = {}) {
595
+ return new Privacy(options);
596
+ }