euredact 0.6.0 → 0.7.1

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 +455 -44
  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 +124 -14
  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 +83 -13
  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 +479 -26
  19. package/dist/cjs/rules/validators.js +50 -0
  20. package/dist/cjs/sdk.js +108 -14
  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 +83 -1
  28. package/dist/esm/cloud/client.js +119 -22
  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 +83 -13
  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 +478 -25
  51. package/dist/esm/rules/validators.d.ts +14 -0
  52. package/dist/esm/rules/validators.js +47 -0
  53. package/dist/esm/sdk.d.ts +77 -4
  54. package/dist/esm/sdk.js +108 -17
  55. package/dist/esm/types.d.ts +64 -6
  56. package/dist/esm/types.js +4 -1
  57. package/package.json +13 -4
@@ -0,0 +1,175 @@
1
+ /**
2
+ * [CLOUD EXTENSION] The account and job endpoints an API key may call.
3
+ *
4
+ * Everything a customer's key can do on the service is reachable from the SDK
5
+ * (rules-engine#89): redaction (`CloudClient`, `redactAsync`), batches
6
+ * (`Batches`), a past job (`Jobs`) and the account itself (`Account`). What
7
+ * needs a signed-in person -- logging in, minting keys, changing settings,
8
+ * members, accepting terms -- is not here, because a key cannot do it.
9
+ *
10
+ * Every typed value carries `raw`, the service's JSON, so a field the SDK does
11
+ * not know yet is still reachable.
12
+ */
13
+ import { getConfig, requireSecureBaseUrl } from "./config.js";
14
+ import { CloudError, NotConfiguredError } from "./errors.js";
15
+ import { RETRY_STATUS, backoffMs, cloudErrorFor, isInteger, isQuota, readJson, requireFetch, retryAfterMs, sleep, toResult, uuid, } from "./client.js";
16
+ const str = (v) => (typeof v === "string" ? v : null);
17
+ const int = (v) => (isInteger(v) ? v : null);
18
+ const bool = (v) => (typeof v === "boolean" ? v : null);
19
+ const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
20
+ const objects = (v) => (Array.isArray(v) ? v : []).filter((x) => !!x && typeof x === "object" && !Array.isArray(x));
21
+ const usageDay = (r) => ({
22
+ day: str(r.day), documents: int(r.documents), tokens: int(r.tokens),
23
+ failures: int(r.failures), rulesOnly: int(r.rules_only), raw: r,
24
+ });
25
+ /** A JSON client: GETs are retried like the redact call; writes are sent once. */
26
+ class ApiClient {
27
+ config;
28
+ fetchImpl;
29
+ constructor(config, options = {}) {
30
+ const resolved = config ?? getConfig();
31
+ if (resolved === null)
32
+ throw new NotConfiguredError();
33
+ requireSecureBaseUrl(resolved.baseUrl);
34
+ this.config = resolved;
35
+ this.fetchImpl = options.fetchImpl;
36
+ }
37
+ async call(method, path, params = {}, retry = method === "GET") {
38
+ const doFetch = requireFetch(this.fetchImpl);
39
+ const query = Object.entries(params).map(([k, v]) => `${k}=${v}`).join("&");
40
+ const url = `${this.config.baseUrl}${path}${query ? `?${query}` : ""}`;
41
+ const maxRetries = retry ? this.config.maxRetries : 0;
42
+ for (let attempt = 0;; attempt++) {
43
+ let response;
44
+ let payload = {};
45
+ const controller = new AbortController();
46
+ const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
47
+ try {
48
+ response = await doFetch(url, {
49
+ method,
50
+ headers: {
51
+ Authorization: `Bearer ${this.config.apiKey}`,
52
+ "Idempotency-Key": uuid(),
53
+ ...this.config.headers,
54
+ },
55
+ signal: controller.signal,
56
+ });
57
+ payload = (await readJson(response));
58
+ }
59
+ catch (e) {
60
+ if (attempt >= maxRetries) {
61
+ throw new CloudError(`cloud request failed: ${e instanceof Error ? e.message : String(e)}`);
62
+ }
63
+ }
64
+ finally {
65
+ clearTimeout(timer);
66
+ }
67
+ if (response) {
68
+ if (response.status === 200)
69
+ return payload;
70
+ if (!RETRY_STATUS.has(response.status) || attempt >= maxRetries
71
+ || (response.status === 429 && isQuota(payload))) {
72
+ throw cloudErrorFor(response.status, payload);
73
+ }
74
+ }
75
+ await sleep(backoffMs(attempt, retryAfterMs(response?.headers)));
76
+ }
77
+ }
78
+ }
79
+ /**
80
+ * The account an API key belongs to: summary, credits, usage and keys.
81
+ *
82
+ * These calls pass with an API key while the service accepts account calls
83
+ * without a browser `Origin` (its `EUREDACT_CONSOLE_REQUIRE_ORIGIN=0`); a
84
+ * refusal arrives as a `CloudError` with status 403.
85
+ */
86
+ export class Account extends ApiClient {
87
+ async summary() {
88
+ const raw = await this.call("GET", "/v1/account");
89
+ const tenant = obj(raw.tenant), retention = obj(raw.retention), usage = obj(raw.usage);
90
+ return {
91
+ tenantId: str(tenant.id), tenantName: str(tenant.name), active: bool(tenant.active),
92
+ memberSince: str(tenant.member_since), role: str(raw.role),
93
+ retentionMode: str(retention.mode), payloadTtlHours: int(retention.payload_ttl_hours),
94
+ documentsToday: int(usage.documents_today), documentsThisMonth: int(usage.documents_this_month),
95
+ tokensThisMonth: int(usage.tokens_this_month), failures24h: int(usage.failures_24h),
96
+ dailyQuota: int(usage.daily_quota), quotaRemaining: int(usage.quota_remaining),
97
+ activeKeys: int(obj(raw.keys).active), batchesAvailable: bool(obj(raw.batches).available),
98
+ raw,
99
+ };
100
+ }
101
+ async credits() {
102
+ const raw = await this.call("GET", "/v1/account/credits");
103
+ return { balance: int(raw.balance), granted: int(raw.granted), spent: int(raw.spent),
104
+ unit: str(raw.unit), raw };
105
+ }
106
+ /** Recent credit movements, newest first (1-500). */
107
+ async creditHistory(limit = 50) {
108
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
109
+ throw new RangeError("limit must be between 1 and 500");
110
+ }
111
+ const data = await this.call("GET", "/v1/account/credits/history", { limit });
112
+ return objects(data.entries).map(r => ({
113
+ at: str(r.at), delta: int(r.delta), reason: str(r.reason), count: int(r.count),
114
+ jobId: str(r.job_id), sourceRef: str(r.source_ref), raw: r,
115
+ }));
116
+ }
117
+ /** Documents, tokens and failures per day over the last `days`. */
118
+ async usage(days = 30) {
119
+ if (!Number.isInteger(days) || days < 1)
120
+ throw new RangeError("days must be at least 1");
121
+ const data = await this.call("GET", "/v1/account/usage", { days });
122
+ return objects(data.series).map(usageDay);
123
+ }
124
+ /** The same window, split by the key that submitted the work. */
125
+ async usageByKey(days = 30) {
126
+ if (!Number.isInteger(days) || days < 1)
127
+ throw new RangeError("days must be at least 1");
128
+ const data = await this.call("GET", "/v1/account/usage/by-key", { days });
129
+ return objects(data.keys).map(r => ({
130
+ keyId: int(r.key_id), name: str(r.name), documents: int(r.documents),
131
+ tokens: int(r.tokens), failures: int(r.failures), series: objects(r.series).map(usageDay), raw: r,
132
+ }));
133
+ }
134
+ async keys() {
135
+ const data = await this.call("GET", "/v1/account/keys");
136
+ return objects(data.keys).map(r => ({
137
+ id: int(r.id), name: str(r.name), createdBy: str(r.created_by), createdAt: str(r.created_at),
138
+ lastUsedAt: str(r.last_used_at), revokedAt: str(r.revoked_at), active: bool(r.active), raw: r,
139
+ }));
140
+ }
141
+ /**
142
+ * Revoke a key, e.g. one that leaked: the one write a key may make. Sent once,
143
+ * never retried. Already revoked throws a `CloudError` with status 409; a key
144
+ * that does not exist, or is another account's, throws `NotFoundError`.
145
+ * Revoking the key this client uses works, and is reported by `selfRevoked`.
146
+ */
147
+ async revokeKey(keyId) {
148
+ if (!Number.isInteger(keyId))
149
+ throw new RangeError("keyId must be an integer");
150
+ const raw = await this.call("POST", `/v1/account/keys/${keyId}/revoke`);
151
+ return { id: int(raw.id), keysRemaining: int(raw.keys_remaining), lockedOut: bool(raw.locked_out),
152
+ selfRevoked: bool(raw.self_revoked), raw };
153
+ }
154
+ }
155
+ /** Past jobs, by id. */
156
+ export class Jobs extends ApiClient {
157
+ /**
158
+ * A job's state, and its result if it has one. Throws `ResultExpiredError`
159
+ * when the job succeeded but its result is no longer retained, and
160
+ * `NotFoundError` for an unknown job (or another account's).
161
+ */
162
+ async retrieve(jobId) {
163
+ const raw = await this.call("GET", `/v1/jobs/${encodeURIComponent(jobId)}`);
164
+ const result = typeof raw.redacted_text === "string"
165
+ ? toResult(raw, raw.redacted_text)
166
+ : null;
167
+ return {
168
+ jobId: str(raw.job_id) ?? jobId,
169
+ status: str(raw.status) ?? (result ? "succeeded" : ""),
170
+ createdAt: str(raw.created_at),
171
+ result,
172
+ raw,
173
+ };
174
+ }
175
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * [CLOUD EXTENSION] Batches: cloud mode, with hours between masking and mapping
3
+ * (rules-engine#84; the gateway side is `docs/BATCHES.md` in euredact-inference).
4
+ *
5
+ * Cloud mode masks a document here, sends only the masked text and maps the
6
+ * service's answer back onto the original, all in one call. A batch returns up
7
+ * to 24 h later, so `create()` writes one **local batch file** per batch: each
8
+ * original, its local detections and the SHA-256 of the masked text that was
9
+ * sent. None of it is uploaded. `results()` maps the service's spans back from
10
+ * that file, checking the hash before placing a span, then wipes the originals
11
+ * and leaves a text-free receipt.
12
+ *
13
+ * The file format is shared with the Python SDK, offsets counted in code points,
14
+ * so a batch created by one can be resolved by the other.
15
+ *
16
+ * In Node the file is `~/.euredact/batches/<batchId>.json` (`0600` in a `0700`
17
+ * directory); `batchDir` moves it. A page has no filesystem, so there `store`
18
+ * is required: any object with async `read`, `write`, `delete` and `list`.
19
+ * `cipher` encrypts the file at rest with a key the caller holds.
20
+ */
21
+ import type { RedactResult } from "../types.js";
22
+ import { EuRedact } from "../sdk.js";
23
+ import { type CloudConfig } from "./config.js";
24
+ import { CloudError } from "./errors.js";
25
+ /** The gateway's limits (docs/BATCHES.md §2-§3), checked before upload. */
26
+ export declare const MAX_DOCUMENTS = 5000;
27
+ export declare const MAX_CUSTOM_ID = 64;
28
+ /** The gateway's customId rule (euredact-inference gateway/batches.py), whole string. */
29
+ export declare const CUSTOM_ID: RegExp;
30
+ export declare const MAX_BODY_BYTES: number;
31
+ /**
32
+ * Per document, counted by the model's tokenizer on the gateway. The SDK has no
33
+ * copy of it and no character count decides it, so it is **not** checked before
34
+ * upload: a document over it comes back as an error with code `too_long`.
35
+ */
36
+ export declare const MAX_DOCUMENT_TOKENS = 5000;
37
+ export declare const FILE_FORMAT = "euredact-batch/1";
38
+ export declare class BatchError extends CloudError {
39
+ constructor(message: string);
40
+ }
41
+ /** Where local batch files live. One opaque blob per batch id. */
42
+ export interface BatchStore {
43
+ read(batchId: string): Promise<Uint8Array | null>;
44
+ write(batchId: string, data: Uint8Array): Promise<void>;
45
+ delete(batchId: string): Promise<void>;
46
+ list(): Promise<string[]>;
47
+ }
48
+ /** Encryption at rest, with a key the caller holds. The SDK does not pick a scheme. */
49
+ export interface Cipher {
50
+ encrypt(data: Uint8Array): Uint8Array | Promise<Uint8Array>;
51
+ decrypt(data: Uint8Array): Uint8Array | Promise<Uint8Array>;
52
+ }
53
+ export interface BatchDocument {
54
+ customId: string;
55
+ text: string;
56
+ /** Exactly one, as in cloud mode. */
57
+ countries: string[];
58
+ language?: string;
59
+ }
60
+ export interface Batch {
61
+ id: string;
62
+ status: string;
63
+ createdAt: string | null;
64
+ expiresAt: string | null;
65
+ endedAt: string | null;
66
+ resultsExpireAt: string | null;
67
+ counts: Record<string, number>;
68
+ documents: number | null;
69
+ /** `{ prompt, completion }` tokens so far (rules-engine#89). */
70
+ tokens: Record<string, number>;
71
+ /** Credits debited for the batch so far, at `billingRate`. */
72
+ creditsCharged: number | null;
73
+ /** The batch rate, e.g. `0.75`: batches are billed at their own rate. */
74
+ billingRate: number | null;
75
+ raw: Record<string, unknown>;
76
+ }
77
+ /** One document of a resolved batch: a result, or why there is none. */
78
+ export interface DocumentOutcome {
79
+ customId: string;
80
+ ok: boolean;
81
+ result: RedactResult | null;
82
+ /**
83
+ * `null` on success. Otherwise a code: the gateway's (`too_long`, …),
84
+ * `expired`, `cancelled`, or the SDK's own `local_mismatch` (the local file
85
+ * does not match what was sent) or `missing_local_entry`.
86
+ */
87
+ error: string | null;
88
+ message: string;
89
+ }
90
+ /**
91
+ * `status`: `not_ended` (nothing changed locally), `resolved` (mapped now; the
92
+ * file is a text-free receipt), `already_resolved` (never mapped twice), or
93
+ * `expired` (the gateway no longer holds the results; the entries are wiped).
94
+ */
95
+ export interface BatchResults {
96
+ batchId: string;
97
+ status: "not_ended" | "resolved" | "already_resolved" | "expired";
98
+ documents: Record<string, DocumentOutcome>;
99
+ batch: Batch | null;
100
+ }
101
+ export interface BatchesOptions {
102
+ config?: CloudConfig | null;
103
+ batchDir?: string;
104
+ store?: BatchStore;
105
+ cipher?: Cipher;
106
+ sdk?: EuRedact;
107
+ /** Injectable for tests. Defaults to the platform `fetch`. */
108
+ fetchImpl?: typeof fetch;
109
+ }
110
+ export declare class Batches {
111
+ readonly config: CloudConfig;
112
+ readonly store: BatchStore;
113
+ private readonly cipher;
114
+ private readonly fetchImpl;
115
+ private sdk;
116
+ constructor(options?: BatchesOptions);
117
+ private engine;
118
+ private request;
119
+ private json;
120
+ private raiseFor;
121
+ private load;
122
+ private save;
123
+ private static batch;
124
+ /**
125
+ * Mask every document here, upload only the masked text, keep the rest in
126
+ * the local batch file. The document count, `customId`s, countries and body
127
+ * size are checked before anything is uploaded or written. The 5,000-token
128
+ * limit per document is **not**: it is counted by the model's tokenizer on
129
+ * the gateway, which the SDK does not have, so a document over it comes back
130
+ * from `results()` as an error with code `too_long` that says so.
131
+ */
132
+ create(documents: BatchDocument[], options?: {
133
+ idempotencyKey?: string;
134
+ }): Promise<Batch>;
135
+ /** The gateway's view: status, counts and timestamps. */
136
+ retrieve(batchId: string): Promise<Batch>;
137
+ /**
138
+ * This account's batches, newest first (1-100, the gateway's bound). The
139
+ * gateway's view only: `pending()` lists the ones with a local file here.
140
+ */
141
+ list(limit?: number): Promise<Batch[]>;
142
+ /** Cancel queued documents; the local file stays pending until the finished
143
+ * ones are mapped by `results()`. */
144
+ cancel(batchId: string): Promise<Batch>;
145
+ /** Batch ids whose local file still waits to be mapped. Reads files only. */
146
+ pending(): Promise<string[]>;
147
+ /** Delete the local file, whatever its status. */
148
+ purge(batchId: string): Promise<void>;
149
+ /**
150
+ * Delete receipts and expire pending files past `results_expire_at`. Runs at
151
+ * the start of the calls that look in the store, so nothing has to be
152
+ * scheduled. A pending file past that time can never be mapped -- the gateway
153
+ * has swept the results -- so its originals are wiped.
154
+ */
155
+ sweep(at?: Date): Promise<void>;
156
+ private wipe;
157
+ /**
158
+ * Map an ended batch back onto the originals in its local file. Each
159
+ * document's masked text is rebuilt from the file and must hash to both the
160
+ * stored SHA-256 and the gateway's `masked_sha256` before a span is placed.
161
+ * Anything that cannot be mapped is a per-document error, never the masked
162
+ * text passed off as a result.
163
+ */
164
+ results(batchId: string): Promise<BatchResults>;
165
+ private outcome;
166
+ }