euredact 0.5.1 → 0.7.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.
Files changed (57) hide show
  1. package/NOTICE +11 -0
  2. package/README.md +658 -45
  3. package/dist/cjs/cache.js +2 -8
  4. package/dist/cjs/cloud/account.js +180 -0
  5. package/dist/cjs/cloud/batches.js +469 -0
  6. package/dist/cjs/cloud/client.js +131 -18
  7. package/dist/cjs/cloud/config.js +28 -2
  8. package/dist/cjs/cloud/errors.js +39 -2
  9. package/dist/cjs/cloud/index.js +16 -1
  10. package/dist/cjs/index.js +9 -1
  11. package/dist/cjs/platform.browser.js +98 -0
  12. package/dist/cjs/platform.js +140 -0
  13. package/dist/cjs/rules/bicGleif.js +57 -0
  14. package/dist/cjs/rules/bicRegistry.js +37 -11
  15. package/dist/cjs/rules/countries/index.js +138 -48
  16. package/dist/cjs/rules/cues.js +29 -9
  17. package/dist/cjs/rules/engine.js +14 -6
  18. package/dist/cjs/rules/suppressors.js +418 -23
  19. package/dist/cjs/rules/validators.js +103 -0
  20. package/dist/cjs/sdk.js +150 -44
  21. package/dist/cjs/types.js +4 -1
  22. package/dist/esm/cache.js +2 -8
  23. package/dist/esm/cloud/account.d.ts +155 -0
  24. package/dist/esm/cloud/account.js +175 -0
  25. package/dist/esm/cloud/batches.d.ts +166 -0
  26. package/dist/esm/cloud/batches.js +464 -0
  27. package/dist/esm/cloud/client.d.ts +90 -5
  28. package/dist/esm/cloud/client.js +126 -26
  29. package/dist/esm/cloud/config.d.ts +6 -0
  30. package/dist/esm/cloud/config.js +27 -2
  31. package/dist/esm/cloud/errors.d.ts +26 -1
  32. package/dist/esm/cloud/errors.js +35 -1
  33. package/dist/esm/cloud/index.d.ts +4 -2
  34. package/dist/esm/cloud/index.js +4 -2
  35. package/dist/esm/index.d.ts +2 -2
  36. package/dist/esm/index.js +1 -1
  37. package/dist/esm/platform.browser.d.ts +27 -0
  38. package/dist/esm/platform.browser.js +92 -0
  39. package/dist/esm/platform.d.ts +20 -0
  40. package/dist/esm/platform.js +101 -0
  41. package/dist/esm/rules/bicGleif.d.ts +4 -0
  42. package/dist/esm/rules/bicGleif.js +53 -0
  43. package/dist/esm/rules/bicRegistry.d.ts +25 -11
  44. package/dist/esm/rules/bicRegistry.js +36 -11
  45. package/dist/esm/rules/countries/index.js +138 -48
  46. package/dist/esm/rules/cues.d.ts +1 -1
  47. package/dist/esm/rules/cues.js +29 -9
  48. package/dist/esm/rules/engine.js +14 -6
  49. package/dist/esm/rules/suppressors.d.ts +1 -0
  50. package/dist/esm/rules/suppressors.js +417 -22
  51. package/dist/esm/rules/validators.d.ts +14 -0
  52. package/dist/esm/rules/validators.js +100 -0
  53. package/dist/esm/sdk.d.ts +63 -19
  54. package/dist/esm/sdk.js +148 -44
  55. package/dist/esm/types.d.ts +58 -2
  56. package/dist/esm/types.js +4 -1
  57. package/package.json +12 -4
package/dist/cjs/cache.js CHANGED
@@ -2,7 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ResultCache = void 0;
4
4
  exports.resultChars = resultChars;
5
- const node_crypto_1 = require("node:crypto");
5
+ const platform_js_1 = require("./platform.js");
6
6
  // Default ceiling on retained characters, not entries. A count-only cap of
7
7
  // 1024 entries says nothing about memory: 1024 cached 1 MB documents is a
8
8
  // gigabyte of PII-bearing text held live. ~16M characters is a few tens of MB.
@@ -37,13 +37,7 @@ class ResultCache {
37
37
  // deployment an attacker who can guess a victim's document could submit a
38
38
  // same-length colliding one and be served the victim's cached result,
39
39
  // detections and raw matched PII included.
40
- return (0, node_crypto_1.createHash)("sha256")
41
- .update(text)
42
- .update("|")
43
- .update(sorted.join("|"))
44
- .update("|")
45
- .update(mode)
46
- .digest("hex");
40
+ return (0, platform_js_1.sha256Hex)([text, "|", sorted.join("|"), "|", mode]);
47
41
  }
