@usekaval/kaval
Advanced tools
| #!/usr/bin/env node | ||
| /** | ||
| * `kaval` — the terminal surface. | ||
| * | ||
| * A thin renderer over the SDK in this same package, which is the point: every field it prints came | ||
| * off a real response, and the wire types cannot drift from the ones the SDK already publishes | ||
| * because they are the same types. It invents nothing. If the server did not say it, this does not | ||
| * print it — including the failures. A CLI that can only show a good day is a fixture with a | ||
| * network call in it. | ||
| * | ||
| * Exit codes make `kaval check` usable as a shell gate: | ||
| * | ||
| * 0 ALLOW 1 usage, transport or auth failure | ||
| * 2 REVIEW 3 BLOCK | ||
| * | ||
| * The API key comes from `KAVAL_API_KEY` and nowhere else — never a flag. A key on the command line | ||
| * lands in shell history and in `ps` output for every other user on the box. | ||
| */ | ||
| export {}; |
| #!/usr/bin/env node | ||
| /** | ||
| * `kaval` — the terminal surface. | ||
| * | ||
| * A thin renderer over the SDK in this same package, which is the point: every field it prints came | ||
| * off a real response, and the wire types cannot drift from the ones the SDK already publishes | ||
| * because they are the same types. It invents nothing. If the server did not say it, this does not | ||
| * print it — including the failures. A CLI that can only show a good day is a fixture with a | ||
| * network call in it. | ||
| * | ||
| * Exit codes make `kaval check` usable as a shell gate: | ||
| * | ||
| * 0 ALLOW 1 usage, transport or auth failure | ||
| * 2 REVIEW 3 BLOCK | ||
| * | ||
| * The API key comes from `KAVAL_API_KEY` and nowhere else — never a flag. A key on the command line | ||
| * lands in shell history and in `ps` output for every other user on the box. | ||
| */ | ||
| import process from "node:process"; | ||
| import { Kaval, KavalError } from "../index.js"; | ||
| const HELP = `Usage: | ||
| kaval sources add <name-or-url> [--intent <text>] [--kind entity|url] | ||
| kaval sources ls [--all] | ||
| kaval sources plan <source-id> | ||
| kaval check "<action>" [--origin <url>]... [--fast] | ||
| kaval exposure [--limit <n>] | ||
| kaval receipt <receipt-id> | ||
| Environment: | ||
| KAVAL_API_KEY required — an issued kv_live_ key | ||
| KAVAL_API_BASE optional — defaults to https://api.usekaval.com | ||
| Options: | ||
| --json emit the raw response instead of the rendered view | ||
| -h, --help show this help | ||
| Exit status: 0 ALLOW · 2 REVIEW · 3 BLOCK · 1 usage/transport/auth. | ||
| `; | ||
| /* --------------------------------- rendering --------------------------------- */ | ||
| const tty = process.stdout.isTTY === true && process.env["NO_COLOR"] === undefined; | ||
| const c = (code) => (text) => tty ? `[${code}m${text}[0m` : text; | ||
| const dim = c("38;5;245"); | ||
| const faint = c("38;5;240"); | ||
| const ink = c("38;5;252"); | ||
| const bold = c("1"); | ||
| const green = c("38;5;71"); | ||
| const red = c("38;5;167"); | ||
| const amber = c("38;5;179"); | ||
| const mag = c("38;5;140"); | ||
| /** The verdict chip. Background-filled so it reads at a glance in a recording. */ | ||
| function verdictChip(verdict) { | ||
| if (!tty) | ||
| return verdict; | ||
| const background = verdict === "ALLOW" | ||
| ? "48;5;71" | ||
| : verdict === "BLOCK" | ||
| ? "48;5;167" | ||
| : "48;5;179"; | ||
| return `[${background};38;5;235;1m ${verdict} [0m`; | ||
| } | ||
| const out = (line = "") => process.stdout.write(`${line}\n`); | ||
| /** | ||
| * The decision log, rendered by what a reader can act on. | ||
| * | ||
| * When a reviewed catalog row names the entity's surfaces, search still runs and its candidates | ||
| * still pass through the authority filter — that is what keeps a lookalike's discard visible. But | ||
| * "accepted, and not watched, because a human already said which address" is not a decision anyone | ||
| * needs to read one line at a time. Against a live search for Aetna that is ten near-identical | ||
| * lines burying the one that matters. They are counted, not hidden, and never dropped from --json. | ||
| */ | ||
| function renderAuthority(decisions, watched) { | ||
| let acceptedNotWatched = 0; | ||
| for (const decision of decisions ?? []) { | ||
| if (decision.outcome === "accepted" && !watched.has(decision.url)) { | ||
| acceptedNotWatched += 1; | ||
| continue; | ||
| } | ||
| const mark = decision.outcome === "accepted" | ||
| ? green("✓") | ||
| : decision.outcome === "ambiguous" | ||
| ? amber("?") | ||
| : red("✗"); | ||
| const { host, path } = splitUrl(decision.url); | ||
| out(` ${mark} ${host}${dim(path)}`); | ||
| // The filter's OWN reason string, verbatim. Paraphrasing it here would mean the terminal and the | ||
| // API disagree about why a source was dropped. | ||
| if (decision.reason) | ||
| out(` ${faint(decision.reason)}`); | ||
| if (decision.outcome === "ambiguous") { | ||
| out(` ${amber("needs a decision — narrow it with --intent or a scope key")}`); | ||
| } | ||
| } | ||
| if (acceptedNotWatched > 0) { | ||
| out(` ${faint(`+ ${acceptedNotWatched} more on the same domain passed the filter — a reviewed plan names the surface`)}`); | ||
| } | ||
| } | ||
| function splitUrl(url) { | ||
| try { | ||
| const parsed = new URL(url); | ||
| return { host: parsed.host, path: `${parsed.pathname}${parsed.search}` }; | ||
| } | ||
| catch { | ||
| return { host: url, path: "" }; | ||
| } | ||
| } | ||
| /* ---------------------------------- commands --------------------------------- */ | ||
| async function sourcesAdd(kaval, rest, flags) { | ||
| const target = rest[0]; | ||
| if (target === undefined) | ||
| return usage("sources add needs a name or URL"); | ||
| const kind = flags.kind === "url" || flags.kind === "entity" | ||
| ? flags.kind | ||
| : /^https?:\/\//u.test(target) | ||
| ? "url" | ||
| : "entity"; | ||
| const result = await kaval.addSource({ | ||
| kind, | ||
| ...(kind === "entity" ? { name: target } : { locator: target }), | ||
| ...(flags.intent === undefined ? {} : { intent: flags.intent }), | ||
| }); | ||
| if (flags.json) | ||
| return json(result); | ||
| const watchedUrls = new Set(result.resolved.map((source) => source.locator)); | ||
| out(); | ||
| renderAuthority(result.authority, watchedUrls); | ||
| // A resolution that produced nothing is the interesting case, and it is reported rather than | ||
| // rendered as an empty success. | ||
| if (result.resolution_error) { | ||
| out(` ${red("✗")} ${ink(result.resolution_error)}`); | ||
| } | ||
| if (result.discovery_error) { | ||
| out(` ${amber("!")} ${ink(`plan discovery: ${result.discovery_error}`)}`); | ||
| } | ||
| out(); | ||
| const watched = result.resolved.length > 0 ? result.resolved : [result.source]; | ||
| for (const source of watched) { | ||
| out(` ${dim("watching")} ${ink(source.label ?? source.locator)}`); | ||
| out(` ${faint(source.id)}`); | ||
| } | ||
| out(); | ||
| return 0; | ||
| } | ||
| async function sourcesList(kaval, flags) { | ||
| const sources = await kaval.listSources(flags.all ? { includeInactive: true } : {}); | ||
| if (flags.json) | ||
| return json({ sources }); | ||
| out(); | ||
| if (sources.length === 0) { | ||
| out(` ${dim("no watched sources yet — try")} ${ink('kaval sources add "Aetna"')}`); | ||
| out(); | ||
| return 0; | ||
| } | ||
| for (const source of sources) { | ||
| const planned = source.current_plan_id | ||
| ? green("planned") | ||
| : faint("no plan"); | ||
| out(` ${ink(source.label ?? source.locator)} ${dim(source.kind)} ${planned}`); | ||
| out(` ${faint(source.id)} ${faint(source.locator)}`); | ||
| } | ||
| out(); | ||
| return 0; | ||
| } | ||
| async function sourcesPlan(kaval, rest, flags) { | ||
| const id = rest[0]; | ||
| if (id === undefined) | ||
| return usage("sources plan needs a source id"); | ||
| const view = await kaval.getSourcePlan(id); | ||
| if (flags.json) | ||
| return json(view); | ||
| out(); | ||
| if (view.plan === null) { | ||
| // "Not yet" and "never will be" are different, and the job below is what distinguishes them. | ||
| out(` ${faint("no acquisition plan yet")}`); | ||
| } | ||
| else { | ||
| const probation = view.plan.last_validated_at === null; | ||
| out(` ${green("✓")} ${bold(`tier ${view.plan.tier}`)} ${dim(`· ${view.plan.origin}`)}` + | ||
| (probation | ||
| ? ` ${faint("· on probation until its first successful poll")}` | ||
| : "")); | ||
| out(` ${faint(view.plan.steps.map((step) => step.kind).join(" → "))}`); | ||
| if (view.plan.items_in_scope !== null) { | ||
| out(` ${dim("in scope")} ${bold(view.plan.items_in_scope.toLocaleString())} ${dim("documents")}`); | ||
| } | ||
| } | ||
| if (view.discovery !== null) { | ||
| // READ, never asserted. This is the only honest way to print "0 model calls". | ||
| const spend = view.discovery.cost_usd; | ||
| const spendText = spend === null | ||
| ? "" | ||
| : Number(spend) === 0 | ||
| ? " · 0 model calls" | ||
| : ` · $${spend}`; | ||
| out(` ${dim("discovery")} ${view.discovery.status}${dim(spendText)}`); | ||
| if (view.discovery.error) | ||
| out(` ${red(view.discovery.error)}`); | ||
| } | ||
| out(); | ||
| return 0; | ||
| } | ||
| async function check(kaval, rest, flags) { | ||
| const action = rest.join(" ").trim(); | ||
| if (action === "") | ||
| return usage("check needs an action"); | ||
| const result = await kaval.check({ | ||
| action, | ||
| // The documents the caller already read. An agent closing a claim knows which bulletin it | ||
| // relied on, and saying so is the difference between research reading THAT page and research | ||
| // going looking: an action naming no document compiled into sound premises and then bound them | ||
| // to the Social Security Administration's policy manual, because "underpayment review" and | ||
| // "denial upheld" are words that live there too. | ||
| ...(flags.origins.length === 0 ? {} : { origin_urls: flags.origins }), | ||
| ...(flags.fast === true ? { mode: "fast" } : {}), | ||
| }); | ||
| if (flags.json) | ||
| return json(result); | ||
| const facts = result.facts ?? []; | ||
| const warm = facts.filter((fact) => fact.served_from_state).length; | ||
| out(); | ||
| out(` ${verdictChip(result.decision)} ${dim(result.reason_codes[0] ?? "")} ` + | ||
| `${dim(`${facts.length} facts · ${warm} from state`)}` + | ||
| ` · ${bold(`${result.latency_ms.total}ms`)}`); | ||
| if (facts.length > 0 && warm === facts.length) { | ||
| out(` ${faint("no fetch, no model call — answered from stored state")}`); | ||
| } | ||
| out(); | ||
| for (const fact of facts) { | ||
| const changed = fact.status === "changed"; | ||
| const dot = changed | ||
| ? red("●") | ||
| : fact.status === "holds" | ||
| ? green("●") | ||
| : amber("●"); | ||
| const label = changed | ||
| ? red("changed") | ||
| : fact.status === "holds" | ||
| ? green("holds ") | ||
| : amber(fact.status.padEnd(7)); | ||
| out(` ${dot} ${label} ${ink(fact.text)}`); | ||
| const source = fact.sources[0]; | ||
| if (source) { | ||
| const { host, path } = splitUrl(source.locator); | ||
| const name = host === source.locator ? source.locator : `${host}${path}`; | ||
| // The digest only when there IS one, and only truncated the way a person reads it. | ||
| const digest = source.version_sha256 | ||
| ? ` ${source.version_sha256.slice(0, 16)}…` | ||
| : ""; | ||
| out(` ${faint(`${name}${digest}`)}`); | ||
| } | ||
| } | ||
| out(); | ||
| out(` ${dim("receipt")} ${mag(result.receipt.id)} ${faint("ed25519 · signed")}`); | ||
| out(); | ||
| return result.decision === "ALLOW" ? 0 : result.decision === "BLOCK" ? 3 : 2; | ||
| } | ||
| async function exposure(kaval, flags) { | ||
| const limit = Number(flags.limit); | ||
| const view = await kaval.getExposure(Number.isFinite(limit) ? { limit } : {}); | ||
| if (flags.json) | ||
| return json(view); | ||
| out(); | ||
| if (view.total_conclusions === 0) { | ||
| out(` ${green("nothing is resting on language that has moved.")}`); | ||
| out(); | ||
| return 0; | ||
| } | ||
| out(` ${bold(String(view.total_conclusions))} ${ink("conclusions rest on language that has moved.")}`); | ||
| out(); | ||
| const pad = (value, width) => value.padEnd(width); | ||
| const rt = (value, width) => String(value).padStart(width); | ||
| out(` ${dim(pad("SOURCE", 34) + pad("MOVED", 12) + rt("CONCLUSIONS", 12))}`); | ||
| for (const row of view.sources) { | ||
| const name = (row.label ?? row.locator).slice(0, 33); | ||
| // An inferred date is marked, not laundered: `~` means "it changed, we do not know when". | ||
| const moved = row.moved_at === null | ||
| ? "—" | ||
| : `${row.moved_at_is_recorded_change ? "" : "~"}${row.moved_at.slice(0, 10)}`; | ||
| out(` ${red("●")} ${ink(pad(name, 34))}${dim(pad(moved, 12))}${ink(rt(row.conclusions, 12))}`); | ||
| } | ||
| out(` ${faint("─".repeat(58))}`); | ||
| out(` ${pad("", 34)}${pad("", 12)}${bold(rt(view.total_conclusions, 12))}`); | ||
| if (view.truncated) { | ||
| out(` ${faint(`showing ${view.sources.length} of ${view.total_sources} sources`)}`); | ||
| } | ||
| out(); | ||
| return 0; | ||
| } | ||
| async function receipt(kaval, rest) { | ||
| const id = rest[0]; | ||
| if (id === undefined) | ||
| return usage("receipt needs a receipt id"); | ||
| // Always raw: a receipt is a document to be piped into a verifier, not a view. | ||
| return json(await kaval.getReceipt(id)); | ||
| } | ||
| /** Pull the API's `{error: {code, message}}` out without inventing a shape it might not have. */ | ||
| function describePayload(payload) { | ||
| const error = payload?.error; | ||
| if (error !== null && typeof error === "object") { | ||
| const { code, message } = error; | ||
| if (typeof code === "string" || typeof message === "string") { | ||
| return [code, message] | ||
| .filter((part) => typeof part === "string") | ||
| .join(" — "); | ||
| } | ||
| } | ||
| return JSON.stringify(payload); | ||
| } | ||
| function json(value) { | ||
| out(JSON.stringify(value, null, 2)); | ||
| return 0; | ||
| } | ||
| function usage(message) { | ||
| process.stderr.write(`kaval: ${message}\n\n${HELP}`); | ||
| return 1; | ||
| } | ||
| /* | ||
| * `--as-of` USED TO BE HERE, and it was a false capability. | ||
| * | ||
| * It parsed a date, normalized it, and sent `as_of` — and the server does read that field, but only | ||
| * to stamp the compiler's clock and the research contract. It never reaches the state lookup: | ||
| * `lookupByFingerprints` takes no time argument and there is no fact-history relation, so a dated | ||
| * check and an undated one read the identical row and return the identical verdict. The flag looked | ||
| * like point-in-time replay and was a no-op. | ||
| * | ||
| * Removed rather than fixed. The demo it existed for now reproduces the reversal with a fact that | ||
| * genuinely holds and then moves (see demo/FACTS.md in the server repo), so nothing needs the flag, | ||
| * and shipping a verb that quietly does nothing is worse than not shipping it. If point-in-time | ||
| * lands later it should be receipt replay — "here is the receipt we signed that day" — which is | ||
| * durable, already signed, and a stronger claim than replaying mutable state. | ||
| */ | ||
| function parse(argv) { | ||
| const rest = []; | ||
| const flags = { json: false, all: false, fast: false, origins: [] }; | ||
| for (let index = 0; index < argv.length; index += 1) { | ||
| const token = argv[index]; | ||
| switch (token) { | ||
| case "--json": | ||
| flags.json = true; | ||
| break; | ||
| case "--all": | ||
| flags.all = true; | ||
| break; | ||
| case "--fast": | ||
| flags.fast = true; | ||
| break; | ||
| case "--intent": | ||
| flags.intent = argv[(index += 1)]; | ||
| break; | ||
| case "--as-of": | ||
| // Accepted and ignored, loudly, so anyone with it in a script learns why rather than | ||
| // silently getting the same answer they were already getting. | ||
| index += 1; | ||
| process.stderr.write("kaval: --as-of is no longer supported. It never reached fact state — a dated check and " + | ||
| "an undated one read the same row — so it has been removed rather than left to look " + | ||
| "like point-in-time replay.\n"); | ||
| break; | ||
| case "--kind": | ||
| flags.kind = argv[(index += 1)]; | ||
| break; | ||
| case "--origin": { | ||
| const value = argv[(index += 1)]; | ||
| if (value !== undefined) | ||
| flags.origins.push(value); | ||
| break; | ||
| } | ||
| case "--limit": | ||
| flags.limit = argv[(index += 1)]; | ||
| break; | ||
| case "--api-base": | ||
| index += 1; | ||
| break; | ||
| default: | ||
| rest.push(token); | ||
| } | ||
| } | ||
| return { rest, flags }; | ||
| } | ||
| async function main() { | ||
| const argv = process.argv.slice(2); | ||
| if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) { | ||
| out(HELP); | ||
| return argv.length === 0 ? 1 : 0; | ||
| } | ||
| const apiKey = process.env["KAVAL_API_KEY"]; | ||
| if (apiKey === undefined || apiKey.trim() === "") { | ||
| // Refused BEFORE any network call, so a missing key never looks like a server problem. | ||
| return usage("KAVAL_API_KEY is not set"); | ||
| } | ||
| const baseIndex = argv.indexOf("--api-base"); | ||
| const baseUrl = baseIndex === -1 | ||
| ? process.env["KAVAL_API_BASE"] | ||
| : (argv[baseIndex + 1] ?? undefined); | ||
| const kaval = new Kaval({ apiKey, ...(baseUrl ? { baseUrl } : {}) }); | ||
| const { rest, flags } = parse(argv); | ||
| const [command, ...tail] = rest; | ||
| if (command === "sources") { | ||
| const [sub, ...args] = tail; | ||
| if (sub === "add") | ||
| return sourcesAdd(kaval, args, flags); | ||
| if (sub === "ls" || sub === "list") | ||
| return sourcesList(kaval, flags); | ||
| if (sub === "plan") | ||
| return sourcesPlan(kaval, args, flags); | ||
| return usage(`unknown sources subcommand: ${sub ?? "(none)"}`); | ||
| } | ||
| if (command === "check") | ||
| return check(kaval, tail, flags); | ||
| if (command === "exposure") | ||
| return exposure(kaval, flags); | ||
| if (command === "receipt") | ||
| return receipt(kaval, tail); | ||
| return usage(`unknown command: ${command ?? "(none)"}`); | ||
| } | ||
| main() | ||
| .then((code) => { | ||
| process.exitCode = code; | ||
| }) | ||
| .catch((error) => { | ||
| // The server's own message, not a summary of it. `kaval: 403 insufficient_scope — this API key | ||
| // is missing the source:manage scope` is actionable; "request failed" is not. | ||
| // The server's own payload, not a summary of it: "403 insufficient_scope — this API key is | ||
| // missing the source:manage scope" is actionable; "request failed" is not. | ||
| const message = error instanceof KavalError | ||
| ? `${error.status} ${describePayload(error.payload)}` | ||
| : error instanceof Error | ||
| ? error.message | ||
| : String(error); | ||
| process.stderr.write(`kaval: ${message}\n`); | ||
| process.exitCode = 1; | ||
| }); |
| /** | ||
| * "Is this delivery really from Kaval?" — the first question a `fact_state.delta` receiver has. | ||
| * | ||
| * Your callback URL is a public HTTPS endpoint. Anything on the internet can POST a plausible delta | ||
| * to it, and a delta says "a fact your agent relies on just flipped" — an instruction worth forging. | ||
| * The signature is what separates the two, and until now every integrator had to reimplement it from | ||
| * prose. | ||
| * | ||
| * Kaval signs Standard-Webhooks style: HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<body>`, | ||
| * base64url, delivered as `webhook-signature: v1,<mac>` beside `webhook-id`, `webhook-timestamp` and | ||
| * `webhook-key-id`. This is the receiving half of exactly that, and nothing else — it authenticates | ||
| * the bytes, it does not parse or validate the event. | ||
| */ | ||
| /** The only signature scheme that exists. A `v2` would be a new format, not a new key. */ | ||
| export declare const WEBHOOK_SIGNATURE_VERSION = "v1"; | ||
| /** What the MAC is taken over, verbatim from `webhook_verification.signed_content`. */ | ||
| export declare const WEBHOOK_SIGNED_CONTENT = "<webhook-id>.<webhook-timestamp>.<exact UTF-8 request body>"; | ||
| /** | ||
| * Five minutes either way, the Standard Webhooks recommendation. | ||
| * | ||
| * The window is a replay blunter, not replay protection: it bounds how long a captured delivery | ||
| * stays useful. Real deduplication is yours — keep the `webhookId` of everything you have processed, | ||
| * because delivery is at-least-once by design and a retry is a legitimate duplicate. | ||
| */ | ||
| export declare const DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300; | ||
| export type WebhookRejectionReason = | ||
| /** A required `webhook-*` header is absent, or arrived more than once. */ | ||
| "missing_header" | ||
| /** `webhook-timestamp` is not the 10-13 digit Unix time the signer emits. */ | ||
| | "malformed_timestamp" | ||
| /** `webhook-key-id` names a generation you passed no secret for. */ | ||
| | "unknown_key_id" | ||
| /** The signature is versioned, but not `v1`. */ | ||
| | "unsupported_signature_version" | ||
| /** `webhook-signature` is not `<version>,<base64url 32-byte MAC>`. */ | ||
| | "malformed_signature" | ||
| /** Well-formed and checkable, and it is not the MAC over these bytes. */ | ||
| | "signature_mismatch" | ||
| /** Authentic, but dated outside the tolerance window. */ | ||
| | "timestamp_out_of_tolerance"; | ||
| export interface WebhookSignatureAccepted { | ||
| valid: true; | ||
| /** The generation that signed it — `webhook-key-id`. */ | ||
| keyId: string; | ||
| /** | ||
| * The CloudEvent id (`webhook-id`, equal to the event's own `id`). Deliveries are at-least-once: | ||
| * dedupe on this before acting, or a retry replays your side effects. | ||
| */ | ||
| webhookId: string; | ||
| /** `webhook-timestamp`, as sent by the signer. */ | ||
| timestamp: Date; | ||
| } | ||
| export interface WebhookSignatureRejected { | ||
| valid: false; | ||
| reason: WebhookRejectionReason; | ||
| /** Safe to log. Contains no secret material. */ | ||
| message: string; | ||
| } | ||
| export type WebhookSignatureResult = WebhookSignatureAccepted | WebhookSignatureRejected; | ||
| /** | ||
| * Anything a web framework calls a header bag: a `fetch`-style object with `.get()` (Next.js route | ||
| * handlers, Hono, Cloudflare Workers) or a plain record (Express, Fastify, Node's own `http`). | ||
| */ | ||
| export type WebhookHeaderSource = { | ||
| get(name: string): string | null | undefined; | ||
| } | Readonly<Record<string, string | readonly string[] | undefined>>; | ||
| export interface VerifyWebhookSignatureInput { | ||
| /** | ||
| * The EXACT bytes of the request body, before any JSON parsing. | ||
| * | ||
| * The MAC covers the octets on the wire. `JSON.parse` followed by `JSON.stringify` is not a byte | ||
| * round trip — it drops insignificant whitespace and respells numbers and escapes — so verifying a | ||
| * re-serialised body computes a different MAC and rejects every genuine delivery. In Express that | ||
| * means `express.raw({ type: "application/json" })` on this route, not `express.json()`. | ||
| */ | ||
| body: string | Uint8Array; | ||
| headers: WebhookHeaderSource; | ||
| /** | ||
| * `webhook-key-id` → the base64url secret from that generation's `webhook_verification.secret`. | ||
| * | ||
| * A map rather than a single secret because rotation overlaps deliberately: after | ||
| * `rotateWebhookSigningKey()` both generations sign real deliveries until `overlap_until`. Hold | ||
| * both here and the rollover is a config change instead of an outage. | ||
| */ | ||
| secrets: Readonly<Record<string, string>>; | ||
| /** Seconds either side of now. `null` disables the check — then replay defence is entirely yours. */ | ||
| toleranceSeconds?: number | null; | ||
| /** Verification instant; defaults to `Date.now()`. Milliseconds since the epoch, or a `Date`. */ | ||
| now?: Date | number; | ||
| } | ||
| /** | ||
| * Verify the HMAC-SHA256 signature on an inbound Kaval webhook. | ||
| * | ||
| * Checks run cheapest-first, and the order is deliberate: structural problems are named before any | ||
| * MAC is computed, and the replay window is checked LAST, so `timestamp_out_of_tolerance` can only | ||
| * ever be reported for a delivery that is genuinely ours and merely old. | ||
| * | ||
| * ```ts | ||
| * const result = verifyWebhookSignature({ body: rawBody, headers: req.headers, secrets }); | ||
| * if (!result.valid) return res.status(400).send(result.reason); | ||
| * ``` | ||
| * | ||
| * `result` is an object, so `if (result)` is always true — branch on `result.valid`. | ||
| * | ||
| * Throws `TypeError` only for caller mistakes (an empty secret map, a body that is neither text nor | ||
| * bytes). Everything attacker-controlled comes back as a rejection you can log, never as a throw. | ||
| */ | ||
| export declare function verifyWebhookSignature(input: VerifyWebhookSignatureInput): WebhookSignatureResult; |
| /** | ||
| * "Is this delivery really from Kaval?" — the first question a `fact_state.delta` receiver has. | ||
| * | ||
| * Your callback URL is a public HTTPS endpoint. Anything on the internet can POST a plausible delta | ||
| * to it, and a delta says "a fact your agent relies on just flipped" — an instruction worth forging. | ||
| * The signature is what separates the two, and until now every integrator had to reimplement it from | ||
| * prose. | ||
| * | ||
| * Kaval signs Standard-Webhooks style: HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<body>`, | ||
| * base64url, delivered as `webhook-signature: v1,<mac>` beside `webhook-id`, `webhook-timestamp` and | ||
| * `webhook-key-id`. This is the receiving half of exactly that, and nothing else — it authenticates | ||
| * the bytes, it does not parse or validate the event. | ||
| */ | ||
| import { createHmac, timingSafeEqual } from "node:crypto"; | ||
| /** The only signature scheme that exists. A `v2` would be a new format, not a new key. */ | ||
| export const WEBHOOK_SIGNATURE_VERSION = "v1"; | ||
| /** What the MAC is taken over, verbatim from `webhook_verification.signed_content`. */ | ||
| export const WEBHOOK_SIGNED_CONTENT = "<webhook-id>.<webhook-timestamp>.<exact UTF-8 request body>"; | ||
| /** | ||
| * Five minutes either way, the Standard Webhooks recommendation. | ||
| * | ||
| * The window is a replay blunter, not replay protection: it bounds how long a captured delivery | ||
| * stays useful. Real deduplication is yours — keep the `webhookId` of everything you have processed, | ||
| * because delivery is at-least-once by design and a retry is a legitimate duplicate. | ||
| */ | ||
| export const DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300; | ||
| /** SHA-256's digest size — the only length a `v1` MAC can decode to. */ | ||
| const MAC_BYTES = 32; | ||
| function reject(reason, message) { | ||
| return { valid: false, reason, message }; | ||
| } | ||
| /** Attacker-controlled header text ends up in someone's log line. Bound what it can put there. */ | ||
| function quoted(value) { | ||
| return value.length <= 128 ? value : `${value.slice(0, 128)}…`; | ||
| } | ||
| /** | ||
| * Read one header out of whatever the framework handed us. | ||
| * | ||
| * A repeated header is refused rather than joined: Node concatenates duplicates with ", ", and | ||
| * silently MAC-ing a concatenation of an attacker's header and ours is exactly the ambiguity a | ||
| * verifier must not resolve by guessing. | ||
| */ | ||
| function headerValue(source, name) { | ||
| const getter = source.get; | ||
| if (typeof getter === "function") { | ||
| const value = getter.call(source, name); | ||
| return typeof value === "string" ? value : null; | ||
| } | ||
| const bag = source; | ||
| let value = bag[name]; | ||
| if (value === undefined) { | ||
| // Node lowercases what arrives over the wire; a hand-built object may not have. | ||
| for (const [key, candidate] of Object.entries(bag)) { | ||
| if (key.toLowerCase() === name) { | ||
| value = candidate; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| if (typeof value === "string") | ||
| return value; | ||
| if (Array.isArray(value) && | ||
| value.length === 1 && | ||
| typeof value[0] === "string") { | ||
| return value[0]; | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * Standard Webhooks permits a space-separated list, so a sender can offer several signatures for one | ||
| * body. Kaval sends exactly one today; accepting the list costs nothing and means a future | ||
| * dual-signed rollout does not need a new SDK on the receiving side. | ||
| */ | ||
| function offeredSignatures(header) { | ||
| const macs = []; | ||
| let otherVersion = false; | ||
| for (const element of header | ||
| .split(/\s+/u) | ||
| .filter((part) => part.length > 0)) { | ||
| const parts = element.split(","); | ||
| const encoded = parts[1]; | ||
| if (parts.length !== 2 || encoded === undefined || encoded === "") | ||
| continue; | ||
| if (parts[0] !== WEBHOOK_SIGNATURE_VERSION) { | ||
| otherVersion = true; | ||
| continue; | ||
| } | ||
| // Decoded leniently and then length-checked, which is what the signer's own receiver does. A MAC | ||
| // that is not 32 bytes cannot be a SHA-256 one whatever it decodes from. | ||
| const decoded = Buffer.from(encoded, "base64url"); | ||
| if (decoded.byteLength === MAC_BYTES) | ||
| macs.push(decoded); | ||
| } | ||
| return { macs, otherVersion }; | ||
| } | ||
| /** | ||
| * The signer emits Unix seconds today and its contract admits 10-13 digits. Twelve digits or more is | ||
| * milliseconds; fewer is seconds. Both real spellings (10-digit seconds, 13-digit milliseconds) stay | ||
| * unambiguous until the year 2286, and the in-between widths are absurd as either reading. | ||
| */ | ||
| function timestampMilliseconds(digits) { | ||
| if (!/^\d{10,13}$/u.test(digits)) | ||
| return null; | ||
| const value = Number(digits); | ||
| return digits.length >= 12 ? value : value * 1000; | ||
| } | ||
| /** | ||
| * Verify the HMAC-SHA256 signature on an inbound Kaval webhook. | ||
| * | ||
| * Checks run cheapest-first, and the order is deliberate: structural problems are named before any | ||
| * MAC is computed, and the replay window is checked LAST, so `timestamp_out_of_tolerance` can only | ||
| * ever be reported for a delivery that is genuinely ours and merely old. | ||
| * | ||
| * ```ts | ||
| * const result = verifyWebhookSignature({ body: rawBody, headers: req.headers, secrets }); | ||
| * if (!result.valid) return res.status(400).send(result.reason); | ||
| * ``` | ||
| * | ||
| * `result` is an object, so `if (result)` is always true — branch on `result.valid`. | ||
| * | ||
| * Throws `TypeError` only for caller mistakes (an empty secret map, a body that is neither text nor | ||
| * bytes). Everything attacker-controlled comes back as a rejection you can log, never as a throw. | ||
| */ | ||
| export function verifyWebhookSignature(input) { | ||
| const secrets = input.secrets; | ||
| if (secrets === null || | ||
| typeof secrets !== "object" || | ||
| Object.keys(secrets).length === 0) { | ||
| throw new TypeError("secrets must map at least one webhook-key-id to its base64url webhook_verification.secret"); | ||
| } | ||
| const body = typeof input.body === "string" | ||
| ? Buffer.from(input.body, "utf8") | ||
| : input.body; | ||
| if (!(body instanceof Uint8Array)) { | ||
| throw new TypeError("body must be the raw request bytes (Buffer/Uint8Array) or the raw body text, never a parsed object"); | ||
| } | ||
| const tolerance = input.toleranceSeconds === undefined | ||
| ? DEFAULT_WEBHOOK_TOLERANCE_SECONDS | ||
| : input.toleranceSeconds; | ||
| if (tolerance !== null && | ||
| (typeof tolerance !== "number" || | ||
| !Number.isFinite(tolerance) || | ||
| tolerance < 0)) { | ||
| throw new TypeError("toleranceSeconds must be a non-negative finite number, or null to disable the window"); | ||
| } | ||
| const webhookId = headerValue(input.headers, "webhook-id"); | ||
| const timestamp = headerValue(input.headers, "webhook-timestamp"); | ||
| const keyId = headerValue(input.headers, "webhook-key-id"); | ||
| const signature = headerValue(input.headers, "webhook-signature"); | ||
| const UNSIGNED = "is missing, empty, or repeated — Kaval did not sign this"; | ||
| if (!webhookId) | ||
| return reject("missing_header", `webhook-id ${UNSIGNED}`); | ||
| if (!timestamp) | ||
| return reject("missing_header", `webhook-timestamp ${UNSIGNED}`); | ||
| if (!keyId) | ||
| return reject("missing_header", `webhook-key-id ${UNSIGNED}`); | ||
| if (!signature) | ||
| return reject("missing_header", `webhook-signature ${UNSIGNED}`); | ||
| const milliseconds = timestampMilliseconds(timestamp); | ||
| if (milliseconds === null) { | ||
| return reject("malformed_timestamp", "webhook-timestamp must be 10-13 digits of Unix time"); | ||
| } | ||
| const secret = Object.hasOwn(secrets, keyId) ? secrets[keyId] : undefined; | ||
| if (typeof secret !== "string" || secret === "") { | ||
| return reject("unknown_key_id", `no secret was supplied for webhook-key-id ${quoted(keyId)} — if the key was just rotated, add the new generation`); | ||
| } | ||
| const { macs, otherVersion } = offeredSignatures(signature); | ||
| if (macs.length === 0) { | ||
| return otherVersion | ||
| ? reject("unsupported_signature_version", `webhook-signature offers no ${WEBHOOK_SIGNATURE_VERSION} signature; this SDK is older than the delivery`) | ||
| : reject("malformed_signature", `webhook-signature must be ${WEBHOOK_SIGNATURE_VERSION},<base64url ${MAC_BYTES}-byte MAC>`); | ||
| } | ||
| const expected = createHmac("sha256", Buffer.from(secret, "base64url")) | ||
| .update(webhookId, "utf8") | ||
| .update(".", "utf8") | ||
| .update(timestamp, "utf8") | ||
| .update(".", "utf8") | ||
| .update(body) | ||
| .digest(); | ||
| // Compare every offer, and compare all of them: bailing on the first match would leak, through | ||
| // timing, which position matched. | ||
| let matched = false; | ||
| for (const mac of macs) { | ||
| if (timingSafeEqual(mac, expected)) | ||
| matched = true; | ||
| } | ||
| if (!matched) { | ||
| return reject("signature_mismatch", "webhook-signature is not the MAC over these exact body bytes"); | ||
| } | ||
| if (tolerance !== null) { | ||
| const now = input.now === undefined ? Date.now() : Number(input.now); | ||
| if (!Number.isFinite(now)) { | ||
| throw new TypeError("now must be a Date or a millisecond timestamp"); | ||
| } | ||
| const skewSeconds = Math.abs(now - milliseconds) / 1000; | ||
| if (skewSeconds > tolerance) { | ||
| return reject("timestamp_out_of_tolerance", `signature is authentic but ${Math.round(skewSeconds)}s from now, outside the ${tolerance}s window`); | ||
| } | ||
| } | ||
| return { valid: true, keyId, webhookId, timestamp: new Date(milliseconds) }; | ||
| } |
+56
-0
@@ -193,2 +193,58 @@ /** | ||
| } | ||
| /** How Kaval reaches one source. The plan document itself is deliberately not published. */ | ||
| export interface WatchedSourcePlan { | ||
| source_id: string; | ||
| plan: { | ||
| id: string; | ||
| plan_version: number; | ||
| /** `manual` (a reviewed catalog row), `deterministic`, `llm`, or `ratchet`. */ | ||
| origin: string; | ||
| active: boolean; | ||
| /** NULL means PROBATION: adopted or derived, and not yet proven against the live source. */ | ||
| last_validated_at: string | null; | ||
| /** 0 declared feed · 1 backing document · 2 templated · 3 static crawl · 4 scripted browser. */ | ||
| tier: number; | ||
| steps: Array<{ | ||
| id: string; | ||
| kind: string; | ||
| }>; | ||
| emit_kind: string; | ||
| /** Documents the last successful poll found in the library. Null until one has run. */ | ||
| items_in_scope: number | null; | ||
| } | null; | ||
| discovery: { | ||
| status: string; | ||
| reason: string; | ||
| attempts: number; | ||
| error: string | null; | ||
| /** Model spend on working out how to reach this source. `"0"` is the common case. */ | ||
| cost_usd: string | null; | ||
| completed_at: string | null; | ||
| } | null; | ||
| } | ||
| /** One source whose content moved, and how many of your conclusions were resting on it. */ | ||
| export interface SourceExposure { | ||
| source_id: string; | ||
| locator: string; | ||
| label: string | null; | ||
| kind: string; | ||
| moved_at: string | null; | ||
| /** | ||
| * False when `moved_at` is inferred from the current version's fetch time rather than read off a | ||
| * recorded diff — the source moved, but there was no previous text to date the change against. | ||
| */ | ||
| moved_at_is_recorded_change: boolean; | ||
| conclusions: number; | ||
| /** Already re-adjudicated and flipped. */ | ||
| conclusions_changed: number; | ||
| /** Not yet re-read. A check answers REVIEW for these today. */ | ||
| conclusions_pending: number; | ||
| } | ||
| export interface PortfolioExposure { | ||
| sources: SourceExposure[]; | ||
| /** Distinct conclusions across every exposed source — not a sum of the page. */ | ||
| total_conclusions: number; | ||
| total_sources: number; | ||
| truncated: boolean; | ||
| } | ||
| export interface AddSourceResult { | ||
@@ -195,0 +251,0 @@ source: WatchedSource; |
+21
-1
@@ -11,3 +11,3 @@ /** | ||
| */ | ||
| import type { AddSourceInput, AddSourceResult, CheckInput, CheckReceipt, CheckResult, CreateWebhookInput, CreateWebhookResult, RecompileSourceResult, RotateWebhookSigningKeyResult, SourceEventInput, SourceEventResult, WatchedSource, WebhookDeliveryPage, WebhookSubscription } from "./check.js"; | ||
| import type { AddSourceInput, AddSourceResult, CheckInput, CheckReceipt, CheckResult, CreateWebhookInput, CreateWebhookResult, PortfolioExposure, RecompileSourceResult, RotateWebhookSigningKeyResult, SourceEventInput, SourceEventResult, WatchedSource, WatchedSourcePlan, WebhookDeliveryPage, WebhookSubscription } from "./check.js"; | ||
| import type { IsoTimestamp, VerifyRequest, VerifyResponse } from "./proof.js"; | ||
@@ -123,2 +123,22 @@ export type * from "./proof.js"; | ||
| /** Stop polling a source without forgetting it or the facts that depend on it. */ | ||
| /** | ||
| * How Kaval reaches one source: the active acquisition plan's SHAPE, how much is in scope, and | ||
| * the state of its last discovery job. | ||
| * | ||
| * The plan DOCUMENT is deliberately not published — it carries the user agent the plan was | ||
| * validated with, the selectors it extracts by, and the interstitial markers it rejects on, which | ||
| * together are a map of how to serve Kaval something it would accept. What you get is enough to | ||
| * answer "is this watched properly, and what did working that out cost". | ||
| */ | ||
| getSourcePlan(sourceId: string, options?: RequestOptions): Promise<WatchedSourcePlan>; | ||
| /** | ||
| * Every conclusion in this workspace resting on a source whose content has moved since the | ||
| * conclusion was reached, grouped by source. | ||
| * | ||
| * The same predicate a check uses to refuse a warm answer, run across the whole portfolio: these | ||
| * are exactly the facts that would come back REVIEW or BLOCK if you asked about them again. | ||
| */ | ||
| getExposure(options?: RequestOptions & { | ||
| limit?: number; | ||
| }): Promise<PortfolioExposure>; | ||
| pauseSource(sourceId: string, options?: RequestOptions): Promise<WatchedSource>; | ||
@@ -125,0 +145,0 @@ resumeSource(sourceId: string, options?: RequestOptions): Promise<WatchedSource>; |
+25
-0
@@ -356,2 +356,27 @@ /** | ||
| /** Stop polling a source without forgetting it or the facts that depend on it. */ | ||
| /** | ||
| * How Kaval reaches one source: the active acquisition plan's SHAPE, how much is in scope, and | ||
| * the state of its last discovery job. | ||
| * | ||
| * The plan DOCUMENT is deliberately not published — it carries the user agent the plan was | ||
| * validated with, the selectors it extracts by, and the interstitial markers it rejects on, which | ||
| * together are a map of how to serve Kaval something it would accept. What you get is enough to | ||
| * answer "is this watched properly, and what did working that out cost". | ||
| */ | ||
| async getSourcePlan(sourceId, options) { | ||
| return this.request("GET", `/v1/sources/${encodeId(sourceId)}/plan`, undefined, options); | ||
| } | ||
| /** | ||
| * Every conclusion in this workspace resting on a source whose content has moved since the | ||
| * conclusion was reached, grouped by source. | ||
| * | ||
| * The same predicate a check uses to refuse a warm answer, run across the whole portfolio: these | ||
| * are exactly the facts that would come back REVIEW or BLOCK if you asked about them again. | ||
| */ | ||
| async getExposure(options) { | ||
| const query = options?.limit === undefined | ||
| ? "" | ||
| : `?limit=${encodeURIComponent(String(options.limit))}`; | ||
| return this.request("GET", `/v1/exposure${query}`, undefined, options); | ||
| } | ||
| async pauseSource(sourceId, options) { | ||
@@ -358,0 +383,0 @@ const { source } = await this.request("POST", `/v1/sources/${encodeId(sourceId)}/pause`, {}, options); |
| /** | ||
| * `@usekaval/kaval/verify` — the offline receipt verifier. | ||
| * `@usekaval/kaval/verify` — the offline verifier: everything Kaval signs, checked without Kaval. | ||
| * | ||
@@ -10,2 +10,8 @@ * Nothing reachable from this entry point performs I/O of any kind: no `fetch`, no `node:http`, | ||
| * | ||
| * `verifyWebhookSignature` is here for the same reason: authenticating an inbound `fact_state.delta` | ||
| * is a pure HMAC over bytes you were handed, and a receiver that had to reach the network to decide | ||
| * whether a request is genuine would be a worse receiver. It is deliberately NOT re-exported from | ||
| * the package root — the root entry runs in browsers and edge runtimes on the global `fetch` alone, | ||
| * and hoisting a `node:crypto` import into it would break that. | ||
| * | ||
| * Live HTTPS key discovery lives on `@usekaval/kaval/verify/discovery`, one import away, so that | ||
@@ -19,1 +25,2 @@ * choosing it is explicit. | ||
| export { extractReceipt, verifyReceipt, verifyReceiptText } from "./verify.js"; | ||
| export { DEFAULT_WEBHOOK_TOLERANCE_SECONDS, verifyWebhookSignature, WEBHOOK_SIGNATURE_VERSION, WEBHOOK_SIGNED_CONTENT, type VerifyWebhookSignatureInput, type WebhookHeaderSource, type WebhookRejectionReason, type WebhookSignatureAccepted, type WebhookSignatureRejected, type WebhookSignatureResult, } from "./webhook.js"; |
| /** | ||
| * `@usekaval/kaval/verify` — the offline receipt verifier. | ||
| * `@usekaval/kaval/verify` — the offline verifier: everything Kaval signs, checked without Kaval. | ||
| * | ||
@@ -10,2 +10,8 @@ * Nothing reachable from this entry point performs I/O of any kind: no `fetch`, no `node:http`, | ||
| * | ||
| * `verifyWebhookSignature` is here for the same reason: authenticating an inbound `fact_state.delta` | ||
| * is a pure HMAC over bytes you were handed, and a receiver that had to reach the network to decide | ||
| * whether a request is genuine would be a worse receiver. It is deliberately NOT re-exported from | ||
| * the package root — the root entry runs in browsers and edge runtimes on the global `fetch` alone, | ||
| * and hoisting a `node:crypto` import into it would break that. | ||
| * | ||
| * Live HTTPS key discovery lives on `@usekaval/kaval/verify/discovery`, one import away, so that | ||
@@ -19,1 +25,2 @@ * choosing it is explicit. | ||
| export { extractReceipt, verifyReceipt, verifyReceiptText } from "./verify.js"; | ||
| export { DEFAULT_WEBHOOK_TOLERANCE_SECONDS, verifyWebhookSignature, WEBHOOK_SIGNATURE_VERSION, WEBHOOK_SIGNED_CONTENT, } from "./webhook.js"; |
+2
-1
| { | ||
| "name": "@usekaval/kaval", | ||
| "version": "0.6.0", | ||
| "version": "0.7.0", | ||
| "license": "Apache-2.0", | ||
@@ -25,2 +25,3 @@ "description": "Before an AI agent acts, Kaval verifies the facts the action depends on and returns ALLOW, REVIEW, or BLOCK with an Ed25519-signed receipt.", | ||
| "bin": { | ||
| "kaval": "./dist/cli/index.js", | ||
| "kaval-receipt-verify": "./dist/verify/cli.js" | ||
@@ -27,0 +28,0 @@ }, |
+83
-13
@@ -37,7 +37,7 @@ # @usekaval/kaval | ||
| | `@usekaval/kaval` | the API client — `check()` and everything that configures it | | ||
| | `@usekaval/kaval/verify` | the offline receipt verifier; no network code in its import graph | | ||
| | `@usekaval/kaval/verify` | the offline verifier — receipts + webhook signatures, no network code | | ||
| | `@usekaval/kaval/verify/discovery` | live HTTPS key discovery, kept separate so the network choice is loud | | ||
| It also installs one command, `kaval-receipt-verify`. See | ||
| [`/verify`](#verify--the-offline-receipt-verifier). | ||
| [`/verify`](#verify--the-offline-verifier). | ||
@@ -140,3 +140,3 @@ ## check() — the one call | ||
| re-derives the verdict offline, byte for byte, with no server — verify the Ed25519 signature with | ||
| [`@usekaval/kaval/verify`](#verify--the-offline-receipt-verifier), which ships inside this package. | ||
| [`@usekaval/kaval/verify`](#verify--the-offline-verifier), which ships inside this package. | ||
@@ -149,8 +149,11 @@ Each fact carries its `basis`: the sources it was proved against. When a basis entry has a | ||
| ## `/verify` — the offline receipt verifier | ||
| ## `/verify` — the offline verifier | ||
| ```ts | ||
| import { verifyReceipt } from "@usekaval/kaval/verify"; | ||
| import { verifyReceipt, verifyWebhookSignature } from "@usekaval/kaval/verify"; | ||
| ``` | ||
| Everything Kaval signs, checked without Kaval: the Ed25519 **receipt** a check returns, and the HMAC | ||
| **webhook signature** on an inbound `fact_state.delta` ([worked example](#verifying-a-delivery)). | ||
| The subpath is the whole verifier: zero dependencies, and **nothing in its import graph touches the | ||
@@ -162,4 +165,7 @@ network** — no `fetch`, no `node:http`, no sockets, transitively. That is enforced by a test that | ||
| It answers three questions **separately**, and conflating them is the mistake it exists to prevent: | ||
| ### Receipts | ||
| `verifyReceipt` answers three questions **separately**, and conflating them is the mistake it exists | ||
| to prevent: | ||
| 1. **Cryptographic validity** — does the Ed25519 signature cover the exact canonical unsigned bytes? | ||
@@ -198,7 +204,8 @@ 2. **Key trust** — is the immutable `key_id` active or benignly retired, or revoked/compromised? | ||
| Exported: `verifyReceipt` · `verifyReceiptText` · `extractReceipt` · `parseJsonStrict` · | ||
| `stableCanonicalJson` · `canonicalUnsignedReceiptJson` · `canonicalUnsignedReceiptBytes` · | ||
| `parseVerificationKey` · `verificationKeyFromDocument` · `isRfc3339Timestamp` · | ||
| `parseRfc3339Instant` · `rfc3339TimestampMilliseconds` · `rfc3339TimestampNanoseconds` · | ||
| `KAVAL_CANONICALIZATION` · `MAX_JSON_NUMBER_CHARACTERS`. | ||
| Exported: `verifyReceipt` · `verifyReceiptText` · `extractReceipt` · `verifyWebhookSignature` · | ||
| `parseJsonStrict` · `stableCanonicalJson` · `canonicalUnsignedReceiptJson` · | ||
| `canonicalUnsignedReceiptBytes` · `parseVerificationKey` · `verificationKeyFromDocument` · | ||
| `isRfc3339Timestamp` · `parseRfc3339Instant` · `rfc3339TimestampMilliseconds` · | ||
| `rfc3339TimestampNanoseconds` · `KAVAL_CANONICALIZATION` · `MAX_JSON_NUMBER_CHARACTERS` · | ||
| `WEBHOOK_SIGNATURE_VERSION` · `WEBHOOK_SIGNED_CONTENT` · `DEFAULT_WEBHOOK_TOLERANCE_SECONDS`. | ||
@@ -336,3 +343,66 @@ Both documents Kaval signs verify here: a ProofPacket, whose signature block is | ||
| ### Verifying a delivery | ||
| Your callback URL is a public HTTPS endpoint, and a delta is an instruction worth forging — "a fact | ||
| your agent relies on just flipped". `verifyWebhookSignature` is the receiving half of Kaval's | ||
| signature: HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<raw body>`, compared in constant time. | ||
| ```ts | ||
| import express from "express"; | ||
| import type { FactStateDeltaEvent } from "@usekaval/kaval"; | ||
| import { verifyWebhookSignature } from "@usekaval/kaval/verify"; | ||
| // webhook-key-id → that generation's secret. During a rotation overlap BOTH generations sign real | ||
| // deliveries, so hold both here and the rollover is a config change instead of an outage. | ||
| const secrets = { | ||
| [process.env.KAVAL_WEBHOOK_KEY_ID!]: process.env.KAVAL_WEBHOOK_SECRET!, | ||
| }; | ||
| const seen = new Set<string>(); // illustrative; a real receiver dedupes in its database | ||
| const app = express(); | ||
| // express.raw, NOT express.json: the signature covers the exact bytes on the wire. A body that has | ||
| // been parsed and re-serialised has a different MAC, and no genuine delivery would ever verify. | ||
| app.post("/hooks/kaval", express.raw({ type: "application/json" }), (req, res) => { | ||
| const result = verifyWebhookSignature({ | ||
| body: req.body, // Buffer, untouched | ||
| headers: req.headers, | ||
| secrets, | ||
| toleranceSeconds: 300, // default; the replay window either side of now | ||
| }); | ||
| if (!result.valid) { | ||
| // 400, not 401 — a retry will not make an unsigned request signed. `result.reason` is one of | ||
| // missing_header · malformed_timestamp · unknown_key_id · unsupported_signature_version · | ||
| // malformed_signature · signature_mismatch · timestamp_out_of_tolerance, and is safe to log. | ||
| return res.status(400).json({ error: result.reason }); | ||
| } | ||
| // Delivery is at-least-once by design: a retry after your 500 is a legitimate duplicate. Dedupe | ||
| // on result.webhookId (the event's own id) before doing anything with side effects. | ||
| if (seen.has(result.webhookId)) return res.status(200).end(); | ||
| seen.add(result.webhookId); | ||
| const event = JSON.parse(req.body.toString("utf8")) as FactStateDeltaEvent; | ||
| const { source, old_version_sha256, new_version_sha256, diff_summary, facts } = event.data; | ||
| for (const fact of facts) { | ||
| // "Aetna requires prior auth for CPT 12345" — holds → changed, at critical materiality. | ||
| console.log( | ||
| `${fact.materiality} ${fact.old_state} → ${fact.new_state}: ${fact.text}`, | ||
| `via ${source.locator} (${old_version_sha256?.slice(0, 12)} → ${new_version_sha256.slice(0, 12)})`, | ||
| fact.basis.map((ref) => ref.source_locator), | ||
| ); | ||
| } | ||
| void diff_summary; // changed_sections + stats, if you want to show what moved in the document | ||
| // Answer 2xx quickly and do the work after; Kaval retries non-2xx with backoff, then dead-letters. | ||
| res.status(202).end(); | ||
| }); | ||
| ``` | ||
| The verifier lives on the `/verify` subpath, so a receiver that never constructs a client pulls in no | ||
| HTTP code — and, like the receipt verifier, it never contacts Kaval to decide whether a request is | ||
| genuine. | ||
| ```ts | ||
| await kaval.listWebhooks(); | ||
@@ -464,4 +534,4 @@ await kaval.setWebhookEnabled(subscription.subscription_id, false); | ||
| Offline verification is a separate surface with no client and no key: | ||
| [`@usekaval/kaval/verify`](#verify--the-offline-receipt-verifier), plus the | ||
| `kaval-receipt-verify` command. | ||
| [`@usekaval/kaval/verify`](#verify--the-offline-verifier) — `verifyReceipt` for receipts, | ||
| `verifyWebhookSignature` for inbound deliveries — plus the `kaval-receipt-verify` command. | ||
@@ -468,0 +538,0 @@ **Env vars:** this package does **not** read `KAVAL_BASE_URL` from the environment — pass |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
203010
27.13%29
16%3809
29.65%541
14.86%8
-11.11%5
150%