@zarex/client 0.1.3 → 0.1.4

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/dist/index.cjs CHANGED
@@ -108,7 +108,7 @@ var ZarexConfigError = class extends Error {
108
108
  };
109
109
 
110
110
  // src/client.ts
111
- var SDK_VERSION = "0.1.0";
111
+ var SDK_VERSION = "0.1.4";
112
112
  var MAX_RESPONSE_BYTES = 256 * 1024;
113
113
  var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
114
114
  var DEFAULT_BASE_URL = "https://engine.zarex.dev";
@@ -1 +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":[]}
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\";\nimport { ZarexClientError, ZarexConfigError } from \"./errors.js\";\nimport type { DecideRequest, DecideResponse, Verdict } from \"./types.js\";\n\nexport interface ClientOptions {\n /** Clé API du projet (zarex_live_… / zarex_test_…). Jamais journalisée. */\n key: string;\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. */\n baseUrl?: string;\n /** Délai maximal d'un appel, en ms. Défaut 500. */\n timeoutMs?: number;\n /** Autoriser http:// vers un hôte distant (déconseillé : la clé circulerait en clair). */\n allowInsecureHttp?: boolean;\n /** Disjoncteur. `false` pour le désactiver. */\n breaker?: BreakerOptions | false;\n /** Implémentation de fetch (tests, runtimes particuliers). */\n fetch?: typeof fetch;\n /** Version annoncée au moteur. */\n sdkVersion?: string;\n}\n\nexport interface ZarexClient {\n decide(request: DecideRequest): Promise<DecideResponse>;\n}\n\nexport const SDK_VERSION = \"0.1.4\";\nconst MAX_RESPONSE_BYTES = 256 * 1024;\nconst LOCAL_HOSTS = new Set([\"localhost\", \"127.0.0.1\", \"[::1]\", \"::1\"]);\n/**\n * Adresse du service Zarex en production — un seul service partagé par tous\n * les clients, différenciés par leur clé API (comme Stripe/Twilio : jamais\n * besoin de configurer où se trouve l'API). `baseUrl`/`ZAREX_URL` restent\n * disponibles pour la remplacer (tests, environnement local), mais plus\n * personne n'a besoin de le faire pour un usage normal.\n */\nexport const DEFAULT_BASE_URL = \"https://engine.zarex.dev\";\n\nfunction readEnv(name: string): string | undefined {\n try {\n return typeof process !== \"undefined\" ? process.env?.[name] : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** Valide la clé sans jamais la recopier dans un message d'erreur. */\nexport function validateKey(key: unknown): string {\n if (typeof key !== \"string\" || key.length < 16 || key.length > 256 || !/^[A-Za-z0-9_-]+$/.test(key)) {\n throw new ZarexConfigError(\n \"Zarex: `key` is missing or malformed (expected the project API key, e.g. from process.env.ZAREX_KEY).\"\n );\n }\n return key;\n}\n\nexport function resolveBaseUrl(baseUrl: string | undefined, allowInsecureHttp = false): string {\n const raw = baseUrl ?? readEnv(\"ZAREX_URL\") ?? DEFAULT_BASE_URL;\n let u: URL;\n try {\n u = new URL(raw);\n } catch {\n throw new ZarexConfigError(\"Zarex: `baseUrl` is not a valid URL.\");\n }\n if (u.protocol !== \"https:\" && u.protocol !== \"http:\") {\n throw new ZarexConfigError(\"Zarex: `baseUrl` must use https:// (or http:// for localhost).\");\n }\n if (u.username || u.password) {\n throw new ZarexConfigError(\"Zarex: `baseUrl` must not contain credentials.\");\n }\n if (u.protocol === \"http:\" && !LOCAL_HOSTS.has(u.hostname) && !allowInsecureHttp) {\n throw new ZarexConfigError(\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).\"\n );\n }\n u.search = \"\";\n u.hash = \"\";\n return u.toString().replace(/\\/+$/, \"\");\n}\n\nexport function createClient(opts: ClientOptions): ZarexClient {\n const key = validateKey(opts.key);\n const base = resolveBaseUrl(opts.baseUrl, opts.allowInsecureHttp);\n const timeoutMs = opts.timeoutMs ?? 500;\n if (!Number.isFinite(timeoutMs) || timeoutMs < 10 || timeoutMs > 30_000) {\n throw new ZarexConfigError(\"Zarex: `timeoutMs` must be between 10 and 30000.\");\n }\n const doFetch = opts.fetch ?? (typeof fetch === \"function\" ? fetch : undefined);\n if (!doFetch) {\n throw new ZarexConfigError(\"Zarex: no global fetch available; pass `fetch` in the options (Node >= 18 has one).\");\n }\n const breaker = opts.breaker === false ? null : new CircuitBreaker(opts.breaker ?? {});\n const url = `${base}/v1/decide`;\n const version = opts.sdkVersion ?? SDK_VERSION;\n\n async function decide(request: DecideRequest): Promise<DecideResponse> {\n if (breaker && !breaker.allow()) {\n throw new ZarexClientError(\"circuit_open\", \"Zarex engine temporarily skipped after repeated failures\");\n }\n\n const ctrl = new AbortController();\n const timer = setTimeout(() => ctrl.abort(), timeoutMs);\n let outcome: \"ok\" | \"infra\" | \"neutral\" = \"neutral\";\n try {\n let res: Response;\n try {\n res = await doFetch!(url, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n accept: \"application/json\",\n authorization: `Bearer ${key}`,\n \"user-agent\": `zarex-js/${version}`,\n },\n body: JSON.stringify(request),\n signal: ctrl.signal,\n // Ne JAMAIS suivre une redirection : elle renverrait la clé API ailleurs.\n // \"manual\" plutôt que \"error\" : Cloudflare Workers ne connaît pas \"error\". La réponse 3xx est refusée plus bas.\n redirect: \"manual\",\n });\n } catch (err) {\n outcome = \"infra\";\n if (ctrl.signal.aborted) throw new ZarexClientError(\"timeout\", `Zarex engine did not answer within ${timeoutMs} ms`);\n // Le message natif peut contenir l'URL : on n'en garde que la nature.\n throw new ZarexClientError(\"network\", `Zarex engine unreachable (${errKind(err)})`);\n }\n\n if (res.type === \"opaqueredirect\" || (res.status >= 300 && res.status < 400)) {\n outcome = \"infra\";\n void res.body?.cancel().catch(() => undefined);\n throw new ZarexClientError(\"network\", \"Zarex engine unreachable (unexpected redirect)\");\n }\n\n const text = await readCapped(res, MAX_RESPONSE_BYTES, ctrl.signal).catch(() => {\n outcome = \"infra\";\n if (ctrl.signal.aborted) throw new ZarexClientError(\"timeout\", `Zarex engine did not answer within ${timeoutMs} ms`);\n throw new ZarexClientError(\"bad_response\", \"Zarex engine response could not be read\");\n });\n\n if (res.status !== 200) {\n const engineCode = extractEngineCode(text);\n const code = statusToCode(res.status);\n outcome = code === \"server_error\" ? \"infra\" : \"neutral\";\n throw new ZarexClientError(code, `Zarex engine answered HTTP ${res.status}${engineCode ? ` (${engineCode})` : \"\"}`, {\n status: res.status,\n ...(engineCode ? { engineCode } : {}),\n });\n }\n\n // Un 200 avec un corps JSON illisible/inattendu est la MÊME défaillance (« bad_response ») que celle\n // de readCapped juste au-dessus, et doit compter de la même façon pour le disjoncteur (« infra ») —\n // sans ce catch, `outcome` restait à \"neutral\" (sa valeur par défaut, jamais réécrite si parseDecision\n // lève), donc un moteur qui renvoie du JSON corrompu en 200 n'ouvrait jamais le disjoncteur (trouvé\n // lors de l'audit du 04/11/2026, voir AUDIT_SECURITE_COMPLET.md).\n let parsed: DecideResponse;\n try {\n parsed = parseDecision(text);\n } catch (err) {\n outcome = \"infra\";\n throw err;\n }\n outcome = \"ok\";\n return parsed;\n } finally {\n clearTimeout(timer);\n if (breaker) {\n if (outcome === \"ok\") breaker.success();\n else if (outcome === \"infra\") breaker.failure();\n else breaker.neutral();\n }\n }\n }\n\n return { decide };\n}\n\nfunction errKind(err: unknown): string {\n const msg = err instanceof Error ? `${err.name} ${err.message} ${(err as { cause?: { code?: string } }).cause?.code ?? \"\"}` : \"\";\n if (/redirect/i.test(msg)) return \"unexpected redirect\";\n if (/ECONNREFUSED|refused/i.test(msg)) return \"connection refused\";\n if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) return \"DNS lookup failed\";\n if (/certificate|TLS|SSL|CERT/i.test(msg)) return \"TLS error\";\n return \"network error\";\n}\n\nfunction statusToCode(status: number): ZarexClientError[\"code\"] {\n if (status === 401) return \"unauthorized\";\n if (status === 403) return \"forbidden\";\n if (status === 429) return \"quota_exceeded\";\n if (status >= 500) return \"server_error\";\n return \"invalid_request\";\n}\n\nfunction extractEngineCode(text: string): string | undefined {\n try {\n const code = (JSON.parse(text) as { error?: { code?: unknown } })?.error?.code;\n return typeof code === \"string\" && /^[a-z_]{1,40}$/.test(code) ? code : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** Lit au plus `max` octets : une réponse démesurée ne doit pas saturer la mémoire du client. */\nasync function readCapped(res: Response, max: number, signal: AbortSignal): Promise<string> {\n const declared = Number(res.headers.get(\"content-length\"));\n if (Number.isFinite(declared) && declared > max) throw new Error(\"too large\");\n if (!res.body) return await res.text();\n const reader = res.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n for (;;) {\n if (signal.aborted) {\n await reader.cancel().catch(() => undefined);\n throw new Error(\"aborted\");\n }\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > max) {\n await reader.cancel().catch(() => undefined);\n throw new Error(\"too large\");\n }\n chunks.push(value);\n }\n const all = new Uint8Array(total);\n let off = 0;\n for (const c of chunks) {\n all.set(c, off);\n off += c.byteLength;\n }\n return new TextDecoder().decode(all);\n}\n\nconst VERDICTS: ReadonlySet<string> = new Set<Verdict>([\"ALLOW\", \"DENY\", \"ERROR\"]);\n\n/** Validation stricte de la réponse : ce qu'on ne comprend pas n'est jamais interprété comme un verdict. */\nexport function parseDecision(text: string): DecideResponse {\n let v: unknown;\n try {\n v = JSON.parse(text);\n } catch {\n throw new ZarexClientError(\"bad_response\", \"Zarex engine returned invalid JSON\");\n }\n const o = v as Record<string, unknown> | null;\n if (!o || typeof o !== \"object\" || typeof o.verdict !== \"string\" || !VERDICTS.has(o.verdict) || !Array.isArray(o.results)) {\n throw new ZarexClientError(\"bad_response\", \"Zarex engine returned an unexpected response\");\n }\n const results = [];\n for (const r of o.results as unknown[]) {\n const x = r as Record<string, unknown> | null;\n if (!x || typeof x !== \"object\" || typeof x.rule_type !== \"string\" || typeof x.verdict !== \"string\" || !VERDICTS.has(x.verdict)) {\n throw new ZarexClientError(\"bad_response\", \"Zarex engine returned an unexpected rule result\");\n }\n results.push({\n rule_type: x.rule_type,\n verdict: x.verdict as Verdict,\n is_dry_run: x.is_dry_run === true,\n ...(x.reason && typeof x.reason === \"object\" ? { reason: x.reason as Record<string, unknown> } : {}),\n });\n }\n const warnings = Array.isArray(o.warnings)\n ? (o.warnings as unknown[]).filter((w): w is string => typeof w === \"string\" && /^[a-z_]{1,40}$/.test(w)).slice(0, 10)\n : undefined;\n const rawIp = o.ip as Record<string, unknown> | null | undefined;\n const ipInfo =\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)\n ? { country: rawIp.country, asn: rawIp.asn }\n : undefined;\n return {\n decision_id: typeof o.decision_id === \"string\" ? o.decision_id.slice(0, 64) : \"\",\n verdict: o.verdict as Verdict,\n results,\n ...(typeof o.ttl_ms === \"number\" ? { ttl_ms: o.ttl_ms } : {}),\n ...(warnings && warnings.length ? { warnings } : {}),\n ...(ipInfo ? { ip: ipInfo } : {}),\n ...(typeof o.ruleset_version === \"string\" && /^[A-Za-z0-9._+-]{1,64}$/.test(o.ruleset_version) ? { ruleset_version: o.ruleset_version } : {}),\n };\n}"],"mappings":"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","names":[]}
package/dist/index.d.cts CHANGED
@@ -198,7 +198,7 @@ interface ClientOptions {
198
198
  interface ZarexClient {
199
199
  decide(request: DecideRequest): Promise<DecideResponse>;
200
200
  }
201
- declare const SDK_VERSION = "0.1.0";
201
+ declare const SDK_VERSION = "0.1.4";
202
202
  /** Valide la clé sans jamais la recopier dans un message d'erreur. */
203
203
  declare function validateKey(key: unknown): string;
204
204
  declare function resolveBaseUrl(baseUrl: string | undefined, allowInsecureHttp?: boolean): string;
package/dist/index.d.ts CHANGED
@@ -198,7 +198,7 @@ interface ClientOptions {
198
198
  interface ZarexClient {
199
199
  decide(request: DecideRequest): Promise<DecideResponse>;
200
200
  }
201
- declare const SDK_VERSION = "0.1.0";
201
+ declare const SDK_VERSION = "0.1.4";
202
202
  /** Valide la clé sans jamais la recopier dans un message d'erreur. */
203
203
  declare function validateKey(key: unknown): string;
204
204
  declare function resolveBaseUrl(baseUrl: string | undefined, allowInsecureHttp?: boolean): string;
package/dist/index.js CHANGED
@@ -75,7 +75,7 @@ var ZarexConfigError = class extends Error {
75
75
  };
76
76
 
77
77
  // src/client.ts
78
- var SDK_VERSION = "0.1.0";
78
+ var SDK_VERSION = "0.1.4";
79
79
  var MAX_RESPONSE_BYTES = 256 * 1024;
80
80
  var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
81
81
  var DEFAULT_BASE_URL = "https://engine.zarex.dev";
package/dist/index.js.map CHANGED
@@ -1 +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":[]}
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\";\nimport { ZarexClientError, ZarexConfigError } from \"./errors.js\";\nimport type { DecideRequest, DecideResponse, Verdict } from \"./types.js\";\n\nexport interface ClientOptions {\n /** Clé API du projet (zarex_live_… / zarex_test_…). Jamais journalisée. */\n key: string;\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. */\n baseUrl?: string;\n /** Délai maximal d'un appel, en ms. Défaut 500. */\n timeoutMs?: number;\n /** Autoriser http:// vers un hôte distant (déconseillé : la clé circulerait en clair). */\n allowInsecureHttp?: boolean;\n /** Disjoncteur. `false` pour le désactiver. */\n breaker?: BreakerOptions | false;\n /** Implémentation de fetch (tests, runtimes particuliers). */\n fetch?: typeof fetch;\n /** Version annoncée au moteur. */\n sdkVersion?: string;\n}\n\nexport interface ZarexClient {\n decide(request: DecideRequest): Promise<DecideResponse>;\n}\n\nexport const SDK_VERSION = \"0.1.4\";\nconst MAX_RESPONSE_BYTES = 256 * 1024;\nconst LOCAL_HOSTS = new Set([\"localhost\", \"127.0.0.1\", \"[::1]\", \"::1\"]);\n/**\n * Adresse du service Zarex en production — un seul service partagé par tous\n * les clients, différenciés par leur clé API (comme Stripe/Twilio : jamais\n * besoin de configurer où se trouve l'API). `baseUrl`/`ZAREX_URL` restent\n * disponibles pour la remplacer (tests, environnement local), mais plus\n * personne n'a besoin de le faire pour un usage normal.\n */\nexport const DEFAULT_BASE_URL = \"https://engine.zarex.dev\";\n\nfunction readEnv(name: string): string | undefined {\n try {\n return typeof process !== \"undefined\" ? process.env?.[name] : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** Valide la clé sans jamais la recopier dans un message d'erreur. */\nexport function validateKey(key: unknown): string {\n if (typeof key !== \"string\" || key.length < 16 || key.length > 256 || !/^[A-Za-z0-9_-]+$/.test(key)) {\n throw new ZarexConfigError(\n \"Zarex: `key` is missing or malformed (expected the project API key, e.g. from process.env.ZAREX_KEY).\"\n );\n }\n return key;\n}\n\nexport function resolveBaseUrl(baseUrl: string | undefined, allowInsecureHttp = false): string {\n const raw = baseUrl ?? readEnv(\"ZAREX_URL\") ?? DEFAULT_BASE_URL;\n let u: URL;\n try {\n u = new URL(raw);\n } catch {\n throw new ZarexConfigError(\"Zarex: `baseUrl` is not a valid URL.\");\n }\n if (u.protocol !== \"https:\" && u.protocol !== \"http:\") {\n throw new ZarexConfigError(\"Zarex: `baseUrl` must use https:// (or http:// for localhost).\");\n }\n if (u.username || u.password) {\n throw new ZarexConfigError(\"Zarex: `baseUrl` must not contain credentials.\");\n }\n if (u.protocol === \"http:\" && !LOCAL_HOSTS.has(u.hostname) && !allowInsecureHttp) {\n throw new ZarexConfigError(\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).\"\n );\n }\n u.search = \"\";\n u.hash = \"\";\n return u.toString().replace(/\\/+$/, \"\");\n}\n\nexport function createClient(opts: ClientOptions): ZarexClient {\n const key = validateKey(opts.key);\n const base = resolveBaseUrl(opts.baseUrl, opts.allowInsecureHttp);\n const timeoutMs = opts.timeoutMs ?? 500;\n if (!Number.isFinite(timeoutMs) || timeoutMs < 10 || timeoutMs > 30_000) {\n throw new ZarexConfigError(\"Zarex: `timeoutMs` must be between 10 and 30000.\");\n }\n const doFetch = opts.fetch ?? (typeof fetch === \"function\" ? fetch : undefined);\n if (!doFetch) {\n throw new ZarexConfigError(\"Zarex: no global fetch available; pass `fetch` in the options (Node >= 18 has one).\");\n }\n const breaker = opts.breaker === false ? null : new CircuitBreaker(opts.breaker ?? {});\n const url = `${base}/v1/decide`;\n const version = opts.sdkVersion ?? SDK_VERSION;\n\n async function decide(request: DecideRequest): Promise<DecideResponse> {\n if (breaker && !breaker.allow()) {\n throw new ZarexClientError(\"circuit_open\", \"Zarex engine temporarily skipped after repeated failures\");\n }\n\n const ctrl = new AbortController();\n const timer = setTimeout(() => ctrl.abort(), timeoutMs);\n let outcome: \"ok\" | \"infra\" | \"neutral\" = \"neutral\";\n try {\n let res: Response;\n try {\n res = await doFetch!(url, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n accept: \"application/json\",\n authorization: `Bearer ${key}`,\n \"user-agent\": `zarex-js/${version}`,\n },\n body: JSON.stringify(request),\n signal: ctrl.signal,\n // Ne JAMAIS suivre une redirection : elle renverrait la clé API ailleurs.\n // \"manual\" plutôt que \"error\" : Cloudflare Workers ne connaît pas \"error\". La réponse 3xx est refusée plus bas.\n redirect: \"manual\",\n });\n } catch (err) {\n outcome = \"infra\";\n if (ctrl.signal.aborted) throw new ZarexClientError(\"timeout\", `Zarex engine did not answer within ${timeoutMs} ms`);\n // Le message natif peut contenir l'URL : on n'en garde que la nature.\n throw new ZarexClientError(\"network\", `Zarex engine unreachable (${errKind(err)})`);\n }\n\n if (res.type === \"opaqueredirect\" || (res.status >= 300 && res.status < 400)) {\n outcome = \"infra\";\n void res.body?.cancel().catch(() => undefined);\n throw new ZarexClientError(\"network\", \"Zarex engine unreachable (unexpected redirect)\");\n }\n\n const text = await readCapped(res, MAX_RESPONSE_BYTES, ctrl.signal).catch(() => {\n outcome = \"infra\";\n if (ctrl.signal.aborted) throw new ZarexClientError(\"timeout\", `Zarex engine did not answer within ${timeoutMs} ms`);\n throw new ZarexClientError(\"bad_response\", \"Zarex engine response could not be read\");\n });\n\n if (res.status !== 200) {\n const engineCode = extractEngineCode(text);\n const code = statusToCode(res.status);\n outcome = code === \"server_error\" ? \"infra\" : \"neutral\";\n throw new ZarexClientError(code, `Zarex engine answered HTTP ${res.status}${engineCode ? ` (${engineCode})` : \"\"}`, {\n status: res.status,\n ...(engineCode ? { engineCode } : {}),\n });\n }\n\n // Un 200 avec un corps JSON illisible/inattendu est la MÊME défaillance (« bad_response ») que celle\n // de readCapped juste au-dessus, et doit compter de la même façon pour le disjoncteur (« infra ») —\n // sans ce catch, `outcome` restait à \"neutral\" (sa valeur par défaut, jamais réécrite si parseDecision\n // lève), donc un moteur qui renvoie du JSON corrompu en 200 n'ouvrait jamais le disjoncteur (trouvé\n // lors de l'audit du 04/11/2026, voir AUDIT_SECURITE_COMPLET.md).\n let parsed: DecideResponse;\n try {\n parsed = parseDecision(text);\n } catch (err) {\n outcome = \"infra\";\n throw err;\n }\n outcome = \"ok\";\n return parsed;\n } finally {\n clearTimeout(timer);\n if (breaker) {\n if (outcome === \"ok\") breaker.success();\n else if (outcome === \"infra\") breaker.failure();\n else breaker.neutral();\n }\n }\n }\n\n return { decide };\n}\n\nfunction errKind(err: unknown): string {\n const msg = err instanceof Error ? `${err.name} ${err.message} ${(err as { cause?: { code?: string } }).cause?.code ?? \"\"}` : \"\";\n if (/redirect/i.test(msg)) return \"unexpected redirect\";\n if (/ECONNREFUSED|refused/i.test(msg)) return \"connection refused\";\n if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) return \"DNS lookup failed\";\n if (/certificate|TLS|SSL|CERT/i.test(msg)) return \"TLS error\";\n return \"network error\";\n}\n\nfunction statusToCode(status: number): ZarexClientError[\"code\"] {\n if (status === 401) return \"unauthorized\";\n if (status === 403) return \"forbidden\";\n if (status === 429) return \"quota_exceeded\";\n if (status >= 500) return \"server_error\";\n return \"invalid_request\";\n}\n\nfunction extractEngineCode(text: string): string | undefined {\n try {\n const code = (JSON.parse(text) as { error?: { code?: unknown } })?.error?.code;\n return typeof code === \"string\" && /^[a-z_]{1,40}$/.test(code) ? code : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** Lit au plus `max` octets : une réponse démesurée ne doit pas saturer la mémoire du client. */\nasync function readCapped(res: Response, max: number, signal: AbortSignal): Promise<string> {\n const declared = Number(res.headers.get(\"content-length\"));\n if (Number.isFinite(declared) && declared > max) throw new Error(\"too large\");\n if (!res.body) return await res.text();\n const reader = res.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n for (;;) {\n if (signal.aborted) {\n await reader.cancel().catch(() => undefined);\n throw new Error(\"aborted\");\n }\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > max) {\n await reader.cancel().catch(() => undefined);\n throw new Error(\"too large\");\n }\n chunks.push(value);\n }\n const all = new Uint8Array(total);\n let off = 0;\n for (const c of chunks) {\n all.set(c, off);\n off += c.byteLength;\n }\n return new TextDecoder().decode(all);\n}\n\nconst VERDICTS: ReadonlySet<string> = new Set<Verdict>([\"ALLOW\", \"DENY\", \"ERROR\"]);\n\n/** Validation stricte de la réponse : ce qu'on ne comprend pas n'est jamais interprété comme un verdict. */\nexport function parseDecision(text: string): DecideResponse {\n let v: unknown;\n try {\n v = JSON.parse(text);\n } catch {\n throw new ZarexClientError(\"bad_response\", \"Zarex engine returned invalid JSON\");\n }\n const o = v as Record<string, unknown> | null;\n if (!o || typeof o !== \"object\" || typeof o.verdict !== \"string\" || !VERDICTS.has(o.verdict) || !Array.isArray(o.results)) {\n throw new ZarexClientError(\"bad_response\", \"Zarex engine returned an unexpected response\");\n }\n const results = [];\n for (const r of o.results as unknown[]) {\n const x = r as Record<string, unknown> | null;\n if (!x || typeof x !== \"object\" || typeof x.rule_type !== \"string\" || typeof x.verdict !== \"string\" || !VERDICTS.has(x.verdict)) {\n throw new ZarexClientError(\"bad_response\", \"Zarex engine returned an unexpected rule result\");\n }\n results.push({\n rule_type: x.rule_type,\n verdict: x.verdict as Verdict,\n is_dry_run: x.is_dry_run === true,\n ...(x.reason && typeof x.reason === \"object\" ? { reason: x.reason as Record<string, unknown> } : {}),\n });\n }\n const warnings = Array.isArray(o.warnings)\n ? (o.warnings as unknown[]).filter((w): w is string => typeof w === \"string\" && /^[a-z_]{1,40}$/.test(w)).slice(0, 10)\n : undefined;\n const rawIp = o.ip as Record<string, unknown> | null | undefined;\n const ipInfo =\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)\n ? { country: rawIp.country, asn: rawIp.asn }\n : undefined;\n return {\n decision_id: typeof o.decision_id === \"string\" ? o.decision_id.slice(0, 64) : \"\",\n verdict: o.verdict as Verdict,\n results,\n ...(typeof o.ttl_ms === \"number\" ? { ttl_ms: o.ttl_ms } : {}),\n ...(warnings && warnings.length ? { warnings } : {}),\n ...(ipInfo ? { ip: ipInfo } : {}),\n ...(typeof o.ruleset_version === \"string\" && /^[A-Za-z0-9._+-]{1,64}$/.test(o.ruleset_version) ? { ruleset_version: o.ruleset_version } : {}),\n };\n}"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zarex/client",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Zarex — client HTTP du Decision Engine (fetch, délai, disjoncteur, fail-open)",
5
5
  "license": "MIT",
6
6
  "publishConfig": {