@zarex/client 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +50 -2
- package/dist/index.cjs +336 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +230 -0
- package/dist/index.d.ts +230 -0
- package/dist/index.js +302 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kodjogbe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zarex/client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Client HTTP bas niveau du SDK Zarex, avec disjoncteur (circuit breaker) intégré pour échouer vite plutôt que de faire attendre vos visiteurs quand Zarex est lent ou injoignable.
|
|
4
|
+
|
|
5
|
+
> **Vous n'avez normalement pas besoin d'installer ce paquet directement.** Il est utilisé par [`@zarex/core`](https://www.npmjs.com/package/@zarex/core) et, par transitivité, par tous les adaptateurs de framework (`@zarex/express`, `@zarex/fastify`, `@zarex/next`, `@zarex/sveltekit`, `@zarex/bun`). Installez-le directement seulement si vous construisez votre propre intégration, en dehors de `@zarex/core`.
|
|
6
|
+
|
|
7
|
+
## Qu'est-ce que Zarex ?
|
|
8
|
+
|
|
9
|
+
Zarex est une plateforme de sécurité applicative : un service qui protège une application web contre les bots, les abus de trafic, les injections et les fuites de données sensibles.
|
|
10
|
+
|
|
11
|
+
## Utilisation
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createClient, parseDecision } from "@zarex/client";
|
|
15
|
+
|
|
16
|
+
const client = createClient({ key: process.env.ZAREX_KEY! });
|
|
17
|
+
const response = await client.decide(request);
|
|
18
|
+
const decision = parseDecision(response);
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### `ClientOptions`
|
|
22
|
+
|
|
23
|
+
| Option | Type | Description |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `key` | `string` | Clé API du projet. |
|
|
26
|
+
| `timeoutMs` | `number` | Délai maximal avant d'abandonner l'appel (défaut 500 ms). |
|
|
27
|
+
| `breaker` | `BreakerOptions \| false` | Configuration du disjoncteur, ou `false` pour le désactiver. |
|
|
28
|
+
| `baseUrl` | `string` | Inutile en usage normal. À ne définir que pour les tests ou un environnement local. |
|
|
29
|
+
|
|
30
|
+
### Le disjoncteur (`CircuitBreaker`)
|
|
31
|
+
|
|
32
|
+
Après un nombre configurable d'échecs consécutifs, le disjoncteur s'ouvre : les appels suivants échouent immédiatement (sans round-trip réseau) pendant une période de pause, puis une requête de sonde est tentée pour voir si le service est de nouveau disponible. Ce comportement évite qu'un ralentissement ne se répercute sur chaque requête de votre application par un délai d'attente répété.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { CircuitBreaker } from "@zarex/client";
|
|
36
|
+
|
|
37
|
+
const breaker = new CircuitBreaker({ failureThreshold: 5, cooldownMs: 10_000 });
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Erreurs
|
|
41
|
+
|
|
42
|
+
| Classe | Quand |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `ZarexConfigError` | Configuration invalide (clé manquante, option hors bornes…) — levée à la construction, jamais pendant le traitement d'une requête. |
|
|
45
|
+
| `ZarexClientError` | Erreur réseau ou réponse inattendue lors d'un appel — voir `error.code` (`ClientErrorCode`). |
|
|
46
|
+
|
|
47
|
+
Dans tous les adaptateurs de haut niveau, ces erreurs sont systématiquement interceptées : elles ne remontent jamais jusqu'à votre application (fail-open — si Zarex est injoignable, lent ou répond de façon inattendue, la requête du visiteur passe toujours).
|
|
48
|
+
|
|
49
|
+
## Licence
|
|
50
|
+
|
|
51
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
CircuitBreaker: () => CircuitBreaker,
|
|
24
|
+
SDK_VERSION: () => SDK_VERSION,
|
|
25
|
+
ZarexClientError: () => ZarexClientError,
|
|
26
|
+
ZarexConfigError: () => ZarexConfigError,
|
|
27
|
+
createClient: () => createClient,
|
|
28
|
+
parseDecision: () => parseDecision,
|
|
29
|
+
resolveBaseUrl: () => resolveBaseUrl,
|
|
30
|
+
validateKey: () => validateKey
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(index_exports);
|
|
33
|
+
|
|
34
|
+
// src/breaker.ts
|
|
35
|
+
var CircuitBreaker = class {
|
|
36
|
+
state = "closed";
|
|
37
|
+
failures = 0;
|
|
38
|
+
openedAt = 0;
|
|
39
|
+
probing = false;
|
|
40
|
+
threshold;
|
|
41
|
+
cooldown;
|
|
42
|
+
now;
|
|
43
|
+
constructor(opts = {}) {
|
|
44
|
+
this.threshold = opts.failureThreshold ?? 5;
|
|
45
|
+
this.cooldown = opts.cooldownMs ?? 1e4;
|
|
46
|
+
this.now = opts.now ?? Date.now;
|
|
47
|
+
}
|
|
48
|
+
/** true = l'appel est autorisé. Quand le circuit est semi-ouvert, un seul appel à la fois passe. */
|
|
49
|
+
allow() {
|
|
50
|
+
if (this.state === "closed") return true;
|
|
51
|
+
if (this.state === "open") {
|
|
52
|
+
if (this.now() - this.openedAt < this.cooldown) return false;
|
|
53
|
+
this.state = "half-open";
|
|
54
|
+
this.probing = false;
|
|
55
|
+
}
|
|
56
|
+
if (this.probing) return false;
|
|
57
|
+
this.probing = true;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
success() {
|
|
61
|
+
this.failures = 0;
|
|
62
|
+
this.probing = false;
|
|
63
|
+
this.state = "closed";
|
|
64
|
+
}
|
|
65
|
+
/** À appeler pour une panne d'infrastructure (délai, réseau, 5xx), pas pour une erreur de configuration. */
|
|
66
|
+
failure() {
|
|
67
|
+
this.probing = false;
|
|
68
|
+
if (this.state === "half-open") {
|
|
69
|
+
this.trip();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.failures++;
|
|
73
|
+
if (this.failures >= this.threshold) this.trip();
|
|
74
|
+
}
|
|
75
|
+
/** Un appel autorisé qui se termine sans verdict sur la santé du moteur (ex. 401) : libère l'essai. */
|
|
76
|
+
neutral() {
|
|
77
|
+
this.probing = false;
|
|
78
|
+
if (this.state === "half-open") this.state = "closed";
|
|
79
|
+
}
|
|
80
|
+
trip() {
|
|
81
|
+
this.state = "open";
|
|
82
|
+
this.openedAt = this.now();
|
|
83
|
+
}
|
|
84
|
+
get isOpen() {
|
|
85
|
+
return this.state !== "closed";
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/errors.ts
|
|
90
|
+
var ZarexClientError = class extends Error {
|
|
91
|
+
code;
|
|
92
|
+
status;
|
|
93
|
+
/** Code d'erreur renvoyé par le moteur (ex. « too_large »), si présent. */
|
|
94
|
+
engineCode;
|
|
95
|
+
constructor(code, message, opts = {}) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.name = "ZarexClientError";
|
|
98
|
+
this.code = code;
|
|
99
|
+
if (opts.status !== void 0) this.status = opts.status;
|
|
100
|
+
if (opts.engineCode !== void 0) this.engineCode = opts.engineCode;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
var ZarexConfigError = class extends Error {
|
|
104
|
+
constructor(message) {
|
|
105
|
+
super(message);
|
|
106
|
+
this.name = "ZarexConfigError";
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// src/client.ts
|
|
111
|
+
var SDK_VERSION = "0.1.0";
|
|
112
|
+
var MAX_RESPONSE_BYTES = 256 * 1024;
|
|
113
|
+
var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
114
|
+
var DEFAULT_BASE_URL = "https://engine.zarex.dev";
|
|
115
|
+
function readEnv(name) {
|
|
116
|
+
try {
|
|
117
|
+
return typeof process !== "undefined" ? process.env?.[name] : void 0;
|
|
118
|
+
} catch {
|
|
119
|
+
return void 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function validateKey(key) {
|
|
123
|
+
if (typeof key !== "string" || key.length < 16 || key.length > 256 || !/^[A-Za-z0-9_-]+$/.test(key)) {
|
|
124
|
+
throw new ZarexConfigError(
|
|
125
|
+
"Zarex: `key` is missing or malformed (expected the project API key, e.g. from process.env.ZAREX_KEY)."
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return key;
|
|
129
|
+
}
|
|
130
|
+
function resolveBaseUrl(baseUrl, allowInsecureHttp = false) {
|
|
131
|
+
const raw = baseUrl ?? readEnv("ZAREX_URL") ?? DEFAULT_BASE_URL;
|
|
132
|
+
let u;
|
|
133
|
+
try {
|
|
134
|
+
u = new URL(raw);
|
|
135
|
+
} catch {
|
|
136
|
+
throw new ZarexConfigError("Zarex: `baseUrl` is not a valid URL.");
|
|
137
|
+
}
|
|
138
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") {
|
|
139
|
+
throw new ZarexConfigError("Zarex: `baseUrl` must use https:// (or http:// for localhost).");
|
|
140
|
+
}
|
|
141
|
+
if (u.username || u.password) {
|
|
142
|
+
throw new ZarexConfigError("Zarex: `baseUrl` must not contain credentials.");
|
|
143
|
+
}
|
|
144
|
+
if (u.protocol === "http:" && !LOCAL_HOSTS.has(u.hostname) && !allowInsecureHttp) {
|
|
145
|
+
throw new ZarexConfigError(
|
|
146
|
+
"Zarex: refusing to send the API key over plain http:// to a remote host. Use https:// (or set allowInsecureHttp: true at your own risk)."
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
u.search = "";
|
|
150
|
+
u.hash = "";
|
|
151
|
+
return u.toString().replace(/\/+$/, "");
|
|
152
|
+
}
|
|
153
|
+
function createClient(opts) {
|
|
154
|
+
const key = validateKey(opts.key);
|
|
155
|
+
const base = resolveBaseUrl(opts.baseUrl, opts.allowInsecureHttp);
|
|
156
|
+
const timeoutMs = opts.timeoutMs ?? 500;
|
|
157
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 10 || timeoutMs > 3e4) {
|
|
158
|
+
throw new ZarexConfigError("Zarex: `timeoutMs` must be between 10 and 30000.");
|
|
159
|
+
}
|
|
160
|
+
const doFetch = opts.fetch ?? (typeof fetch === "function" ? fetch : void 0);
|
|
161
|
+
if (!doFetch) {
|
|
162
|
+
throw new ZarexConfigError("Zarex: no global fetch available; pass `fetch` in the options (Node >= 18 has one).");
|
|
163
|
+
}
|
|
164
|
+
const breaker = opts.breaker === false ? null : new CircuitBreaker(opts.breaker ?? {});
|
|
165
|
+
const url = `${base}/v1/decide`;
|
|
166
|
+
const version = opts.sdkVersion ?? SDK_VERSION;
|
|
167
|
+
async function decide(request) {
|
|
168
|
+
if (breaker && !breaker.allow()) {
|
|
169
|
+
throw new ZarexClientError("circuit_open", "Zarex engine temporarily skipped after repeated failures");
|
|
170
|
+
}
|
|
171
|
+
const ctrl = new AbortController();
|
|
172
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
173
|
+
let outcome = "neutral";
|
|
174
|
+
try {
|
|
175
|
+
let res;
|
|
176
|
+
try {
|
|
177
|
+
res = await doFetch(url, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: {
|
|
180
|
+
"content-type": "application/json",
|
|
181
|
+
accept: "application/json",
|
|
182
|
+
authorization: `Bearer ${key}`,
|
|
183
|
+
"user-agent": `zarex-js/${version}`
|
|
184
|
+
},
|
|
185
|
+
body: JSON.stringify(request),
|
|
186
|
+
signal: ctrl.signal,
|
|
187
|
+
// Ne JAMAIS suivre une redirection : elle renverrait la clé API ailleurs.
|
|
188
|
+
// "manual" plutôt que "error" : Cloudflare Workers ne connaît pas "error". La réponse 3xx est refusée plus bas.
|
|
189
|
+
redirect: "manual"
|
|
190
|
+
});
|
|
191
|
+
} catch (err) {
|
|
192
|
+
outcome = "infra";
|
|
193
|
+
if (ctrl.signal.aborted) throw new ZarexClientError("timeout", `Zarex engine did not answer within ${timeoutMs} ms`);
|
|
194
|
+
throw new ZarexClientError("network", `Zarex engine unreachable (${errKind(err)})`);
|
|
195
|
+
}
|
|
196
|
+
if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
|
|
197
|
+
outcome = "infra";
|
|
198
|
+
void res.body?.cancel().catch(() => void 0);
|
|
199
|
+
throw new ZarexClientError("network", "Zarex engine unreachable (unexpected redirect)");
|
|
200
|
+
}
|
|
201
|
+
const text = await readCapped(res, MAX_RESPONSE_BYTES, ctrl.signal).catch(() => {
|
|
202
|
+
outcome = "infra";
|
|
203
|
+
if (ctrl.signal.aborted) throw new ZarexClientError("timeout", `Zarex engine did not answer within ${timeoutMs} ms`);
|
|
204
|
+
throw new ZarexClientError("bad_response", "Zarex engine response could not be read");
|
|
205
|
+
});
|
|
206
|
+
if (res.status !== 200) {
|
|
207
|
+
const engineCode = extractEngineCode(text);
|
|
208
|
+
const code = statusToCode(res.status);
|
|
209
|
+
outcome = code === "server_error" ? "infra" : "neutral";
|
|
210
|
+
throw new ZarexClientError(code, `Zarex engine answered HTTP ${res.status}${engineCode ? ` (${engineCode})` : ""}`, {
|
|
211
|
+
status: res.status,
|
|
212
|
+
...engineCode ? { engineCode } : {}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
let parsed;
|
|
216
|
+
try {
|
|
217
|
+
parsed = parseDecision(text);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
outcome = "infra";
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
outcome = "ok";
|
|
223
|
+
return parsed;
|
|
224
|
+
} finally {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
if (breaker) {
|
|
227
|
+
if (outcome === "ok") breaker.success();
|
|
228
|
+
else if (outcome === "infra") breaker.failure();
|
|
229
|
+
else breaker.neutral();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { decide };
|
|
234
|
+
}
|
|
235
|
+
function errKind(err) {
|
|
236
|
+
const msg = err instanceof Error ? `${err.name} ${err.message} ${err.cause?.code ?? ""}` : "";
|
|
237
|
+
if (/redirect/i.test(msg)) return "unexpected redirect";
|
|
238
|
+
if (/ECONNREFUSED|refused/i.test(msg)) return "connection refused";
|
|
239
|
+
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) return "DNS lookup failed";
|
|
240
|
+
if (/certificate|TLS|SSL|CERT/i.test(msg)) return "TLS error";
|
|
241
|
+
return "network error";
|
|
242
|
+
}
|
|
243
|
+
function statusToCode(status) {
|
|
244
|
+
if (status === 401) return "unauthorized";
|
|
245
|
+
if (status === 403) return "forbidden";
|
|
246
|
+
if (status === 429) return "quota_exceeded";
|
|
247
|
+
if (status >= 500) return "server_error";
|
|
248
|
+
return "invalid_request";
|
|
249
|
+
}
|
|
250
|
+
function extractEngineCode(text) {
|
|
251
|
+
try {
|
|
252
|
+
const code = JSON.parse(text)?.error?.code;
|
|
253
|
+
return typeof code === "string" && /^[a-z_]{1,40}$/.test(code) ? code : void 0;
|
|
254
|
+
} catch {
|
|
255
|
+
return void 0;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async function readCapped(res, max, signal) {
|
|
259
|
+
const declared = Number(res.headers.get("content-length"));
|
|
260
|
+
if (Number.isFinite(declared) && declared > max) throw new Error("too large");
|
|
261
|
+
if (!res.body) return await res.text();
|
|
262
|
+
const reader = res.body.getReader();
|
|
263
|
+
const chunks = [];
|
|
264
|
+
let total = 0;
|
|
265
|
+
for (; ; ) {
|
|
266
|
+
if (signal.aborted) {
|
|
267
|
+
await reader.cancel().catch(() => void 0);
|
|
268
|
+
throw new Error("aborted");
|
|
269
|
+
}
|
|
270
|
+
const { done, value } = await reader.read();
|
|
271
|
+
if (done) break;
|
|
272
|
+
total += value.byteLength;
|
|
273
|
+
if (total > max) {
|
|
274
|
+
await reader.cancel().catch(() => void 0);
|
|
275
|
+
throw new Error("too large");
|
|
276
|
+
}
|
|
277
|
+
chunks.push(value);
|
|
278
|
+
}
|
|
279
|
+
const all = new Uint8Array(total);
|
|
280
|
+
let off = 0;
|
|
281
|
+
for (const c of chunks) {
|
|
282
|
+
all.set(c, off);
|
|
283
|
+
off += c.byteLength;
|
|
284
|
+
}
|
|
285
|
+
return new TextDecoder().decode(all);
|
|
286
|
+
}
|
|
287
|
+
var VERDICTS = /* @__PURE__ */ new Set(["ALLOW", "DENY", "ERROR"]);
|
|
288
|
+
function parseDecision(text) {
|
|
289
|
+
let v;
|
|
290
|
+
try {
|
|
291
|
+
v = JSON.parse(text);
|
|
292
|
+
} catch {
|
|
293
|
+
throw new ZarexClientError("bad_response", "Zarex engine returned invalid JSON");
|
|
294
|
+
}
|
|
295
|
+
const o = v;
|
|
296
|
+
if (!o || typeof o !== "object" || typeof o.verdict !== "string" || !VERDICTS.has(o.verdict) || !Array.isArray(o.results)) {
|
|
297
|
+
throw new ZarexClientError("bad_response", "Zarex engine returned an unexpected response");
|
|
298
|
+
}
|
|
299
|
+
const results = [];
|
|
300
|
+
for (const r of o.results) {
|
|
301
|
+
const x = r;
|
|
302
|
+
if (!x || typeof x !== "object" || typeof x.rule_type !== "string" || typeof x.verdict !== "string" || !VERDICTS.has(x.verdict)) {
|
|
303
|
+
throw new ZarexClientError("bad_response", "Zarex engine returned an unexpected rule result");
|
|
304
|
+
}
|
|
305
|
+
results.push({
|
|
306
|
+
rule_type: x.rule_type,
|
|
307
|
+
verdict: x.verdict,
|
|
308
|
+
is_dry_run: x.is_dry_run === true,
|
|
309
|
+
...x.reason && typeof x.reason === "object" ? { reason: x.reason } : {}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const warnings = Array.isArray(o.warnings) ? o.warnings.filter((w) => typeof w === "string" && /^[a-z_]{1,40}$/.test(w)).slice(0, 10) : void 0;
|
|
313
|
+
const rawIp = o.ip;
|
|
314
|
+
const ipInfo = rawIp && typeof rawIp === "object" && typeof rawIp.country === "string" && /^[A-Z]{2}$/.test(rawIp.country) && typeof rawIp.asn === "string" && /^AS\d{1,10}$/.test(rawIp.asn) ? { country: rawIp.country, asn: rawIp.asn } : void 0;
|
|
315
|
+
return {
|
|
316
|
+
decision_id: typeof o.decision_id === "string" ? o.decision_id.slice(0, 64) : "",
|
|
317
|
+
verdict: o.verdict,
|
|
318
|
+
results,
|
|
319
|
+
...typeof o.ttl_ms === "number" ? { ttl_ms: o.ttl_ms } : {},
|
|
320
|
+
...warnings && warnings.length ? { warnings } : {},
|
|
321
|
+
...ipInfo ? { ip: ipInfo } : {},
|
|
322
|
+
...typeof o.ruleset_version === "string" && /^[A-Za-z0-9._+-]{1,64}$/.test(o.ruleset_version) ? { ruleset_version: o.ruleset_version } : {}
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
326
|
+
0 && (module.exports = {
|
|
327
|
+
CircuitBreaker,
|
|
328
|
+
SDK_VERSION,
|
|
329
|
+
ZarexClientError,
|
|
330
|
+
ZarexConfigError,
|
|
331
|
+
createClient,
|
|
332
|
+
parseDecision,
|
|
333
|
+
resolveBaseUrl,
|
|
334
|
+
validateKey
|
|
335
|
+
});
|
|
336
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/breaker.ts","../src/errors.ts","../src/client.ts"],"sourcesContent":["export { createClient, resolveBaseUrl, validateKey, parseDecision, SDK_VERSION } from \"./client.js\";\r\nexport type { ClientOptions, ZarexClient } from \"./client.js\";\r\nexport { CircuitBreaker } from \"./breaker.js\";\r\nexport type { BreakerOptions } from \"./breaker.js\";\r\nexport { ZarexClientError, ZarexConfigError } from \"./errors.js\";\r\nexport type { ClientErrorCode } from \"./errors.js\";\r\nexport type * from \"./types.js\";","/**\r\n * Disjoncteur côté SDK.\r\n *\r\n * Pourquoi : si le moteur est en panne ou très lent, CHAQUE requête du client\r\n * attendrait le délai maximal (ex. 500 ms) avant de laisser passer. Le site du\r\n * client serait ralenti par NOTRE panne. Après quelques échecs d'affilée, on\r\n * cesse d'appeler le moteur pendant un moment (« circuit ouvert ») : les\r\n * requêtes passent immédiatement. Puis UNE requête d'essai vérifie s'il est revenu.\r\n */\r\nexport interface BreakerOptions {\r\n /** Échecs consécutifs avant ouverture. Défaut 5. */\r\n failureThreshold?: number;\r\n /** Durée pendant laquelle on n'appelle plus le moteur. Défaut 10 000 ms. */\r\n cooldownMs?: number;\r\n /** Horloge (tests). */\r\n now?: () => number;\r\n}\r\n\r\ntype State = \"closed\" | \"open\" | \"half-open\";\r\n\r\nexport class CircuitBreaker {\r\n private state: State = \"closed\";\r\n private failures = 0;\r\n private openedAt = 0;\r\n private probing = false;\r\n private readonly threshold: number;\r\n private readonly cooldown: number;\r\n private readonly now: () => number;\r\n\r\n constructor(opts: BreakerOptions = {}) {\r\n this.threshold = opts.failureThreshold ?? 5;\r\n this.cooldown = opts.cooldownMs ?? 10_000;\r\n this.now = opts.now ?? Date.now;\r\n }\r\n\r\n /** true = l'appel est autorisé. Quand le circuit est semi-ouvert, un seul appel à la fois passe. */\r\n allow(): boolean {\r\n if (this.state === \"closed\") return true;\r\n if (this.state === \"open\") {\r\n if (this.now() - this.openedAt < this.cooldown) return false;\r\n this.state = \"half-open\";\r\n this.probing = false;\r\n }\r\n // half-open : un seul essai en vol\r\n if (this.probing) return false;\r\n this.probing = true;\r\n return true;\r\n }\r\n\r\n success(): void {\r\n this.failures = 0;\r\n this.probing = false;\r\n this.state = \"closed\";\r\n }\r\n\r\n /** À appeler pour une panne d'infrastructure (délai, réseau, 5xx), pas pour une erreur de configuration. */\r\n failure(): void {\r\n this.probing = false;\r\n if (this.state === \"half-open\") {\r\n this.trip();\r\n return;\r\n }\r\n this.failures++;\r\n if (this.failures >= this.threshold) this.trip();\r\n }\r\n \r\n /** Un appel autorisé qui se termine sans verdict sur la santé du moteur (ex. 401) : libère l'essai. */\r\n neutral(): void {\r\n this.probing = false;\r\n if (this.state === \"half-open\") this.state = \"closed\";\r\n }\r\n \r\n private trip(): void {\r\n this.state = \"open\";\r\n this.openedAt = this.now();\r\n }\r\n \r\n get isOpen(): boolean {\r\n return this.state !== \"closed\";\r\n }\r\n}","/**\r\n * Codes d'erreur du client. Le SDK ne lève JAMAIS ces erreurs jusqu'à\r\n * l'application du client : `protect()` les attrape et laisse passer la requête\r\n * (fail-open). Elles servent aux journaux et à `decision.error`.\r\n */\r\nexport type ClientErrorCode =\r\n | \"timeout\" // le moteur n'a pas répondu à temps\r\n | \"network\" // connexion impossible / coupée\r\n | \"circuit_open\" // trop d'échecs récents : appel évité pour protéger l'application\r\n | \"unauthorized\" // 401 : clé API absente, invalide ou révoquée\r\n | \"forbidden\" // 403\r\n | \"quota_exceeded\" // 429 : quota mensuel du plan atteint\r\n | \"invalid_request\" // 400/413/415 : bug du SDK ou règle invalide\r\n | \"server_error\" // 5xx\r\n | \"bad_response\"; // réponse 200 mais illisible / inattendue\r\n \r\nexport class ZarexClientError extends Error {\r\n readonly code: ClientErrorCode;\r\n readonly status?: number;\r\n /** Code d'erreur renvoyé par le moteur (ex. « too_large »), si présent. */\r\n readonly engineCode?: string;\r\n \r\n constructor(code: ClientErrorCode, message: string, opts: { status?: number; engineCode?: string } = {}) {\r\n super(message);\r\n this.name = \"ZarexClientError\";\r\n this.code = code;\r\n if (opts.status !== undefined) this.status = opts.status;\r\n if (opts.engineCode !== undefined) this.engineCode = opts.engineCode;\r\n }\r\n}\r\n \r\n/** Erreur de configuration : levée immédiatement à la création, jamais pendant une requête. */\r\nexport class ZarexConfigError extends Error {\r\n constructor(message: string) {\r\n super(message);\r\n this.name = \"ZarexConfigError\";\r\n }\r\n}","import { CircuitBreaker, type BreakerOptions } from \"./breaker.js\";\r\nimport { ZarexClientError, ZarexConfigError } from \"./errors.js\";\r\nimport type { DecideRequest, DecideResponse, Verdict } from \"./types.js\";\r\n\r\nexport interface ClientOptions {\r\n /** Clé API du projet (zarex_live_… / zarex_test_…). Jamais journalisée. */\r\n key: string;\r\n /** Adresse du service Zarex. Inutile en usage normal (défaut : le service de production) — à ne définir que pour les tests ou un environnement local. Variable ZAREX_URL acceptée aussi. */\r\n baseUrl?: string;\r\n /** Délai maximal d'un appel, en ms. Défaut 500. */\r\n timeoutMs?: number;\r\n /** Autoriser http:// vers un hôte distant (déconseillé : la clé circulerait en clair). */\r\n allowInsecureHttp?: boolean;\r\n /** Disjoncteur. `false` pour le désactiver. */\r\n breaker?: BreakerOptions | false;\r\n /** Implémentation de fetch (tests, runtimes particuliers). */\r\n fetch?: typeof fetch;\r\n /** Version annoncée au moteur. */\r\n sdkVersion?: string;\r\n}\r\n\r\nexport interface ZarexClient {\r\n decide(request: DecideRequest): Promise<DecideResponse>;\r\n}\r\n\r\nexport const SDK_VERSION = \"0.1.0\";\r\nconst MAX_RESPONSE_BYTES = 256 * 1024;\r\nconst LOCAL_HOSTS = new Set([\"localhost\", \"127.0.0.1\", \"[::1]\", \"::1\"]);\r\n/**\r\n * Adresse du service Zarex en production — un seul service partagé par tous\r\n * les clients, différenciés par leur clé API (comme Stripe/Twilio : jamais\r\n * besoin de configurer où se trouve l'API). `baseUrl`/`ZAREX_URL` restent\r\n * disponibles pour la remplacer (tests, environnement local), mais plus\r\n * personne n'a besoin de le faire pour un usage normal.\r\n */\r\nexport const DEFAULT_BASE_URL = \"https://engine.zarex.dev\";\r\n\r\nfunction readEnv(name: string): string | undefined {\r\n try {\r\n return typeof process !== \"undefined\" ? process.env?.[name] : undefined;\r\n } catch {\r\n return undefined;\r\n }\r\n}\r\n\r\n/** Valide la clé sans jamais la recopier dans un message d'erreur. */\r\nexport function validateKey(key: unknown): string {\r\n if (typeof key !== \"string\" || key.length < 16 || key.length > 256 || !/^[A-Za-z0-9_-]+$/.test(key)) {\r\n throw new ZarexConfigError(\r\n \"Zarex: `key` is missing or malformed (expected the project API key, e.g. from process.env.ZAREX_KEY).\"\r\n );\r\n }\r\n return key;\r\n}\r\n\r\nexport function resolveBaseUrl(baseUrl: string | undefined, allowInsecureHttp = false): string {\r\n const raw = baseUrl ?? readEnv(\"ZAREX_URL\") ?? DEFAULT_BASE_URL;\r\n let u: URL;\r\n try {\r\n u = new URL(raw);\r\n } catch {\r\n throw new ZarexConfigError(\"Zarex: `baseUrl` is not a valid URL.\");\r\n }\r\n if (u.protocol !== \"https:\" && u.protocol !== \"http:\") {\r\n throw new ZarexConfigError(\"Zarex: `baseUrl` must use https:// (or http:// for localhost).\");\r\n }\r\n if (u.username || u.password) {\r\n throw new ZarexConfigError(\"Zarex: `baseUrl` must not contain credentials.\");\r\n }\r\n if (u.protocol === \"http:\" && !LOCAL_HOSTS.has(u.hostname) && !allowInsecureHttp) {\r\n throw new ZarexConfigError(\r\n \"Zarex: refusing to send the API key over plain http:// to a remote host. Use https:// (or set allowInsecureHttp: true at your own risk).\"\r\n );\r\n }\r\n u.search = \"\";\r\n u.hash = \"\";\r\n return u.toString().replace(/\\/+$/, \"\");\r\n}\r\n\r\nexport function createClient(opts: ClientOptions): ZarexClient {\r\n const key = validateKey(opts.key);\r\n const base = resolveBaseUrl(opts.baseUrl, opts.allowInsecureHttp);\r\n const timeoutMs = opts.timeoutMs ?? 500;\r\n if (!Number.isFinite(timeoutMs) || timeoutMs < 10 || timeoutMs > 30_000) {\r\n throw new ZarexConfigError(\"Zarex: `timeoutMs` must be between 10 and 30000.\");\r\n }\r\n const doFetch = opts.fetch ?? (typeof fetch === \"function\" ? fetch : undefined);\r\n if (!doFetch) {\r\n throw new ZarexConfigError(\"Zarex: no global fetch available; pass `fetch` in the options (Node >= 18 has one).\");\r\n }\r\n const breaker = opts.breaker === false ? null : new CircuitBreaker(opts.breaker ?? {});\r\n const url = `${base}/v1/decide`;\r\n const version = opts.sdkVersion ?? SDK_VERSION;\r\n\r\n async function decide(request: DecideRequest): Promise<DecideResponse> {\r\n if (breaker && !breaker.allow()) {\r\n throw new ZarexClientError(\"circuit_open\", \"Zarex engine temporarily skipped after repeated failures\");\r\n }\r\n\r\n const ctrl = new AbortController();\r\n const timer = setTimeout(() => ctrl.abort(), timeoutMs);\r\n let outcome: \"ok\" | \"infra\" | \"neutral\" = \"neutral\";\r\n try {\r\n let res: Response;\r\n try {\r\n res = await doFetch!(url, {\r\n method: \"POST\",\r\n headers: {\r\n \"content-type\": \"application/json\",\r\n accept: \"application/json\",\r\n authorization: `Bearer ${key}`,\r\n \"user-agent\": `zarex-js/${version}`,\r\n },\r\n body: JSON.stringify(request),\r\n signal: ctrl.signal,\r\n // Ne JAMAIS suivre une redirection : elle renverrait la clé API ailleurs.\r\n // \"manual\" plutôt que \"error\" : Cloudflare Workers ne connaît pas \"error\". La réponse 3xx est refusée plus bas.\r\n redirect: \"manual\",\r\n });\r\n } catch (err) {\r\n outcome = \"infra\";\r\n if (ctrl.signal.aborted) throw new ZarexClientError(\"timeout\", `Zarex engine did not answer within ${timeoutMs} ms`);\r\n // Le message natif peut contenir l'URL : on n'en garde que la nature.\r\n throw new ZarexClientError(\"network\", `Zarex engine unreachable (${errKind(err)})`);\r\n }\r\n\r\n if (res.type === \"opaqueredirect\" || (res.status >= 300 && res.status < 400)) {\r\n outcome = \"infra\";\r\n void res.body?.cancel().catch(() => undefined);\r\n throw new ZarexClientError(\"network\", \"Zarex engine unreachable (unexpected redirect)\");\r\n }\r\n\r\n const text = await readCapped(res, MAX_RESPONSE_BYTES, ctrl.signal).catch(() => {\r\n outcome = \"infra\";\r\n if (ctrl.signal.aborted) throw new ZarexClientError(\"timeout\", `Zarex engine did not answer within ${timeoutMs} ms`);\r\n throw new ZarexClientError(\"bad_response\", \"Zarex engine response could not be read\");\r\n });\r\n\r\n if (res.status !== 200) {\r\n const engineCode = extractEngineCode(text);\r\n const code = statusToCode(res.status);\r\n outcome = code === \"server_error\" ? \"infra\" : \"neutral\";\r\n throw new ZarexClientError(code, `Zarex engine answered HTTP ${res.status}${engineCode ? ` (${engineCode})` : \"\"}`, {\r\n status: res.status,\r\n ...(engineCode ? { engineCode } : {}),\r\n });\r\n }\r\n\r\n // Un 200 avec un corps JSON illisible/inattendu est la MÊME défaillance (« bad_response ») que celle\r\n // de readCapped juste au-dessus, et doit compter de la même façon pour le disjoncteur (« infra ») —\r\n // sans ce catch, `outcome` restait à \"neutral\" (sa valeur par défaut, jamais réécrite si parseDecision\r\n // lève), donc un moteur qui renvoie du JSON corrompu en 200 n'ouvrait jamais le disjoncteur (trouvé\r\n // lors de l'audit du 04/11/2026, voir AUDIT_SECURITE_COMPLET.md).\r\n let parsed: DecideResponse;\r\n try {\r\n parsed = parseDecision(text);\r\n } catch (err) {\r\n outcome = \"infra\";\r\n throw err;\r\n }\r\n outcome = \"ok\";\r\n return parsed;\r\n } finally {\r\n clearTimeout(timer);\r\n if (breaker) {\r\n if (outcome === \"ok\") breaker.success();\r\n else if (outcome === \"infra\") breaker.failure();\r\n else breaker.neutral();\r\n }\r\n }\r\n }\r\n\r\n return { decide };\r\n}\r\n\r\nfunction errKind(err: unknown): string {\r\n const msg = err instanceof Error ? `${err.name} ${err.message} ${(err as { cause?: { code?: string } }).cause?.code ?? \"\"}` : \"\";\r\n if (/redirect/i.test(msg)) return \"unexpected redirect\";\r\n if (/ECONNREFUSED|refused/i.test(msg)) return \"connection refused\";\r\n if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) return \"DNS lookup failed\";\r\n if (/certificate|TLS|SSL|CERT/i.test(msg)) return \"TLS error\";\r\n return \"network error\";\r\n}\r\n\r\nfunction statusToCode(status: number): ZarexClientError[\"code\"] {\r\n if (status === 401) return \"unauthorized\";\r\n if (status === 403) return \"forbidden\";\r\n if (status === 429) return \"quota_exceeded\";\r\n if (status >= 500) return \"server_error\";\r\n return \"invalid_request\";\r\n}\r\n\r\nfunction extractEngineCode(text: string): string | undefined {\r\n try {\r\n const code = (JSON.parse(text) as { error?: { code?: unknown } })?.error?.code;\r\n return typeof code === \"string\" && /^[a-z_]{1,40}$/.test(code) ? code : undefined;\r\n } catch {\r\n return undefined;\r\n }\r\n}\r\n\r\n/** Lit au plus `max` octets : une réponse démesurée ne doit pas saturer la mémoire du client. */\r\nasync function readCapped(res: Response, max: number, signal: AbortSignal): Promise<string> {\r\n const declared = Number(res.headers.get(\"content-length\"));\r\n if (Number.isFinite(declared) && declared > max) throw new Error(\"too large\");\r\n if (!res.body) return await res.text();\r\n const reader = res.body.getReader();\r\n const chunks: Uint8Array[] = [];\r\n let total = 0;\r\n for (;;) {\r\n if (signal.aborted) {\r\n await reader.cancel().catch(() => undefined);\r\n throw new Error(\"aborted\");\r\n }\r\n const { done, value } = await reader.read();\r\n if (done) break;\r\n total += value.byteLength;\r\n if (total > max) {\r\n await reader.cancel().catch(() => undefined);\r\n throw new Error(\"too large\");\r\n }\r\n chunks.push(value);\r\n }\r\n const all = new Uint8Array(total);\r\n let off = 0;\r\n for (const c of chunks) {\r\n all.set(c, off);\r\n off += c.byteLength;\r\n }\r\n return new TextDecoder().decode(all);\r\n}\r\n\r\nconst VERDICTS: ReadonlySet<string> = new Set<Verdict>([\"ALLOW\", \"DENY\", \"ERROR\"]);\r\n\r\n/** Validation stricte de la réponse : ce qu'on ne comprend pas n'est jamais interprété comme un verdict. */\r\nexport function parseDecision(text: string): DecideResponse {\r\n let v: unknown;\r\n try {\r\n v = JSON.parse(text);\r\n } catch {\r\n throw new ZarexClientError(\"bad_response\", \"Zarex engine returned invalid JSON\");\r\n }\r\n const o = v as Record<string, unknown> | null;\r\n if (!o || typeof o !== \"object\" || typeof o.verdict !== \"string\" || !VERDICTS.has(o.verdict) || !Array.isArray(o.results)) {\r\n throw new ZarexClientError(\"bad_response\", \"Zarex engine returned an unexpected response\");\r\n }\r\n const results = [];\r\n for (const r of o.results as unknown[]) {\r\n const x = r as Record<string, unknown> | null;\r\n if (!x || typeof x !== \"object\" || typeof x.rule_type !== \"string\" || typeof x.verdict !== \"string\" || !VERDICTS.has(x.verdict)) {\r\n throw new ZarexClientError(\"bad_response\", \"Zarex engine returned an unexpected rule result\");\r\n }\r\n results.push({\r\n rule_type: x.rule_type,\r\n verdict: x.verdict as Verdict,\r\n is_dry_run: x.is_dry_run === true,\r\n ...(x.reason && typeof x.reason === \"object\" ? { reason: x.reason as Record<string, unknown> } : {}),\r\n });\r\n }\r\n const warnings = Array.isArray(o.warnings)\r\n ? (o.warnings as unknown[]).filter((w): w is string => typeof w === \"string\" && /^[a-z_]{1,40}$/.test(w)).slice(0, 10)\r\n : undefined;\r\n const rawIp = o.ip as Record<string, unknown> | null | undefined;\r\n const ipInfo =\r\n rawIp && typeof rawIp === \"object\" && typeof rawIp.country === \"string\" && /^[A-Z]{2}$/.test(rawIp.country) && typeof rawIp.asn === \"string\" && /^AS\\d{1,10}$/.test(rawIp.asn)\r\n ? { country: rawIp.country, asn: rawIp.asn }\r\n : undefined;\r\n return {\r\n decision_id: typeof o.decision_id === \"string\" ? o.decision_id.slice(0, 64) : \"\",\r\n verdict: o.verdict as Verdict,\r\n results,\r\n ...(typeof o.ttl_ms === \"number\" ? { ttl_ms: o.ttl_ms } : {}),\r\n ...(warnings && warnings.length ? { warnings } : {}),\r\n ...(ipInfo ? { ip: ipInfo } : {}),\r\n ...(typeof o.ruleset_version === \"string\" && /^[A-Za-z0-9._+-]{1,64}$/.test(o.ruleset_version) ? { ruleset_version: o.ruleset_version } : {}),\r\n };\r\n}"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disjoncteur côté SDK.
|
|
3
|
+
*
|
|
4
|
+
* Pourquoi : si le moteur est en panne ou très lent, CHAQUE requête du client
|
|
5
|
+
* attendrait le délai maximal (ex. 500 ms) avant de laisser passer. Le site du
|
|
6
|
+
* client serait ralenti par NOTRE panne. Après quelques échecs d'affilée, on
|
|
7
|
+
* cesse d'appeler le moteur pendant un moment (« circuit ouvert ») : les
|
|
8
|
+
* requêtes passent immédiatement. Puis UNE requête d'essai vérifie s'il est revenu.
|
|
9
|
+
*/
|
|
10
|
+
interface BreakerOptions {
|
|
11
|
+
/** Échecs consécutifs avant ouverture. Défaut 5. */
|
|
12
|
+
failureThreshold?: number;
|
|
13
|
+
/** Durée pendant laquelle on n'appelle plus le moteur. Défaut 10 000 ms. */
|
|
14
|
+
cooldownMs?: number;
|
|
15
|
+
/** Horloge (tests). */
|
|
16
|
+
now?: () => number;
|
|
17
|
+
}
|
|
18
|
+
declare class CircuitBreaker {
|
|
19
|
+
private state;
|
|
20
|
+
private failures;
|
|
21
|
+
private openedAt;
|
|
22
|
+
private probing;
|
|
23
|
+
private readonly threshold;
|
|
24
|
+
private readonly cooldown;
|
|
25
|
+
private readonly now;
|
|
26
|
+
constructor(opts?: BreakerOptions);
|
|
27
|
+
/** true = l'appel est autorisé. Quand le circuit est semi-ouvert, un seul appel à la fois passe. */
|
|
28
|
+
allow(): boolean;
|
|
29
|
+
success(): void;
|
|
30
|
+
/** À appeler pour une panne d'infrastructure (délai, réseau, 5xx), pas pour une erreur de configuration. */
|
|
31
|
+
failure(): void;
|
|
32
|
+
/** Un appel autorisé qui se termine sans verdict sur la santé du moteur (ex. 401) : libère l'essai. */
|
|
33
|
+
neutral(): void;
|
|
34
|
+
private trip;
|
|
35
|
+
get isOpen(): boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Contrat JSON de POST /v1/decide (voir proto/zarex.v1.proto, la référence). */
|
|
39
|
+
type Mode = "LIVE" | "DRY_RUN" | "OFF";
|
|
40
|
+
type Verdict = "ALLOW" | "DENY" | "ERROR";
|
|
41
|
+
type Algorithm = "FIXED_WINDOW" | "SLIDING_WINDOW";
|
|
42
|
+
/**
|
|
43
|
+
* Que faire d'une entrée plus grande que ce que Zarex inspecte (chemin, en-têtes, corps, texte) :
|
|
44
|
+
* "deny" = la refuser · "inspect" = analyser le début et la fin et le signaler · "allow" = laisser passer (signalé).
|
|
45
|
+
*/
|
|
46
|
+
type Oversize = "deny" | "inspect" | "allow";
|
|
47
|
+
interface RateLimitRuleDTO {
|
|
48
|
+
mode: Mode;
|
|
49
|
+
max: number;
|
|
50
|
+
window: string;
|
|
51
|
+
algorithm: Algorithm;
|
|
52
|
+
characteristics: string[];
|
|
53
|
+
}
|
|
54
|
+
interface ShieldRuleDTO {
|
|
55
|
+
mode: Mode;
|
|
56
|
+
oversize?: Oversize;
|
|
57
|
+
threshold?: number;
|
|
58
|
+
}
|
|
59
|
+
interface BotDetectionRuleDTO {
|
|
60
|
+
mode: Mode;
|
|
61
|
+
allow: string[];
|
|
62
|
+
deny: string[];
|
|
63
|
+
}
|
|
64
|
+
interface TokenBucketRuleDTO {
|
|
65
|
+
mode: Mode;
|
|
66
|
+
capacity: number;
|
|
67
|
+
refill_rate: number;
|
|
68
|
+
interval: string;
|
|
69
|
+
characteristics: string[];
|
|
70
|
+
}
|
|
71
|
+
interface FilterExpressionDTO {
|
|
72
|
+
field: string;
|
|
73
|
+
operator: "EQ" | "NE" | "CONTAINS" | "STARTS_WITH" | "IN" | "NOT_IN" | "REGEX" | "LIKE" | "LEN_EQ" | "LEN_GT" | "LEN_LT";
|
|
74
|
+
value?: string;
|
|
75
|
+
values?: string[];
|
|
76
|
+
action: "DENY" | "ALLOW";
|
|
77
|
+
not?: boolean;
|
|
78
|
+
}
|
|
79
|
+
interface RequestFilterRuleDTO {
|
|
80
|
+
mode: Mode;
|
|
81
|
+
oversize?: Oversize;
|
|
82
|
+
expressions: FilterExpressionDTO[];
|
|
83
|
+
logic: "AND" | "OR";
|
|
84
|
+
default?: "ALLOW" | "DENY";
|
|
85
|
+
}
|
|
86
|
+
interface SensitiveInfoRuleDTO {
|
|
87
|
+
mode: Mode;
|
|
88
|
+
oversize?: Oversize;
|
|
89
|
+
deny?: string[];
|
|
90
|
+
inspect?: ("body" | "text_content")[];
|
|
91
|
+
}
|
|
92
|
+
interface EmailValidationRuleDTO {
|
|
93
|
+
mode: Mode;
|
|
94
|
+
deny?: string[];
|
|
95
|
+
allow_domains?: string[];
|
|
96
|
+
deny_domains?: string[];
|
|
97
|
+
}
|
|
98
|
+
interface PhoneValidationRuleDTO {
|
|
99
|
+
mode: Mode;
|
|
100
|
+
deny?: string[];
|
|
101
|
+
operators?: string[];
|
|
102
|
+
country_code?: string;
|
|
103
|
+
countries?: string[];
|
|
104
|
+
}
|
|
105
|
+
interface PromptInjectionRuleDTO {
|
|
106
|
+
mode: Mode;
|
|
107
|
+
oversize?: Oversize;
|
|
108
|
+
inspect?: ("body" | "text_content")[];
|
|
109
|
+
confidence_threshold?: number;
|
|
110
|
+
}
|
|
111
|
+
type RuleDTO = {
|
|
112
|
+
rate_limit: RateLimitRuleDTO;
|
|
113
|
+
} | {
|
|
114
|
+
shield: ShieldRuleDTO;
|
|
115
|
+
} | {
|
|
116
|
+
bot_detection: BotDetectionRuleDTO;
|
|
117
|
+
} | {
|
|
118
|
+
token_bucket: TokenBucketRuleDTO;
|
|
119
|
+
} | {
|
|
120
|
+
request_filter: RequestFilterRuleDTO;
|
|
121
|
+
} | {
|
|
122
|
+
sensitive_info: SensitiveInfoRuleDTO;
|
|
123
|
+
} | {
|
|
124
|
+
email_validation: EmailValidationRuleDTO;
|
|
125
|
+
} | {
|
|
126
|
+
phone_validation: PhoneValidationRuleDTO;
|
|
127
|
+
} | {
|
|
128
|
+
prompt_injection: PromptInjectionRuleDTO;
|
|
129
|
+
};
|
|
130
|
+
interface RequestContextDTO {
|
|
131
|
+
ip: string;
|
|
132
|
+
method: string;
|
|
133
|
+
path: string;
|
|
134
|
+
host: string;
|
|
135
|
+
body?: string;
|
|
136
|
+
body_truncated?: boolean;
|
|
137
|
+
headers: Record<string, string>;
|
|
138
|
+
characteristics?: {
|
|
139
|
+
key: string;
|
|
140
|
+
value: string;
|
|
141
|
+
}[];
|
|
142
|
+
tokens_requested?: number;
|
|
143
|
+
email?: string;
|
|
144
|
+
phone?: string;
|
|
145
|
+
text_content?: string;
|
|
146
|
+
text_truncated?: boolean;
|
|
147
|
+
/** Le SDK a coupé le chemin (plus de 8 Ko) : début + fin envoyés. */
|
|
148
|
+
path_truncated?: boolean;
|
|
149
|
+
/** Le SDK a coupé ou écarté des en-têtes. */
|
|
150
|
+
headers_truncated?: boolean;
|
|
151
|
+
}
|
|
152
|
+
interface DecideRequest {
|
|
153
|
+
site_key: string;
|
|
154
|
+
sdk_version: string;
|
|
155
|
+
sdk_lang: string;
|
|
156
|
+
entry_point: "REQUEST" | "GUARD";
|
|
157
|
+
context: RequestContextDTO;
|
|
158
|
+
rules: RuleDTO[];
|
|
159
|
+
ip_source?: string;
|
|
160
|
+
}
|
|
161
|
+
interface ResultDTO {
|
|
162
|
+
rule_type: string;
|
|
163
|
+
verdict: Verdict;
|
|
164
|
+
is_dry_run: boolean;
|
|
165
|
+
reason?: Record<string, unknown>;
|
|
166
|
+
}
|
|
167
|
+
interface DecideResponse {
|
|
168
|
+
decision_id: string;
|
|
169
|
+
verdict: Verdict;
|
|
170
|
+
results: ResultDTO[];
|
|
171
|
+
ttl_ms?: number;
|
|
172
|
+
warnings?: string[];
|
|
173
|
+
/** Version des règles du moteur qui ont décidé (ex. "11.3.0+shield-2.0"). Absente d'un ancien moteur. */
|
|
174
|
+
ruleset_version?: string;
|
|
175
|
+
/** Pays et réseau de l'IP du visiteur (étape 12). Absent si le moteur n'a pas de base d'adresses. */
|
|
176
|
+
ip?: {
|
|
177
|
+
country: string;
|
|
178
|
+
asn: string;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface ClientOptions {
|
|
183
|
+
/** Clé API du projet (zarex_live_… / zarex_test_…). Jamais journalisée. */
|
|
184
|
+
key: string;
|
|
185
|
+
/** Adresse du service Zarex. Inutile en usage normal (défaut : le service de production) — à ne définir que pour les tests ou un environnement local. Variable ZAREX_URL acceptée aussi. */
|
|
186
|
+
baseUrl?: string;
|
|
187
|
+
/** Délai maximal d'un appel, en ms. Défaut 500. */
|
|
188
|
+
timeoutMs?: number;
|
|
189
|
+
/** Autoriser http:// vers un hôte distant (déconseillé : la clé circulerait en clair). */
|
|
190
|
+
allowInsecureHttp?: boolean;
|
|
191
|
+
/** Disjoncteur. `false` pour le désactiver. */
|
|
192
|
+
breaker?: BreakerOptions | false;
|
|
193
|
+
/** Implémentation de fetch (tests, runtimes particuliers). */
|
|
194
|
+
fetch?: typeof fetch;
|
|
195
|
+
/** Version annoncée au moteur. */
|
|
196
|
+
sdkVersion?: string;
|
|
197
|
+
}
|
|
198
|
+
interface ZarexClient {
|
|
199
|
+
decide(request: DecideRequest): Promise<DecideResponse>;
|
|
200
|
+
}
|
|
201
|
+
declare const SDK_VERSION = "0.1.0";
|
|
202
|
+
/** Valide la clé sans jamais la recopier dans un message d'erreur. */
|
|
203
|
+
declare function validateKey(key: unknown): string;
|
|
204
|
+
declare function resolveBaseUrl(baseUrl: string | undefined, allowInsecureHttp?: boolean): string;
|
|
205
|
+
declare function createClient(opts: ClientOptions): ZarexClient;
|
|
206
|
+
/** Validation stricte de la réponse : ce qu'on ne comprend pas n'est jamais interprété comme un verdict. */
|
|
207
|
+
declare function parseDecision(text: string): DecideResponse;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Codes d'erreur du client. Le SDK ne lève JAMAIS ces erreurs jusqu'à
|
|
211
|
+
* l'application du client : `protect()` les attrape et laisse passer la requête
|
|
212
|
+
* (fail-open). Elles servent aux journaux et à `decision.error`.
|
|
213
|
+
*/
|
|
214
|
+
type ClientErrorCode = "timeout" | "network" | "circuit_open" | "unauthorized" | "forbidden" | "quota_exceeded" | "invalid_request" | "server_error" | "bad_response";
|
|
215
|
+
declare class ZarexClientError extends Error {
|
|
216
|
+
readonly code: ClientErrorCode;
|
|
217
|
+
readonly status?: number;
|
|
218
|
+
/** Code d'erreur renvoyé par le moteur (ex. « too_large »), si présent. */
|
|
219
|
+
readonly engineCode?: string;
|
|
220
|
+
constructor(code: ClientErrorCode, message: string, opts?: {
|
|
221
|
+
status?: number;
|
|
222
|
+
engineCode?: string;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** Erreur de configuration : levée immédiatement à la création, jamais pendant une requête. */
|
|
226
|
+
declare class ZarexConfigError extends Error {
|
|
227
|
+
constructor(message: string);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export { type Algorithm, type BotDetectionRuleDTO, type BreakerOptions, CircuitBreaker, type ClientErrorCode, type ClientOptions, type DecideRequest, type DecideResponse, type EmailValidationRuleDTO, type FilterExpressionDTO, type Mode, type Oversize, type PhoneValidationRuleDTO, type PromptInjectionRuleDTO, type RateLimitRuleDTO, type RequestContextDTO, type RequestFilterRuleDTO, type ResultDTO, type RuleDTO, SDK_VERSION, type SensitiveInfoRuleDTO, type ShieldRuleDTO, type TokenBucketRuleDTO, type Verdict, type ZarexClient, ZarexClientError, ZarexConfigError, createClient, parseDecision, resolveBaseUrl, validateKey };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disjoncteur côté SDK.
|
|
3
|
+
*
|
|
4
|
+
* Pourquoi : si le moteur est en panne ou très lent, CHAQUE requête du client
|
|
5
|
+
* attendrait le délai maximal (ex. 500 ms) avant de laisser passer. Le site du
|
|
6
|
+
* client serait ralenti par NOTRE panne. Après quelques échecs d'affilée, on
|
|
7
|
+
* cesse d'appeler le moteur pendant un moment (« circuit ouvert ») : les
|
|
8
|
+
* requêtes passent immédiatement. Puis UNE requête d'essai vérifie s'il est revenu.
|
|
9
|
+
*/
|
|
10
|
+
interface BreakerOptions {
|
|
11
|
+
/** Échecs consécutifs avant ouverture. Défaut 5. */
|
|
12
|
+
failureThreshold?: number;
|
|
13
|
+
/** Durée pendant laquelle on n'appelle plus le moteur. Défaut 10 000 ms. */
|
|
14
|
+
cooldownMs?: number;
|
|
15
|
+
/** Horloge (tests). */
|
|
16
|
+
now?: () => number;
|
|
17
|
+
}
|
|
18
|
+
declare class CircuitBreaker {
|
|
19
|
+
private state;
|
|
20
|
+
private failures;
|
|
21
|
+
private openedAt;
|
|
22
|
+
private probing;
|
|
23
|
+
private readonly threshold;
|
|
24
|
+
private readonly cooldown;
|
|
25
|
+
private readonly now;
|
|
26
|
+
constructor(opts?: BreakerOptions);
|
|
27
|
+
/** true = l'appel est autorisé. Quand le circuit est semi-ouvert, un seul appel à la fois passe. */
|
|
28
|
+
allow(): boolean;
|
|
29
|
+
success(): void;
|
|
30
|
+
/** À appeler pour une panne d'infrastructure (délai, réseau, 5xx), pas pour une erreur de configuration. */
|
|
31
|
+
failure(): void;
|
|
32
|
+
/** Un appel autorisé qui se termine sans verdict sur la santé du moteur (ex. 401) : libère l'essai. */
|
|
33
|
+
neutral(): void;
|
|
34
|
+
private trip;
|
|
35
|
+
get isOpen(): boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Contrat JSON de POST /v1/decide (voir proto/zarex.v1.proto, la référence). */
|
|
39
|
+
type Mode = "LIVE" | "DRY_RUN" | "OFF";
|
|
40
|
+
type Verdict = "ALLOW" | "DENY" | "ERROR";
|
|
41
|
+
type Algorithm = "FIXED_WINDOW" | "SLIDING_WINDOW";
|
|
42
|
+
/**
|
|
43
|
+
* Que faire d'une entrée plus grande que ce que Zarex inspecte (chemin, en-têtes, corps, texte) :
|
|
44
|
+
* "deny" = la refuser · "inspect" = analyser le début et la fin et le signaler · "allow" = laisser passer (signalé).
|
|
45
|
+
*/
|
|
46
|
+
type Oversize = "deny" | "inspect" | "allow";
|
|
47
|
+
interface RateLimitRuleDTO {
|
|
48
|
+
mode: Mode;
|
|
49
|
+
max: number;
|
|
50
|
+
window: string;
|
|
51
|
+
algorithm: Algorithm;
|
|
52
|
+
characteristics: string[];
|
|
53
|
+
}
|
|
54
|
+
interface ShieldRuleDTO {
|
|
55
|
+
mode: Mode;
|
|
56
|
+
oversize?: Oversize;
|
|
57
|
+
threshold?: number;
|
|
58
|
+
}
|
|
59
|
+
interface BotDetectionRuleDTO {
|
|
60
|
+
mode: Mode;
|
|
61
|
+
allow: string[];
|
|
62
|
+
deny: string[];
|
|
63
|
+
}
|
|
64
|
+
interface TokenBucketRuleDTO {
|
|
65
|
+
mode: Mode;
|
|
66
|
+
capacity: number;
|
|
67
|
+
refill_rate: number;
|
|
68
|
+
interval: string;
|
|
69
|
+
characteristics: string[];
|
|
70
|
+
}
|
|
71
|
+
interface FilterExpressionDTO {
|
|
72
|
+
field: string;
|
|
73
|
+
operator: "EQ" | "NE" | "CONTAINS" | "STARTS_WITH" | "IN" | "NOT_IN" | "REGEX" | "LIKE" | "LEN_EQ" | "LEN_GT" | "LEN_LT";
|
|
74
|
+
value?: string;
|
|
75
|
+
values?: string[];
|
|
76
|
+
action: "DENY" | "ALLOW";
|
|
77
|
+
not?: boolean;
|
|
78
|
+
}
|
|
79
|
+
interface RequestFilterRuleDTO {
|
|
80
|
+
mode: Mode;
|
|
81
|
+
oversize?: Oversize;
|
|
82
|
+
expressions: FilterExpressionDTO[];
|
|
83
|
+
logic: "AND" | "OR";
|
|
84
|
+
default?: "ALLOW" | "DENY";
|
|
85
|
+
}
|
|
86
|
+
interface SensitiveInfoRuleDTO {
|
|
87
|
+
mode: Mode;
|
|
88
|
+
oversize?: Oversize;
|
|
89
|
+
deny?: string[];
|
|
90
|
+
inspect?: ("body" | "text_content")[];
|
|
91
|
+
}
|
|
92
|
+
interface EmailValidationRuleDTO {
|
|
93
|
+
mode: Mode;
|
|
94
|
+
deny?: string[];
|
|
95
|
+
allow_domains?: string[];
|
|
96
|
+
deny_domains?: string[];
|
|
97
|
+
}
|
|
98
|
+
interface PhoneValidationRuleDTO {
|
|
99
|
+
mode: Mode;
|
|
100
|
+
deny?: string[];
|
|
101
|
+
operators?: string[];
|
|
102
|
+
country_code?: string;
|
|
103
|
+
countries?: string[];
|
|
104
|
+
}
|
|
105
|
+
interface PromptInjectionRuleDTO {
|
|
106
|
+
mode: Mode;
|
|
107
|
+
oversize?: Oversize;
|
|
108
|
+
inspect?: ("body" | "text_content")[];
|
|
109
|
+
confidence_threshold?: number;
|
|
110
|
+
}
|
|
111
|
+
type RuleDTO = {
|
|
112
|
+
rate_limit: RateLimitRuleDTO;
|
|
113
|
+
} | {
|
|
114
|
+
shield: ShieldRuleDTO;
|
|
115
|
+
} | {
|
|
116
|
+
bot_detection: BotDetectionRuleDTO;
|
|
117
|
+
} | {
|
|
118
|
+
token_bucket: TokenBucketRuleDTO;
|
|
119
|
+
} | {
|
|
120
|
+
request_filter: RequestFilterRuleDTO;
|
|
121
|
+
} | {
|
|
122
|
+
sensitive_info: SensitiveInfoRuleDTO;
|
|
123
|
+
} | {
|
|
124
|
+
email_validation: EmailValidationRuleDTO;
|
|
125
|
+
} | {
|
|
126
|
+
phone_validation: PhoneValidationRuleDTO;
|
|
127
|
+
} | {
|
|
128
|
+
prompt_injection: PromptInjectionRuleDTO;
|
|
129
|
+
};
|
|
130
|
+
interface RequestContextDTO {
|
|
131
|
+
ip: string;
|
|
132
|
+
method: string;
|
|
133
|
+
path: string;
|
|
134
|
+
host: string;
|
|
135
|
+
body?: string;
|
|
136
|
+
body_truncated?: boolean;
|
|
137
|
+
headers: Record<string, string>;
|
|
138
|
+
characteristics?: {
|
|
139
|
+
key: string;
|
|
140
|
+
value: string;
|
|
141
|
+
}[];
|
|
142
|
+
tokens_requested?: number;
|
|
143
|
+
email?: string;
|
|
144
|
+
phone?: string;
|
|
145
|
+
text_content?: string;
|
|
146
|
+
text_truncated?: boolean;
|
|
147
|
+
/** Le SDK a coupé le chemin (plus de 8 Ko) : début + fin envoyés. */
|
|
148
|
+
path_truncated?: boolean;
|
|
149
|
+
/** Le SDK a coupé ou écarté des en-têtes. */
|
|
150
|
+
headers_truncated?: boolean;
|
|
151
|
+
}
|
|
152
|
+
interface DecideRequest {
|
|
153
|
+
site_key: string;
|
|
154
|
+
sdk_version: string;
|
|
155
|
+
sdk_lang: string;
|
|
156
|
+
entry_point: "REQUEST" | "GUARD";
|
|
157
|
+
context: RequestContextDTO;
|
|
158
|
+
rules: RuleDTO[];
|
|
159
|
+
ip_source?: string;
|
|
160
|
+
}
|
|
161
|
+
interface ResultDTO {
|
|
162
|
+
rule_type: string;
|
|
163
|
+
verdict: Verdict;
|
|
164
|
+
is_dry_run: boolean;
|
|
165
|
+
reason?: Record<string, unknown>;
|
|
166
|
+
}
|
|
167
|
+
interface DecideResponse {
|
|
168
|
+
decision_id: string;
|
|
169
|
+
verdict: Verdict;
|
|
170
|
+
results: ResultDTO[];
|
|
171
|
+
ttl_ms?: number;
|
|
172
|
+
warnings?: string[];
|
|
173
|
+
/** Version des règles du moteur qui ont décidé (ex. "11.3.0+shield-2.0"). Absente d'un ancien moteur. */
|
|
174
|
+
ruleset_version?: string;
|
|
175
|
+
/** Pays et réseau de l'IP du visiteur (étape 12). Absent si le moteur n'a pas de base d'adresses. */
|
|
176
|
+
ip?: {
|
|
177
|
+
country: string;
|
|
178
|
+
asn: string;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface ClientOptions {
|
|
183
|
+
/** Clé API du projet (zarex_live_… / zarex_test_…). Jamais journalisée. */
|
|
184
|
+
key: string;
|
|
185
|
+
/** Adresse du service Zarex. Inutile en usage normal (défaut : le service de production) — à ne définir que pour les tests ou un environnement local. Variable ZAREX_URL acceptée aussi. */
|
|
186
|
+
baseUrl?: string;
|
|
187
|
+
/** Délai maximal d'un appel, en ms. Défaut 500. */
|
|
188
|
+
timeoutMs?: number;
|
|
189
|
+
/** Autoriser http:// vers un hôte distant (déconseillé : la clé circulerait en clair). */
|
|
190
|
+
allowInsecureHttp?: boolean;
|
|
191
|
+
/** Disjoncteur. `false` pour le désactiver. */
|
|
192
|
+
breaker?: BreakerOptions | false;
|
|
193
|
+
/** Implémentation de fetch (tests, runtimes particuliers). */
|
|
194
|
+
fetch?: typeof fetch;
|
|
195
|
+
/** Version annoncée au moteur. */
|
|
196
|
+
sdkVersion?: string;
|
|
197
|
+
}
|
|
198
|
+
interface ZarexClient {
|
|
199
|
+
decide(request: DecideRequest): Promise<DecideResponse>;
|
|
200
|
+
}
|
|
201
|
+
declare const SDK_VERSION = "0.1.0";
|
|
202
|
+
/** Valide la clé sans jamais la recopier dans un message d'erreur. */
|
|
203
|
+
declare function validateKey(key: unknown): string;
|
|
204
|
+
declare function resolveBaseUrl(baseUrl: string | undefined, allowInsecureHttp?: boolean): string;
|
|
205
|
+
declare function createClient(opts: ClientOptions): ZarexClient;
|
|
206
|
+
/** Validation stricte de la réponse : ce qu'on ne comprend pas n'est jamais interprété comme un verdict. */
|
|
207
|
+
declare function parseDecision(text: string): DecideResponse;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Codes d'erreur du client. Le SDK ne lève JAMAIS ces erreurs jusqu'à
|
|
211
|
+
* l'application du client : `protect()` les attrape et laisse passer la requête
|
|
212
|
+
* (fail-open). Elles servent aux journaux et à `decision.error`.
|
|
213
|
+
*/
|
|
214
|
+
type ClientErrorCode = "timeout" | "network" | "circuit_open" | "unauthorized" | "forbidden" | "quota_exceeded" | "invalid_request" | "server_error" | "bad_response";
|
|
215
|
+
declare class ZarexClientError extends Error {
|
|
216
|
+
readonly code: ClientErrorCode;
|
|
217
|
+
readonly status?: number;
|
|
218
|
+
/** Code d'erreur renvoyé par le moteur (ex. « too_large »), si présent. */
|
|
219
|
+
readonly engineCode?: string;
|
|
220
|
+
constructor(code: ClientErrorCode, message: string, opts?: {
|
|
221
|
+
status?: number;
|
|
222
|
+
engineCode?: string;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** Erreur de configuration : levée immédiatement à la création, jamais pendant une requête. */
|
|
226
|
+
declare class ZarexConfigError extends Error {
|
|
227
|
+
constructor(message: string);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export { type Algorithm, type BotDetectionRuleDTO, type BreakerOptions, CircuitBreaker, type ClientErrorCode, type ClientOptions, type DecideRequest, type DecideResponse, type EmailValidationRuleDTO, type FilterExpressionDTO, type Mode, type Oversize, type PhoneValidationRuleDTO, type PromptInjectionRuleDTO, type RateLimitRuleDTO, type RequestContextDTO, type RequestFilterRuleDTO, type ResultDTO, type RuleDTO, SDK_VERSION, type SensitiveInfoRuleDTO, type ShieldRuleDTO, type TokenBucketRuleDTO, type Verdict, type ZarexClient, ZarexClientError, ZarexConfigError, createClient, parseDecision, resolveBaseUrl, validateKey };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// src/breaker.ts
|
|
2
|
+
var CircuitBreaker = class {
|
|
3
|
+
state = "closed";
|
|
4
|
+
failures = 0;
|
|
5
|
+
openedAt = 0;
|
|
6
|
+
probing = false;
|
|
7
|
+
threshold;
|
|
8
|
+
cooldown;
|
|
9
|
+
now;
|
|
10
|
+
constructor(opts = {}) {
|
|
11
|
+
this.threshold = opts.failureThreshold ?? 5;
|
|
12
|
+
this.cooldown = opts.cooldownMs ?? 1e4;
|
|
13
|
+
this.now = opts.now ?? Date.now;
|
|
14
|
+
}
|
|
15
|
+
/** true = l'appel est autorisé. Quand le circuit est semi-ouvert, un seul appel à la fois passe. */
|
|
16
|
+
allow() {
|
|
17
|
+
if (this.state === "closed") return true;
|
|
18
|
+
if (this.state === "open") {
|
|
19
|
+
if (this.now() - this.openedAt < this.cooldown) return false;
|
|
20
|
+
this.state = "half-open";
|
|
21
|
+
this.probing = false;
|
|
22
|
+
}
|
|
23
|
+
if (this.probing) return false;
|
|
24
|
+
this.probing = true;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
success() {
|
|
28
|
+
this.failures = 0;
|
|
29
|
+
this.probing = false;
|
|
30
|
+
this.state = "closed";
|
|
31
|
+
}
|
|
32
|
+
/** À appeler pour une panne d'infrastructure (délai, réseau, 5xx), pas pour une erreur de configuration. */
|
|
33
|
+
failure() {
|
|
34
|
+
this.probing = false;
|
|
35
|
+
if (this.state === "half-open") {
|
|
36
|
+
this.trip();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.failures++;
|
|
40
|
+
if (this.failures >= this.threshold) this.trip();
|
|
41
|
+
}
|
|
42
|
+
/** Un appel autorisé qui se termine sans verdict sur la santé du moteur (ex. 401) : libère l'essai. */
|
|
43
|
+
neutral() {
|
|
44
|
+
this.probing = false;
|
|
45
|
+
if (this.state === "half-open") this.state = "closed";
|
|
46
|
+
}
|
|
47
|
+
trip() {
|
|
48
|
+
this.state = "open";
|
|
49
|
+
this.openedAt = this.now();
|
|
50
|
+
}
|
|
51
|
+
get isOpen() {
|
|
52
|
+
return this.state !== "closed";
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// src/errors.ts
|
|
57
|
+
var ZarexClientError = class extends Error {
|
|
58
|
+
code;
|
|
59
|
+
status;
|
|
60
|
+
/** Code d'erreur renvoyé par le moteur (ex. « too_large »), si présent. */
|
|
61
|
+
engineCode;
|
|
62
|
+
constructor(code, message, opts = {}) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "ZarexClientError";
|
|
65
|
+
this.code = code;
|
|
66
|
+
if (opts.status !== void 0) this.status = opts.status;
|
|
67
|
+
if (opts.engineCode !== void 0) this.engineCode = opts.engineCode;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var ZarexConfigError = class extends Error {
|
|
71
|
+
constructor(message) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = "ZarexConfigError";
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// src/client.ts
|
|
78
|
+
var SDK_VERSION = "0.1.0";
|
|
79
|
+
var MAX_RESPONSE_BYTES = 256 * 1024;
|
|
80
|
+
var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
81
|
+
var DEFAULT_BASE_URL = "https://engine.zarex.dev";
|
|
82
|
+
function readEnv(name) {
|
|
83
|
+
try {
|
|
84
|
+
return typeof process !== "undefined" ? process.env?.[name] : void 0;
|
|
85
|
+
} catch {
|
|
86
|
+
return void 0;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function validateKey(key) {
|
|
90
|
+
if (typeof key !== "string" || key.length < 16 || key.length > 256 || !/^[A-Za-z0-9_-]+$/.test(key)) {
|
|
91
|
+
throw new ZarexConfigError(
|
|
92
|
+
"Zarex: `key` is missing or malformed (expected the project API key, e.g. from process.env.ZAREX_KEY)."
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return key;
|
|
96
|
+
}
|
|
97
|
+
function resolveBaseUrl(baseUrl, allowInsecureHttp = false) {
|
|
98
|
+
const raw = baseUrl ?? readEnv("ZAREX_URL") ?? DEFAULT_BASE_URL;
|
|
99
|
+
let u;
|
|
100
|
+
try {
|
|
101
|
+
u = new URL(raw);
|
|
102
|
+
} catch {
|
|
103
|
+
throw new ZarexConfigError("Zarex: `baseUrl` is not a valid URL.");
|
|
104
|
+
}
|
|
105
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") {
|
|
106
|
+
throw new ZarexConfigError("Zarex: `baseUrl` must use https:// (or http:// for localhost).");
|
|
107
|
+
}
|
|
108
|
+
if (u.username || u.password) {
|
|
109
|
+
throw new ZarexConfigError("Zarex: `baseUrl` must not contain credentials.");
|
|
110
|
+
}
|
|
111
|
+
if (u.protocol === "http:" && !LOCAL_HOSTS.has(u.hostname) && !allowInsecureHttp) {
|
|
112
|
+
throw new ZarexConfigError(
|
|
113
|
+
"Zarex: refusing to send the API key over plain http:// to a remote host. Use https:// (or set allowInsecureHttp: true at your own risk)."
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
u.search = "";
|
|
117
|
+
u.hash = "";
|
|
118
|
+
return u.toString().replace(/\/+$/, "");
|
|
119
|
+
}
|
|
120
|
+
function createClient(opts) {
|
|
121
|
+
const key = validateKey(opts.key);
|
|
122
|
+
const base = resolveBaseUrl(opts.baseUrl, opts.allowInsecureHttp);
|
|
123
|
+
const timeoutMs = opts.timeoutMs ?? 500;
|
|
124
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 10 || timeoutMs > 3e4) {
|
|
125
|
+
throw new ZarexConfigError("Zarex: `timeoutMs` must be between 10 and 30000.");
|
|
126
|
+
}
|
|
127
|
+
const doFetch = opts.fetch ?? (typeof fetch === "function" ? fetch : void 0);
|
|
128
|
+
if (!doFetch) {
|
|
129
|
+
throw new ZarexConfigError("Zarex: no global fetch available; pass `fetch` in the options (Node >= 18 has one).");
|
|
130
|
+
}
|
|
131
|
+
const breaker = opts.breaker === false ? null : new CircuitBreaker(opts.breaker ?? {});
|
|
132
|
+
const url = `${base}/v1/decide`;
|
|
133
|
+
const version = opts.sdkVersion ?? SDK_VERSION;
|
|
134
|
+
async function decide(request) {
|
|
135
|
+
if (breaker && !breaker.allow()) {
|
|
136
|
+
throw new ZarexClientError("circuit_open", "Zarex engine temporarily skipped after repeated failures");
|
|
137
|
+
}
|
|
138
|
+
const ctrl = new AbortController();
|
|
139
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
140
|
+
let outcome = "neutral";
|
|
141
|
+
try {
|
|
142
|
+
let res;
|
|
143
|
+
try {
|
|
144
|
+
res = await doFetch(url, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: {
|
|
147
|
+
"content-type": "application/json",
|
|
148
|
+
accept: "application/json",
|
|
149
|
+
authorization: `Bearer ${key}`,
|
|
150
|
+
"user-agent": `zarex-js/${version}`
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify(request),
|
|
153
|
+
signal: ctrl.signal,
|
|
154
|
+
// Ne JAMAIS suivre une redirection : elle renverrait la clé API ailleurs.
|
|
155
|
+
// "manual" plutôt que "error" : Cloudflare Workers ne connaît pas "error". La réponse 3xx est refusée plus bas.
|
|
156
|
+
redirect: "manual"
|
|
157
|
+
});
|
|
158
|
+
} catch (err) {
|
|
159
|
+
outcome = "infra";
|
|
160
|
+
if (ctrl.signal.aborted) throw new ZarexClientError("timeout", `Zarex engine did not answer within ${timeoutMs} ms`);
|
|
161
|
+
throw new ZarexClientError("network", `Zarex engine unreachable (${errKind(err)})`);
|
|
162
|
+
}
|
|
163
|
+
if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
|
|
164
|
+
outcome = "infra";
|
|
165
|
+
void res.body?.cancel().catch(() => void 0);
|
|
166
|
+
throw new ZarexClientError("network", "Zarex engine unreachable (unexpected redirect)");
|
|
167
|
+
}
|
|
168
|
+
const text = await readCapped(res, MAX_RESPONSE_BYTES, ctrl.signal).catch(() => {
|
|
169
|
+
outcome = "infra";
|
|
170
|
+
if (ctrl.signal.aborted) throw new ZarexClientError("timeout", `Zarex engine did not answer within ${timeoutMs} ms`);
|
|
171
|
+
throw new ZarexClientError("bad_response", "Zarex engine response could not be read");
|
|
172
|
+
});
|
|
173
|
+
if (res.status !== 200) {
|
|
174
|
+
const engineCode = extractEngineCode(text);
|
|
175
|
+
const code = statusToCode(res.status);
|
|
176
|
+
outcome = code === "server_error" ? "infra" : "neutral";
|
|
177
|
+
throw new ZarexClientError(code, `Zarex engine answered HTTP ${res.status}${engineCode ? ` (${engineCode})` : ""}`, {
|
|
178
|
+
status: res.status,
|
|
179
|
+
...engineCode ? { engineCode } : {}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
let parsed;
|
|
183
|
+
try {
|
|
184
|
+
parsed = parseDecision(text);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
outcome = "infra";
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
outcome = "ok";
|
|
190
|
+
return parsed;
|
|
191
|
+
} finally {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
if (breaker) {
|
|
194
|
+
if (outcome === "ok") breaker.success();
|
|
195
|
+
else if (outcome === "infra") breaker.failure();
|
|
196
|
+
else breaker.neutral();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { decide };
|
|
201
|
+
}
|
|
202
|
+
function errKind(err) {
|
|
203
|
+
const msg = err instanceof Error ? `${err.name} ${err.message} ${err.cause?.code ?? ""}` : "";
|
|
204
|
+
if (/redirect/i.test(msg)) return "unexpected redirect";
|
|
205
|
+
if (/ECONNREFUSED|refused/i.test(msg)) return "connection refused";
|
|
206
|
+
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) return "DNS lookup failed";
|
|
207
|
+
if (/certificate|TLS|SSL|CERT/i.test(msg)) return "TLS error";
|
|
208
|
+
return "network error";
|
|
209
|
+
}
|
|
210
|
+
function statusToCode(status) {
|
|
211
|
+
if (status === 401) return "unauthorized";
|
|
212
|
+
if (status === 403) return "forbidden";
|
|
213
|
+
if (status === 429) return "quota_exceeded";
|
|
214
|
+
if (status >= 500) return "server_error";
|
|
215
|
+
return "invalid_request";
|
|
216
|
+
}
|
|
217
|
+
function extractEngineCode(text) {
|
|
218
|
+
try {
|
|
219
|
+
const code = JSON.parse(text)?.error?.code;
|
|
220
|
+
return typeof code === "string" && /^[a-z_]{1,40}$/.test(code) ? code : void 0;
|
|
221
|
+
} catch {
|
|
222
|
+
return void 0;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function readCapped(res, max, signal) {
|
|
226
|
+
const declared = Number(res.headers.get("content-length"));
|
|
227
|
+
if (Number.isFinite(declared) && declared > max) throw new Error("too large");
|
|
228
|
+
if (!res.body) return await res.text();
|
|
229
|
+
const reader = res.body.getReader();
|
|
230
|
+
const chunks = [];
|
|
231
|
+
let total = 0;
|
|
232
|
+
for (; ; ) {
|
|
233
|
+
if (signal.aborted) {
|
|
234
|
+
await reader.cancel().catch(() => void 0);
|
|
235
|
+
throw new Error("aborted");
|
|
236
|
+
}
|
|
237
|
+
const { done, value } = await reader.read();
|
|
238
|
+
if (done) break;
|
|
239
|
+
total += value.byteLength;
|
|
240
|
+
if (total > max) {
|
|
241
|
+
await reader.cancel().catch(() => void 0);
|
|
242
|
+
throw new Error("too large");
|
|
243
|
+
}
|
|
244
|
+
chunks.push(value);
|
|
245
|
+
}
|
|
246
|
+
const all = new Uint8Array(total);
|
|
247
|
+
let off = 0;
|
|
248
|
+
for (const c of chunks) {
|
|
249
|
+
all.set(c, off);
|
|
250
|
+
off += c.byteLength;
|
|
251
|
+
}
|
|
252
|
+
return new TextDecoder().decode(all);
|
|
253
|
+
}
|
|
254
|
+
var VERDICTS = /* @__PURE__ */ new Set(["ALLOW", "DENY", "ERROR"]);
|
|
255
|
+
function parseDecision(text) {
|
|
256
|
+
let v;
|
|
257
|
+
try {
|
|
258
|
+
v = JSON.parse(text);
|
|
259
|
+
} catch {
|
|
260
|
+
throw new ZarexClientError("bad_response", "Zarex engine returned invalid JSON");
|
|
261
|
+
}
|
|
262
|
+
const o = v;
|
|
263
|
+
if (!o || typeof o !== "object" || typeof o.verdict !== "string" || !VERDICTS.has(o.verdict) || !Array.isArray(o.results)) {
|
|
264
|
+
throw new ZarexClientError("bad_response", "Zarex engine returned an unexpected response");
|
|
265
|
+
}
|
|
266
|
+
const results = [];
|
|
267
|
+
for (const r of o.results) {
|
|
268
|
+
const x = r;
|
|
269
|
+
if (!x || typeof x !== "object" || typeof x.rule_type !== "string" || typeof x.verdict !== "string" || !VERDICTS.has(x.verdict)) {
|
|
270
|
+
throw new ZarexClientError("bad_response", "Zarex engine returned an unexpected rule result");
|
|
271
|
+
}
|
|
272
|
+
results.push({
|
|
273
|
+
rule_type: x.rule_type,
|
|
274
|
+
verdict: x.verdict,
|
|
275
|
+
is_dry_run: x.is_dry_run === true,
|
|
276
|
+
...x.reason && typeof x.reason === "object" ? { reason: x.reason } : {}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const warnings = Array.isArray(o.warnings) ? o.warnings.filter((w) => typeof w === "string" && /^[a-z_]{1,40}$/.test(w)).slice(0, 10) : void 0;
|
|
280
|
+
const rawIp = o.ip;
|
|
281
|
+
const ipInfo = rawIp && typeof rawIp === "object" && typeof rawIp.country === "string" && /^[A-Z]{2}$/.test(rawIp.country) && typeof rawIp.asn === "string" && /^AS\d{1,10}$/.test(rawIp.asn) ? { country: rawIp.country, asn: rawIp.asn } : void 0;
|
|
282
|
+
return {
|
|
283
|
+
decision_id: typeof o.decision_id === "string" ? o.decision_id.slice(0, 64) : "",
|
|
284
|
+
verdict: o.verdict,
|
|
285
|
+
results,
|
|
286
|
+
...typeof o.ttl_ms === "number" ? { ttl_ms: o.ttl_ms } : {},
|
|
287
|
+
...warnings && warnings.length ? { warnings } : {},
|
|
288
|
+
...ipInfo ? { ip: ipInfo } : {},
|
|
289
|
+
...typeof o.ruleset_version === "string" && /^[A-Za-z0-9._+-]{1,64}$/.test(o.ruleset_version) ? { ruleset_version: o.ruleset_version } : {}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
export {
|
|
293
|
+
CircuitBreaker,
|
|
294
|
+
SDK_VERSION,
|
|
295
|
+
ZarexClientError,
|
|
296
|
+
ZarexConfigError,
|
|
297
|
+
createClient,
|
|
298
|
+
parseDecision,
|
|
299
|
+
resolveBaseUrl,
|
|
300
|
+
validateKey
|
|
301
|
+
};
|
|
302
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/breaker.ts","../src/errors.ts","../src/client.ts"],"sourcesContent":["/**\r\n * Disjoncteur côté SDK.\r\n *\r\n * Pourquoi : si le moteur est en panne ou très lent, CHAQUE requête du client\r\n * attendrait le délai maximal (ex. 500 ms) avant de laisser passer. Le site du\r\n * client serait ralenti par NOTRE panne. Après quelques échecs d'affilée, on\r\n * cesse d'appeler le moteur pendant un moment (« circuit ouvert ») : les\r\n * requêtes passent immédiatement. Puis UNE requête d'essai vérifie s'il est revenu.\r\n */\r\nexport interface BreakerOptions {\r\n /** Échecs consécutifs avant ouverture. Défaut 5. */\r\n failureThreshold?: number;\r\n /** Durée pendant laquelle on n'appelle plus le moteur. Défaut 10 000 ms. */\r\n cooldownMs?: number;\r\n /** Horloge (tests). */\r\n now?: () => number;\r\n}\r\n\r\ntype State = \"closed\" | \"open\" | \"half-open\";\r\n\r\nexport class CircuitBreaker {\r\n private state: State = \"closed\";\r\n private failures = 0;\r\n private openedAt = 0;\r\n private probing = false;\r\n private readonly threshold: number;\r\n private readonly cooldown: number;\r\n private readonly now: () => number;\r\n\r\n constructor(opts: BreakerOptions = {}) {\r\n this.threshold = opts.failureThreshold ?? 5;\r\n this.cooldown = opts.cooldownMs ?? 10_000;\r\n this.now = opts.now ?? Date.now;\r\n }\r\n\r\n /** true = l'appel est autorisé. Quand le circuit est semi-ouvert, un seul appel à la fois passe. */\r\n allow(): boolean {\r\n if (this.state === \"closed\") return true;\r\n if (this.state === \"open\") {\r\n if (this.now() - this.openedAt < this.cooldown) return false;\r\n this.state = \"half-open\";\r\n this.probing = false;\r\n }\r\n // half-open : un seul essai en vol\r\n if (this.probing) return false;\r\n this.probing = true;\r\n return true;\r\n }\r\n\r\n success(): void {\r\n this.failures = 0;\r\n this.probing = false;\r\n this.state = \"closed\";\r\n }\r\n\r\n /** À appeler pour une panne d'infrastructure (délai, réseau, 5xx), pas pour une erreur de configuration. */\r\n failure(): void {\r\n this.probing = false;\r\n if (this.state === \"half-open\") {\r\n this.trip();\r\n return;\r\n }\r\n this.failures++;\r\n if (this.failures >= this.threshold) this.trip();\r\n }\r\n \r\n /** Un appel autorisé qui se termine sans verdict sur la santé du moteur (ex. 401) : libère l'essai. */\r\n neutral(): void {\r\n this.probing = false;\r\n if (this.state === \"half-open\") this.state = \"closed\";\r\n }\r\n \r\n private trip(): void {\r\n this.state = \"open\";\r\n this.openedAt = this.now();\r\n }\r\n \r\n get isOpen(): boolean {\r\n return this.state !== \"closed\";\r\n }\r\n}","/**\r\n * Codes d'erreur du client. Le SDK ne lève JAMAIS ces erreurs jusqu'à\r\n * l'application du client : `protect()` les attrape et laisse passer la requête\r\n * (fail-open). Elles servent aux journaux et à `decision.error`.\r\n */\r\nexport type ClientErrorCode =\r\n | \"timeout\" // le moteur n'a pas répondu à temps\r\n | \"network\" // connexion impossible / coupée\r\n | \"circuit_open\" // trop d'échecs récents : appel évité pour protéger l'application\r\n | \"unauthorized\" // 401 : clé API absente, invalide ou révoquée\r\n | \"forbidden\" // 403\r\n | \"quota_exceeded\" // 429 : quota mensuel du plan atteint\r\n | \"invalid_request\" // 400/413/415 : bug du SDK ou règle invalide\r\n | \"server_error\" // 5xx\r\n | \"bad_response\"; // réponse 200 mais illisible / inattendue\r\n \r\nexport class ZarexClientError extends Error {\r\n readonly code: ClientErrorCode;\r\n readonly status?: number;\r\n /** Code d'erreur renvoyé par le moteur (ex. « too_large »), si présent. */\r\n readonly engineCode?: string;\r\n \r\n constructor(code: ClientErrorCode, message: string, opts: { status?: number; engineCode?: string } = {}) {\r\n super(message);\r\n this.name = \"ZarexClientError\";\r\n this.code = code;\r\n if (opts.status !== undefined) this.status = opts.status;\r\n if (opts.engineCode !== undefined) this.engineCode = opts.engineCode;\r\n }\r\n}\r\n \r\n/** Erreur de configuration : levée immédiatement à la création, jamais pendant une requête. */\r\nexport class ZarexConfigError extends Error {\r\n constructor(message: string) {\r\n super(message);\r\n this.name = \"ZarexConfigError\";\r\n }\r\n}","import { CircuitBreaker, type BreakerOptions } from \"./breaker.js\";\r\nimport { ZarexClientError, ZarexConfigError } from \"./errors.js\";\r\nimport type { DecideRequest, DecideResponse, Verdict } from \"./types.js\";\r\n\r\nexport interface ClientOptions {\r\n /** Clé API du projet (zarex_live_… / zarex_test_…). Jamais journalisée. */\r\n key: string;\r\n /** Adresse du service Zarex. Inutile en usage normal (défaut : le service de production) — à ne définir que pour les tests ou un environnement local. Variable ZAREX_URL acceptée aussi. */\r\n baseUrl?: string;\r\n /** Délai maximal d'un appel, en ms. Défaut 500. */\r\n timeoutMs?: number;\r\n /** Autoriser http:// vers un hôte distant (déconseillé : la clé circulerait en clair). */\r\n allowInsecureHttp?: boolean;\r\n /** Disjoncteur. `false` pour le désactiver. */\r\n breaker?: BreakerOptions | false;\r\n /** Implémentation de fetch (tests, runtimes particuliers). */\r\n fetch?: typeof fetch;\r\n /** Version annoncée au moteur. */\r\n sdkVersion?: string;\r\n}\r\n\r\nexport interface ZarexClient {\r\n decide(request: DecideRequest): Promise<DecideResponse>;\r\n}\r\n\r\nexport const SDK_VERSION = \"0.1.0\";\r\nconst MAX_RESPONSE_BYTES = 256 * 1024;\r\nconst LOCAL_HOSTS = new Set([\"localhost\", \"127.0.0.1\", \"[::1]\", \"::1\"]);\r\n/**\r\n * Adresse du service Zarex en production — un seul service partagé par tous\r\n * les clients, différenciés par leur clé API (comme Stripe/Twilio : jamais\r\n * besoin de configurer où se trouve l'API). `baseUrl`/`ZAREX_URL` restent\r\n * disponibles pour la remplacer (tests, environnement local), mais plus\r\n * personne n'a besoin de le faire pour un usage normal.\r\n */\r\nexport const DEFAULT_BASE_URL = \"https://engine.zarex.dev\";\r\n\r\nfunction readEnv(name: string): string | undefined {\r\n try {\r\n return typeof process !== \"undefined\" ? process.env?.[name] : undefined;\r\n } catch {\r\n return undefined;\r\n }\r\n}\r\n\r\n/** Valide la clé sans jamais la recopier dans un message d'erreur. */\r\nexport function validateKey(key: unknown): string {\r\n if (typeof key !== \"string\" || key.length < 16 || key.length > 256 || !/^[A-Za-z0-9_-]+$/.test(key)) {\r\n throw new ZarexConfigError(\r\n \"Zarex: `key` is missing or malformed (expected the project API key, e.g. from process.env.ZAREX_KEY).\"\r\n );\r\n }\r\n return key;\r\n}\r\n\r\nexport function resolveBaseUrl(baseUrl: string | undefined, allowInsecureHttp = false): string {\r\n const raw = baseUrl ?? readEnv(\"ZAREX_URL\") ?? DEFAULT_BASE_URL;\r\n let u: URL;\r\n try {\r\n u = new URL(raw);\r\n } catch {\r\n throw new ZarexConfigError(\"Zarex: `baseUrl` is not a valid URL.\");\r\n }\r\n if (u.protocol !== \"https:\" && u.protocol !== \"http:\") {\r\n throw new ZarexConfigError(\"Zarex: `baseUrl` must use https:// (or http:// for localhost).\");\r\n }\r\n if (u.username || u.password) {\r\n throw new ZarexConfigError(\"Zarex: `baseUrl` must not contain credentials.\");\r\n }\r\n if (u.protocol === \"http:\" && !LOCAL_HOSTS.has(u.hostname) && !allowInsecureHttp) {\r\n throw new ZarexConfigError(\r\n \"Zarex: refusing to send the API key over plain http:// to a remote host. Use https:// (or set allowInsecureHttp: true at your own risk).\"\r\n );\r\n }\r\n u.search = \"\";\r\n u.hash = \"\";\r\n return u.toString().replace(/\\/+$/, \"\");\r\n}\r\n\r\nexport function createClient(opts: ClientOptions): ZarexClient {\r\n const key = validateKey(opts.key);\r\n const base = resolveBaseUrl(opts.baseUrl, opts.allowInsecureHttp);\r\n const timeoutMs = opts.timeoutMs ?? 500;\r\n if (!Number.isFinite(timeoutMs) || timeoutMs < 10 || timeoutMs > 30_000) {\r\n throw new ZarexConfigError(\"Zarex: `timeoutMs` must be between 10 and 30000.\");\r\n }\r\n const doFetch = opts.fetch ?? (typeof fetch === \"function\" ? fetch : undefined);\r\n if (!doFetch) {\r\n throw new ZarexConfigError(\"Zarex: no global fetch available; pass `fetch` in the options (Node >= 18 has one).\");\r\n }\r\n const breaker = opts.breaker === false ? null : new CircuitBreaker(opts.breaker ?? {});\r\n const url = `${base}/v1/decide`;\r\n const version = opts.sdkVersion ?? SDK_VERSION;\r\n\r\n async function decide(request: DecideRequest): Promise<DecideResponse> {\r\n if (breaker && !breaker.allow()) {\r\n throw new ZarexClientError(\"circuit_open\", \"Zarex engine temporarily skipped after repeated failures\");\r\n }\r\n\r\n const ctrl = new AbortController();\r\n const timer = setTimeout(() => ctrl.abort(), timeoutMs);\r\n let outcome: \"ok\" | \"infra\" | \"neutral\" = \"neutral\";\r\n try {\r\n let res: Response;\r\n try {\r\n res = await doFetch!(url, {\r\n method: \"POST\",\r\n headers: {\r\n \"content-type\": \"application/json\",\r\n accept: \"application/json\",\r\n authorization: `Bearer ${key}`,\r\n \"user-agent\": `zarex-js/${version}`,\r\n },\r\n body: JSON.stringify(request),\r\n signal: ctrl.signal,\r\n // Ne JAMAIS suivre une redirection : elle renverrait la clé API ailleurs.\r\n // \"manual\" plutôt que \"error\" : Cloudflare Workers ne connaît pas \"error\". La réponse 3xx est refusée plus bas.\r\n redirect: \"manual\",\r\n });\r\n } catch (err) {\r\n outcome = \"infra\";\r\n if (ctrl.signal.aborted) throw new ZarexClientError(\"timeout\", `Zarex engine did not answer within ${timeoutMs} ms`);\r\n // Le message natif peut contenir l'URL : on n'en garde que la nature.\r\n throw new ZarexClientError(\"network\", `Zarex engine unreachable (${errKind(err)})`);\r\n }\r\n\r\n if (res.type === \"opaqueredirect\" || (res.status >= 300 && res.status < 400)) {\r\n outcome = \"infra\";\r\n void res.body?.cancel().catch(() => undefined);\r\n throw new ZarexClientError(\"network\", \"Zarex engine unreachable (unexpected redirect)\");\r\n }\r\n\r\n const text = await readCapped(res, MAX_RESPONSE_BYTES, ctrl.signal).catch(() => {\r\n outcome = \"infra\";\r\n if (ctrl.signal.aborted) throw new ZarexClientError(\"timeout\", `Zarex engine did not answer within ${timeoutMs} ms`);\r\n throw new ZarexClientError(\"bad_response\", \"Zarex engine response could not be read\");\r\n });\r\n\r\n if (res.status !== 200) {\r\n const engineCode = extractEngineCode(text);\r\n const code = statusToCode(res.status);\r\n outcome = code === \"server_error\" ? \"infra\" : \"neutral\";\r\n throw new ZarexClientError(code, `Zarex engine answered HTTP ${res.status}${engineCode ? ` (${engineCode})` : \"\"}`, {\r\n status: res.status,\r\n ...(engineCode ? { engineCode } : {}),\r\n });\r\n }\r\n\r\n // Un 200 avec un corps JSON illisible/inattendu est la MÊME défaillance (« bad_response ») que celle\r\n // de readCapped juste au-dessus, et doit compter de la même façon pour le disjoncteur (« infra ») —\r\n // sans ce catch, `outcome` restait à \"neutral\" (sa valeur par défaut, jamais réécrite si parseDecision\r\n // lève), donc un moteur qui renvoie du JSON corrompu en 200 n'ouvrait jamais le disjoncteur (trouvé\r\n // lors de l'audit du 04/11/2026, voir AUDIT_SECURITE_COMPLET.md).\r\n let parsed: DecideResponse;\r\n try {\r\n parsed = parseDecision(text);\r\n } catch (err) {\r\n outcome = \"infra\";\r\n throw err;\r\n }\r\n outcome = \"ok\";\r\n return parsed;\r\n } finally {\r\n clearTimeout(timer);\r\n if (breaker) {\r\n if (outcome === \"ok\") breaker.success();\r\n else if (outcome === \"infra\") breaker.failure();\r\n else breaker.neutral();\r\n }\r\n }\r\n }\r\n\r\n return { decide };\r\n}\r\n\r\nfunction errKind(err: unknown): string {\r\n const msg = err instanceof Error ? `${err.name} ${err.message} ${(err as { cause?: { code?: string } }).cause?.code ?? \"\"}` : \"\";\r\n if (/redirect/i.test(msg)) return \"unexpected redirect\";\r\n if (/ECONNREFUSED|refused/i.test(msg)) return \"connection refused\";\r\n if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) return \"DNS lookup failed\";\r\n if (/certificate|TLS|SSL|CERT/i.test(msg)) return \"TLS error\";\r\n return \"network error\";\r\n}\r\n\r\nfunction statusToCode(status: number): ZarexClientError[\"code\"] {\r\n if (status === 401) return \"unauthorized\";\r\n if (status === 403) return \"forbidden\";\r\n if (status === 429) return \"quota_exceeded\";\r\n if (status >= 500) return \"server_error\";\r\n return \"invalid_request\";\r\n}\r\n\r\nfunction extractEngineCode(text: string): string | undefined {\r\n try {\r\n const code = (JSON.parse(text) as { error?: { code?: unknown } })?.error?.code;\r\n return typeof code === \"string\" && /^[a-z_]{1,40}$/.test(code) ? code : undefined;\r\n } catch {\r\n return undefined;\r\n }\r\n}\r\n\r\n/** Lit au plus `max` octets : une réponse démesurée ne doit pas saturer la mémoire du client. */\r\nasync function readCapped(res: Response, max: number, signal: AbortSignal): Promise<string> {\r\n const declared = Number(res.headers.get(\"content-length\"));\r\n if (Number.isFinite(declared) && declared > max) throw new Error(\"too large\");\r\n if (!res.body) return await res.text();\r\n const reader = res.body.getReader();\r\n const chunks: Uint8Array[] = [];\r\n let total = 0;\r\n for (;;) {\r\n if (signal.aborted) {\r\n await reader.cancel().catch(() => undefined);\r\n throw new Error(\"aborted\");\r\n }\r\n const { done, value } = await reader.read();\r\n if (done) break;\r\n total += value.byteLength;\r\n if (total > max) {\r\n await reader.cancel().catch(() => undefined);\r\n throw new Error(\"too large\");\r\n }\r\n chunks.push(value);\r\n }\r\n const all = new Uint8Array(total);\r\n let off = 0;\r\n for (const c of chunks) {\r\n all.set(c, off);\r\n off += c.byteLength;\r\n }\r\n return new TextDecoder().decode(all);\r\n}\r\n\r\nconst VERDICTS: ReadonlySet<string> = new Set<Verdict>([\"ALLOW\", \"DENY\", \"ERROR\"]);\r\n\r\n/** Validation stricte de la réponse : ce qu'on ne comprend pas n'est jamais interprété comme un verdict. */\r\nexport function parseDecision(text: string): DecideResponse {\r\n let v: unknown;\r\n try {\r\n v = JSON.parse(text);\r\n } catch {\r\n throw new ZarexClientError(\"bad_response\", \"Zarex engine returned invalid JSON\");\r\n }\r\n const o = v as Record<string, unknown> | null;\r\n if (!o || typeof o !== \"object\" || typeof o.verdict !== \"string\" || !VERDICTS.has(o.verdict) || !Array.isArray(o.results)) {\r\n throw new ZarexClientError(\"bad_response\", \"Zarex engine returned an unexpected response\");\r\n }\r\n const results = [];\r\n for (const r of o.results as unknown[]) {\r\n const x = r as Record<string, unknown> | null;\r\n if (!x || typeof x !== \"object\" || typeof x.rule_type !== \"string\" || typeof x.verdict !== \"string\" || !VERDICTS.has(x.verdict)) {\r\n throw new ZarexClientError(\"bad_response\", \"Zarex engine returned an unexpected rule result\");\r\n }\r\n results.push({\r\n rule_type: x.rule_type,\r\n verdict: x.verdict as Verdict,\r\n is_dry_run: x.is_dry_run === true,\r\n ...(x.reason && typeof x.reason === \"object\" ? { reason: x.reason as Record<string, unknown> } : {}),\r\n });\r\n }\r\n const warnings = Array.isArray(o.warnings)\r\n ? (o.warnings as unknown[]).filter((w): w is string => typeof w === \"string\" && /^[a-z_]{1,40}$/.test(w)).slice(0, 10)\r\n : undefined;\r\n const rawIp = o.ip as Record<string, unknown> | null | undefined;\r\n const ipInfo =\r\n rawIp && typeof rawIp === \"object\" && typeof rawIp.country === \"string\" && /^[A-Z]{2}$/.test(rawIp.country) && typeof rawIp.asn === \"string\" && /^AS\\d{1,10}$/.test(rawIp.asn)\r\n ? { country: rawIp.country, asn: rawIp.asn }\r\n : undefined;\r\n return {\r\n decision_id: typeof o.decision_id === \"string\" ? o.decision_id.slice(0, 64) : \"\",\r\n verdict: o.verdict as Verdict,\r\n results,\r\n ...(typeof o.ttl_ms === \"number\" ? { ttl_ms: o.ttl_ms } : {}),\r\n ...(warnings && warnings.length ? { warnings } : {}),\r\n ...(ipInfo ? { ip: ipInfo } : {}),\r\n ...(typeof o.ruleset_version === \"string\" && /^[A-Za-z0-9._+-]{1,64}$/.test(o.ruleset_version) ? { ruleset_version: o.ruleset_version } : {}),\r\n };\r\n}"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zarex/client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Zarex — client HTTP du Decision Engine (fetch, délai, disjoncteur, fail-open)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./dist/index.cjs",
|
|
11
|
+
"module": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"require": {
|
|
20
|
+
"types": "./dist/index.d.cts",
|
|
21
|
+
"default": "./dist/index.cjs"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup"
|
|
31
|
+
}
|
|
6
32
|
}
|