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.
@@ -0,0 +1,243 @@
1
+ "use strict";
2
+ /**
3
+ * [CLOUD EXTENSION] The cloud client.
4
+ *
5
+ * Speaks to the euRedact inference service: a deterministic rules engine
6
+ * followed by a fine-tuned model asked only *what did the rules miss?* The
7
+ * service returns both the redacted document and located spans, so this
8
+ * module's job is transport and translation, not detection.
9
+ *
10
+ * Three things it hides from the caller:
11
+ *
12
+ * - **Whether the document finished inside the service's sync window.** A short
13
+ * document comes back on the original request; a long one, or one arriving
14
+ * while a GPU node is cold, returns a job handle and is polled. Callers
15
+ * should never write that branch, so `redact()` resolves either way.
16
+ * - **Retries.** Every request carries an `Idempotency-Key`, so a retry after a
17
+ * timeout cannot produce a second job or a second usage row.
18
+ * - **`Retry-After`.** When the service says how long to wait, that is obeyed
19
+ * rather than second-guessed with a backoff curve.
20
+ *
21
+ * Zero dependencies, like the rest of the package: this uses the platform's
22
+ * own `fetch`.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.CloudClient = void 0;
26
+ const config_js_1 = require("./config.js");
27
+ const errors_js_1 = require("./errors.js");
28
+ const types_js_1 = require("../types.js");
29
+ const RETRY_STATUS = new Set([429, 500, 502, 503, 504]);
30
+ const MAX_BACKOFF_MS = 30_000;
31
+ function requireFetch(supplied) {
32
+ const impl = supplied ?? globalThis.fetch;
33
+ if (typeof impl !== "function") {
34
+ throw new errors_js_1.NotConfiguredError("the cloud tier needs a global fetch. Node 18+ provides one; on Node 16 " +
35
+ "supply one via the fetchImpl option or install a polyfill.");
36
+ }
37
+ return impl;
38
+ }
39
+ function sleep(ms) {
40
+ return new Promise(resolve => setTimeout(resolve, ms));
41
+ }
42
+ function retryAfterMs(headers) {
43
+ const raw = headers?.get("Retry-After");
44
+ if (!raw)
45
+ return null;
46
+ const seconds = Number(raw);
47
+ // The HTTP-date form is legal too; fall back to backoff rather than parse it.
48
+ return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : null;
49
+ }
50
+ /**
51
+ * Full jitter. A synchronised retry storm from many clients is how a
52
+ * recovering service is knocked back over.
53
+ */
54
+ function backoffMs(attempt, retryAfter) {
55
+ if (retryAfter !== null)
56
+ return Math.min(retryAfter, MAX_BACKOFF_MS);
57
+ return Math.min(MAX_BACKOFF_MS, Math.random() * 2 ** attempt * 1000);
58
+ }
59
+ function uuid() {
60
+ const c = globalThis.crypto;
61
+ if (c && typeof c.randomUUID === "function")
62
+ return c.randomUUID();
63
+ return `idem-${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
64
+ }
65
+ /**
66
+ * Canon name -> EntityType, tolerating one this build has not heard of.
67
+ *
68
+ * An unknown type is passed through as a plain string rather than dropped:
69
+ * `Detection.entityType` is `EntityType | string` precisely so a client one
70
+ * release behind the service does not silently lose a whole category of PII.
71
+ */
72
+ function toEntityType(raw) {
73
+ const canonical = (0, types_js_1.canonicalType)(raw);
74
+ const known = Object.values(types_js_1.EntityType).includes(canonical);
75
+ return known ? canonical : canonical;
76
+ }
77
+ /**
78
+ * Map the service's offsets, which count code points, onto this SDK's, which
79
+ * count UTF-16 units.
80
+ *
81
+ * The two agree until the first character outside the BMP — an emoji, say —
82
+ * after which every service offset is short by one unit per such character.
83
+ * Returns the identity when the text has none, which is the common case and
84
+ * costs one regex test.
85
+ */
86
+ function codePointOffsets(text) {
87
+ if (!/[\uD800-\uDBFF]/.test(text))
88
+ return cp => cp;
89
+ const units = [];
90
+ let i = 0;
91
+ for (const ch of text) {
92
+ units.push(i);
93
+ i += ch.length;
94
+ }
95
+ units.push(i);
96
+ return cp => units[Math.min(cp, units.length - 1)];
97
+ }
98
+ function toResult(payload, text) {
99
+ const offset = codePointOffsets(text);
100
+ const detections = (payload.entities ?? []).map(span => ({
101
+ entityType: toEntityType(span.type),
102
+ start: offset(span.start),
103
+ end: offset(span.end),
104
+ text: span.text,
105
+ source: span.source === "model" ? types_js_1.DetectionSource.CLOUD : types_js_1.DetectionSource.RULES,
106
+ country: null,
107
+ confidence: span.confidence ?? "high",
108
+ }));
109
+ detections.sort((a, b) => a.start - b.start || b.end - a.end);
110
+ return {
111
+ redactedText: payload.redacted_text ?? text,
112
+ detections,
113
+ source: "cloud",
114
+ degraded: false,
115
+ inferredCountries: [],
116
+ evidence: [],
117
+ detectionMode: "declared",
118
+ tokens: {},
119
+ exempted: [],
120
+ };
121
+ }
122
+ class CloudClient {
123
+ config;
124
+ constructor(config) {
125
+ const resolved = config ?? (0, config_js_1.getConfig)();
126
+ if (resolved === null)
127
+ throw new errors_js_1.NotConfiguredError();
128
+ this.config = resolved;
129
+ }
130
+ headers(idempotencyKey) {
131
+ return {
132
+ Authorization: `Bearer ${this.config.apiKey}`,
133
+ "Content-Type": "application/json",
134
+ // A retry after a timeout must not create a second job or a second usage
135
+ // row. The key is per logical document, not per attempt.
136
+ "Idempotency-Key": idempotencyKey,
137
+ ...this.config.headers,
138
+ };
139
+ }
140
+ raiseFor(status, payload) {
141
+ const message = payload.error ?? `HTTP ${status}`;
142
+ const detail = payload.detail ?? {};
143
+ if (status === 401)
144
+ throw new errors_js_1.CloudError(`authentication failed: ${message}`, status);
145
+ if (status === 413)
146
+ throw new errors_js_1.TooLargeError(message, status, detail);
147
+ if (status === 429)
148
+ throw new errors_js_1.QuotaExceededError(message, status, detail);
149
+ throw new errors_js_1.CloudError(message, status, detail);
150
+ }
151
+ async redact(text, options) {
152
+ const doFetch = requireFetch(options.fetchImpl);
153
+ const key = options.idempotencyKey ?? uuid();
154
+ const body = JSON.stringify({
155
+ text,
156
+ country: options.country.toUpperCase(),
157
+ language: options.language ?? "",
158
+ priority: options.priority ?? "interactive",
159
+ rules_only: options.rulesOnly ?? false,
160
+ });
161
+ let attempt = 0;
162
+ for (;;) {
163
+ let response;
164
+ let payload = {};
165
+ try {
166
+ response = await this.withTimeout(doFetch, `${this.config.baseUrl}/v1/redact`, {
167
+ method: "POST",
168
+ headers: this.headers(key),
169
+ body,
170
+ });
171
+ payload = await readJson(response);
172
+ }
173
+ catch (e) {
174
+ if (attempt >= this.config.maxRetries) {
175
+ throw new errors_js_1.CloudError(`cloud request failed: ${e instanceof Error ? e.message : String(e)}`);
176
+ }
177
+ }
178
+ if (response) {
179
+ if (response.status === 202) {
180
+ // Outlived the sync window. Location is where the answer will appear;
181
+ // polling it is this client's job, not the caller's.
182
+ const location = response.headers.get("Location") ??
183
+ payload.location ??
184
+ `/v1/jobs/${payload.job_id ?? ""}`;
185
+ return toResult(await this.poll(doFetch, location, key), text);
186
+ }
187
+ if (response.status === 200)
188
+ return toResult(payload, text);
189
+ if (!RETRY_STATUS.has(response.status) || attempt >= this.config.maxRetries) {
190
+ this.raiseFor(response.status, payload);
191
+ }
192
+ }
193
+ await sleep(backoffMs(attempt, retryAfterMs(response?.headers)));
194
+ attempt++;
195
+ }
196
+ }
197
+ async poll(doFetch, location, key) {
198
+ const url = location.startsWith("http")
199
+ ? location
200
+ : `${this.config.baseUrl}${location}`;
201
+ const deadline = Date.now() + this.config.pollTimeoutMs;
202
+ let delay = 500;
203
+ for (;;) {
204
+ const response = await this.withTimeout(doFetch, url, {
205
+ method: "GET",
206
+ headers: this.headers(key),
207
+ });
208
+ const payload = await readJson(response);
209
+ if (response.status === 200 && payload.redacted_text !== undefined) {
210
+ return payload;
211
+ }
212
+ if (response.status !== 200 && !RETRY_STATUS.has(response.status)) {
213
+ this.raiseFor(response.status, payload);
214
+ }
215
+ if (Date.now() >= deadline) {
216
+ throw new errors_js_1.CloudError(`document did not complete within ${Math.round(this.config.pollTimeoutMs / 1000)}s (${url})`);
217
+ }
218
+ const after = retryAfterMs(response.headers);
219
+ await sleep(after ?? delay);
220
+ delay = Math.min(delay * 1.5, 5000);
221
+ }
222
+ }
223
+ async withTimeout(doFetch, url, init) {
224
+ const controller = new AbortController();
225
+ const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
226
+ try {
227
+ return await doFetch(url, { ...init, signal: controller.signal });
228
+ }
229
+ finally {
230
+ clearTimeout(timer);
231
+ }
232
+ }
233
+ }
234
+ exports.CloudClient = CloudClient;
235
+ async function readJson(response) {
236
+ try {
237
+ const data = await response.json();
238
+ return data && typeof data === "object" ? data : {};
239
+ }
240
+ catch {
241
+ return {};
242
+ }
243
+ }
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ /** [CLOUD EXTENSION] Cloud tier configuration. */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.DEFAULT_MAX_RETRIES = exports.DEFAULT_POLL_TIMEOUT_MS = exports.DEFAULT_TIMEOUT_MS = exports.DEFAULT_BASE_URL = void 0;
5
+ exports.configure = configure;
6
+ exports.getConfig = getConfig;
7
+ exports.reset = reset;
8
+ exports.DEFAULT_BASE_URL = "https://api.euredact.dev";
9
+ /**
10
+ * How long to wait for a single document. The service holds a connection open
11
+ * for its sync window and then hands back a job handle, so this has to exceed
12
+ * that window or the client abandons requests the service is about to answer.
13
+ */
14
+ exports.DEFAULT_TIMEOUT_MS = 90_000;
15
+ /**
16
+ * Total wall-clock budget for a document that outlives the sync window and has
17
+ * to be polled. Cold-starting a GPU node and loading an ~18 GB checkpoint takes
18
+ * minutes, and an async job absorbs that invisibly — but not forever.
19
+ */
20
+ exports.DEFAULT_POLL_TIMEOUT_MS = 900_000;
21
+ exports.DEFAULT_MAX_RETRIES = 3;
22
+ let config = null;
23
+ /** Read an env var where there is an environment to read. Browsers have none. */
24
+ function env(name) {
25
+ const proc = globalThis.process;
26
+ return proc?.env?.[name];
27
+ }
28
+ /**
29
+ * Configure the cloud tier.
30
+ *
31
+ * Falls back to `EUREDACT_API_KEY` and `EUREDACT_BASE_URL` so a key never has
32
+ * to be written into source to get started.
33
+ */
34
+ function configure(options = {}) {
35
+ const apiKey = options.apiKey ?? env("EUREDACT_API_KEY") ?? "";
36
+ if (!apiKey) {
37
+ throw new Error("no API key: pass configure({ apiKey }) or set EUREDACT_API_KEY");
38
+ }
39
+ config = {
40
+ apiKey,
41
+ baseUrl: (options.baseUrl ?? env("EUREDACT_BASE_URL") ?? exports.DEFAULT_BASE_URL)
42
+ .replace(/\/+$/, ""),
43
+ timeoutMs: options.timeoutMs ?? exports.DEFAULT_TIMEOUT_MS,
44
+ pollTimeoutMs: options.pollTimeoutMs ?? exports.DEFAULT_POLL_TIMEOUT_MS,
45
+ maxRetries: options.maxRetries ?? exports.DEFAULT_MAX_RETRIES,
46
+ headers: { ...(options.headers ?? {}) },
47
+ };
48
+ return config;
49
+ }
50
+ /** The active configuration, or null. Never throws. */
51
+ function getConfig() {
52
+ return config;
53
+ }
54
+ /** Forget the configuration. Mainly for tests. */
55
+ function reset() {
56
+ config = null;
57
+ }
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ /** [CLOUD EXTENSION] Errors the cloud tier can raise. */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.TooLargeError = exports.QuotaExceededError = exports.NotConfiguredError = exports.CloudError = void 0;
5
+ class CloudError extends Error {
6
+ status;
7
+ detail;
8
+ constructor(message, status, detail) {
9
+ super(message);
10
+ this.name = "CloudError";
11
+ this.status = status;
12
+ this.detail = detail ?? {};
13
+ }
14
+ }
15
+ exports.CloudError = CloudError;
16
+ /**
17
+ * Raised when the cloud tier is used without configuration.
18
+ *
19
+ * Deliberately an error rather than a quiet fallback to rules-only output.
20
+ * `mode: "cloud"` that silently returns rules-only results is the worst failure
21
+ * this library can have: the caller believes names, employers and diagnoses
22
+ * were checked, sees a plausible redacted document, and ships it with the PII
23
+ * still in it.
24
+ */
25
+ class NotConfiguredError extends CloudError {
26
+ constructor(message) {
27
+ super(message ??
28
+ "Cloud tier not configured. Call euredact.configure({ apiKey }) first, " +
29
+ "or set EUREDACT_API_KEY.");
30
+ this.name = "NotConfiguredError";
31
+ }
32
+ }
33
+ exports.NotConfiguredError = NotConfiguredError;
34
+ /** 429 after retries were exhausted. */
35
+ class QuotaExceededError extends CloudError {
36
+ constructor(message, status, detail) {
37
+ super(message, status, detail);
38
+ this.name = "QuotaExceededError";
39
+ }
40
+ }
41
+ exports.QuotaExceededError = QuotaExceededError;
42
+ /**
43
+ * 413. The document is over the service's input cap.
44
+ *
45
+ * Permanent and not retryable. The model has never seen a chunk boundary, so
46
+ * the service refuses oversized input rather than splitting it and changing the
47
+ * accuracy story silently.
48
+ */
49
+ class TooLargeError extends CloudError {
50
+ constructor(message, status, detail) {
51
+ super(message, status, detail);
52
+ this.name = "TooLargeError";
53
+ }
54
+ }
55
+ exports.TooLargeError = TooLargeError;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ /**
3
+ * [CLOUD EXTENSION] Cloud tier — rules engine plus a fine-tuned model.
4
+ *
5
+ * The rule engine catches what has a shape: IBANs, national IDs, phone
6
+ * numbers, anything with a checksum. It cannot catch what does not — a
7
+ * person's name, an employer, a diagnosis, a job title. Those are what the
8
+ * cloud tier adds, and the `EntityType` members marked `[CLOUD EXTENSION]` are
9
+ * exactly that gap.
10
+ *
11
+ * ```ts
12
+ * import { configure, redactAsync } from "euredact";
13
+ *
14
+ * configure({ apiKey: "erk_..." });
15
+ * const result = await redactAsync(text, { countries: ["BE"], mode: "cloud" });
16
+ * ```
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.TooLargeError = exports.QuotaExceededError = exports.NotConfiguredError = exports.CloudError = exports.DEFAULT_BASE_URL = exports.reset = exports.getConfig = exports.configure = exports.CloudClient = void 0;
20
+ var client_js_1 = require("./client.js");
21
+ Object.defineProperty(exports, "CloudClient", { enumerable: true, get: function () { return client_js_1.CloudClient; } });
22
+ var config_js_1 = require("./config.js");
23
+ Object.defineProperty(exports, "configure", { enumerable: true, get: function () { return config_js_1.configure; } });
24
+ Object.defineProperty(exports, "getConfig", { enumerable: true, get: function () { return config_js_1.getConfig; } });
25
+ Object.defineProperty(exports, "reset", { enumerable: true, get: function () { return config_js_1.reset; } });
26
+ Object.defineProperty(exports, "DEFAULT_BASE_URL", { enumerable: true, get: function () { return config_js_1.DEFAULT_BASE_URL; } });
27
+ var errors_js_1 = require("./errors.js");
28
+ Object.defineProperty(exports, "CloudError", { enumerable: true, get: function () { return errors_js_1.CloudError; } });
29
+ Object.defineProperty(exports, "NotConfiguredError", { enumerable: true, get: function () { return errors_js_1.NotConfiguredError; } });
30
+ Object.defineProperty(exports, "QuotaExceededError", { enumerable: true, get: function () { return errors_js_1.QuotaExceededError; } });
31
+ Object.defineProperty(exports, "TooLargeError", { enumerable: true, get: function () { return errors_js_1.TooLargeError; } });
package/dist/cjs/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SEED_BIC6_PREFIXES = exports.getBicRegistry = exports.setBicRegistry = exports.COUNTRY_CONFIGS = exports.EuRedact = exports.DocumentContext = exports.LEGACY_TYPE_ALIASES = exports.DetectionSource = exports.EntityType = void 0;
3
+ exports.TooLargeError = exports.QuotaExceededError = exports.NotConfiguredError = exports.CloudError = exports.CloudClient = exports.getConfig = exports.configure = exports.SEED_BIC6_PREFIXES = exports.getBicRegistry = exports.setBicRegistry = exports.COUNTRY_CONFIGS = exports.restore = exports.EuRedact = exports.DocumentContext = exports.canonicalType = exports.LEGACY_TYPE_ALIASES = exports.DetectionSource = exports.EntityType = void 0;
4
4
  exports.availableCountries = availableCountries;
5
5
  exports.redact = redact;
6
+ exports.redactAsync = redactAsync;
6
7
  exports.addCustomPattern = addCustomPattern;
7
8
  exports.clear = clear;
8
9
  exports.redactBatch = redactBatch;
@@ -10,18 +11,28 @@ var types_js_1 = require("./types.js");
10
11
  Object.defineProperty(exports, "EntityType", { enumerable: true, get: function () { return types_js_1.EntityType; } });
11
12
  Object.defineProperty(exports, "DetectionSource", { enumerable: true, get: function () { return types_js_1.DetectionSource; } });
12
13
  Object.defineProperty(exports, "LEGACY_TYPE_ALIASES", { enumerable: true, get: function () { return types_js_1.LEGACY_TYPE_ALIASES; } });
14
+ Object.defineProperty(exports, "canonicalType", { enumerable: true, get: function () { return types_js_1.canonicalType; } });
13
15
  var context_js_1 = require("./rules/context.js");
14
16
  Object.defineProperty(exports, "DocumentContext", { enumerable: true, get: function () { return context_js_1.DocumentContext; } });
15
17
  var sdk_js_1 = require("./sdk.js");
16
18
  Object.defineProperty(exports, "EuRedact", { enumerable: true, get: function () { return sdk_js_1.EuRedact; } });
19
+ Object.defineProperty(exports, "restore", { enumerable: true, get: function () { return sdk_js_1.restore; } });
17
20
  var index_js_1 = require("./rules/countries/index.js");
18
21
  Object.defineProperty(exports, "COUNTRY_CONFIGS", { enumerable: true, get: function () { return index_js_1.COUNTRY_CONFIGS; } });
19
22
  var bicRegistry_js_1 = require("./rules/bicRegistry.js");
20
23
  Object.defineProperty(exports, "setBicRegistry", { enumerable: true, get: function () { return bicRegistry_js_1.setBicRegistry; } });
21
24
  Object.defineProperty(exports, "getBicRegistry", { enumerable: true, get: function () { return bicRegistry_js_1.getBicRegistry; } });
22
25
  Object.defineProperty(exports, "SEED_BIC6_PREFIXES", { enumerable: true, get: function () { return bicRegistry_js_1.SEED_BIC6_PREFIXES; } });
26
+ var index_js_2 = require("./cloud/index.js");
27
+ Object.defineProperty(exports, "configure", { enumerable: true, get: function () { return index_js_2.configure; } });
28
+ Object.defineProperty(exports, "getConfig", { enumerable: true, get: function () { return index_js_2.getConfig; } });
29
+ Object.defineProperty(exports, "CloudClient", { enumerable: true, get: function () { return index_js_2.CloudClient; } });
30
+ Object.defineProperty(exports, "CloudError", { enumerable: true, get: function () { return index_js_2.CloudError; } });
31
+ Object.defineProperty(exports, "NotConfiguredError", { enumerable: true, get: function () { return index_js_2.NotConfiguredError; } });
32
+ Object.defineProperty(exports, "QuotaExceededError", { enumerable: true, get: function () { return index_js_2.QuotaExceededError; } });
33
+ Object.defineProperty(exports, "TooLargeError", { enumerable: true, get: function () { return index_js_2.TooLargeError; } });
23
34
  const sdk_js_2 = require("./sdk.js");
24
- const index_js_2 = require("./rules/countries/index.js");
35
+ const index_js_3 = require("./rules/countries/index.js");
25
36
  let _instance = null;
26
37
  function getInstance() {
27
38
  if (_instance === null)
@@ -29,11 +40,20 @@ function getInstance() {
29
40
  return _instance;
30
41
  }
31
42
  function availableCountries() {
32
- return Object.keys(index_js_2.COUNTRY_CONFIGS).filter(c => c !== "SHARED").sort();
43
+ return Object.keys(index_js_3.COUNTRY_CONFIGS).filter(c => c !== "SHARED").sort();
33
44
  }
34
45
  function redact(text, options) {
35
46
  return getInstance().redact(text, options);
36
47
  }
48
+ /**
49
+ * Redact, awaiting the cloud tier when `mode: "cloud"` is asked for.
50
+ *
51
+ * `mode: "rules"` resolves immediately with exactly what `redact()` returns,
52
+ * so a caller that may or may not use the cloud tier can hold one code path.
53
+ */
54
+ function redactAsync(text, options) {
55
+ return getInstance().redactAsync(text, options);
56
+ }
37
57
  function addCustomPattern(name, pattern) {
38
58
  getInstance().addCustomPattern(name, pattern);
39
59
  }
@@ -99,7 +99,14 @@ const SHARED = {
99
99
  // is Unicode-aware) matched local parts like "petras_ž@..." while this one
100
100
  // did not. \p{L}\p{N} with the `u` flag restores parity; the leading guard
101
101
  // is a Unicode-safe word boundary.
102
- p(types_js_1.EntityType.EMAIL, String.raw `(?<![\p{L}\p{N}._%+\-])[\p{L}\p{N}._%+\-]+@(?:[\p{L}\p{N}\-]+\.)+\p{L}{2,}\b`),
102
+ // The apostrophe is allowed only between word characters: it belongs in a
103
+ // local part, but putting it in the class would let the match absorb a
104
+ // quote from the surrounding text, while leaving it out made
105
+ // `johno'neill@outlook.ie` mask as `johno'[EMAIL]` (issue rules-engine#10).
106
+ // The second lookbehind refuses to start a match after `<word-char>'`,
107
+ // which is a match already in progress; without it every apostrophe was a
108
+ // fresh start offset and the scan went quadratic. Mirrors Python.
109
+ p(types_js_1.EntityType.EMAIL, String.raw `(?<![\p{L}\p{N}._%+\-])(?<![\p{L}\p{N}._%+\-]')[\p{L}\p{N}._%+\-]+(?:'[\p{L}\p{N}._%+\-]+)*@(?:[\p{L}\p{N}\-]+\.)+\p{L}{2,}\b`),
103
110
  // IBAN (any country) — structure + mod-97 only. Country-specific IBAN
104
111
  // patterns still exist and win on identical spans; this one guarantees an
105
112
  // IBAN is never missed just because its country was not requested.
@@ -109,7 +116,11 @@ const SHARED = {
109
116
  // "+43 664 8213 907" was invisible to the Austrian pattern, which expects
110
117
  // an unbroken subscriber number. A leading "+" is self-identifying, so
111
118
  // this is deliberately not country-gated.
112
- p(types_js_1.EntityType.PHONE, String.raw `\+\d{1,3}(?:[\s\-/]?\(?\d{1,4}\)?){2,7}`, "e164", "International phone number (E.164) — any country"),
119
+ // Parentheses are matched as a pair inside one group: `\(?...\)?` let the
120
+ // closing parenthesis of the surrounding text join the number, so
121
+ // "Call (+32 475 12 34 56) today." also masked the ")" (issue
122
+ // rules-engine#3). Mirrors the Python pattern character for character.
123
+ p(types_js_1.EntityType.PHONE, String.raw `\+\d{1,3}(?:[\s\-/]?(?:\(\d{1,4}\)|\d{1,4})){2,7}`, "e164", "International phone number (E.164) — any country"),
113
124
  p(types_js_1.EntityType.BIC, String.raw `\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b`, "bic"),
114
125
  // Space-separated form: "RZBA AT WW", "NICA BE BB". Written as a separate
115
126
  // pattern requiring a space between *every* group, rather than making the
@@ -120,7 +131,15 @@ const SHARED = {
120
131
  p(types_js_1.EntityType.CREDIT_CARD, String.raw `\b(?:4[0-9]{3}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}|5[1-5][0-9]{2}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}|3[47][0-9]{2}[\s\-]?[0-9]{6}[\s\-]?[0-9]{5})\b`, "luhn"),
121
132
  p(types_js_1.EntityType.VIN, String.raw `\b[A-HJ-NPR-Z0-9]{17}\b`, "vin"),
122
133
  p(types_js_1.EntityType.IP_ADDRESS, String.raw `\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`),
123
- p(types_js_1.EntityType.IPV6_ADDRESS, String.raw `\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|\b(?:[0-9a-fA-F]{1,4}:){1,7}:\b|\b(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}\b|\b::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}\b`),
134
+ // Branch order is load-bearing: alternation is leftmost-first, not
135
+ // longest-match, so the form that consumes most is tried first, and the
136
+ // IPv4-embedded branches lead because every group of a dotted quad is
137
+ // also valid hex. `\b` is the wrong anchor around ":" (a non-word
138
+ // character), which is why `::1` and `2001:db8::` were not detected at
139
+ // all. A bare "::" is deliberately not matched. Mirrors the Python
140
+ // pattern character for character -- see the comment there
141
+ // (issue rules-engine#5).
142
+ p(types_js_1.EntityType.IPV6_ADDRESS, String.raw `(?<![0-9A-Za-z_:.])(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|(?:[0-9A-Fa-f]{1,4}:){1,5}:(?:[0-9A-Fa-f]{1,4}:)*(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|::(?:[0-9A-Fa-f]{1,4}:)*(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}(?::[0-9A-Fa-f]{1,4}){1,7}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|:(?::[0-9A-Fa-f]{1,4}){1,7})(?:%[0-9A-Za-z][0-9A-Za-z._-]*)?(?![0-9A-Za-z_:])`),
124
143
  p(types_js_1.EntityType.MAC_ADDRESS, String.raw `\b(?:[0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}\b`, null, "colon/dash"),
125
144
  p(types_js_1.EntityType.MAC_ADDRESS, String.raw `\b[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\b`, null, "Cisco"),
126
145
  p(types_js_1.EntityType.IMEI, String.raw `\b\d{15}\b`, "imei"),
@@ -224,12 +243,12 @@ const DE = {
224
243
  patterns: [
225
244
  p(types_js_1.EntityType.TAX_ID, String.raw `\b[1-9]\d{10}\b`, "german_tax_id"),
226
245
  p(types_js_1.EntityType.TAX_ID, String.raw `\b[1-9]\d[\s.]?\d{3}[\s.]?\d{3}[\s.]?\d{3}\b`, "german_tax_id"),
227
- p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b[CFGHJKLMNPRTVWXYZ][0-9A-Z]{8,9}\b`, null, "", ["Personalausweis", "Personalausweisnummer", "Ausweis", "Ausweisnummer", "Ausweis-Nr", "identity card", "Identitätskarte", "Perso", "PA-Nummer"], true),
246
+ p(types_js_1.EntityType.NATIONAL_ID, String.raw `\b[CFGHJKLMNPRTVWXYZ][0-9CFGHJKLMNPRTVWXYZ]{8}\d?\b`, null, "", ["Personalausweis", "Personalausweisnummer", "Ausweis", "Ausweisnummer", "Ausweis-Nr", "identity card", "Identitätskarte", "Perso", "PA-Nummer"], true),
228
247
  p(types_js_1.EntityType.IBAN, String.raw `\bDE\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{2}\b`, "iban"),
229
248
  p(types_js_1.EntityType.VAT, String.raw `\bDE\s?\d{9}\b`, "vat_de"),
230
249
  p(types_js_1.EntityType.PHONE, String.raw `\b0[1-9]\d{1,4}[\s/\-]?\d{3,8}\b`),
231
250
  p(types_js_1.EntityType.PHONE, String.raw `\+49\s?\d{2,5}[\s/\-]?\d{3,8}\b`),
232
- p(types_js_1.EntityType.PASSPORT, String.raw `\b[CFGHJK][0-9A-Z]{8,9}\b`, null, "", ["Reisepass", "passport", "Passnummer", "Reisepassnummer", "Reisepass Nummer", "Reisepass-Nr", "Pass Nr"], true),
251
+ p(types_js_1.EntityType.PASSPORT, String.raw `\b[CFGHJK][0-9CFGHJKLMNPRTVWXYZ]{8}\d?\b`, null, "", ["Reisepass", "passport", "Passnummer", "Reisepassnummer", "Reisepass Nummer", "Reisepass-Nr", "Pass Nr"], true),
233
252
  // The separator after the city code is mandatory. When it was optional, a
234
253
  // contiguous letter run split across both groups and the hyphen was
235
254
  // consumed as the *second* separator, so any "LETTERS-DIGITS" token