🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@usekaval/kaval

Package Overview
Dependencies
Maintainers
1
Versions
11
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@usekaval/kaval - npm Package Compare versions

Comparing version
0.6.0
to
0.7.0
+19
dist/cli/index.d.ts
#!/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}` : 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} `;
}
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>;

@@ -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