{"version":3,"file":"index.mjs","names":[],"sources":["../src/redact.ts"],"sourcesContent":["import {\n  DEFAULT_OPERATOR_CONFIG,\n  maskReplacementSpans,\n  OPERATOR_REGISTRY,\n  operatorType,\n  requireMaskSelection,\n  resolveOperator,\n} from \"./operators\";\nimport type {\n  Entity,\n  OperatorConfig,\n  OperatorType,\n  RedactionResult,\n} from \"./types\";\nimport type { PipelineContext } from \"./context\";\nimport { defaultContext } from \"./context\";\n\nconst WHITESPACE_RE = /\\s+/g;\nconst NON_PHONE_DIGIT_RE = /\\D/g;\nconst ETHEREUM_ADDRESS_RE = /0x[0-9A-Fa-f]{40}/;\nconst BECH32_ADDRESS_RE = /\\bbc1[ac-hj-np-z02-9]{11,71}\\b/i;\nconst BASE58_ADDRESS_RE = /\\b[13][a-km-zA-HJ-NP-Z1-9]{25,34}\\b/;\nconst NHS_NUMBER_CUE_RE = /\\b(?:NHS|National\\s+Health\\s+Service)\\b/i;\nconst PLACEHOLDER_TOKEN_RE = /\\[[^\\s[\\]]+_[1-9]\\d*\\]/g;\nconst PASSPORT_IDENTIFIER_RE =\n  /\\b(?:[A-Za-z]{1,2}\\d{6,8}|\\d{2}[A-Za-z]{2}\\d{5}|\\d{7,9})\\b/;\n// Strip all separators the ID detectors accept so the\n// same real-world value canonicalises to one placeholder:\n//   - whitespace and `-` for IBAN, NIP, REGON, etc.\n//   - `/` for birth numbers (\"900101/1234\") and Czech\n//     bank accounts (\"123-4567/0100\").\n//   - `.` for credit cards (\"4111.1111.1111.1111\") and\n//     other dotted IDs.\nconst ID_SEPARATOR_RE = /[\\s\\-/.]/g;\n\nconst nextPlaceholder = (\n  labelKey: string,\n  counters: Map<string, number>,\n  reservedPlaceholders: ReadonlySet<string>,\n): string => {\n  let count = counters.get(labelKey) ?? 0;\n\n  while (true) {\n    count += 1;\n    const placeholder = `[${labelKey}_${count}]`;\n    if (reservedPlaceholders.has(placeholder)) continue;\n\n    counters.set(labelKey, count);\n    return placeholder;\n  }\n};\n\nconst collectReservedPlaceholders = (\n  reservedText: string,\n): ReadonlySet<string> => new Set(reservedText.match(PLACEHOLDER_TOKEN_RE));\n\nconst normalizeCryptoText = (text: string): string => {\n  const trimmed = text.trim();\n\n  const ethereumAddress = ETHEREUM_ADDRESS_RE.exec(trimmed)?.[0];\n  if (ethereumAddress) {\n    return ethereumAddress.toLowerCase();\n  }\n\n  const bech32Address = BECH32_ADDRESS_RE.exec(trimmed)?.[0];\n  if (bech32Address) {\n    return bech32Address.toLowerCase();\n  }\n\n  const base58Address = BASE58_ADDRESS_RE.exec(trimmed)?.[0];\n  return base58Address ?? trimmed;\n};\n\nconst normalizePassportText = (text: string): string => {\n  const passportIdentifier = PASSPORT_IDENTIFIER_RE.exec(text)?.[0] ?? text;\n  return passportIdentifier.replace(ID_SEPARATOR_RE, \"\").toUpperCase();\n};\n\n/**\n * Normalize entity text so that surface-form variations\n * of the same real-world value map to a single canonical\n * key. Lowercased emails, stripped phone formatting, etc.\n */\nconst normalizeEntityText = (label: string, text: string): string => {\n  const upper = label.toUpperCase().replace(WHITESPACE_RE, \"_\");\n\n  if (upper === \"EMAIL_ADDRESS\" || upper === \"EMAIL\") {\n    return text.toLowerCase().trim();\n  }\n  if (upper === \"PHONE_NUMBER\" || upper === \"PHONE\") {\n    const digits = text.replace(NON_PHONE_DIGIT_RE, \"\");\n    return digits.startsWith(\"00\") ? digits.slice(2) : digits;\n  }\n  if (upper === \"CRYPTO\") {\n    return normalizeCryptoText(text);\n  }\n  if (\n    upper === \"NATIONAL_IDENTIFICATION_NUMBER\" &&\n    NHS_NUMBER_CUE_RE.test(text)\n  ) {\n    return text.replace(/\\D/g, \"\");\n  }\n  if (\n    upper === \"IBAN\" ||\n    upper === \"BANK_ACCOUNT_NUMBER\" ||\n    upper === \"TAX_IDENTIFICATION_NUMBER\" ||\n    upper === \"REGISTRATION_NUMBER\" ||\n    upper === \"NATIONAL_IDENTIFICATION_NUMBER\" ||\n    upper === \"SOCIAL_SECURITY_NUMBER\" ||\n    upper === \"BIRTH_NUMBER\" ||\n    upper === \"IDENTITY_CARD_NUMBER\" ||\n    upper === \"CREDIT_CARD_NUMBER\"\n  ) {\n    return text.replace(ID_SEPARATOR_RE, \"\").toUpperCase();\n  }\n  if (upper === \"PASSPORT_NUMBER\") {\n    return normalizePassportText(text);\n  }\n  if (\n    upper === \"PERSON\" ||\n    upper === \"ORGANIZATION\" ||\n    upper === \"ADDRESS\" ||\n    upper === \"LAND_PARCEL\" ||\n    upper === \"MISC\"\n  ) {\n    return text.replace(WHITESPACE_RE, \" \").toLowerCase().trim();\n  }\n  return text.trim();\n};\n\nconst nonOverlappingEntities = (entities: Entity[]): Entity[] => {\n  const result: Entity[] = [];\n  let lastEnd = 0;\n  for (const entity of entities) {\n    if (entity.start < lastEnd) continue;\n    result.push(entity);\n    lastEnd = entity.end;\n  }\n  return result;\n};\n\ntype MaskReplacementSpan = {\n  start: number;\n  end: number;\n  replacement: string;\n};\n\nconst removeRedactedMaskOverlaps = (\n  replacements: MaskReplacementSpan[],\n  redacted: Entity[],\n): MaskReplacementSpan[] => {\n  const result: MaskReplacementSpan[] = [];\n  let redactedIndex = 0;\n  for (const replacement of replacements) {\n    while (true) {\n      const candidate = redacted.at(redactedIndex);\n      if (candidate === undefined || candidate.end > replacement.start) break;\n      redactedIndex += 1;\n    }\n    const redactedEntity = redacted.at(redactedIndex);\n    const overlaps =\n      redactedEntity !== undefined &&\n      redactedEntity.start < replacement.end &&\n      replacement.start < redactedEntity.end;\n    if (!overlaps) result.push(replacement);\n  }\n  return result;\n};\n\n/**\n * Build a stable mapping from entity text to numbered\n * placeholders. Same real-world value always maps to the\n * same placeholder (e.g., \"Dr. Muller\" and \"Dr.  Muller\"\n * share one person placeholder).\n *\n * Placeholder format: [LABEL_N] where LABEL is uppercase.\n * N is allocated per label and skips tokens already present\n * in reserved text.\n *\n * @param _ctx Unused. Kept for signature compatibility;\n *   coref alias links now travel on the entities\n *   themselves (`corefSourceText`).\n */\ntype PlaceholderMapOptions = {\n  reservedText?: string;\n};\n\nexport const buildPlaceholderMap = (\n  entities: Entity[],\n  _ctx: PipelineContext = defaultContext,\n  { reservedText = \"\" }: PlaceholderMapOptions = {},\n): Map<string, string> => {\n  const counters = new Map<string, number>();\n  const textLabelToPlaceholder = new Map<string, string>();\n  const normalizedToPlaceholder = new Map<string, string>();\n  const reservedPlaceholders = collectReservedPlaceholders(reservedText);\n\n  const sorted = entities.toSorted((a, b) => a.start - b.start);\n\n  for (const entity of sorted) {\n    const compositeKey = `${entity.label}\\0${entity.text}`;\n    if (textLabelToPlaceholder.has(compositeKey)) {\n      continue;\n    }\n\n    const labelKey = entity.label.toUpperCase().replace(WHITESPACE_RE, \"_\");\n\n    // If this entity is a coref alias, unify its key\n    // with the source entity's key so both get the same\n    // number — in either direction: a backward alias\n    // joins the source's existing placeholder, and a\n    // forward alias (bare mention before the full form)\n    // reserves its placeholder under the source key so\n    // the source joins it when numbered later. The link\n    // is carried on the entity itself, so it cannot be\n    // lost between detection and redaction.\n    const sourceText =\n      entity.source === \"coreference\" ? entity.corefSourceText : undefined;\n    const sourceNormalizedKey =\n      sourceText === undefined\n        ? undefined\n        : `${labelKey}\\0${normalizeEntityText(entity.label, sourceText)}`;\n    if (sourceNormalizedKey !== undefined) {\n      const sourceExisting = normalizedToPlaceholder.get(sourceNormalizedKey);\n      if (sourceExisting) {\n        textLabelToPlaceholder.set(compositeKey, sourceExisting);\n        continue;\n      }\n    }\n\n    const normalized = normalizeEntityText(entity.label, entity.text);\n    const normalizedKey = `${labelKey}\\0${normalized}`;\n    const existing = normalizedToPlaceholder.get(normalizedKey);\n    if (existing) {\n      textLabelToPlaceholder.set(compositeKey, existing);\n      if (sourceNormalizedKey !== undefined) {\n        normalizedToPlaceholder.set(sourceNormalizedKey, existing);\n      }\n      continue;\n    }\n\n    const placeholder = nextPlaceholder(\n      labelKey,\n      counters,\n      reservedPlaceholders,\n    );\n    textLabelToPlaceholder.set(compositeKey, placeholder);\n    normalizedToPlaceholder.set(normalizedKey, placeholder);\n    if (sourceNormalizedKey !== undefined) {\n      normalizedToPlaceholder.set(sourceNormalizedKey, placeholder);\n    }\n  }\n\n  return textLabelToPlaceholder;\n};\n\n/**\n * Apply redactions to the source text, replacing each\n * confirmed entity span using the configured operator.\n *\n * Co-references are consistent: if the same text appears\n * multiple times, all occurrences get the same placeholder.\n *\n * @param ctx Pipeline context. Must be the same instance\n *   passed to `runPipeline` (or `findCoreferenceSpans`)\n *   so coreference placeholder links are preserved.\n *   Defaults to `defaultContext` for single-tenant usage.\n */\nexport const redactText = (\n  fullText: string,\n  entities: Entity[],\n  config: OperatorConfig = DEFAULT_OPERATOR_CONFIG,\n  ctx: PipelineContext = defaultContext,\n): RedactionResult => {\n  if (entities.length === 0) {\n    return {\n      redactedText: fullText,\n      redactionMap: new Map(),\n      operatorMap: new Map(),\n      entityCount: 0,\n    };\n  }\n\n  const placeholderMap = buildPlaceholderMap(entities, ctx, {\n    reservedText: fullText,\n  });\n\n  const sorted = entities.toSorted((a, b) => a.start - b.start);\n\n  const kept: Entity[] = [];\n  const masked: Entity[] = [];\n  const redacted: Entity[] = [];\n  for (const entity of sorted) {\n    const opType = operatorType(resolveOperator(config, entity.label));\n    if (opType === \"keep\") {\n      kept.push(entity);\n    } else if (opType === \"mask\") {\n      masked.push(entity);\n    } else {\n      redacted.push(entity);\n    }\n  }\n  const selectedKept = nonOverlappingEntities(kept);\n  const selectedMasked = nonOverlappingEntities(masked);\n  const selectedRedacted = nonOverlappingEntities(redacted);\n\n  const maskReplacements: MaskReplacementSpan[] = [];\n  for (const entity of selectedMasked) {\n    const selection = resolveOperator(config, entity.label);\n    const sourceText = fullText.slice(entity.start, entity.end);\n    for (const replacement of maskReplacementSpans(\n      sourceText,\n      requireMaskSelection(selection),\n    )) {\n      maskReplacements.push({\n        start: entity.start + replacement.start,\n        end: entity.start + replacement.end,\n        replacement: replacement.replacement,\n      });\n    }\n  }\n  const visibleMaskReplacements = removeRedactedMaskOverlaps(\n    maskReplacements,\n    selectedRedacted,\n  );\n\n  const parts: string[] = [];\n  const redactionMap = new Map<string, string>();\n  const operatorMap = new Map<string, OperatorType>();\n  let cursor = 0;\n\n  const placeholderFor = (entity: Entity): string =>\n    placeholderMap.get(`${entity.label}\\0${entity.text}`) ??\n    `[${entity.label.toUpperCase().replace(/\\s+/g, \"_\")}]`;\n  const processed = [\n    ...selectedKept,\n    ...selectedMasked,\n    ...selectedRedacted,\n  ].toSorted((a, b) => a.start - b.start);\n  for (const entity of processed) {\n    operatorMap.set(\n      placeholderFor(entity),\n      operatorType(resolveOperator(config, entity.label)),\n    );\n  }\n\n  let redactedIndex = 0;\n  let maskIndex = 0;\n  while (\n    redactedIndex < selectedRedacted.length ||\n    maskIndex < visibleMaskReplacements.length\n  ) {\n    const redactedEntity = selectedRedacted.at(redactedIndex);\n    const maskReplacement = visibleMaskReplacements.at(maskIndex);\n    const useRedacted =\n      redactedEntity !== undefined &&\n      (maskReplacement === undefined ||\n        redactedEntity.start <= maskReplacement.start);\n    const start = useRedacted\n      ? (redactedEntity?.start ?? cursor)\n      : (maskReplacement?.start ?? cursor);\n    const end = useRedacted\n      ? (redactedEntity?.end ?? start)\n      : (maskReplacement?.end ?? start);\n    if (start > cursor) parts.push(fullText.slice(cursor, start));\n\n    if (!useRedacted && maskReplacement !== undefined) {\n      parts.push(maskReplacement.replacement);\n      cursor = end;\n      maskIndex += 1;\n      continue;\n    }\n    if (redactedEntity === undefined) break;\n\n    const entity = redactedEntity;\n    const placeholder = placeholderFor(entity);\n\n    const selection = resolveOperator(config, entity.label);\n    const opType = operatorType(selection);\n    const operator = OPERATOR_REGISTRY[opType];\n\n    const replacement = operator.apply(\n      entity.text,\n      entity.label,\n      placeholder,\n      config.redactString,\n      selection,\n    );\n\n    parts.push(replacement);\n    // Only populate redactionMap for reversible operators.\n    // A coref alias contributes its source's full text, so\n    // a forward alias (\"Acme\" before \"Acme Corporation\")\n    // cannot pin the shortened surface form as the key's\n    // canonical value for the shared placeholder.\n    if (\n      operator.reversibility === \"reversible\" &&\n      !redactionMap.has(placeholder)\n    ) {\n      redactionMap.set(\n        placeholder,\n        entity.source === \"coreference\" ? entity.corefSourceText : entity.text,\n      );\n    }\n\n    cursor = end;\n    redactedIndex += 1;\n  }\n\n  if (cursor < fullText.length) {\n    parts.push(fullText.slice(cursor));\n  }\n\n  return {\n    redactedText: parts.join(\"\"),\n    redactionMap,\n    operatorMap,\n    entityCount:\n      selectedKept.length + selectedMasked.length + selectedRedacted.length,\n  };\n};\n\n/**\n * Serialize the redaction key to JSON for export.\n * Includes operator metadata so the export is self-describing.\n */\nexport const exportRedactionKey = (\n  redactionMap: Map<string, string>,\n  operatorMap: Map<string, OperatorType>,\n): string => {\n  const entries: Record<string, { original: string; operator: OperatorType }> =\n    {};\n\n  for (const [placeholder, value] of redactionMap) {\n    entries[placeholder] = {\n      original: value,\n      operator: operatorMap.get(placeholder) ?? \"replace\",\n    };\n  }\n\n  return JSON.stringify({ entries }, null, 2);\n};\n\n/**\n * De-anonymise text using a redaction key.\n * Replaces placeholders back with original values.\n * Only works for reversible operators (replace).\n */\nexport const deanonymise = (\n  redactedText: string,\n  redactionMap: Map<string, string>,\n): string => {\n  let result = redactedText;\n\n  for (const [placeholder, original] of redactionMap) {\n    result = result.replaceAll(placeholder, original);\n  }\n\n  return result;\n};\n"],"mappings":";;;;;;;;;AA0aA,MAAa,sBACX,cACA,gBACW;CACX,MAAM,UACJ,CAAC;CAEH,KAAK,MAAM,CAAC,aAAa,UAAU,cACjC,QAAQ,eAAe;EACrB,UAAU;EACV,UAAU,YAAY,IAAI,WAAW,KAAK;CAC5C;CAGF,OAAO,KAAK,UAAU,EAAE,QAAQ,GAAG,MAAM,CAAC;AAC5C;;;;;;AAOA,MAAa,eACX,cACA,iBACW;CACX,IAAI,SAAS;CAEb,KAAK,MAAM,CAAC,aAAa,aAAa,cACpC,SAAS,OAAO,WAAW,aAAa,QAAQ;CAGlD,OAAO;AACT"}