/** * kirelta — official JavaScript / TypeScript client. * * Copyright (c) 2026 Kirelta. All rights reserved. Provided for use with the Kirelta service * under its Terms of Service (https://kirelta.com/terms). * * Zero dependencies. Native `fetch` and `node:crypto` only, matching the engine's own * philosophy: a reliability product whose client library drags in a dependency tree undermines * its own pitch, and `npm i kirelta` should never be the reason a customer's build breaks or * their audit turns up a transitive CVE. * * import { Kirelta } from "kirelta"; * * const kir = new Kirelta({ apiKey: "kir_..." }); * await kir.fit("prod-api", baselineRows); // picks sync or async for you * const report = await kir.assess("prod-api", liveRows); * * if (!report.trusted) console.warn(report.explain()); * * Five things here are not sugar over `fetch`, and are why this file exists: * * 1. **Retries do not double-charge you.** `/assess` is metered. A naive client that retries a * timeout bills the customer twice for one batch and writes two readings into their history. * Every retryable call carries an `Idempotency-Key` generated ONCE per logical call and * reused across attempts, so the server recognises attempt 2 as the same call. * * 2. **`fit()` routes by size.** Measured on the engine: ~1.6 s at 1 000 rows, ~8 s at 200 000, * dominated by model selection rather than transfer. Callers should not have to know where * the timeout cliff is, so large baselines are submitted as a job and polled. * * 3. **Errors say what to do.** `QuotaExceeded` carries the plan and the limit. `RateLimited` * carries the delay. `ModelNotFitted` says to fit first. Every error carries the server's * `X-Request-Id`, so a customer's stack trace and our logs are the same id. * * 4. **`verifyWebhook()`** — constant-time, replay-checked, and it fails closed on the mistake * integrators actually make: passing a re-serialised `JSON.stringify(req.body)` instead of * the raw bytes. The MAC covers bytes, not meaning. * * 5. **`report.explain()`** distinguishes the two untrusted cases in words. Drift alarmed means * re-fit or fix upstream; not alarmed means investigate the rows and do NOT re-fit, because * a re-fit would absorb the outliers into the baseline. */ export const VERSION = "1.0.0"; export const DEFAULT_BASE_URL = "https://api.kirelta.com"; /** Above this many rows, fit() goes async. See note 2 above. */ export const ASYNC_FIT_THRESHOLD = 25000; export const WEBHOOK_TOLERANCE_SECONDS = 300; /* ── errors ──────────────────────────────────────────────────────────────── */ export class KireltaError extends Error { constructor(message, { status = null, requestId = null, detail = null, body = null } = {}) { super(requestId ? `${message} (requestId=${requestId})` : message); this.name = new.target.name; this.status = status; this.requestId = requestId; // matches X-Request-Id in Kirelta's logs this.detail = detail; this.body = body || {}; } } /** 401/403 — key or session missing, wrong, or lacking permission. */ export class AuthError extends KireltaError {} /** 400 — malformed request. The message is the server's, which is usually specific. */ export class ValidationError extends KireltaError {} /** 409 — assess() on a model with no baseline. Call fit() first. */ export class ModelNotFitted extends KireltaError {} /** 402 / quota — a plan limit was reached. Carries `plan` and `limit` when the server says. */ export class QuotaExceeded extends KireltaError { constructor(m, o = {}) { super(m, o); this.plan = o.plan ?? null; this.limit = o.limit ?? null; } } /** 429 — too many requests for the plan. `retryAfter` is seconds. */ export class RateLimited extends KireltaError { constructor(m, o = {}) { super(m, o); this.retryAfter = o.retryAfter ?? null; } } /** 5xx or transport failure. Retried automatically before it reaches you. */ export class ServerError extends KireltaError {} /** An async fit finished in the `failed` state. */ export class JobFailed extends KireltaError {} /** Thrown by `report.raiseForVerdict()` when the verdict is not TRUSTED. */ export class NotTrusted extends KireltaError { constructor(report) { super(`verdict is ${report.verdict}: ${report.action}`); this.report = report; } } /** A webhook failed verification. Treat the delivery as hostile: drop it. */ export class SignatureError extends KireltaError {} /* ── result objects ──────────────────────────────────────────────────────── */ export class Report { constructor(raw) { this.raw = raw || {}; } get model() { return this.raw.model; } get verdict() { return this.raw.verdict; } get action() { return this.raw.action; } get driftAlarmed() { return !!this.raw.drift_alarmed; } get flaggedRate() { return this.raw.flagged_rate; } get n() { return this.raw.n; } get decisionId() { return this.raw.decision_id; } get temporal() { return this.raw.temporal || {}; } get trusted() { return this.raw.verdict === "TRUSTED"; } get degraded() { return this.raw.verdict === "DEGRADED"; } /** True when an unusual share of live points look perfectly typical — a stuck feed, or a * distribution shape change the drift alarm cannot see. Read `typicalityMedian` to tell * which: → 1.00 leans stuck feed, ≈ 0.50 leans shape change. */ get inputCollapsed() { return !!this.raw.input_collapsed; } get typicalityMedian() { return this.raw.typicality_median; } /** Gate a pipeline in one line. `allowDegraded` because DEGRADED is a warning, not a failure, * and whether to stop on it is the caller's call, not ours. */ raiseForVerdict({ allowDegraded = false } = {}) { if (this.trusted || (allowDegraded && this.degraded)) return this; throw new NotTrusted(this); } explain() { const bits = [`${this.model}: ${this.verdict}`]; if (this.flaggedRate != null) bits.push(`${(this.flaggedRate * 100).toFixed(1)}% of rows flagged`); if (this.driftAlarmed) { bits.push("population drift alarm FIRED — the batch as a whole no longer matches the " + "baseline, so a re-fit or an upstream fix is needed"); } else if (!this.trusted) { bits.push("drift alarm did not fire — individual outliers rather than a distribution " + "shift, so investigate the rows, not the baseline"); } if (this.inputCollapsed) { const m = this.typicalityMedian; bits.push(`an unusual share of points look perfectly typical (typicality ` + `${m == null ? "?" : m.toFixed(2)}) — ` + (m != null && m > 0.7 ? "leans towards a stuck feed: check the pipeline" : "leans towards a distribution shape change")); } if (this.temporal && this.temporal.elevated) { bits.push("the rows in this batch are related to one another rather than independent, " + "so the drift alarm is weaker here — the per-row flags and the flagged rate " + "are unaffected"); } if (this.decisionId) bits.push(`decision ${this.decisionId}`); return bits.join("; "); } } export class Job { constructor(raw, client) { this.raw = raw || {}; this._client = client; } get id() { return this.raw.id || this.raw.job_id; } get status() { return this.raw.status; } get model() { return this.raw.model; } get attempts() { return this.raw.attempts; } get worker() { return this.raw.worker; } get error() { return this.raw.error; } get durationMs() { return this.raw.duration_ms; } get runningForS() { return this.raw.running_for_s; } get done() { return this.status === "completed" || this.status === "failed"; } async refresh() { this.raw = (await this._client.job(this.id)).raw; return this; } async wait({ timeoutMs = 600000, pollMs = 1000 } = {}) { const deadline = Date.now() + timeoutMs; for (;;) { await this.refresh(); if (this.status === "completed") return this; if (this.status === "failed") { throw new JobFailed(`fit job ${this.id} failed: ${this.error || "no reason given"}`); } if (Date.now() > deadline) { throw new KireltaError( `fit job ${this.id} still ${this.status} after ${timeoutMs}ms — it is not lost, ` + `poll it later with client.job(${JSON.stringify(this.id)})`); } await new Promise((r) => setTimeout(r, pollMs)); } } } /* ── client ──────────────────────────────────────────────────────────────── */ function uid(n = 24) { // globalThis.crypto is standard in Node 18+, Deno, Bun, browsers and edge runtimes. An // earlier version fell back to require("node:crypto"), which cannot appear in an ES module // at all -- Node refuses the file outright with ERR_AMBIGUOUS_MODULE_SYNTAX. Caught on the // first real run, which is the argument for integration tests over mocks in one line. const b = new Uint8Array(n); globalThis.crypto.getRandomValues(b); return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); } /** Accept arrays, typed arrays, or anything array-like, without requiring a matrix library. */ function coerceRows(rows) { if (rows == null) throw new ValidationError("rows is required"); if (!Array.isArray(rows)) { if (typeof rows.length === "number") rows = Array.from(rows); else throw new ValidationError(`rows must be an array of arrays, got ${typeof rows}`); } if (rows.length === 0) throw new ValidationError("rows is empty — send at least one row"); return rows.map((r) => (Array.isArray(r) ? r : Array.from(r))); } export class Kirelta { constructor({ apiKey = null, session = null, baseUrl = DEFAULT_BASE_URL, timeoutMs = 60000, maxRetries = 3, userAgent = null } = {}) { if (!apiKey && !session) throw new Error("one of apiKey or session is required"); this.apiKey = apiKey; this.session = session; this.baseUrl = String(baseUrl).replace(/\/+$/, ""); this.timeoutMs = timeoutMs; this.maxRetries = Math.max(1, maxRetries); this.userAgent = userAgent || `kirelta-js/${VERSION}`; } _headers(idemKey, requestId) { const h = { "Content-Type": "application/json", Accept: "application/json", "X-Request-Id": requestId, }; // Browsers reject a manually-set User-Agent; sending it only on the server keeps the same // file working in both without a build flag. if (typeof window === "undefined") h["User-Agent"] = this.userAgent; if (this.apiKey) h["X-API-Key"] = this.apiKey; if (this.session) h["Authorization"] = `Bearer ${this.session}`; if (idemKey) h["Idempotency-Key"] = idemKey; return h; } _raise(status, body, requestId) { const err = (body && body.error) || ""; const detail = (body && body.detail) || ""; const msg = detail || err || `HTTP ${status}`; const common = { status, requestId, detail, body }; if (status === 401 || status === 403) throw new AuthError(msg, common); if (status === 402 || err === "quota_exceeded") { throw new QuotaExceeded(msg, { ...common, plan: body?.plan, limit: body?.limit }); } if (status === 429) throw new RateLimited(msg, { ...common, retryAfter: body?.retry_after }); if (status === 409) throw new ModelNotFitted(msg, common); if (status >= 400 && status < 500) throw new ValidationError(msg, common); throw new ServerError(msg, common); } async _request(method, path, { params, payload, idempotent = false, timeoutMs } = {}) { let url = this.baseUrl + path; if (params) { const q = new URLSearchParams(); for (const [k, v] of Object.entries(params)) if (v != null) q.set(k, String(v)); const s = q.toString(); if (s) url += "?" + s; } const body = payload === undefined ? undefined : JSON.stringify(payload); // Generated ONCE, outside the retry loop, and reused by every attempt. That is what makes // retrying a metered call safe. const idemKey = idempotent ? `sdk_${uid(16)}` : null; const requestId = `cli_${uid(8)}`; let last = null; for (let attempt = 0; attempt < this.maxRetries; attempt++) { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), timeoutMs || this.timeoutMs); try { const res = await fetch(url, { method, body, signal: ac.signal, headers: this._headers(idemKey, requestId), }); const rid = res.headers.get("X-Request-Id") || requestId; const text = await res.text(); let parsed = {}; if (text) { try { parsed = JSON.parse(text); } catch { parsed = {}; } } if (res.ok) return parsed; if (res.status === 429 || res.status >= 500) { last = { status: res.status, body: parsed, rid }; if (attempt < this.maxRetries - 1) { const ra = Number(res.headers.get("Retry-After")); // Exponential backoff with jitter, so a fleet of clients does not retry in lockstep. const delay = Number.isFinite(ra) ? ra * 1000 : (2 ** attempt) * 1000 + Math.random() * 500; await new Promise((r) => setTimeout(r, Math.min(delay, 30000))); continue; } } this._raise(res.status, parsed, rid); } catch (e) { if (e instanceof KireltaError) throw e; last = { status: null, body: { detail: String(e && e.message || e) }, rid: requestId }; if (attempt < this.maxRetries - 1) { await new Promise((r) => setTimeout(r, (2 ** attempt) * 1000 + Math.random() * 500)); continue; } throw new ServerError(`could not reach Kirelta at ${this.baseUrl}: ${e && e.message}`, { requestId }); } finally { clearTimeout(timer); } } this._raise(last?.status || 500, last?.body || {}, last?.rid || requestId); } /* -- core -- */ health() { return this._request("GET", "/health"); } /** Learn a baseline. Chooses sync or async by size unless `async_` is given. */ async fit(model, rows, { featureNames = null, async_ = null, wait = true, timeoutMs = 600000 } = {}) { rows = coerceRows(rows); const useAsync = async_ == null ? rows.length > ASYNC_FIT_THRESHOLD : async_; const payload = { rows }; if (featureNames) payload.feature_names = featureNames; const params = { model }; if (useAsync) params.async = 1; const out = await this._request("POST", "/fit", { params, payload, idempotent: true, timeoutMs }); if (!useAsync) return out; const job = new Job({ id: out.job_id, status: out.status || "queued", model }, this); return wait ? job.wait({ timeoutMs }) : job; } fitAsync(model, rows, opts = {}) { return this.fit(model, rows, { ...opts, async_: true, wait: false }); } /** Score a batch against the baseline. Metered — hence the idempotency key on retries. */ async assess(model, rows) { rows = coerceRows(rows); return new Report(await this._request("POST", "/assess", { params: { model }, payload: { rows }, idempotent: true })); } /** assess() + raiseForVerdict() — the one-line pipeline gate. */ async check(model, rows, { allowDegraded = false } = {}) { return (await this.assess(model, rows)).raiseForVerdict({ allowDegraded }); } models() { return this._request("GET", "/models"); } usage() { return this._request("GET", "/usage"); } /* -- jobs -- */ async job(jobId) { const out = await this._request("GET", `/jobs/${encodeURIComponent(jobId)}`); return new Job(out.job || out, this); } async jobs({ status = null, limit = 50 } = {}) { const out = await this._request("GET", "/jobs", { params: { status, limit } }); return { jobs: (out.jobs || []).map((j) => new Job(j, this)), summary: out.summary || {} }; } /* -- decisions -- */ async decisions({ status = null, model = null, limit = 50, offset = 0 } = {}) { const out = await this._request("GET", "/decisions", { params: { status, model, limit, offset } }); return { decisions: out.decisions || [], total: out.total || 0 }; } /** Walk every decision without hand-rolling pagination. */ async *iterDecisions({ status = null, model = null, pageSize = 100 } = {}) { let offset = 0; for (;;) { const page = await this.decisions({ status, model, limit: pageSize, offset }); if (!page.decisions.length) return; for (const d of page.decisions) yield d; offset += page.decisions.length; if (offset >= page.total) return; } } decision(id) { return this._request("GET", `/decisions/${encodeURIComponent(id)}`); } evidence(id) { return this._request("GET", `/decisions/${encodeURIComponent(id)}/evidence`); } transition(id, to, note = "") { return this._request("POST", `/decisions/${encodeURIComponent(id)}/transition`, { payload: { to, note } }); } /** The published webhook contract: kinds, severities, guaranteed fields, version. */ eventRegistry() { return this._request("GET", "/events/registry"); } } /* ── webhook verification ────────────────────────────────────────────────── */ /** * Verify an inbound Kirelta webhook and return the parsed event. * * Throws SignatureError on ANY failure. There is no "probably fine" branch: an endpoint that * logs a warning and processes the event anyway is an unverified endpoint with extra steps. * * app.post("/kirelta-hook", express.raw({type:"application/json"}), (req, res) => { * let event; * try { event = verifyWebhook(req.body, req.get("X-Kirelta-Signature"), SECRET); } * catch { return res.sendStatus(400); } * ... * }); * * `rawBody` MUST be the bytes as received. Passing `JSON.stringify(req.body)` will fail: * re-serialising changes whitespace and key order, and the MAC covers bytes, not meaning. This * is the single most common way people get webhook verification wrong, and it fails closed here * rather than silently — which is the correct direction to fail. */ export async function verifyWebhook(rawBody, signatureHeader, secret, { toleranceSeconds = WEBHOOK_TOLERANCE_SECONDS, now = null } = {}) { // Web Crypto rather than node:crypto, so this file runs unchanged in Node, Deno, Bun, // browsers and edge runtimes (Cloudflare Workers is a common place to terminate a webhook). // That makes the function async -- an acceptable trade, since a webhook handler is async // anyway, and subtle.verify does the comparison in constant time by construction rather than // relying on the caller to remember timingSafeEqual. const subtle = globalThis.crypto?.subtle; if (!subtle) throw new SignatureError("Web Crypto is unavailable in this runtime"); if (!secret) throw new SignatureError("no signing secret supplied"); if (!signatureHeader) throw new SignatureError("missing X-Kirelta-Signature header"); if (rawBody == null) throw new SignatureError("rawBody must be the bytes exactly as received"); let bytes; if (typeof rawBody === "string") bytes = new TextEncoder().encode(rawBody); else if (rawBody instanceof Uint8Array) bytes = rawBody; else if (rawBody instanceof ArrayBuffer) bytes = new Uint8Array(rawBody); else { // A parsed object is refused rather than re-serialised. Re-serialising changes whitespace // and key order, so the MAC would fail anyway -- but failing HERE with a message that says // why is the difference between a five-minute fix and an afternoon. throw new SignatureError( "rawBody must be the RAW request bytes (Buffer/Uint8Array/string). A parsed object " + "cannot be re-serialised back to the exact bytes that were signed."); } const parts = {}; for (const chunk of String(signatureHeader).split(",")) { const i = chunk.indexOf("="); if (i > 0) parts[chunk.slice(0, i).trim()] = chunk.slice(i + 1).trim(); } const ts = parts.t, sig = parts.v1; if (!ts || !sig) throw new SignatureError(`malformed signature header: ${signatureHeader}`); const tsNum = Number(ts); if (!Number.isInteger(tsNum)) throw new SignatureError("signature timestamp is not an integer"); // Freshness first: a stale-but-valid signature is a replay, and cheap to reject. const nowS = now == null ? Date.now() / 1000 : now; const skew = Math.abs(nowS - tsNum); if (toleranceSeconds && skew > toleranceSeconds) { throw new SignatureError( `signature timestamp is ${skew.toFixed(0)}s away (tolerance ${toleranceSeconds}s) — ` + "replay, or your server clock is wrong"); } if (!/^[0-9a-fA-F]+$/.test(sig) || sig.length % 2) { throw new SignatureError("signature is not hex"); } const key = await subtle.importKey("raw", new TextEncoder().encode(String(secret)), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]); const prefix = new TextEncoder().encode(`${tsNum}.`); const signed = new Uint8Array(prefix.length + bytes.length); signed.set(prefix, 0); signed.set(bytes, prefix.length); const sigBytes = Uint8Array.from(sig.match(/../g).map((h) => parseInt(h, 16))); if (!(await subtle.verify("HMAC", key, sigBytes, signed))) { throw new SignatureError("signature does not match — treat this delivery as forged"); } try { return JSON.parse(new TextDecoder().decode(bytes)); } catch (e) { throw new SignatureError(`body verified but is not valid JSON: ${e.message}`); } } export default Kirelta;