@privacyscrubber/sdk 2.2.1 → 2.2.2

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/index.js CHANGED
@@ -43,7 +43,12 @@ export const DEVOPS_SECRETS_DETECTOR = [
43
43
  { name: 'Stripe API Key', regex: /\b(?:[rs]k)_(?:test|live)_[a-zA-Z0-9]{24,}\b/g, label: 'STRIPE_KEY' },
44
44
  { name: 'OpenAI Project API Key', regex: /\b(?:sk|pk)-(?:proj-)?[a-zA-Z0-9_-]{16,}\b/gi, label: 'OPENAI_KEY' },
45
45
  { name: 'Database Connection URI', regex: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\/[^\s"']+/gi, label: 'DB_URI' },
46
- { name: 'Database/API Secret', regex: /\b(DB|POSTGRES|REDIS|MYSQL|AWS|SECRET|PASSWORD|TOKEN|API|KEY)[A-Z0-9_]*\s*[:=]\s*[^ \t\r\n"']{8,}\b/gi, label: 'SECRET' }
46
+ { name: 'Embedded Credentials URI', regex: /\b(?:https?|s?ftp):\/\/[a-zA-Z0-9_.%+-]+:[^\s/@:"']+@[a-zA-Z0-9_.-]+(?::\d+)?(?:\/[^\s"']*)?/gi, label: 'SECRET' },
47
+ { name: 'URL Query Parameter Secret', regex: /(?<=[?&](?:api_key|apikey|access_token|client_secret|auth_token|token|secret|password|private_key)=)[^\s"'\x26#)>]{8,}/gi, label: 'SECRET' },
48
+ { name: 'Bearer / Auth Header Token', regex: /(?<=\b(?:Bearer|Token)\s+)[a-zA-Z0-9_\-\.~+/=]{16,}\b/gi, label: 'SECRET' },
49
+ { name: 'Database/API Secret', regex: /\b(DB|POSTGRES|REDIS|MYSQL|AWS|SECRET|PASSWORD|TOKEN|API|KEY)[A-Z0-9_]*\s*[:=]\s*[^ \t\r\n"']{8,}\b/gi, label: 'SECRET' },
50
+ { name: 'Private Cryptographic Key (PEM Block)', regex: /-----BEGIN (?:RSA |EC |PGP |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----[\s\S]{10,8192}?-----END (?:RSA |EC |PGP |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g, label: 'SECRET' },
51
+ { name: 'Private Cryptographic Key (Header)', regex: /-----BEGIN (?:RSA |EC |PGP |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g, label: 'SECRET' }
47
52
  ];
48
53
 
49
54
  /**
@@ -198,6 +203,160 @@ export function _resetCommunityNoticeForTesting() {
198
203
  communityNoticeLogged = false;
199
204
  }
200
205
 
206
+ let devSecretNoticeLogged = false;
207
+
208
+ function emitDevSecretNoticeIfNeeded(isPro, tokenMap = {}, limit = 15000) {
209
+ if (devSecretNoticeLogged || isPro) return;
210
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'production') return;
211
+
212
+ const tokens = Object.keys(tokenMap || {});
213
+ const secretTokens = tokens.filter(t => {
214
+ const upper = t.toUpperCase();
215
+ return ['API_KEY', 'SECRET', 'PASSWORD', 'KEY', 'AWS_KEY', 'STRIPE_KEY', 'JWT_TOKEN', 'API_TOKEN', 'TOKEN'].some(k => upper.includes(k));
216
+ });
217
+
218
+ if (secretTokens.length > 0) {
219
+ devSecretNoticeLogged = true;
220
+ const count = secretTokens.length;
221
+ const secretSample = secretTokens.slice(0, 2).join(', ');
222
+ if (typeof process !== 'undefined' && process.stderr && typeof process.stderr.write === 'function') {
223
+ process.stderr.write(
224
+ `\n[PrivacyScrubber] Intercepted ${count} API secret(s) before LLM egress: ${secretSample}\n` +
225
+ `[PrivacyScrubber] Protected in local RAM (0 byte network egress). Free Tier active (${limit.toLocaleString()} char cap).\n` +
226
+ `[PrivacyScrubber] Developer SDK ($199/mo flat) for unlimited backend nodes, CI/CD gates & ETL pipelines: https://privacyscrubber.com/pricing?tier=sdk&src=cli_leak_guard\n\n`
227
+ );
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Resets the dev secret notice state for testing purposes.
234
+ * @private
235
+ */
236
+ export function _resetDevSecretNoticeForTesting() {
237
+ devSecretNoticeLogged = false;
238
+ }
239
+
240
+ /**
241
+ * Generates an executive CISO & Legal Procurement Memo for enterprise approval of @privacyscrubber/sdk.
242
+ *
243
+ * @param {object} [options={}] - Custom memo configuration options.
244
+ * @param {string} [options.appName='Internal AI Applications & LLM Pipelines'] - Target system name.
245
+ * @param {string} [options.developer='Engineering / AI Platform Team'] - Authoring team name.
246
+ * @param {'markdown'|'text'} [options.format='markdown'] - Output format ('markdown' or 'text').
247
+ * @param {number} [options.teamSize=10] - Size of engineering team.
248
+ * @param {string} [options.estimatedVolume='50M - 500M characters/month'] - Projected token volume.
249
+ * @returns {string}
250
+ */
251
+ export function generateProcurementMemo(options = {}) {
252
+ const {
253
+ appName = 'Internal AI Applications & LLM Pipelines',
254
+ developer = 'Engineering / AI Platform Team',
255
+ format = 'markdown',
256
+ teamSize = 10,
257
+ estimatedVolume = '50M - 500M characters/month'
258
+ } = options;
259
+
260
+ if (format === 'text') {
261
+ return `MEMORANDUM: ADOPTION OF PRIVACYSCRUBBER DEVELOPER SDK (ZTDS STANDARD)
262
+
263
+ TO: Chief Information Security Officer (CISO), Data Protection Officer (DPO), VP Engineering
264
+ FROM: ${developer}
265
+ SUBJECT: Approval Request: Zero-Trust Data Sanitization (@privacyscrubber/sdk)
266
+ TARGET SYSTEM: ${appName}
267
+ ESTIMATED VOLUME: ${estimatedVolume}
268
+
269
+ 1. EXECUTIVE SUMMARY
270
+ We propose standardizing on the PrivacyScrubber Developer SDK (@privacyscrubber/sdk) for in-memory Zero-Trust Data Sanitization across our AI ingestion pipelines. The SDK intercepts and redacts PII, regulatory identifiers, and cloud secrets locally in RAM before any prompt leaves our infrastructure.
271
+
272
+ 2. ZERO-TRUST ARCHITECTURAL INVARIANTS
273
+ * Zero Network Egress: 100% local in-memory processing. Zero external API calls, zero telemetry egress, zero third-party sub-processors.
274
+ * In-Memory Volatility: All masking maps remain in volatile process RAM and are discarded after request completion.
275
+ * Sub-Millisecond Latency: Executes in <0.1ms per prompt in local RAM, eliminating latency overhead.
276
+
277
+ 3. COMPLIANCE & REGULATORY COVERAGE
278
+ Pre-configured for 25 specialized industry profiles and compliance frameworks:
279
+ * GDPR (Art. 4, 9, 32) & CCPA/CPRA: Ephemeral tokenization.
280
+ * HIPAA (§164.514 Safe Harbor): MRNs, medical record codes, patient identifiers.
281
+ * SOC 2 Type II & ISO 27001 (A.8.11): Zero persistent unencrypted PII.
282
+ * PCI DSS v4.0: PAN, CVV, IBAN, bank ledger sanitization.
283
+ * NIST SP 800-53: Cloud API keys, JWT tokens, AWS/Stripe secret leak protection.
284
+
285
+ 4. TOTAL COST OF OWNERSHIP (TCO) COMPARISON
286
+ * Cloud DLP Proxies (AWS Macie, GCP DLP, Nightfall):
287
+ - Cost: $1,500 - $4,000 / month (usage-based billing).
288
+ - Latency: +200ms - 400ms added network roundtrip per AI prompt.
289
+ - Sub-processor Risk: Requires vendor DPA, external network egress.
290
+ * PrivacyScrubber Developer SDK:
291
+ - Cost: $199 / month flat (or $1,990 / year) for unlimited nodes, microservices, and prompts.
292
+ - Latency: <0.1ms (in-process RAM).
293
+ - Sub-processor Risk: Zero (pure in-memory software library).
294
+
295
+ 5. ACTION REQUESTED
296
+ Approve commercial license upgrade for ${developer} (${teamSize} engineers, unlimited backend nodes):
297
+ * License Tier: Developer SDK ($199/month flat or $1,990/year)
298
+ * Direct Procurement & License Activation: https://privacyscrubber.com/pricing?tier=sdk&src=ciso_memo
299
+ * Enterprise / Invoicing Contact: enterprise@privacyscrubber.com
300
+ `;
301
+ }
302
+
303
+ return `# Internal Memo: Adoption of PrivacyScrubber Developer SDK (ZTDS Standard)
304
+
305
+ | **To** | Chief Information Security Officer (CISO), Data Protection Officer (DPO), VP Engineering |
306
+ | :--- | :--- |
307
+ | **From** | ${developer} |
308
+ | **Date** | ${new Date().toISOString().split('T')[0]} |
309
+ | **Subject** | Architectural Approval & License Procurement for Zero-Trust AI Sanitization |
310
+ | **Target System** | ${appName} |
311
+ | **Estimated Scope** | ~${teamSize} engineers / ${estimatedVolume} |
312
+
313
+ ---
314
+
315
+ ### 1. Executive Summary
316
+ We recommend adopting the **PrivacyScrubber Developer SDK** (\`@privacyscrubber/sdk\`) to enforce **Zero-Trust Data Sanitization (ZTDS)** across all internal AI pipelines, LangChain agents, microservices, and developer toolchains.
317
+
318
+ The SDK redacts PII, regulatory identifiers, and developer secrets **in-process before external API egress to OpenAI, Anthropic, or external LLM providers**, and deterministically restores original values upon response return.
319
+
320
+ ---
321
+
322
+ ### 2. Architectural Invariants (Zero-Trust Data Sanitization)
323
+ * **Zero Remote Network Egress:** 100% of sanitization and token restoration executes in local Node.js process RAM. No prompt data or telemetry ever leaves our VPC/infrastructure.
324
+ * **Ephemeral In-Memory Tokens:** Masking mappings (\`[EMAIL_1]\`, \`[NAME_1]\`, \`[SECRET_1]\`) are volatile and strictly scoped to request execution context.
325
+ * **Ultra-Low Latency:** Micro-benchmarks verify <0.1ms execution overhead per prompt, compared to 200–400ms for network-based cloud DLP proxies.
326
+ * **Full Reversibility:** Bidirectional round-trip reconstruction ensures downstream application logic receives intact responses without exposing sensitive data to model providers.
327
+
328
+ ---
329
+
330
+ ### 3. Compliance Framework Mapping
331
+ The SDK enforces out-of-the-box sanitization matching mandatory compliance controls:
332
+ * **GDPR (Regulation (EU) 2016/679) & CCPA/CPRA:** Eliminates cross-border PII transfer risk to third-party AI models.
333
+ * **HIPAA (§164.514 Safe Harbor):** Automated scrubbing of Medical Record Numbers (MRNs), patient identifiers, and health insurance IDs.
334
+ * **SOC 2 Type II & ISO 27001 (Control A.8.11):** Enforces data masking and prevents unauthorized logging of confidential client data.
335
+ * **PCI DSS v4.0:** Sanitization of payment cards, IBANs, and financial ledger data.
336
+ * **NIST SP 800-53:** DevOps secret interception for AWS keys, JWT tokens, database URIs, and private API keys.
337
+
338
+ ---
339
+
340
+ ### 4. Financial & Architectural Comparison (TCO)
341
+
342
+ | Evaluation Factor | Traditional Cloud DLP (AWS/GCP/Nightfall) | PrivacyScrubber Developer SDK |
343
+ | :--- | :--- | :--- |
344
+ | **Pricing Model** | \$1,500 – \$4,000 / month (metered per GB/token) | **\$199 / month flat** (unlimited nodes & tokens) |
345
+ | **Network Egress** | Requires outbound network calls to DLP vendor | **0 bytes** (100% local Node.js process memory) |
346
+ | **Added Latency** | +200ms – +450ms network round-trip | **<0.1ms** (in-memory regular expressions) |
347
+ | **Vendor Sub-processors** | Third-party cloud vendor added to vendor list | **None** (zero-network software library) |
348
+ | **Deployment** | Complex API gateways and proxy configurations | \`npm install @privacyscrubber/sdk\` in 3 minutes |
349
+
350
+ ---
351
+
352
+ ### 5. Procurement Recommendation
353
+ We request approval to acquire a **Developer SDK License** (\$199/month flat or \$1,990/year) to unlock unlimited throughput across all microservices and CI/CD pipelines.
354
+
355
+ * **Direct Procurement Portal:** [https://privacyscrubber.com/pricing?tier=sdk&src=ciso_memo](https://privacyscrubber.com/pricing?tier=sdk&src=ciso_memo)
356
+ * **Procurement / Invoicing Contact:** \`enterprise@privacyscrubber.com\`
357
+ `;
358
+ }
359
+
201
360
  /**
202
361
  * Sanitizes input text, masking detected PII & secrets with reversible tokens.
203
362
  *
@@ -210,6 +369,7 @@ export function _resetCommunityNoticeForTesting() {
210
369
  * @param {Record<string, string>} [options.existingSessionMap={}] - In-memory session map to preserve token IDs across turns.
211
370
  * @param {boolean} [options.generateReceipt=true] - Generate a CISO Markdown audit receipt.
212
371
  * @param {boolean} [options.throwOnLimit=true] - Throw PrivacyScrubberLicenseError when free tier limit is hit.
372
+ * @param {'throw'|'truncate'} [options.onLimit='throw'] - Action when character limit exceeded ('throw' or 'truncate').
213
373
  * @param {boolean} [options.detectSecrets=false] - Auto-detect cloud credentials and API keys.
214
374
  * @param {Set<string>|Array<string>} [options.ignoreList=null] - Values to preserve in cleartext.
215
375
  * @returns {{
@@ -217,7 +377,7 @@ export function _resetCommunityNoticeForTesting() {
217
377
  * tokenMap: Record<string, string>,
218
378
  * count: number,
219
379
  * executionMs: number,
220
- * license: { isPro: boolean, tier: 'PRO'|'TEAMS'|'FREE', limit: number, upgradeUrl?: string },
380
+ * license: { isPro: boolean, tier: 'PRO'|'TEAMS'|'FREE', limit: number, wasTruncated: boolean, upgradeUrl?: string, recommendation?: string },
221
381
  * telemetry: object,
222
382
  * auditReceipt: string
223
383
  * }}
@@ -229,7 +389,7 @@ export function sanitize(text, options = {}) {
229
389
  tokenMap: {},
230
390
  count: 0,
231
391
  executionMs: 0,
232
- license: { isPro: false, tier: 'FREE', limit: 15000 },
392
+ license: { isPro: false, tier: 'FREE', limit: 15000, wasTruncated: false },
233
393
  telemetry: buildAuditTelemetry({}),
234
394
  auditReceipt: formatAuditReceipt(buildAuditTelemetry({}))
235
395
  };
@@ -243,6 +403,7 @@ export function sanitize(text, options = {}) {
243
403
  existingSessionMap = {},
244
404
  generateReceipt = true,
245
405
  throwOnLimit = true,
406
+ onLimit = 'throw',
246
407
  detectSecrets = false,
247
408
  ignoreList = null
248
409
  } = options;
@@ -256,9 +417,12 @@ export function sanitize(text, options = {}) {
256
417
  const isSpecialized = profile && profile.toLowerCase() !== 'general';
257
418
  const limit = isPro ? Infinity : (isSpecialized ? 5000 : 15000);
258
419
 
420
+ const shouldThrow = throwOnLimit && onLimit !== 'truncate';
421
+ let wasTruncated = false;
422
+
259
423
  if (!isPro && text.length > limit) {
260
- const errorMsg = `[PrivacyScrubber] Free Tier character limit exceeded (${limit.toLocaleString()} chars for profile '${profile}', received ${text.length.toLocaleString()} chars). Upgrade to PRO, TEAMS, or Developer SDK for unlimited throughput: https://privacyscrubber.com/pricing?utm_source=npm_sdk&utm_medium=sdk_error&utm_campaign=dev_upsell`;
261
- if (throwOnLimit) {
424
+ if (shouldThrow) {
425
+ const errorMsg = `[PrivacyScrubber] Free Tier character limit exceeded (${limit.toLocaleString()} chars for profile '${profile}', received ${text.length.toLocaleString()} chars). Upgrade to PRO, TEAMS, or Developer SDK for unlimited throughput: https://privacyscrubber.com/pricing?utm_source=npm_sdk&utm_medium=sdk_error&utm_campaign=dev_upsell`;
262
426
  throw new PrivacyScrubberLicenseError(errorMsg, {
263
427
  tier: 'FREE',
264
428
  currentLength: text.length,
@@ -266,6 +430,7 @@ export function sanitize(text, options = {}) {
266
430
  profile: profile
267
431
  });
268
432
  }
433
+ wasTruncated = true;
269
434
  }
270
435
 
271
436
  // Auto-inject DevOps Secrets rules when in Dev profile or when detectSecrets is requested
@@ -305,8 +470,15 @@ export function sanitize(text, options = {}) {
305
470
  const telemetry = buildAuditTelemetry(rawResult.tokenMap || {});
306
471
  const auditReceipt = generateReceipt ? formatAuditReceipt(telemetry) : '';
307
472
 
473
+ emitDevSecretNoticeIfNeeded(isPro, rawResult.tokenMap, limit);
474
+
475
+ let finalScrubbedText = rawResult.scrubbedText;
476
+ if (wasTruncated && (onLimit === 'truncate' || throwOnLimit === false)) {
477
+ finalScrubbedText += `\n\n<!-- [PrivacyScrubber] Input truncated at free tier limit (${limit.toLocaleString()} chars). For unlimited throughput: https://privacyscrubber.com/pricing?tier=sdk&src=truncation -->`;
478
+ }
479
+
308
480
  return {
309
- scrubbedText: rawResult.scrubbedText,
481
+ scrubbedText: finalScrubbedText,
310
482
  tokenMap: rawResult.tokenMap || {},
311
483
  count: rawResult.count || 0,
312
484
  executionMs: executionMs,
@@ -314,6 +486,7 @@ export function sanitize(text, options = {}) {
314
486
  isPro: isPro,
315
487
  tier: tier,
316
488
  limit: limit,
489
+ wasTruncated: wasTruncated,
317
490
  ...(!isPro && {
318
491
  upgradeUrl: 'https://privacyscrubber.com/pricing?utm_source=npm_sdk&utm_medium=sdk_telemetry&utm_campaign=dev_upsell',
319
492
  recommendation: 'Upgrade to PRO, TEAMS, or Developer SDK for unlimited text processing, custom regex rules, and specialized compliance profiles.'
@@ -463,12 +636,34 @@ export class PrivacyScrubberEngine {
463
636
  clearIgnoreList() {
464
637
  this.ignoreList.clear();
465
638
  }
639
+
640
+ /**
641
+ * Creates a streaming token restorer bound to this engine's session map.
642
+ * @param {Record<string, string>} [tokenMap=null]
643
+ * @param {object} [options={}]
644
+ */
645
+ createStreamRestorer(tokenMap = null, options = {}) {
646
+ const activeMap = tokenMap || this.sessionMap;
647
+ return createStreamTokenRestorer(activeMap, options);
648
+ }
649
+
650
+ /**
651
+ * Wraps an async iterable LLM stream with real-time de-tokenization.
652
+ * @param {AsyncIterable<any>} stream
653
+ * @param {Record<string, string>} [tokenMap=null]
654
+ * @param {object} [options={}]
655
+ */
656
+ async* wrapStream(stream, tokenMap = null, options = {}) {
657
+ const activeMap = tokenMap || this.sessionMap;
658
+ yield* wrapAiStream(stream, activeMap, options);
659
+ }
466
660
  }
467
661
 
468
662
  /**
469
663
  * Transparent Zero-Trust Wrapper for OpenAI Client.
470
664
  * Automatically sanitizes outgoing messages before transmission to OpenAI,
471
665
  * and restores completion tokens in the assistant response before returning!
666
+ * Seamlessly de-tokenizes streaming responses in real-time when stream: true.
472
667
  *
473
668
  * @param {object} openaiClient - Instance of OpenAI SDK (e.g. new OpenAI()).
474
669
  * @param {object} [options={}] - PrivacyScrubber configuration.
@@ -500,6 +695,12 @@ export function wrapOpenAI(openaiClient, options = {}) {
500
695
  const modifiedParams = { ...params, messages: sanitizedMessages };
501
696
  const response = await originalCreate(modifiedParams, requestOptions);
502
697
 
698
+ // Streaming support: transparently de-tokenize async iterable chunks
699
+ if (params.stream) {
700
+ const detok = new StreamingDetokenizer(sessionTokenMap);
701
+ return detok.wrapStream(response);
702
+ }
703
+
503
704
  if (response && Array.isArray(response.choices)) {
504
705
  response.choices.forEach(choice => {
505
706
  if (choice.message && typeof choice.message.content === 'string') {
@@ -526,88 +727,246 @@ export function createLangChainTransform(options = {}) {
526
727
  }
527
728
 
528
729
  /**
529
- * Creates a zero-latency streaming token restorer for LLM Server-Sent Events (SSE) and Web Streams.
530
- * Solves the broken chunk boundary problem where tokens like [EMAIL_1] are split across multiple chunks.
531
- *
532
- * Works with:
533
- * - Vercel AI SDK (streamText / createDataStreamResponse)
534
- * - OpenAI streaming (chat.completions.create({ stream: true }))
535
- * - LangChain streaming
536
- * - Native Web Streams (ReadableStream.pipeThrough)
537
- * - Async generators (for await ... of)
538
- *
539
- * @param {Record<string, string>} tokenMap - Volatile token mapping object.
540
- * @param {object} [options] - Configuration options.
541
- * @returns {TransformStream & { process: (chunk: string) => string, flush: () => string, wrapStream: (asyncIterable: AsyncIterable<string>) => AsyncGenerator<string> }}
730
+ * Helper to escape special regex characters.
542
731
  */
543
- export function createStreamTokenRestorer(tokenMap = {}, options = {}) {
544
- let pendingBuffer = '';
545
- const map = tokenMap && typeof tokenMap === 'object' ? tokenMap : {};
732
+ function escapeRegex(s) {
733
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
734
+ }
546
735
 
547
- function directRestore(text) {
736
+ /**
737
+ * Builds an ultra-fast streaming token replacement function.
738
+ * Preserves all whitespace, indentation, newlines, and code blocks (does NOT trim).
739
+ * Handles descending key ordering (e.g. [NAME_10] before [NAME_1]), case insensitivity,
740
+ * alternative delimiters (<...>, {{...}}, __...__), and possessive suffixes.
741
+ *
742
+ * @param {Record<string, string>} tokenMap
743
+ * @returns {(text: string) => string}
744
+ */
745
+ export function buildStreamReplacer(tokenMap = {}) {
746
+ if (!tokenMap || typeof tokenMap !== 'object' || Object.keys(tokenMap).length === 0) {
747
+ return (text) => text || '';
748
+ }
749
+
750
+ const rules = [];
751
+ const sortedKeys = Object.keys(tokenMap).sort((a, b) => b.length - a.length);
752
+
753
+ for (const k of sortedKeys) {
754
+ const val = tokenMap[k];
755
+ const inner = k.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
756
+ const match = inner.match(/^([A-Za-z_0-9]+?)[-_ ]?(\d+)$/);
757
+
758
+ if (match) {
759
+ const label = escapeRegex(match[1]);
760
+ const idx = match[2];
761
+ const patternStr = '(?:\\[\\s*' + label + '[-_\\s]*0*' + idx + '\\s*\\]|<\\s*' + label + '[-_\\s]*0*' + idx + '\\s*>|\\{\\{\\s*' + label + '[-_\\s]*0*' + idx + '\\s*\\}\\}|__\\s*' + label + '[-_\\s]*0*' + idx + '\\s*__|\\b' + label + '[-_\\s]+0*' + idx + '\\b)(?:\'s|’s|s)?';
762
+ rules.push({
763
+ regex: new RegExp(patternStr, 'gi'),
764
+ val,
765
+ k
766
+ });
767
+ } else {
768
+ const safe = escapeRegex(k);
769
+ rules.push({
770
+ regex: new RegExp(safe + '(?:\'s|’s|s)?', 'gi'),
771
+ val,
772
+ k
773
+ });
774
+ }
775
+ }
776
+
777
+ return function replaceTokens(text) {
548
778
  if (!text) return '';
549
779
  let result = text;
550
- for (const token in map) {
551
- if (result.includes(token)) {
552
- result = result.split(token).join(map[token]);
553
- }
780
+ for (let i = 0; i < rules.length; i++) {
781
+ const rule = rules[i];
782
+ rule.regex.lastIndex = 0;
783
+ result = result.replace(rule.regex, (m) => {
784
+ const isPossessive = /('s|’s)$/i.test(m);
785
+ return rule.val + (isPossessive ? "'s" : '');
786
+ });
554
787
  }
555
788
  return result;
789
+ };
790
+ }
791
+
792
+ /**
793
+ * Streaming Token Detokenizer with sliding window buffer for LLM chunk boundary splits.
794
+ * Seamlessly restores split tokens (e.g. [EM + AIL_1]), preserves multi-byte UTF-8,
795
+ * and maintains zero-latency streaming throughput.
796
+ */
797
+ export class StreamingDetokenizer {
798
+ constructor(tokenMap = {}, options = {}) {
799
+ this.tokenMap = tokenMap && typeof tokenMap === 'object' ? tokenMap : {};
800
+ this.options = options || {};
801
+ this.replacer = buildStreamReplacer(this.tokenMap);
802
+ this.pendingBuffer = '';
803
+ this.hasTokens = Object.keys(this.tokenMap).length > 0;
804
+ this.decoder = new TextDecoder('utf-8', { fatal: false });
556
805
  }
557
806
 
558
- function processChunk(chunk) {
559
- if (!chunk || typeof chunk !== 'string') return '';
560
- pendingBuffer += chunk;
561
-
562
- // Check if there is an unclosed potential token bracket at the tail of the buffer.
563
- // Token format: [TYPE_INDEX] or [CUSTOM_NAME] e.g. [EMAIL_1], [NAME_2], [SECRET_1]
564
- const lastOpenIdx = pendingBuffer.lastIndexOf('[');
565
- const lastCloseIdx = pendingBuffer.lastIndexOf(']');
566
-
567
- if (lastOpenIdx !== -1 && lastOpenIdx > lastCloseIdx) {
568
- const partial = pendingBuffer.slice(lastOpenIdx);
569
- if (/^\[[A-Za-z0-9_]*$/.test(partial) && partial.length <= 64) {
570
- const safeToEmit = pendingBuffer.slice(0, lastOpenIdx);
571
- pendingBuffer = partial;
572
- if (safeToEmit) {
573
- return directRestore(safeToEmit);
574
- }
575
- return '';
576
- }
807
+ /**
808
+ * Feeds an incoming chunk (string, Uint8Array, or Buffer) and returns de-tokenized text.
809
+ * Buffers any unclosed token candidates at chunk boundaries until closed or flushed.
810
+ * @param {string | Uint8Array | Buffer} chunk
811
+ * @returns {string}
812
+ */
813
+ feed(chunk) {
814
+ if (chunk === undefined || chunk === null) return '';
815
+ let text = '';
816
+ if (typeof chunk === 'string') {
817
+ text = chunk;
818
+ } else if (chunk instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(chunk))) {
819
+ text = this.decoder.decode(chunk, { stream: true });
820
+ } else {
821
+ text = String(chunk);
577
822
  }
578
823
 
579
- const textToRestore = pendingBuffer;
580
- pendingBuffer = '';
581
- return directRestore(textToRestore);
824
+ if (!text) return '';
825
+ if (!this.hasTokens) return text;
826
+
827
+ this.pendingBuffer += text;
828
+
829
+ // Candidate unclosed token at tail of buffer (up to 64 chars)
830
+ // Matches incomplete tokens starting with [, <, {{, or __
831
+ const m = this.pendingBuffer.match(/(?:\[|<|\{\{|__)[A-Za-z0-9_\-А-Яа-яЁё ]{0,64}$/);
832
+ if (m) {
833
+ const safeToEmit = this.pendingBuffer.slice(0, m.index);
834
+ this.pendingBuffer = this.pendingBuffer.slice(m.index);
835
+ return safeToEmit ? this.replacer(safeToEmit) : '';
836
+ }
837
+
838
+ const toEmit = this.pendingBuffer;
839
+ this.pendingBuffer = '';
840
+ return this.replacer(toEmit);
582
841
  }
583
842
 
584
- function flushBuffer() {
585
- if (!pendingBuffer) return '';
586
- const remaining = pendingBuffer;
587
- pendingBuffer = '';
588
- return directRestore(remaining);
843
+ /**
844
+ * Alias for feed() to maintain backwards compatibility with process() callers.
845
+ */
846
+ process(chunk) {
847
+ return this.feed(chunk);
589
848
  }
590
849
 
591
- async function* wrapStream(asyncIterable) {
850
+ /**
851
+ * Flushes remaining buffered tokens and finalizes UTF-8 stream decoder.
852
+ * @returns {string}
853
+ */
854
+ flush() {
855
+ let text = '';
856
+ try {
857
+ text = this.decoder.decode(new Uint8Array(), { stream: false });
858
+ } catch (_) {}
859
+
860
+ if (text) {
861
+ this.pendingBuffer += text;
862
+ }
863
+
864
+ if (!this.pendingBuffer) return '';
865
+ const remaining = this.pendingBuffer;
866
+ this.pendingBuffer = '';
867
+ return this.replacer(remaining);
868
+ }
869
+
870
+ /**
871
+ * Returns a native Node.js Transform stream instance for .pipe() / pipeline().
872
+ * @returns {Transform}
873
+ */
874
+ toNodeTransform() {
875
+ const self = this;
876
+ return new Transform({
877
+ transform(chunk, encoding, callback) {
878
+ try {
879
+ const out = self.feed(chunk);
880
+ if (out) this.push(out);
881
+ callback();
882
+ } catch (err) {
883
+ callback(err);
884
+ }
885
+ },
886
+ flush(callback) {
887
+ try {
888
+ const out = self.flush();
889
+ if (out) this.push(out);
890
+ callback();
891
+ } catch (err) {
892
+ callback(err);
893
+ }
894
+ }
895
+ });
896
+ }
897
+
898
+ /**
899
+ * Wraps an async iterable (e.g. LLM streaming response) yielding restored chunks.
900
+ * @param {AsyncIterable<any>} asyncIterable
901
+ * @returns {AsyncGenerator<any>}
902
+ */
903
+ async* wrapStream(asyncIterable) {
592
904
  for await (const chunk of asyncIterable) {
593
- const text = typeof chunk === 'string' ? chunk : (chunk?.text || chunk?.content || '');
594
- const emitted = processChunk(text);
905
+ if (chunk === null || chunk === undefined) continue;
906
+ // Handle OpenAI chunk object
907
+ if (chunk.choices && Array.isArray(chunk.choices) && chunk.choices[0]?.delta?.content !== undefined) {
908
+ const delta = chunk.choices[0].delta.content;
909
+ if (typeof delta === 'string') {
910
+ const restored = this.feed(delta);
911
+ if (restored) {
912
+ chunk.choices[0].delta.content = restored;
913
+ yield chunk;
914
+ }
915
+ } else {
916
+ yield chunk;
917
+ }
918
+ continue;
919
+ }
920
+
921
+ let text = '';
922
+ if (typeof chunk === 'string') {
923
+ text = chunk;
924
+ } else if (typeof chunk?.text === 'string') {
925
+ text = chunk.text;
926
+ } else if (typeof chunk?.content === 'string') {
927
+ text = chunk.content;
928
+ } else if (chunk instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(chunk))) {
929
+ text = chunk;
930
+ } else {
931
+ text = String(chunk);
932
+ }
933
+
934
+ const emitted = this.feed(text);
595
935
  if (emitted) yield emitted;
596
936
  }
597
- const finalChunk = flushBuffer();
937
+
938
+ const finalChunk = this.flush();
598
939
  if (finalChunk) yield finalChunk;
599
940
  }
941
+ }
942
+
943
+ /**
944
+ * Creates a zero-latency streaming token restorer for LLM Server-Sent Events (SSE) and Web Streams.
945
+ * Solves the broken chunk boundary problem where tokens like [EMAIL_1] are split across multiple chunks.
946
+ *
947
+ * Works with:
948
+ * - Vercel AI SDK (streamText / createDataStreamResponse)
949
+ * - OpenAI streaming (chat.completions.create({ stream: true }))
950
+ * - LangChain streaming
951
+ * - Native Web Streams (ReadableStream.pipeThrough)
952
+ * - Async generators (for await ... of)
953
+ *
954
+ * @param {Record<string, string>} tokenMap - Volatile token mapping object.
955
+ * @param {object} [options] - Configuration options.
956
+ * @returns {TransformStream & { process: (chunk: string) => string, feed: (chunk: string) => string, flush: () => string, wrapStream: (asyncIterable: AsyncIterable<any>) => AsyncGenerator<any>, toNodeTransform: () => Transform, detokenizer: StreamingDetokenizer }}
957
+ */
958
+ export function createStreamTokenRestorer(tokenMap = {}, options = {}) {
959
+ const detok = new StreamingDetokenizer(tokenMap, options);
600
960
 
601
961
  let stream;
602
962
  if (typeof TransformStream !== 'undefined') {
603
963
  stream = new TransformStream({
604
964
  transform(chunk, controller) {
605
- const text = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
606
- const out = processChunk(text);
965
+ const out = detok.feed(chunk);
607
966
  if (out) controller.enqueue(out);
608
967
  },
609
968
  flush(controller) {
610
- const out = flushBuffer();
969
+ const out = detok.flush();
611
970
  if (out) controller.enqueue(out);
612
971
  }
613
972
  });
@@ -615,24 +974,38 @@ export function createStreamTokenRestorer(tokenMap = {}, options = {}) {
615
974
  stream = {};
616
975
  }
617
976
 
618
- stream.process = processChunk;
619
- stream.flush = flushBuffer;
620
- stream.wrapStream = wrapStream;
977
+ stream.process = (chunk) => detok.feed(chunk);
978
+ stream.feed = (chunk) => detok.feed(chunk);
979
+ stream.flush = () => detok.flush();
980
+ stream.wrapStream = (asyncIterable) => detok.wrapStream(asyncIterable);
981
+ stream.toNodeTransform = () => detok.toNodeTransform();
982
+ stream.detokenizer = detok;
621
983
 
622
984
  return stream;
623
985
  }
624
986
 
987
+ /**
988
+ * Creates a Node.js native Transform stream for backend streaming pipelines (stream.pipeline / .pipe()).
989
+ * @param {Record<string, string>} tokenMap - Volatile token mapping object.
990
+ * @param {object} [options] - Configuration options.
991
+ * @returns {Transform}
992
+ */
993
+ export function createNodeStreamTokenRestorer(tokenMap = {}, options = {}) {
994
+ const detok = new StreamingDetokenizer(tokenMap, options);
995
+ return detok.toNodeTransform();
996
+ }
997
+
625
998
  /**
626
999
  * Wraps an async iterable LLM stream (e.g. from Vercel AI SDK textStream or OpenAI stream)
627
1000
  * and yields de-tokenized text chunks in real-time, seamlessly stitching split tokens across chunks.
628
1001
  *
629
1002
  * @param {AsyncIterable<string|object>} stream - The incoming LLM stream.
630
1003
  * @param {Record<string, string>} tokenMap - Volatile token map.
631
- * @returns {AsyncGenerator<string>}
1004
+ * @returns {AsyncGenerator<any>}
632
1005
  */
633
1006
  export async function* wrapAiStream(stream, tokenMap = {}) {
634
- const restorer = createStreamTokenRestorer(tokenMap);
635
- yield* restorer.wrapStream(stream);
1007
+ const detok = new StreamingDetokenizer(tokenMap);
1008
+ yield* detok.wrapStream(stream);
636
1009
  }
637
1010
 
638
1011
  /**
@@ -1156,11 +1529,15 @@ export default {
1156
1529
  buildAuditTelemetry,
1157
1530
  formatAuditReceipt,
1158
1531
  generateAuditReport,
1532
+ generateProcurementMemo,
1159
1533
  PrivacyScrubberEngine,
1160
1534
  PrivacyScrubberLicenseError,
1161
1535
  wrapOpenAI,
1162
1536
  createLangChainTransform,
1537
+ StreamingDetokenizer,
1163
1538
  createStreamTokenRestorer,
1539
+ createNodeStreamTokenRestorer,
1540
+ buildStreamReplacer,
1164
1541
  wrapAiStream,
1165
1542
  withPrivacyScrubber,
1166
1543
  createExpressMiddleware,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@privacyscrubber/sdk",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "Zero-dependency, in-memory PII & secrets redaction SDK for Node.js, Next.js, OpenAI, and LangChain. 100% client-side & server-side zero-trust prompt anonymization under 1ms.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",