48
42
  get(key) {
49
43
  if (!this.enabled)
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ /**
3
+ * [CLOUD EXTENSION] The account and job endpoints an API key may call.
4
+ *
5
+ * Everything a customer's key can do on the service is reachable from the SDK
6
+ * (rules-engine#89): redaction (`CloudClient`, `redactAsync`), batches
7
+ * (`Batches`), a past job (`Jobs`) and the account itself (`Account`). What
8
+ * needs a signed-in person -- logging in, minting keys, changing settings,
9
+ * members, accepting terms -- is not here, because a key cannot do it.
10
+ *
11
+ * Every typed value carries `raw`, the service's JSON, so a field the SDK does
12
+ * not know yet is still reachable.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.Jobs = exports.Account = void 0;
16
+ const config_js_1 = require("./config.js");
17
+ const errors_js_1 = require("./errors.js");
18
+ const client_js_1 = require("./client.js");
19
+ const str = (v) => (typeof v === "string" ? v : null);
20
+ const int = (v) => ((0, client_js_1.isInteger)(v) ? v : null);
21
+ const bool = (v) => (typeof v === "boolean" ? v : null);
22
+ const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
23
+ const objects = (v) => (Array.isArray(v) ? v : []).filter((x) => !!x && typeof x === "object" && !Array.isArray(x));
24
+ const usageDay = (r) => ({
25
+ day: str(r.day), documents: int(r.documents), tokens: int(r.tokens),
26
+ failures: int(r.failures), rulesOnly: int(r.rules_only), raw: r,
27
+ });
28
+ /** A JSON client: GETs are retried like the redact call; writes are sent once. */
29
+ class ApiClient {
30
+ config;
31
+ fetchImpl;
32
+ constructor(config, options = {}) {
33
+ const resolved = config ?? (0, config_js_1.getConfig)();
34
+ if (resolved === null)
35
+ throw new errors_js_1.NotConfiguredError();
36
+ (0, config_js_1.requireSecureBaseUrl)(resolved.baseUrl);
37
+ this.config = resolved;
38
+ this.fetchImpl = options.fetchImpl;
39
+ }
40
+ async call(method, path, params = {}, retry = method === "GET") {
41
+ const doFetch = (0, client_js_1.requireFetch)(this.fetchImpl);
42
+ const query = Object.entries(params).map(([k, v]) => `${k}=${v}`).join("&");
43
+ const url = `${this.config.baseUrl}${path}${query ? `?${query}` : ""}`;
44
+ const maxRetries = retry ? this.config.maxRetries : 0;
45
+ for (let attempt = 0;; attempt++) {
46
+ let response;
47
+ let payload = {};
48
+ const controller = new AbortController();
49
+ const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
50
+ try {
51
+ response = await doFetch(url, {
52
+ method,
53
+ headers: {
54
+ Authorization: `Bearer ${this.config.apiKey}`,
55
+ "Idempotency-Key": (0, client_js_1.uuid)(),
56
+ ...this.config.headers,
57
+ },
58
+ signal: controller.signal,
59
+ });
60
+ payload = (await (0, client_js_1.readJson)(response));
61
+ }
62
+ catch (e) {
63
+ if (attempt >= maxRetries) {
64
+ throw new errors_js_1.CloudError(`cloud request failed: ${e instanceof Error ? e.message : String(e)}`);
65
+ }
66
+ }
67
+ finally {
68
+ clearTimeout(timer);
69
+ }
70
+ if (response) {
71
+ if (response.status === 200)
72
+ return payload;
73
+ if (!client_js_1.RETRY_STATUS.has(response.status) || attempt >= maxRetries
74
+ || (response.status === 429 && (0, client_js_1.isQuota)(payload))) {
75
+ throw (0, client_js_1.cloudErrorFor)(response.status, payload);
76
+ }
77
+ }
78
+ await (0, client_js_1.sleep)((0, client_js_1.backoffMs)(attempt, (0, client_js_1.retryAfterMs)(response?.headers)));
79
+ }
80
+ }
81
+ }
82
+ /**
83
+ * The account an API key belongs to: summary, credits, usage and keys.
84
+ *
85
+ * These calls pass with an API key while the service accepts account calls
86
+ * without a browser `Origin` (its `EUREDACT_CONSOLE_REQUIRE_ORIGIN=0`); a
87
+ * refusal arrives as a `CloudError` with status 403.
88
+ */
89
+ class Account extends ApiClient {
90
+ async summary() {
91
+ const raw = await this.call("GET", "/v1/account");
92
+ const tenant = obj(raw.tenant), retention = obj(raw.retention), usage = obj(raw.usage);
93
+ return {
94
+ tenantId: str(tenant.id), tenantName: str(tenant.name), active: bool(tenant.active),
95
+ memberSince: str(tenant.member_since), role: str(raw.role),
96
+ retentionMode: str(retention.mode), payloadTtlHours: int(retention.payload_ttl_hours),
97
+ documentsToday: int(usage.documents_today), documentsThisMonth: int(usage.documents_this_month),
98
+ tokensThisMonth: int(usage.tokens_this_month), failures24h: int(usage.failures_24h),
99
+ dailyQuota: int(usage.daily_quota), quotaRemaining: int(usage.quota_remaining),
100
+ activeKeys: int(obj(raw.keys).active), batchesAvailable: bool(obj(raw.batches).available),
101
+ raw,
102
+ };
103
+ }
104
+ async credits() {
105
+ const raw = await this.call("GET", "/v1/account/credits");
106
+ return { balance: int(raw.balance), granted: int(raw.granted), spent: int(raw.spent),
107
+ unit: str(raw.unit), raw };
108
+ }
109
+ /** Recent credit movements, newest first (1-500). */
110
+ async creditHistory(limit = 50) {
111
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
112
+ throw new RangeError("limit must be between 1 and 500");
113
+ }
114
+ const data = await this.call("GET", "/v1/account/credits/history", { limit });
115
+ return objects(data.entries).map(r => ({
116
+ at: str(r.at), delta: int(r.delta), reason: str(r.reason), count: int(r.count),
117
+ jobId: str(r.job_id), sourceRef: str(r.source_ref), raw: r,
118
+ }));
119
+ }
120
+ /** Documents, tokens and failures per day over the last `days`. */
121
+ async usage(days = 30) {
122
+ if (!Number.isInteger(days) || days < 1)
123
+ throw new RangeError("days must be at least 1");
124
+ const data = await this.call("GET", "/v1/account/usage", { days });
125
+ return objects(data.series).map(usageDay);
126
+ }
127
+ /** The same window, split by the key that submitted the work. */
128
+ async usageByKey(days = 30) {
129
+ if (!Number.isInteger(days) || days < 1)
130
+ throw new RangeError("days must be at least 1");
131
+ const data = await this.call("GET", "/v1/account/usage/by-key", { days });
132
+ return objects(data.keys).map(r => ({
133
+ keyId: int(r.key_id), name: str(r.name), documents: int(r.documents),
134
+ tokens: int(r.tokens), failures: int(r.failures), series: objects(r.series).map(usageDay), raw: r,
135
+ }));
136
+ }
137
+ async keys() {
138
+ const data = await this.call("GET", "/v1/account/keys");
139
+ return objects(data.keys).map(r => ({
140
+ id: int(r.id), name: str(r.name), createdBy: str(r.created_by), createdAt: str(r.created_at),
141
+ lastUsedAt: str(r.last_used_at), revokedAt: str(r.revoked_at), active: bool(r.active), raw: r,
142
+ }));
143
+ }
144
+ /**
145
+ * Revoke a key, e.g. one that leaked: the one write a key may make. Sent once,
146
+ * never retried. Already revoked throws a `CloudError` with status 409; a key
147
+ * that does not exist, or is another account's, throws `NotFoundError`.
148
+ * Revoking the key this client uses works, and is reported by `selfRevoked`.
149
+ */
150
+ async revokeKey(keyId) {
151
+ if (!Number.isInteger(keyId))
152
+ throw new RangeError("keyId must be an integer");
153
+ const raw = await this.call("POST", `/v1/account/keys/${keyId}/revoke`);
154
+ return { id: int(raw.id), keysRemaining: int(raw.keys_remaining), lockedOut: bool(raw.locked_out),
155
+ selfRevoked: bool(raw.self_revoked), raw };
156
+ }
157
+ }
158
+ exports.Account = Account;
159
+ /** Past jobs, by id. */
160
+ class Jobs extends ApiClient {
161
+ /**
162
+ * A job's state, and its result if it has one. Throws `ResultExpiredError`
163
+ * when the job succeeded but its result is no longer retained, and
164
+ * `NotFoundError` for an unknown job (or another account's).
165
+ */
166
+ async retrieve(jobId) {
167
+ const raw = await this.call("GET", `/v1/jobs/${encodeURIComponent(jobId)}`);
168
+ const result = typeof raw.redacted_text === "string"
169
+ ? (0, client_js_1.toResult)(raw, raw.redacted_text)
170
+ : null;
171
+ return {
172
+ jobId: str(raw.job_id) ?? jobId,
173
+ status: str(raw.status) ?? (result ? "succeeded" : ""),
174
+ createdAt: str(raw.created_at),
175
+ result,
176
+ raw,
177
+ };
178
+ }
179
+ }
180
+ exports.Jobs = Jobs;
@@ -0,0 +1,469 @@
1
+ "use strict";
2
+ /**
3
+ * [CLOUD EXTENSION] Batches: cloud mode, with hours between masking and mapping
4
+ * (rules-engine#84; the gateway side is `docs/BATCHES.md` in euredact-inference).
5
+ *
6
+ * Cloud mode masks a document here, sends only the masked text and maps the
7
+ * service's answer back onto the original, all in one call. A batch returns up
8
+ * to 24 h later, so `create()` writes one **local batch file** per batch: each
9
+ * original, its local detections and the SHA-256 of the masked text that was
10
+ * sent. None of it is uploaded. `results()` maps the service's spans back from
11
+ * that file, checking the hash before placing a span, then wipes the originals
12
+ * and leaves a text-free receipt.
13
+ *
14
+ * The file format is shared with the Python SDK, offsets counted in code points,
15
+ * so a batch created by one can be resolved by the other.
16
+ *
17
+ * In Node the file is `~/.euredact/batches/<batchId>.json` (`0600` in a `0700`
18
+ * directory); `batchDir` moves it. A page has no filesystem, so there `store`
19
+ * is required: any object with async `read`, `write`, `delete` and `list`.
20
+ * `cipher` encrypts the file at rest with a key the caller holds.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.Batches = exports.BatchError = exports.FILE_FORMAT = exports.MAX_DOCUMENT_TOKENS = exports.MAX_BODY_BYTES = exports.CUSTOM_ID = exports.MAX_CUSTOM_ID = exports.MAX_DOCUMENTS = void 0;
24
+ const sdk_js_1 = require("../sdk.js");
25
+ const platform_js_1 = require("../platform.js");
26
+ const client_js_1 = require("./client.js");
27
+ const config_js_1 = require("./config.js");
28
+ const errors_js_1 = require("./errors.js");
29
+ /** The gateway's limits (docs/BATCHES.md §2-§3), checked before upload. */
30
+ exports.MAX_DOCUMENTS = 5000;
31
+ exports.MAX_CUSTOM_ID = 64;
32
+ /** The gateway's customId rule (euredact-inference gateway/batches.py), whole string. */
33
+ exports.CUSTOM_ID = /^[A-Za-z0-9_.:\-]{1,64}$/;
34
+ exports.MAX_BODY_BYTES = 128 * 1024 * 1024;
35
+ /**
36
+ * Per document, counted by the model's tokenizer on the gateway. The SDK has no
37
+ * copy of it and no character count decides it, so it is **not** checked before
38
+ * upload: a document over it comes back as an error with code `too_long`.
39
+ */
40
+ exports.MAX_DOCUMENT_TOKENS = 5000;
41
+ exports.FILE_FORMAT = "euredact-batch/1";
42
+ class BatchError extends errors_js_1.CloudError {
43
+ constructor(message) {
44
+ super(message);
45
+ this.name = "BatchError";
46
+ }
47
+ }
48
+ exports.BatchError = BatchError;
49
+ const encoder = new TextEncoder();
50
+ const decoder = new TextDecoder();
51
+ function now() {
52
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
53
+ }
54
+ /** UTF-16 offset -> code point offset, for writing the shared file format. */
55
+ function utf16ToCodePoints(text) {
56
+ if (!/[\uD800-\uDBFF]/.test(text))
57
+ return unit => unit;
58
+ const map = new Map();
59
+ let cp = 0;
60
+ let unit = 0;
61
+ for (const ch of text) {
62
+ map.set(unit, cp);
63
+ unit += ch.length;
64
+ cp += 1;
65
+ }
66
+ map.set(unit, cp);
67
+ return u => map.get(u) ?? u;
68
+ }
69
+ function toStored(d, cp) {
70
+ return {
71
+ type: String(d.entityType), start: cp(d.start), end: cp(d.end), text: d.text,
72
+ source: String(d.source), country: d.country ?? null, confidence: d.confidence ?? "high",
73
+ country_confidence: d.countryConfidence ?? 0, out_of_scope: d.outOfScope ?? false,
74
+ };
75
+ }
76
+ function fromStored(raw, unit) {
77
+ return {
78
+ entityType: raw.type, start: unit(raw.start), end: unit(raw.end),
79
+ text: raw.text, source: raw.source, country: raw.country,
80
+ confidence: raw.confidence, countryConfidence: raw.country_confidence,
81
+ outOfScope: raw.out_of_scope,
82
+ };
83
+ }
84
+ // ── the client ────────────────────────────────────────────────────────────
85
+ class Batches {
86
+ config;
87
+ store;
88
+ cipher;
89
+ fetchImpl;
90
+ sdk;
91
+ constructor(options = {}) {
92
+ const config = options.config ?? (0, config_js_1.getConfig)();
93
+ if (config === null)
94
+ throw new errors_js_1.NotConfiguredError();
95
+ (0, config_js_1.requireSecureBaseUrl)(config.baseUrl);
96
+ this.config = config;
97
+ if (options.batchDir !== undefined && options.store !== undefined) {
98
+ throw new Error("pass batchDir or store, not both");
99
+ }
100
+ const store = options.store ?? (0, platform_js_1.defaultBatchStore)(options.batchDir);
101
+ if (store === null) {
102
+ throw new Error("no filesystem here: pass a store ({ read, write, delete, list }) to Batches");
103
+ }
104
+ this.store = store;
105
+ this.cipher = options.cipher ?? null;
106
+ this.fetchImpl = options.fetchImpl;
107
+ this.sdk = options.sdk ?? null;
108
+ }
109
+ // -- plumbing ---------------------------------------------------------
110
+ engine() {
111
+ if (this.sdk === null)
112
+ this.sdk = new sdk_js_1.EuRedact();
113
+ return this.sdk;
114
+ }
115
+ async request(method, path, body, headers = {}) {
116
+ const impl = this.fetchImpl ?? globalThis.fetch;
117
+ if (!impl)
118
+ throw new errors_js_1.CloudError("no fetch implementation available");
119
+ try {
120
+ return await impl(`${this.config.baseUrl}${path}`, {
121
+ method,
122
+ body: body,
123
+ headers: {
124
+ Authorization: `Bearer ${this.config.apiKey}`,
125
+ "Idempotency-Key": headers["Idempotency-Key"] ?? crypto.randomUUID(),
126
+ ...this.config.headers,
127
+ ...headers,
128
+ },
129
+ });
130
+ }
131
+ catch (err) {
132
+ throw new errors_js_1.CloudError(`batch request failed: ${err.message}`);
133
+ }
134
+ }
135
+ async json(resp) {
136
+ try {
137
+ return (await resp.json());
138
+ }
139
+ catch {
140
+ return {};
141
+ }
142
+ }
143
+ raiseFor(status, payload) {
144
+ throw (0, client_js_1.cloudErrorFor)(status, payload);
145
+ }
146
+ async load(batchId) {
147
+ let data = await this.store.read(batchId);
148
+ if (data === null)
149
+ return null;
150
+ if (this.cipher)
151
+ data = await this.cipher.decrypt(data);
152
+ const state = JSON.parse(decoder.decode(data));
153
+ if (state.format !== exports.FILE_FORMAT) {
154
+ throw new BatchError(`local batch file ${batchId}: unknown format ${JSON.stringify(state.format)}`);
155
+ }
156
+ return state;
157
+ }
158
+ async save(state) {
159
+ let data = encoder.encode(JSON.stringify(state));
160
+ if (this.cipher)
161
+ data = await this.cipher.encrypt(data);
162
+ await this.store.write(state.batch_id, data);
163
+ }
164
+ static batch(raw) {
165
+ const str = (k) => (typeof raw[k] === "string" ? raw[k] : null);
166
+ return {
167
+ id: str("id") ?? "", status: str("status") ?? "",
168
+ createdAt: str("created_at"), expiresAt: str("expires_at"), endedAt: str("ended_at"),
169
+ resultsExpireAt: str("results_expire_at"),
170
+ counts: { ...(raw.counts ?? {}) },
171
+ documents: (0, client_js_1.isInteger)(raw.documents) ? raw.documents : null,
172
+ tokens: Object.fromEntries(Object.entries(raw.tokens && typeof raw.tokens === "object" ? raw.tokens : {}).filter((e) => (0, client_js_1.isInteger)(e[1]))),
173
+ creditsCharged: (0, client_js_1.isInteger)(raw.credits_charged) ? raw.credits_charged : null,
174
+ billingRate: typeof raw.billing_rate === "number" ? raw.billing_rate : null,
175
+ raw,
176
+ };
177
+ }
178
+ // -- create -----------------------------------------------------------
179
+ /**
180
+ * Mask every document here, upload only the masked text, keep the rest in
181
+ * the local batch file. The document count, `customId`s, countries and body
182
+ * size are checked before anything is uploaded or written. The 5,000-token
183
+ * limit per document is **not**: it is counted by the model's tokenizer on
184
+ * the gateway, which the SDK does not have, so a document over it comes back
185
+ * from `results()` as an error with code `too_long` that says so.
186
+ */
187
+ async create(documents, options = {}) {
188
+ if (documents.length === 0)
189
+ throw new BatchError("a batch needs at least one document");
190
+ if (documents.length > exports.MAX_DOCUMENTS) {
191
+ throw new BatchError(`${documents.length.toLocaleString("en-US")} documents; a batch holds at most ` +
192
+ `${exports.MAX_DOCUMENTS.toLocaleString("en-US")}`);
193
+ }
194
+ // Every document is checked before any is masked: masking 5,000 documents
195
+ // and then learning that the gateway refuses the batch for one bad customId
196
+ // wastes the whole pass (rules-engine#88).
197
+ const seen = new Set();
198
+ const checked = [];
199
+ documents.forEach((doc, index) => {
200
+ const number = index + 1;
201
+ const customId = doc.customId;
202
+ if (typeof customId !== "string" || !exports.CUSTOM_ID.test(customId)) {
203
+ throw new BatchError(`document ${number}: customId must be 1-${exports.MAX_CUSTOM_ID} characters of A-Z a-z 0-9 _ . : -`);
204
+ }
205
+ if (seen.has(customId)) {
206
+ throw new BatchError(`document ${number}: duplicate customId ${JSON.stringify(customId)}`);
207
+ }
208
+ seen.add(customId);
209
+ if (typeof doc.text !== "string")
210
+ throw new BatchError(`document ${number}: text must be a string`);
211
+ const countries = (doc.countries ?? []).map(c => c.toUpperCase());
212
+ if (countries.length !== 1) {
213
+ throw new BatchError(`document ${number}: exactly one country is needed, as in cloud mode`);
214
+ }
215
+ checked.push({ customId, text: doc.text, countries, language: doc.language ?? "" });
216
+ });
217
+ const lines = [];
218
+ const entries = {};
219
+ const engine = this.engine();
220
+ for (const { customId, text, countries, language } of checked) {
221
+ // The same local pass as redactAsync(..., { mode: "cloud" }): dates on, no
222
+ // allowlist, no tokens. What is sent is what cloud mode would send.
223
+ const local = engine.redact(text, { countries, detectDates: true, cache: false });
224
+ const [masked] = (0, sdk_js_1.maskForCloud)(text, local.detections);
225
+ const line = { custom_id: customId, text: masked, countries };
226
+ if (language)
227
+ line.language = language;
228
+ lines.push(JSON.stringify(line));
229
+ const cp = utf16ToCodePoints(text);
230
+ entries[customId] = {
231
+ text: text,
232
+ masked_sha256: (0, platform_js_1.sha256Hex)([masked]),
233
+ countries,
234
+ language,
235
+ detections: local.detections.map(d => toStored(d, cp)),
236
+ detection_mode: local.detectionMode,
237
+ inferred_countries: local.inferredCountries,
238
+ };
239
+ }
240
+ const body = encoder.encode(lines.join("\n") + "\n");
241
+ if (body.length > exports.MAX_BODY_BYTES) {
242
+ throw new BatchError(`the masked batch is ${body.length.toLocaleString("en-US")} bytes; the limit is ` +
243
+ `${exports.MAX_BODY_BYTES.toLocaleString("en-US")}`);
244
+ }
245
+ const compressed = (0, platform_js_1.gzip)(body);
246
+ const headers = {
247
+ "Content-Type": "application/x-ndjson",
248
+ "Idempotency-Key": options.idempotencyKey ?? crypto.randomUUID(),
249
+ };
250
+ if (compressed !== null)
251
+ headers["Content-Encoding"] = "gzip";
252
+ const resp = await this.request("POST", "/v1/batches", compressed ?? body, headers);
253
+ const payload = await this.json(resp);
254
+ if (resp.status !== 200 && resp.status !== 201)
255
+ this.raiseFor(resp.status, payload);
256
+ const batch = Batches.batch(payload);
257
+ if (!batch.id)
258
+ throw new BatchError("the gateway accepted the batch but returned no id");
259
+ await this.save({
260
+ format: exports.FILE_FORMAT,
261
+ batch_id: batch.id,
262
+ status: "pending",
263
+ created_at: batch.createdAt ?? now(),
264
+ expires_at: batch.expiresAt,
265
+ results_expire_at: null,
266
+ resolved_at: null,
267
+ engine: "euredact-ts",
268
+ engine_version: null,
269
+ counts: { documents: Object.keys(entries).length },
270
+ entries,
271
+ });
272
+ return batch;
273
+ }
274
+ // -- retrieve, cancel, pending, purge, sweep ------------------------------
275
+ /** The gateway's view: status, counts and timestamps. */
276
+ async retrieve(batchId) {
277
+ await this.sweep();
278
+ const resp = await this.request("GET", `/v1/batches/${encodeURIComponent(batchId)}`);
279
+ const payload = await this.json(resp);
280
+ if (resp.status !== 200)
281
+ this.raiseFor(resp.status, payload);
282
+ const batch = Batches.batch(payload);
283
+ const state = await this.load(batchId);
284
+ if (state !== null && batch.resultsExpireAt && state.results_expire_at !== batch.resultsExpireAt) {
285
+ state.results_expire_at = batch.resultsExpireAt;
286
+ await this.save(state);
287
+ }
288
+ return batch;
289
+ }
290
+ /**
291
+ * This account's batches, newest first (1-100, the gateway's bound). The
292
+ * gateway's view only: `pending()` lists the ones with a local file here.
293
+ */
294
+ async list(limit = 20) {
295
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
296
+ throw new RangeError("limit must be between 1 and 100");
297
+ }
298
+ const resp = await this.request("GET", `/v1/batches?limit=${limit}`);
299
+ const payload = await this.json(resp);
300
+ if (resp.status !== 200)
301
+ this.raiseFor(resp.status, payload);
302
+ const raw = Array.isArray(payload.batches) ? payload.batches : [];
303
+ return raw.filter((b) => !!b && typeof b === "object" && !Array.isArray(b))
304
+ .map(b => Batches.batch(b));
305
+ }
306
+ /** Cancel queued documents; the local file stays pending until the finished
307
+ * ones are mapped by `results()`. */
308
+ async cancel(batchId) {
309
+ const resp = await this.request("POST", `/v1/batches/${encodeURIComponent(batchId)}/cancel`);
310
+ const payload = await this.json(resp);
311
+ if (resp.status !== 200 && resp.status !== 202)
312
+ this.raiseFor(resp.status, payload);
313
+ return Batches.batch(payload);
314
+ }
315
+ /** Batch ids whose local file still waits to be mapped. Reads files only. */
316
+ async pending() {
317
+ await this.sweep();
318
+ const out = [];
319
+ for (const id of await this.store.list()) {
320
+ const state = await this.load(id);
321
+ if (state !== null && state.status === "pending")
322
+ out.push(id);
323
+ }
324
+ return out;
325
+ }
326
+ /** Delete the local file, whatever its status. */
327
+ async purge(batchId) {
328
+ await this.store.delete(batchId);
329
+ }
330
+ /**
331
+ * Delete receipts and expire pending files past `results_expire_at`. Runs at
332
+ * the start of the calls that look in the store, so nothing has to be
333
+ * scheduled. A pending file past that time can never be mapped -- the gateway
334
+ * has swept the results -- so its originals are wiped.
335
+ */
336
+ async sweep(at = new Date()) {
337
+ for (const id of await this.store.list()) {
338
+ let state;
339
+ try {
340
+ state = await this.load(id);
341
+ }
342
+ catch {
343
+ continue;
344
+ }
345
+ if (state === null || !state.results_expire_at)
346
+ continue;
347
+ if (at < new Date(state.results_expire_at))
348
+ continue;
349
+ if (state.status === "pending")
350
+ await this.wipe(state, "expired");
351
+ else
352
+ await this.store.delete(id);
353
+ }
354
+ }
355
+ async wipe(state, status) {
356
+ state.entries = {};
357
+ state.status = status;
358
+ state.resolved_at = now();
359
+ await this.save(state);
360
+ }
361
+ // -- results ----------------------------------------------------------
362
+ /**
363
+ * Map an ended batch back onto the originals in its local file. Each
364
+ * document's masked text is rebuilt from the file and must hash to both the
365
+ * stored SHA-256 and the gateway's `masked_sha256` before a span is placed.
366
+ * Anything that cannot be mapped is a per-document error, never the masked
367
+ * text passed off as a result.
368
+ */
369
+ async results(batchId) {
370
+ const state = await this.load(batchId);
371
+ if (state !== null && (state.status === "resolved" || state.status === "expired")) {
372
+ return {
373
+ batchId, documents: {}, batch: null,
374
+ status: state.status === "resolved" ? "already_resolved" : "expired",
375
+ };
376
+ }
377
+ const batch = await this.retrieve(batchId);
378
+ if (batch.status !== "ended")
379
+ return { batchId, status: "not_ended", documents: {}, batch };
380
+ const resp = await this.request("GET", `/v1/batches/${encodeURIComponent(batchId)}/results`);
381
+ if (resp.status === 410) {
382
+ if (state !== null)
383
+ await this.wipe(state, "expired");
384
+ return { batchId, status: "expired", documents: {}, batch };
385
+ }
386
+ if (resp.status === 409)
387
+ return { batchId, status: "not_ended", documents: {}, batch };
388
+ if (resp.status !== 200)
389
+ this.raiseFor(resp.status, await this.json(resp));
390
+ const entries = state?.entries ?? {};
391
+ const documents = {};
392
+ for (const line of (await resp.text()).split("\n")) {
393
+ if (!line.trim())
394
+ continue;
395
+ const row = JSON.parse(line);
396
+ const customId = row.custom_id ?? "";
397
+ documents[customId] = this.outcome(customId, row.result ?? {}, entries[customId], state !== null);
398
+ }
399
+ if (state !== null) {
400
+ const counts = {
401
+ documents: Object.keys(documents).length,
402
+ mapped: Object.values(documents).filter(o => o.ok).length,
403
+ };
404
+ for (const o of Object.values(documents)) {
405
+ if (o.error)
406
+ counts[o.error] = (counts[o.error] ?? 0) + 1;
407
+ }
408
+ state.counts = counts;
409
+ await this.wipe(state, "resolved");
410
+ }
411
+ return { batchId, status: "resolved", documents, batch };
412
+ }
413
+ outcome(customId, result, entry, fileExists) {
414
+ const fail = (error, message) => ({ customId, ok: false, result: null, error, message });
415
+ if (result.type !== "succeeded") {
416
+ const err = result.error ?? {};
417
+ const code = err.code ?? result.type ?? "errored";
418
+ let message = err.message ?? "";
419
+ if (code === "too_long") {
420
+ message = `${message}. The ${exports.MAX_DOCUMENT_TOKENS.toLocaleString("en-US")}-token limit is ` +
421
+ "counted by the model's tokenizer on the gateway; the SDK cannot check it before " +
422
+ "upload. Split the document and submit the parts.";
423
+ message = message.replace(/^\. /, "");
424
+ }
425
+ return fail(code, message);
426
+ }
427
+ if (entry === undefined) {
428
+ return fail("missing_local_entry", fileExists
429
+ ? "no entry for this document in the local batch file"
430
+ : "no local batch file on this machine");
431
+ }
432
+ const text = entry.text;
433
+ const unit = (0, client_js_1.codePointOffsets)(text);
434
+ const local = entry.detections.map(d => fromStored(d, unit));
435
+ const [masked, labels] = (0, sdk_js_1.maskForCloud)(text, local);
436
+ if ((0, platform_js_1.sha256Hex)([masked]) !== entry.masked_sha256 || result.masked_sha256 !== entry.masked_sha256) {
437
+ return fail("local_mismatch", "the local batch file does not match what was sent");
438
+ }
439
+ // The gateway's offsets count code points of the masked text; toResult
440
+ // converts them to UTF-16 units, as the cloud client does for redactAsync.
441
+ const remote = (0, client_js_1.toResult)({ entities: result.entities }, masked);
442
+ let placed;
443
+ try {
444
+ placed = (0, sdk_js_1.ontoOriginal)(remote.detections, masked, text, labels, errors_js_1.CloudError);
445
+ }
446
+ catch (err) {
447
+ return fail("local_mismatch", err.message);
448
+ }
449
+ const detections = [...local, ...placed].sort((a, b) => a.start - b.start || b.end - a.end);
450
+ return {
451
+ customId, ok: true, error: null, message: "",
452
+ result: {
453
+ redactedText: (0, sdk_js_1.applyReplacements)(text, detections, det => `[${det.entityType}]`),
454
+ detections,
455
+ source: "cloud",
456
+ degraded: false,
457
+ inferredCountries: entry.inferred_countries,
458
+ evidence: [],
459
+ detectionMode: entry.detection_mode,
460
+ tokens: {},
461
+ exempted: [],
462
+ // A batch line carries the model and anything unplaced; it has no job
463
+ // id and no usage of its own (the batch carries those).
464
+ cloud: (0, client_js_1.toCloudInfo)({ model_version: result.model_version, unlocated: result.unlocated }),
465
+ },
466
+ };
467
+ }
468
+ }
469
+ exports.Batches = Batches;