@usekaval/kaval
Advanced tools
+365
| /** | ||
| * Wire types for the one pipeline: `POST /v1/check`, the watched-source registry, document push, | ||
| * signed check receipts, and the `fact_state.delta` webhook subscriptions the background loops | ||
| * deliver against. Field names match the hosted REST JSON exactly. | ||
| */ | ||
| import type { EntityRef, IsoTimestamp, Materiality, ScalarValue } from "./proof.js"; | ||
| /** The verdict an agent branches on. Only ALLOW means "safe to act". */ | ||
| export type CheckVerdict = "ALLOW" | "REVIEW" | "BLOCK"; | ||
| /** The complete reason-code taxonomy — eight codes, no synonyms, no free text. */ | ||
| export type CheckReasonCode = "ALL_FACTS_HOLD" | "FACT_CHANGED" | "FACT_EXPIRED" | "FACT_UNKNOWN" | "SOURCE_UPDATED_PENDING_REVIEW" | "SOURCE_UNREACHABLE" | "NEW_FACT_UNVERIFIED" | "COMPILATION_UNCERTAIN"; | ||
| /** The public three-valued projection of a fact's internal assessment. */ | ||
| export type FactStatus = "holds" | "changed" | "unknown"; | ||
| /** `fast` skips the live fallback entirely and answers from stored fact state only. */ | ||
| export type CheckMode = "fast" | "standard"; | ||
| /** `0` disables research entirely. This is exactly what `mode: "fast"` sets. */ | ||
| export declare const MIN_CHECK_MAX_WAIT_MS = 0; | ||
| /** What the server applies when `max_wait_ms` is omitted: let the research finish. */ | ||
| export declare const DEFAULT_CHECK_MAX_WAIT_MS = 100000; | ||
| /** Equal to the default on purpose — the budget exists to ask for LESS waiting, never more. */ | ||
| export declare const MAX_CHECK_MAX_WAIT_MS = 100000; | ||
| /** | ||
| * A claim already decomposed by the caller. Structured claims are the zero-LLM path: they | ||
| * canonicalize straight to a fact fingerprint, so the warm lookup needs no model call. | ||
| */ | ||
| export interface StructuredClaim { | ||
| subject: string | EntityRef; | ||
| predicate: string; | ||
| object?: string | EntityRef | ScalarValue; | ||
| /** What the claim is scoped to, e.g. `{ jurisdiction: "US", plan: "HMO" }`. */ | ||
| scope?: Record<string, ScalarValue>; | ||
| materiality?: Materiality; | ||
| /** Optional human rendering; defaults to a deterministic render of the structure. */ | ||
| text?: string; | ||
| } | ||
| export type ClaimInput = string | StructuredClaim; | ||
| /** `POST /v1/check` body. Provide at least one of `action` or `claims`. */ | ||
| export interface CheckInput { | ||
| /** What the agent is about to do, in plain language. Kaval compiles the facts it depends on. */ | ||
| action?: string; | ||
| /** Anything the agent already knows that bears on the action. */ | ||
| context?: string; | ||
| /** Facts to check directly, as plain sentences or structured claims (max 20). */ | ||
| claims?: ClaimInput[]; | ||
| mode?: CheckMode; | ||
| /** | ||
| * Live-path budget in ms (default 100000, max 100000; 0 disables research, which is what | ||
| * `mode: "fast"` sets). Facts that miss it enter as `unknown`. | ||
| */ | ||
| max_wait_ms?: number; | ||
| /** Caller-declared origins, merged with the workspace's registered watched sources. */ | ||
| origin_urls?: string[]; | ||
| materiality?: Materiality; | ||
| as_of?: IsoTimestamp; | ||
| } | ||
| export interface CheckSourceRef { | ||
| locator: string; | ||
| version_sha256?: string; | ||
| fetched_at?: IsoTimestamp; | ||
| } | ||
| export interface CheckFact { | ||
| fingerprint: string; | ||
| text: string; | ||
| status: FactStatus; | ||
| materiality: Materiality; | ||
| /** True when the answer came from warm fact state instead of live research. */ | ||
| served_from_state: boolean; | ||
| last_verified_at: string | null; | ||
| sources: CheckSourceRef[]; | ||
| } | ||
| export interface CheckLatency { | ||
| compile: number; | ||
| lookup: number; | ||
| live: number; | ||
| total: number; | ||
| } | ||
| /** `POST /v1/check` response. `receipt.id` fetches the full signed document via `getReceipt()`. */ | ||
| export interface CheckResult { | ||
| decision: CheckVerdict; | ||
| reason_codes: CheckReasonCode[]; | ||
| facts: CheckFact[]; | ||
| receipt: { | ||
| id: string; | ||
| signature: string; | ||
| signed_at: IsoTimestamp; | ||
| }; | ||
| latency_ms: CheckLatency; | ||
| } | ||
| /** Why a stored fact state could not be served; published so the verdict re-derives offline. */ | ||
| export type CheckFreshnessFailure = "stale" | "dormant" | "basis_superseded" | "source_unreachable" | "ttl_expired"; | ||
| export interface CheckReceiptBasis { | ||
| source_locator: string; | ||
| /** Absent when nothing was pinned — never the source's read-time sha. */ | ||
| version_sha256?: string; | ||
| /** | ||
| * What `version_sha256` covers. A PDF's canonical text is extracted markdown, so the same | ||
| * document has two unequal legitimate digests; unlabelled, a holder cannot know which artifact to | ||
| * hash and the digest is decorative. Travels with `version_sha256` or not at all. | ||
| */ | ||
| version_sha256_of?: "canonical_text" | "raw_bytes"; | ||
| /** The extractor that produced the canonical text, when one did. Absent for a plain HTTP body. */ | ||
| parser_name?: string; | ||
| parser_version?: string; | ||
| fetched_at?: IsoTimestamp; | ||
| publication_time?: IsoTimestamp; | ||
| span_ref?: unknown; | ||
| } | ||
| export interface CheckReceiptFact { | ||
| fingerprint: string; | ||
| text: string; | ||
| materiality: Materiality; | ||
| state: FactStatus; | ||
| checked_at: IsoTimestamp; | ||
| method: "state" | "live" | "timeout"; | ||
| temporal_state: string | null; | ||
| stale_pending: boolean; | ||
| novel: boolean; | ||
| freshness_failure: CheckFreshnessFailure | null; | ||
| basis: CheckReceiptBasis[]; | ||
| } | ||
| /** | ||
| * The receipt EXACTLY as signed. The decision table is published, so this fact list re-derives the | ||
| * verdict offline — verify `signature` with the issuer's Ed25519 public key. | ||
| */ | ||
| export interface CheckReceipt { | ||
| receipt_version: string; | ||
| id: string; | ||
| tenant_id: string; | ||
| workspace_id: string | null; | ||
| decision: CheckVerdict; | ||
| reason_codes: CheckReasonCode[]; | ||
| decision_rule_version: string; | ||
| mode: CheckMode; | ||
| checked_at: IsoTimestamp; | ||
| compilation_uncertain: boolean; | ||
| facts: CheckReceiptFact[]; | ||
| proof_packet_ids: string[]; | ||
| signature: { | ||
| algorithm: string; | ||
| key_id: string; | ||
| signature: string; | ||
| signed_at: IsoTimestamp; | ||
| }; | ||
| } | ||
| /** | ||
| * `entity` resolves a plain name ("Aetna") to the URLs that publish it and watches those; | ||
| * `push` is a document the customer POSTs to `/v1/events`; `discovered` is auto-registered when a | ||
| * check cites a URL nobody registered. | ||
| */ | ||
| export type WatchedSourceKind = "url" | "push" | "connection" | "entity" | "discovered"; | ||
| export type WatchedSourceOrigin = "registered" | "discovered" | "resolved"; | ||
| export interface WatchedSource { | ||
| id: string; | ||
| kind: WatchedSourceKind; | ||
| locator: string; | ||
| label: string | null; | ||
| intent: string | null; | ||
| origin: WatchedSourceOrigin; | ||
| parent_source_id: string | null; | ||
| scope_keys: string[]; | ||
| active: boolean; | ||
| poll_interval_s: number | null; | ||
| next_poll_at: string | null; | ||
| last_success_at: string | null; | ||
| content_sha256: string | null; | ||
| created_at: IsoTimestamp; | ||
| } | ||
| export interface AddSourceInput { | ||
| kind: WatchedSourceKind; | ||
| /** The URL, connection id, or push locator. For `kind: "entity"` use `name` instead. */ | ||
| locator?: string; | ||
| /** `kind: "entity"` reads more naturally as a name — it is the same locator field. */ | ||
| name?: string; | ||
| label?: string; | ||
| /** What you want watched about it, e.g. "payer policy bulletins". Drives entity resolution. */ | ||
| intent?: string; | ||
| /** Scope tags used to route document pushes to the facts they can affect. */ | ||
| scope_keys?: string[]; | ||
| poll_interval_s?: number; | ||
| } | ||
| /** | ||
| * What the authority filter decided about one candidate an `entity` registration resolved to. | ||
| * | ||
| * `ambiguous` is the outcome a customer must actually act on: a real page of the real entity, but | ||
| * governing a different product line with different rules. Watching it silently produces a | ||
| * confident, well-sourced, WRONG answer with a signed receipt on it, so Kaval surfaces the | ||
| * ambiguity rather than guessing either way. | ||
| */ | ||
| export interface AuthorityDecision { | ||
| url: string; | ||
| outcome: "accepted" | "discarded" | "ambiguous"; | ||
| reason: string; | ||
| } | ||
| export interface AddSourceResult { | ||
| source: WatchedSource; | ||
| created: boolean; | ||
| /** Sources an `entity` registration resolved to and is now watching. */ | ||
| resolved: WatchedSource[]; | ||
| resolution_error?: string; | ||
| /** The authority filter's working, discards included. Inspect `ambiguous` entries. */ | ||
| authority?: AuthorityDecision[]; | ||
| /** Set when plan discovery failed for the new source; the source itself is still registered. */ | ||
| discovery_error?: string; | ||
| } | ||
| /** | ||
| * `POST /v1/sources/:id/recompile` — enqueued, not compiled inline: discovery can drive a browser | ||
| * and a model, so it belongs to the worker's budget rather than a request's lifetime. | ||
| */ | ||
| export interface RecompileSourceResult { | ||
| source_id: string; | ||
| job_id: string; | ||
| /** False when a job was already open for this source — the recompile folded into it. */ | ||
| created: boolean; | ||
| } | ||
| /** `POST /v1/events` — the customer-push half of the watch mechanism. */ | ||
| export interface SourceEventInput { | ||
| /** Address an already-registered source… */ | ||
| source_id?: string; | ||
| /** …or address the document as `namespace` + `document_id` (created on first sight). */ | ||
| namespace?: string; | ||
| document_id?: string; | ||
| /** Extracted text. Raw PDF bytes are not accepted. */ | ||
| content?: string; | ||
| content_url?: string; | ||
| content_sha256?: string; | ||
| observed_at?: IsoTimestamp; | ||
| scope_keys?: string[]; | ||
| } | ||
| export interface SourceEventResult { | ||
| accepted: boolean; | ||
| /** False for a same-content push: no version row, no staleness, no delta webhook. */ | ||
| changed: boolean; | ||
| source_id: string; | ||
| version_id: string | null; | ||
| content_sha256: string; | ||
| previous_content_sha256: string | null; | ||
| /** Facts whose basis moved and whose re-evaluation is still running — checks REVIEW meanwhile. */ | ||
| facts_pending_review: number; | ||
| } | ||
| export type WebhookSubscriptionKind = "belief_integrity" | "monitor" | "fact_state"; | ||
| /** The only event a `fact_state` subscription accepts. */ | ||
| export declare const FACT_STATE_DELTA_EVENT_TYPE = "fact_state.delta"; | ||
| export interface CreateWebhookInput { | ||
| subscription_kind: WebhookSubscriptionKind; | ||
| /** Must be https. */ | ||
| callback_url: string; | ||
| event_types: string[]; | ||
| description?: string; | ||
| /** Deliver only deltas whose scope intersects these ids. Empty means everything. */ | ||
| external_scope_ids?: string[]; | ||
| enabled?: boolean; | ||
| } | ||
| export interface WebhookSubscription { | ||
| subscription_id: string; | ||
| workspace_id?: string; | ||
| subscription_kind?: WebhookSubscriptionKind; | ||
| callback_url?: string; | ||
| event_types?: string[]; | ||
| external_scope_ids?: string[]; | ||
| enabled?: boolean; | ||
| signing_key_id?: string; | ||
| [key: string]: unknown; | ||
| } | ||
| /** Everything needed to verify an inbound delta's HMAC signature. Returned once, at creation. */ | ||
| export interface WebhookVerification { | ||
| algorithm: string; | ||
| key_id: string; | ||
| secret: string; | ||
| signed_content: string; | ||
| headers: string[]; | ||
| } | ||
| export interface CreateWebhookResult { | ||
| subscription: WebhookSubscription; | ||
| webhook_verification: WebhookVerification; | ||
| } | ||
| export type WebhookDeliveryState = "pending" | "delivering" | "succeeded" | "retry_scheduled" | "dead_letter" | "cancelled"; | ||
| /** One attempted delivery. `delivery_id` is the only place a replayable id is published. */ | ||
| export interface WebhookDelivery { | ||
| delivery_id: string; | ||
| subscription_id: string; | ||
| callback_event_id: string; | ||
| signing_key_id: string; | ||
| state: WebhookDeliveryState; | ||
| attempt: number; | ||
| response_status: number | null; | ||
| error_code: string | null; | ||
| next_attempt_at: IsoTimestamp | null; | ||
| created_at: IsoTimestamp; | ||
| updated_at: IsoTimestamp; | ||
| delivered_at: IsoTimestamp | null; | ||
| operation_id: string | null; | ||
| is_test: boolean; | ||
| [key: string]: unknown; | ||
| } | ||
| export interface WebhookDeliveryPage { | ||
| items: WebhookDelivery[]; | ||
| /** Feed back as `before` for the next page. Null when this page is the last one. */ | ||
| next_before: IsoTimestamp | null; | ||
| [key: string]: unknown; | ||
| } | ||
| /** | ||
| * The result of rotating a subscription's signing key. `previous_key_expires_at` is the overlap | ||
| * window: bodies signed by the old generation keep verifying until then, so you can redeploy. | ||
| */ | ||
| export interface WebhookKeyRotation { | ||
| subscription_id: string; | ||
| workspace_id?: string; | ||
| signing_key_id: string; | ||
| previous_signing_key_id: string | null; | ||
| previous_key_expires_at: IsoTimestamp | null; | ||
| [key: string]: unknown; | ||
| } | ||
| export interface RotateWebhookSigningKeyResult { | ||
| rotation: WebhookKeyRotation; | ||
| /** The new secret, shown exactly once — same contract as creation. */ | ||
| webhook_verification: WebhookVerification; | ||
| } | ||
| export interface FactBasisRef { | ||
| source_locator: string; | ||
| version_sha256?: string; | ||
| fetched_at?: IsoTimestamp; | ||
| publication_time?: IsoTimestamp; | ||
| span_ref?: unknown; | ||
| } | ||
| export interface FactStateTransition { | ||
| fingerprint: string; | ||
| text: string; | ||
| materiality: Materiality; | ||
| old_state: FactStatus | null; | ||
| new_state: FactStatus; | ||
| basis: FactBasisRef[]; | ||
| } | ||
| /** | ||
| * The body of an inbound `fact_state.delta` webhook: "here is what changed and what it flipped." | ||
| * Typed here so a receiver can parse it without reimplementing the contract. | ||
| */ | ||
| export interface FactStateDeltaEvent { | ||
| specversion: "1.0"; | ||
| id: string; | ||
| type: typeof FACT_STATE_DELTA_EVENT_TYPE; | ||
| source: string; | ||
| subject: string; | ||
| time: IsoTimestamp; | ||
| correlation_id: string; | ||
| sequence: number; | ||
| data: { | ||
| tenant_id: string; | ||
| workspace_id?: string | null; | ||
| source: { | ||
| watched_source_id: string; | ||
| kind: WatchedSourceKind; | ||
| locator: string; | ||
| label?: string; | ||
| }; | ||
| old_version_sha256: string | null; | ||
| new_version_sha256: string; | ||
| diff_summary: unknown; | ||
| facts: FactStateTransition[]; | ||
| receipt?: { | ||
| proof_packet_id?: string; | ||
| receipt_url?: string; | ||
| signature?: string; | ||
| }; | ||
| changed_at: IsoTimestamp; | ||
| }; | ||
| } |
| /** | ||
| * Wire types for the one pipeline: `POST /v1/check`, the watched-source registry, document push, | ||
| * signed check receipts, and the `fact_state.delta` webhook subscriptions the background loops | ||
| * deliver against. Field names match the hosted REST JSON exactly. | ||
| */ | ||
| /* ------------------------------ research budget ----------------------------- * | ||
| * The `/v1/check` research budget, mirrored from the server's own constants so a caller — or the | ||
| * MCP server that wraps this client — can bound `max_wait_ms` against the real numbers instead of | ||
| * hand-typing them. | ||
| * | ||
| * They are two orders of magnitude larger than the three-second default this package used to | ||
| * publish, which was a warm-path latency target applied to a cold path: a first check has to | ||
| * search, fetch and adjudicate several novel facts, and a 3s budget returns every one of them as | ||
| * `unknown` with no basis and no verdict worth reading. The consolation — that the detached | ||
| * research warms state for next time — does not hold either, because the next check recompiles the | ||
| * action and asks about different fingerprints. | ||
| * ---------------------------------------------------------------------------- */ | ||
| /** `0` disables research entirely. This is exactly what `mode: "fast"` sets. */ | ||
| export const MIN_CHECK_MAX_WAIT_MS = 0; | ||
| /** What the server applies when `max_wait_ms` is omitted: let the research finish. */ | ||
| export const DEFAULT_CHECK_MAX_WAIT_MS = 100_000; | ||
| /** Equal to the default on purpose — the budget exists to ask for LESS waiting, never more. */ | ||
| export const MAX_CHECK_MAX_WAIT_MS = 100_000; | ||
| /** The only event a `fact_state` subscription accepts. */ | ||
| export const FACT_STATE_DELTA_EVENT_TYPE = "fact_state.delta"; |
| import type { JsonValue } from "./types.js"; | ||
| export declare const DEFAULT_MAX_JSON_DEPTH = 128; | ||
| export declare const DEFAULT_MAX_JSON_NODES = 1000000; | ||
| export declare const MAX_JSON_NUMBER_CHARACTERS = 1000; | ||
| /** | ||
| * Kaval stable JSON v1. | ||
| * | ||
| * Object keys use ECMAScript's default UTF-16 code-unit ordering, arrays retain their order, | ||
| * strings/numbers use JSON.stringify spelling, integers must be safe integers, and no | ||
| * insignificant whitespace is emitted. | ||
| */ | ||
| export declare function stableCanonicalJson(value: unknown, seen?: Set<object>): string; | ||
| export declare function canonicalUnsignedReceiptJson(receipt: unknown): string; | ||
| export declare function canonicalUnsignedReceiptBytes(receipt: unknown): Uint8Array; | ||
| /** | ||
| * Parse JSON while rejecting duplicate keys, lossy/non-interoperable numbers, and | ||
| * resource-exhaustion shapes. | ||
| */ | ||
| export declare function parseJsonStrict(source: string, options?: { | ||
| max_depth?: number; | ||
| max_nodes?: number; | ||
| }): JsonValue; |
| export const DEFAULT_MAX_JSON_DEPTH = 128; | ||
| export const DEFAULT_MAX_JSON_NODES = 1_000_000; | ||
| export const MAX_JSON_NUMBER_CHARACTERS = 1_000; | ||
| function fail(message) { | ||
| throw new Error(message); | ||
| } | ||
| function isJsonObject(value) { | ||
| if (value === null || typeof value !== "object" || Array.isArray(value)) | ||
| return false; | ||
| const prototype = Object.getPrototypeOf(value); | ||
| return prototype === Object.prototype || prototype === null; | ||
| } | ||
| /** | ||
| * Kaval stable JSON v1. | ||
| * | ||
| * Object keys use ECMAScript's default UTF-16 code-unit ordering, arrays retain their order, | ||
| * strings/numbers use JSON.stringify spelling, integers must be safe integers, and no | ||
| * insignificant whitespace is emitted. | ||
| */ | ||
| export function stableCanonicalJson(value, seen = new Set()) { | ||
| if (value === null || | ||
| typeof value === "boolean" || | ||
| typeof value === "string") { | ||
| return JSON.stringify(value); | ||
| } | ||
| if (typeof value === "number") { | ||
| if (!Number.isFinite(value)) | ||
| return fail("canonical JSON does not permit non-finite numbers"); | ||
| if (Number.isInteger(value) && !Number.isSafeInteger(value)) { | ||
| return fail("canonical JSON does not permit integers outside the interoperable safe-integer range"); | ||
| } | ||
| return JSON.stringify(value); | ||
| } | ||
| if (typeof value !== "object") { | ||
| return fail(`canonical JSON does not permit ${typeof value} values`); | ||
| } | ||
| if (seen.has(value)) | ||
| return fail("canonical JSON does not permit cyclic values"); | ||
| seen.add(value); | ||
| try { | ||
| if (Array.isArray(value)) { | ||
| for (let index = 0; index < value.length; index += 1) { | ||
| if (!Object.hasOwn(value, index)) | ||
| return fail("canonical JSON does not permit sparse arrays"); | ||
| } | ||
| return `[${value.map((entry) => stableCanonicalJson(entry, seen)).join(",")}]`; | ||
| } | ||
| if (!isJsonObject(value)) | ||
| return fail("canonical JSON requires plain JSON objects"); | ||
| const symbolKeys = Object.getOwnPropertySymbols(value); | ||
| if (symbolKeys.length > 0) | ||
| return fail("canonical JSON does not permit symbol keys"); | ||
| const keys = Object.keys(value).sort(); | ||
| return `{${keys | ||
| .map((key) => `${JSON.stringify(key)}:${stableCanonicalJson(value[key], seen)}`) | ||
| .join(",")}}`; | ||
| } | ||
| finally { | ||
| seen.delete(value); | ||
| } | ||
| } | ||
| export function canonicalUnsignedReceiptJson(receipt) { | ||
| if (!isJsonObject(receipt)) | ||
| throw new Error("receipt must be a JSON object"); | ||
| const unsigned = Object.create(null); | ||
| for (const key of Object.keys(receipt)) { | ||
| if (key !== "signature") | ||
| unsigned[key] = receipt[key]; | ||
| } | ||
| return stableCanonicalJson(unsigned); | ||
| } | ||
| export function canonicalUnsignedReceiptBytes(receipt) { | ||
| return new TextEncoder().encode(canonicalUnsignedReceiptJson(receipt)); | ||
| } | ||
| class StrictJsonParser { | ||
| source; | ||
| maxDepth; | ||
| maxNodes; | ||
| index = 0; | ||
| nodes = 0; | ||
| constructor(source, maxDepth, maxNodes) { | ||
| this.source = source; | ||
| this.maxDepth = maxDepth; | ||
| this.maxNodes = maxNodes; | ||
| } | ||
| parse() { | ||
| this.skipWhitespace(); | ||
| const result = this.parseValue(0); | ||
| this.skipWhitespace(); | ||
| if (this.index !== this.source.length) | ||
| this.error("unexpected trailing input"); | ||
| return result; | ||
| } | ||
| error(message) { | ||
| throw new Error(`${message} at JSON offset ${this.index}`); | ||
| } | ||
| countNode() { | ||
| this.nodes += 1; | ||
| if (this.nodes > this.maxNodes) | ||
| this.error("JSON node limit exceeded"); | ||
| } | ||
| skipWhitespace() { | ||
| while (this.source[this.index] === " " || | ||
| this.source[this.index] === "\n" || | ||
| this.source[this.index] === "\r" || | ||
| this.source[this.index] === "\t") { | ||
| this.index += 1; | ||
| } | ||
| } | ||
| parseValue(depth) { | ||
| if (depth > this.maxDepth) | ||
| this.error("JSON depth limit exceeded"); | ||
| this.countNode(); | ||
| const token = this.source[this.index]; | ||
| if (token === '"') | ||
| return this.parseString(); | ||
| if (token === "{") | ||
| return this.parseObject(depth + 1); | ||
| if (token === "[") | ||
| return this.parseArray(depth + 1); | ||
| if (token === "t") | ||
| return this.parseLiteral("true", true); | ||
| if (token === "f") | ||
| return this.parseLiteral("false", false); | ||
| if (token === "n") | ||
| return this.parseLiteral("null", null); | ||
| if (token === "-" || | ||
| (token !== undefined && token >= "0" && token <= "9")) { | ||
| return this.parseNumber(); | ||
| } | ||
| this.error("expected a JSON value"); | ||
| } | ||
| parseLiteral(token, value) { | ||
| if (this.source.slice(this.index, this.index + token.length) !== token) { | ||
| this.error(`invalid ${token} literal`); | ||
| } | ||
| this.index += token.length; | ||
| return value; | ||
| } | ||
| parseString() { | ||
| const start = this.index; | ||
| this.index += 1; | ||
| while (this.index < this.source.length) { | ||
| const character = this.source[this.index]; | ||
| const code = character.charCodeAt(0); | ||
| if (character === '"') { | ||
| this.index += 1; | ||
| try { | ||
| return JSON.parse(this.source.slice(start, this.index)); | ||
| } | ||
| catch { | ||
| this.error("invalid JSON string"); | ||
| } | ||
| } | ||
| if (code < 0x20) | ||
| this.error("unescaped control character in JSON string"); | ||
| if (character === "\\") { | ||
| this.index += 1; | ||
| const escape = this.source[this.index]; | ||
| if (escape === undefined || !'"\\/bfnrtu'.includes(escape)) { | ||
| this.error("invalid JSON string escape"); | ||
| } | ||
| if (escape === "u") { | ||
| const hex = this.source.slice(this.index + 1, this.index + 5); | ||
| if (!/^[0-9A-Fa-f]{4}$/u.test(hex)) | ||
| this.error("invalid JSON unicode escape"); | ||
| this.index += 4; | ||
| } | ||
| } | ||
| this.index += 1; | ||
| } | ||
| this.error("unterminated JSON string"); | ||
| } | ||
| parseNumber() { | ||
| const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/u.exec(this.source.slice(this.index)); | ||
| if (!match) | ||
| this.error("invalid JSON number"); | ||
| const token = match[0]; | ||
| this.index += token.length; | ||
| if (token.length > MAX_JSON_NUMBER_CHARACTERS) { | ||
| this.error(`JSON number exceeds ${MAX_JSON_NUMBER_CHARACTERS} characters`); | ||
| } | ||
| const value = Number(token); | ||
| if (!Number.isFinite(value)) | ||
| this.error("JSON number is outside the finite range"); | ||
| if (Number.isInteger(value) && !Number.isSafeInteger(value)) { | ||
| this.error("JSON integer is outside the interoperable safe-integer range"); | ||
| } | ||
| if (normalizedDecimal(token) !== normalizedDecimal(JSON.stringify(value))) { | ||
| this.error("JSON number loses information under ECMAScript Number coercion"); | ||
| } | ||
| return value; | ||
| } | ||
| parseArray(depth) { | ||
| this.index += 1; | ||
| const values = []; | ||
| this.skipWhitespace(); | ||
| if (this.source[this.index] === "]") { | ||
| this.index += 1; | ||
| return values; | ||
| } | ||
| while (true) { | ||
| this.skipWhitespace(); | ||
| values.push(this.parseValue(depth)); | ||
| this.skipWhitespace(); | ||
| const delimiter = this.source[this.index]; | ||
| if (delimiter === "]") { | ||
| this.index += 1; | ||
| return values; | ||
| } | ||
| if (delimiter !== ",") | ||
| this.error("expected ',' or ']' in JSON array"); | ||
| this.index += 1; | ||
| } | ||
| } | ||
| parseObject(depth) { | ||
| this.index += 1; | ||
| const value = Object.create(null); | ||
| const keys = new Set(); | ||
| this.skipWhitespace(); | ||
| if (this.source[this.index] === "}") { | ||
| this.index += 1; | ||
| return value; | ||
| } | ||
| while (true) { | ||
| this.skipWhitespace(); | ||
| if (this.source[this.index] !== '"') | ||
| this.error("expected a JSON object key"); | ||
| const key = this.parseString(); | ||
| if (keys.has(key)) | ||
| this.error(`duplicate JSON object key ${JSON.stringify(key)}`); | ||
| keys.add(key); | ||
| this.skipWhitespace(); | ||
| if (this.source[this.index] !== ":") | ||
| this.error("expected ':' after JSON object key"); | ||
| this.index += 1; | ||
| this.skipWhitespace(); | ||
| value[key] = this.parseValue(depth); | ||
| this.skipWhitespace(); | ||
| const delimiter = this.source[this.index]; | ||
| if (delimiter === "}") { | ||
| this.index += 1; | ||
| return value; | ||
| } | ||
| if (delimiter !== ",") | ||
| this.error("expected ',' or '}' in JSON object"); | ||
| this.index += 1; | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Parse JSON while rejecting duplicate keys, lossy/non-interoperable numbers, and | ||
| * resource-exhaustion shapes. | ||
| */ | ||
| export function parseJsonStrict(source, options = {}) { | ||
| const maxDepth = options.max_depth ?? DEFAULT_MAX_JSON_DEPTH; | ||
| const maxNodes = options.max_nodes ?? DEFAULT_MAX_JSON_NODES; | ||
| if (!Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > 1_024) { | ||
| throw new Error("max_depth must be an integer between 1 and 1024"); | ||
| } | ||
| if (!Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > 10_000_000) { | ||
| throw new Error("max_nodes must be an integer between 1 and 10000000"); | ||
| } | ||
| return new StrictJsonParser(source, maxDepth, maxNodes).parse(); | ||
| } | ||
| /** | ||
| * Normalize a valid JSON number as an exact decimal coefficient and base-10 exponent. | ||
| * | ||
| * This comparison happens on the source lexeme before Number coercion. It lets equivalent spellings | ||
| * such as `1.0` and `1e0` through while rejecting distinct decimal values that collapse to the same | ||
| * IEEE-754 Number. | ||
| */ | ||
| function normalizedDecimal(token) { | ||
| const match = /^(-)?(0|[1-9][0-9]*)(?:\.([0-9]+))?(?:[eE]([+-]?)([0-9]+))?$/u.exec(token); | ||
| if (!match) | ||
| return fail("internal error: invalid JSON number normalization input"); | ||
| const negative = match[1] === "-"; | ||
| const fraction = match[3] ?? ""; | ||
| let digits = `${match[2]}${fraction}`.replace(/^0+/u, ""); | ||
| if (!digits) | ||
| return "0e0"; | ||
| let trailingZeroes = 0; | ||
| while (digits.endsWith("0")) { | ||
| digits = digits.slice(0, -1); | ||
| trailingZeroes += 1; | ||
| } | ||
| const explicitExponentDigits = (match[5] ?? "0").replace(/^0+(?=\d)/u, ""); | ||
| const explicitExponent = BigInt(explicitExponentDigits) * (match[4] === "-" ? -1n : 1n); | ||
| const exponent = explicitExponent - BigInt(fraction.length) + BigInt(trailingZeroes); | ||
| return `${negative ? "-" : ""}${digits}e${exponent}`; | ||
| } |
| #!/usr/bin/env node | ||
| export {}; |
| #!/usr/bin/env node | ||
| import { readFile, stat } from "node:fs/promises"; | ||
| import process from "node:process"; | ||
| import { parseJsonStrict } from "./canonicalize.js"; | ||
| import { discoverVerificationKeyDocument } from "./discovery.js"; | ||
| import { extractReceipt, verifyReceipt } from "./verify.js"; | ||
| const MAX_RECEIPT_BYTES = 8 * 1_024 * 1_024; | ||
| const MAX_OFFLINE_KEYSET_BYTES = 1 * 1_024 * 1_024; | ||
| const HELP = `Usage: | ||
| kaval-receipt-verify verify <receipt.json|-> --keyset <keys.json> [options] | ||
| kaval-receipt-verify verify <receipt.json|-> --key-url <https-url> [options] | ||
| Options: | ||
| --keyset <path> Offline per-key document or keyset (recommended for reproducibility) | ||
| --key-url <url> HTTPS per-key endpoint or keyset endpoint | ||
| --at <RFC3339> Evaluate freshness at an explicit time | ||
| --require-fresh Exit non-zero unless freshness is "fresh" | ||
| --allow-http-loopback Permit http://localhost/127.0.0.0/8/::1 for local development | ||
| --compact Emit compact JSON | ||
| -h, --help Show this help | ||
| Exit status 0 means the signature is valid and the key is trusted. Freshness is reported | ||
| separately unless --require-fresh is supplied. | ||
| `; | ||
| function argumentError(message) { | ||
| throw new Error(`${message}\n\n${HELP}`); | ||
| } | ||
| function parseArguments(argv) { | ||
| if (argv.includes("--help") || argv.includes("-h")) | ||
| return null; | ||
| const values = [...argv]; | ||
| if (values[0] === "verify") | ||
| values.shift(); | ||
| const receiptPath = values.shift(); | ||
| if (!receiptPath || receiptPath.startsWith("--")) | ||
| argumentError("a receipt path is required"); | ||
| const result = { | ||
| receiptPath, | ||
| requireFresh: false, | ||
| allowHttpLoopback: false, | ||
| compact: false, | ||
| }; | ||
| while (values.length > 0) { | ||
| const flag = values.shift(); | ||
| if (flag === "--require-fresh") | ||
| result.requireFresh = true; | ||
| else if (flag === "--allow-http-loopback") | ||
| result.allowHttpLoopback = true; | ||
| else if (flag === "--compact") | ||
| result.compact = true; | ||
| else if (flag === "--keyset" || flag === "--key-url" || flag === "--at") { | ||
| const value = values.shift(); | ||
| if (!value || value.startsWith("--")) | ||
| argumentError(`${flag} requires a value`); | ||
| if (flag === "--keyset") | ||
| result.keysetPath = value; | ||
| else if (flag === "--key-url") | ||
| result.keyUrl = value; | ||
| else | ||
| result.at = value; | ||
| } | ||
| else | ||
| argumentError(`unknown option ${flag}`); | ||
| } | ||
| if ((result.keysetPath ? 1 : 0) + (result.keyUrl ? 1 : 0) !== 1) { | ||
| argumentError("choose exactly one of --keyset or --key-url"); | ||
| } | ||
| return result; | ||
| } | ||
| async function boundedFile(path, maximumBytes) { | ||
| const metadata = await stat(path); | ||
| if (!metadata.isFile()) | ||
| throw new Error(`${path} is not a regular file`); | ||
| if (metadata.size > maximumBytes) | ||
| throw new Error(`${path} exceeds the ${maximumBytes}-byte limit`); | ||
| return readFile(path, "utf8"); | ||
| } | ||
| async function boundedStdin(maximumBytes) { | ||
| const chunks = []; | ||
| let total = 0; | ||
| for await (const chunk of process.stdin) { | ||
| const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); | ||
| total += bytes.byteLength; | ||
| if (total > maximumBytes) | ||
| throw new Error(`stdin exceeds the ${maximumBytes}-byte limit`); | ||
| chunks.push(bytes); | ||
| } | ||
| return Buffer.concat(chunks).toString("utf8"); | ||
| } | ||
| function signatureKeyId(receipt) { | ||
| if (receipt === null || | ||
| typeof receipt !== "object" || | ||
| Array.isArray(receipt) || | ||
| !("signature" in receipt)) { | ||
| throw new Error("receipt signature is missing"); | ||
| } | ||
| const signature = receipt["signature"]; | ||
| if (signature === null || | ||
| typeof signature !== "object" || | ||
| Array.isArray(signature) || | ||
| typeof signature["key_id"] !== "string") { | ||
| throw new Error("receipt signature key_id is missing"); | ||
| } | ||
| return signature["key_id"]; | ||
| } | ||
| function print(value, compact) { | ||
| process.stdout.write(`${JSON.stringify(value, null, compact ? undefined : 2)}\n`); | ||
| } | ||
| async function main() { | ||
| const args = parseArguments(process.argv.slice(2)); | ||
| if (!args) { | ||
| process.stdout.write(HELP); | ||
| return; | ||
| } | ||
| const receiptText = args.receiptPath === "-" | ||
| ? await boundedStdin(MAX_RECEIPT_BYTES) | ||
| : await boundedFile(args.receiptPath, MAX_RECEIPT_BYTES); | ||
| const receipt = extractReceipt(parseJsonStrict(receiptText)); | ||
| const keyId = signatureKeyId(receipt); | ||
| const keyDocument = args.keysetPath !== undefined | ||
| ? parseJsonStrict(await boundedFile(args.keysetPath, MAX_OFFLINE_KEYSET_BYTES)) | ||
| : await discoverVerificationKeyDocument(args.keyUrl, keyId, { | ||
| allow_http_loopback: args.allowHttpLoopback, | ||
| }); | ||
| const result = verifyReceipt(receipt, keyDocument, { | ||
| ...(args.at ? { at: args.at } : {}), | ||
| }); | ||
| print(result, args.compact); | ||
| if (!result.accepted || | ||
| (args.requireFresh && result.freshness.status !== "fresh")) { | ||
| process.exitCode = 1; | ||
| } | ||
| } | ||
| try { | ||
| await main(); | ||
| } | ||
| catch (error) { | ||
| print({ | ||
| contract_version: "1", | ||
| accepted: false, | ||
| error: { | ||
| code: "verification_failed", | ||
| message: error.message, | ||
| }, | ||
| }, process.argv.includes("--compact")); | ||
| process.exitCode = 2; | ||
| } |
| /** | ||
| * Live HTTPS key discovery. This is the ONLY module in the verifier that touches the network, and | ||
| * it is deliberately NOT reachable from the `@usekaval/kaval/verify` entry point — import it from | ||
| * `@usekaval/kaval/verify/discovery` when you want it. Keeping it on its own subpath is what lets | ||
| * `verify` promise an import graph with no network code in it at all, which is the property an | ||
| * offline auditor is relying on. | ||
| */ | ||
| import type { JsonValue } from "./types.js"; | ||
| /** Lives here rather than in `types.ts` so the offline entry point never references `fetch`. */ | ||
| export interface DiscoveryOptions { | ||
| fetch?: typeof globalThis.fetch; | ||
| timeout_ms?: number; | ||
| max_response_bytes?: number; | ||
| allow_http_loopback?: boolean; | ||
| } | ||
| export declare const DEFAULT_DISCOVERY_TIMEOUT_MS = 5000; | ||
| export declare const MAX_DISCOVERY_TIMEOUT_MS = 30000; | ||
| export declare const DEFAULT_MAX_KEY_DOCUMENT_BYTES: number; | ||
| export declare const MAX_KEY_DOCUMENT_BYTES: number; | ||
| /** | ||
| * Fetch a per-key document or keyset with bounded HTTPS I/O and verify that it contains the | ||
| * requested immutable key ID. Redirects are refused so discovery cannot silently change trust roots. | ||
| */ | ||
| export declare function discoverVerificationKeyDocument(rawUrl: string, keyId: string, options?: DiscoveryOptions): Promise<JsonValue>; |
| /** | ||
| * Live HTTPS key discovery. This is the ONLY module in the verifier that touches the network, and | ||
| * it is deliberately NOT reachable from the `@usekaval/kaval/verify` entry point — import it from | ||
| * `@usekaval/kaval/verify/discovery` when you want it. Keeping it on its own subpath is what lets | ||
| * `verify` promise an import graph with no network code in it at all, which is the property an | ||
| * offline auditor is relying on. | ||
| */ | ||
| import { parseJsonStrict } from "./canonicalize.js"; | ||
| import { verificationKeyFromDocument } from "./key-document.js"; | ||
| export const DEFAULT_DISCOVERY_TIMEOUT_MS = 5_000; | ||
| export const MAX_DISCOVERY_TIMEOUT_MS = 30_000; | ||
| export const DEFAULT_MAX_KEY_DOCUMENT_BYTES = 256 * 1_024; | ||
| export const MAX_KEY_DOCUMENT_BYTES = 1_024 * 1_024; | ||
| function loopback(hostname) { | ||
| const host = hostname.toLowerCase(); | ||
| return (host === "localhost" || | ||
| host === "[::1]" || | ||
| host === "::1" || | ||
| /^127(?:\.[0-9]{1,3}){3}$/u.test(host)); | ||
| } | ||
| function discoveryUrl(raw, allowHttpLoopback) { | ||
| let url; | ||
| try { | ||
| url = new URL(raw); | ||
| } | ||
| catch { | ||
| throw new Error("key discovery URL is invalid"); | ||
| } | ||
| if (url.username || url.password) | ||
| throw new Error("key discovery URL must not contain credentials"); | ||
| if (url.hash) | ||
| throw new Error("key discovery URL must not contain a fragment"); | ||
| if (url.protocol !== "https:" && | ||
| !(allowHttpLoopback && url.protocol === "http:" && loopback(url.hostname))) { | ||
| throw new Error("key discovery requires HTTPS"); | ||
| } | ||
| return url; | ||
| } | ||
| async function boundedBody(response, maximumBytes) { | ||
| const declared = response.headers.get("content-length"); | ||
| if (declared !== null) { | ||
| const length = Number(declared); | ||
| if (!Number.isSafeInteger(length) || length < 0 || length > maximumBytes) { | ||
| throw new Error("key discovery response exceeds the byte limit"); | ||
| } | ||
| } | ||
| if (!response.body) | ||
| return new Uint8Array(); | ||
| const reader = response.body.getReader(); | ||
| const chunks = []; | ||
| let total = 0; | ||
| try { | ||
| while (true) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) | ||
| break; | ||
| total += value.byteLength; | ||
| if (total > maximumBytes) { | ||
| await reader.cancel(); | ||
| throw new Error("key discovery response exceeds the byte limit"); | ||
| } | ||
| chunks.push(value); | ||
| } | ||
| } | ||
| finally { | ||
| reader.releaseLock(); | ||
| } | ||
| const output = new Uint8Array(total); | ||
| let offset = 0; | ||
| for (const chunk of chunks) { | ||
| output.set(chunk, offset); | ||
| offset += chunk.byteLength; | ||
| } | ||
| return output; | ||
| } | ||
| /** | ||
| * Fetch a per-key document or keyset with bounded HTTPS I/O and verify that it contains the | ||
| * requested immutable key ID. Redirects are refused so discovery cannot silently change trust roots. | ||
| */ | ||
| export async function discoverVerificationKeyDocument(rawUrl, keyId, options = {}) { | ||
| const timeoutMs = options.timeout_ms ?? DEFAULT_DISCOVERY_TIMEOUT_MS; | ||
| const maximumBytes = options.max_response_bytes ?? DEFAULT_MAX_KEY_DOCUMENT_BYTES; | ||
| if (!Number.isInteger(timeoutMs) || | ||
| timeoutMs < 100 || | ||
| timeoutMs > MAX_DISCOVERY_TIMEOUT_MS) { | ||
| throw new Error(`discovery timeout_ms must be between 100 and ${MAX_DISCOVERY_TIMEOUT_MS}`); | ||
| } | ||
| if (!Number.isInteger(maximumBytes) || | ||
| maximumBytes < 1_024 || | ||
| maximumBytes > MAX_KEY_DOCUMENT_BYTES) { | ||
| throw new Error(`discovery max_response_bytes must be between 1024 and ${MAX_KEY_DOCUMENT_BYTES}`); | ||
| } | ||
| if (typeof keyId !== "string" || !keyId.trim() || keyId.length > 128) { | ||
| throw new Error("discovery key ID is invalid"); | ||
| } | ||
| const url = discoveryUrl(rawUrl, options.allow_http_loopback === true); | ||
| const fetchImpl = options.fetch ?? globalThis.fetch; | ||
| if (typeof fetchImpl !== "function") | ||
| throw new Error("this runtime does not provide fetch"); | ||
| const controller = new AbortController(); | ||
| const timer = setTimeout(() => controller.abort(new Error("key discovery timed out")), timeoutMs); | ||
| timer.unref?.(); | ||
| try { | ||
| const response = await fetchImpl(url, { | ||
| method: "GET", | ||
| headers: { | ||
| Accept: "application/json", | ||
| "User-Agent": "@usekaval/kaval-receipt-verify/1", | ||
| }, | ||
| redirect: "manual", | ||
| signal: controller.signal, | ||
| }); | ||
| if (response.status >= 300 && response.status < 400) { | ||
| throw new Error("key discovery redirects are not permitted"); | ||
| } | ||
| if (response.status !== 200) { | ||
| throw new Error(`key discovery returned HTTP ${response.status}`); | ||
| } | ||
| const contentType = response.headers | ||
| .get("content-type") | ||
| ?.split(";", 1)[0] | ||
| ?.trim() | ||
| .toLowerCase(); | ||
| if (contentType !== undefined && | ||
| contentType !== "" && | ||
| contentType !== "application/json" && | ||
| !contentType.endsWith("+json")) { | ||
| throw new Error(`key discovery returned unsupported content type ${contentType}`); | ||
| } | ||
| const bytes = await boundedBody(response, maximumBytes); | ||
| let json; | ||
| try { | ||
| json = parseJsonStrict(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); | ||
| } | ||
| catch (error) { | ||
| throw new Error(`key discovery returned invalid JSON: ${error.message}`); | ||
| } | ||
| if (!verificationKeyFromDocument(json, keyId)) { | ||
| throw new Error(`key discovery response does not contain requested key ID ${keyId}`); | ||
| } | ||
| return json; | ||
| } | ||
| finally { | ||
| clearTimeout(timer); | ||
| } | ||
| } |
| /** | ||
| * `@usekaval/kaval/verify` — the offline receipt verifier. | ||
| * | ||
| * Nothing reachable from this entry point performs I/O of any kind: no `fetch`, no `node:http`, | ||
| * no `node:https`, no sockets. It reads a receipt and a key document you already hold and answers | ||
| * three separate questions — is the Ed25519 signature over the exact canonical bytes, is the key | ||
| * trusted, and is the receipt fresh at the instant you name. That is the whole point of the | ||
| * subpath, and `test/verify/no-network.test.ts` holds the import graph to it. | ||
| * | ||
| * Live HTTPS key discovery lives on `@usekaval/kaval/verify/discovery`, one import away, so that | ||
| * choosing it is explicit. | ||
| */ | ||
| export { canonicalUnsignedReceiptBytes, canonicalUnsignedReceiptJson, MAX_JSON_NUMBER_CHARACTERS, parseJsonStrict, stableCanonicalJson, } from "./canonicalize.js"; | ||
| export { parseVerificationKey, verificationKeyFromDocument, } from "./key-document.js"; | ||
| export { isRfc3339Timestamp, parseRfc3339Instant, rfc3339TimestampMilliseconds, rfc3339TimestampNanoseconds, type Rfc3339Instant, } from "./rfc3339.js"; | ||
| export { KAVAL_CANONICALIZATION, type FreshnessStatus, type JsonValue, type KeyLifecycle, type KeyLifecycleStatus, type VerificationKey, type VerificationResult, type VerifyOptions, } from "./types.js"; | ||
| export { extractReceipt, verifyReceipt, verifyReceiptText } from "./verify.js"; |
| /** | ||
| * `@usekaval/kaval/verify` — the offline receipt verifier. | ||
| * | ||
| * Nothing reachable from this entry point performs I/O of any kind: no `fetch`, no `node:http`, | ||
| * no `node:https`, no sockets. It reads a receipt and a key document you already hold and answers | ||
| * three separate questions — is the Ed25519 signature over the exact canonical bytes, is the key | ||
| * trusted, and is the receipt fresh at the instant you name. That is the whole point of the | ||
| * subpath, and `test/verify/no-network.test.ts` holds the import graph to it. | ||
| * | ||
| * Live HTTPS key discovery lives on `@usekaval/kaval/verify/discovery`, one import away, so that | ||
| * choosing it is explicit. | ||
| */ | ||
| export { canonicalUnsignedReceiptBytes, canonicalUnsignedReceiptJson, MAX_JSON_NUMBER_CHARACTERS, parseJsonStrict, stableCanonicalJson, } from "./canonicalize.js"; | ||
| export { parseVerificationKey, verificationKeyFromDocument, } from "./key-document.js"; | ||
| export { isRfc3339Timestamp, parseRfc3339Instant, rfc3339TimestampMilliseconds, rfc3339TimestampNanoseconds, } from "./rfc3339.js"; | ||
| export { KAVAL_CANONICALIZATION, } from "./types.js"; | ||
| export { extractReceipt, verifyReceipt, verifyReceiptText } from "./verify.js"; |
| import type { VerificationKey } from "./types.js"; | ||
| export declare function parseVerificationKey(value: unknown, inheritedCanonicalization?: string): VerificationKey; | ||
| /** Resolve one immutable key ID from a per-key document or a versioned keyset. */ | ||
| export declare function verificationKeyFromDocument(document: unknown, keyId: string): VerificationKey | null; | ||
| export declare function decodeCanonicalBase64Url(value: unknown, length: number, label: string): Buffer; |
| import { isRfc3339Timestamp } from "./rfc3339.js"; | ||
| function record(value) { | ||
| return value !== null && typeof value === "object" && !Array.isArray(value) | ||
| ? value | ||
| : null; | ||
| } | ||
| function canonicalBase64Url(value, length, label) { | ||
| if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/u.test(value)) { | ||
| throw new Error(`${label} must be unpadded base64url`); | ||
| } | ||
| const decoded = Buffer.from(value, "base64url"); | ||
| if (decoded.byteLength !== length || | ||
| decoded.toString("base64url") !== value) { | ||
| throw new Error(`${label} must be canonical base64url encoding exactly ${length} bytes`); | ||
| } | ||
| return value; | ||
| } | ||
| function isoTimestamp(value, label) { | ||
| if (value === undefined) | ||
| return undefined; | ||
| if (!isRfc3339Timestamp(value)) { | ||
| throw new Error(`${label} must be a component-valid RFC 3339 timestamp`); | ||
| } | ||
| return value; | ||
| } | ||
| function parseLifecycle(value) { | ||
| if (value === undefined) | ||
| return undefined; | ||
| const input = record(value); | ||
| if (!input) | ||
| throw new Error("key lifecycle must be an object"); | ||
| const allowed = new Set(["status", "status_changed_at", "reason"]); | ||
| if (Object.keys(input).some((key) => !allowed.has(key))) { | ||
| throw new Error("key lifecycle contains an unknown field"); | ||
| } | ||
| const statuses = new Set(["active", "retired", "revoked", "compromised"]); | ||
| if (typeof input["status"] !== "string" || !statuses.has(input["status"])) { | ||
| throw new Error("key lifecycle status is invalid"); | ||
| } | ||
| const status = input["status"]; | ||
| const statusChangedAt = isoTimestamp(input["status_changed_at"], "key lifecycle status_changed_at"); | ||
| const reason = input["reason"]; | ||
| if (reason !== undefined && | ||
| (typeof reason !== "string" || !reason.trim() || reason.length > 1_000)) { | ||
| throw new Error("key lifecycle reason must be a non-empty string at most 1000 characters"); | ||
| } | ||
| if ((status === "revoked" || status === "compromised") && | ||
| (statusChangedAt === undefined || typeof reason !== "string")) { | ||
| throw new Error(`${status} keys require status_changed_at and reason`); | ||
| } | ||
| return { | ||
| status, | ||
| ...(statusChangedAt === undefined | ||
| ? {} | ||
| : { status_changed_at: statusChangedAt }), | ||
| ...(typeof reason === "string" ? { reason } : {}), | ||
| }; | ||
| } | ||
| export function parseVerificationKey(value, inheritedCanonicalization) { | ||
| const input = record(value); | ||
| if (!input) | ||
| throw new Error("verification key must be an object"); | ||
| if (input["contract_version"] !== "1") { | ||
| throw new Error("verification key contract_version must be '1'"); | ||
| } | ||
| const keyId = input["key_id"]; | ||
| if (typeof keyId !== "string" || !keyId.trim() || keyId.length > 128) { | ||
| throw new Error("verification key key_id must be 1 to 128 characters"); | ||
| } | ||
| if (input["algorithm"] !== "Ed25519" || | ||
| input["use"] !== "proof_verification") { | ||
| throw new Error("verification key algorithm/use is not Ed25519 proof verification"); | ||
| } | ||
| const publicKey = record(input["public_key"]); | ||
| if (!publicKey || | ||
| publicKey["format"] !== "jwk" || | ||
| publicKey["kty"] !== "OKP" || | ||
| publicKey["crv"] !== "Ed25519" || | ||
| Object.hasOwn(publicKey, "d")) { | ||
| throw new Error("verification key must contain a public Ed25519 OKP JWK"); | ||
| } | ||
| const x = canonicalBase64Url(publicKey["x"], 32, "verification key x"); | ||
| const canonicalization = input["canonicalization"] ?? inheritedCanonicalization; | ||
| if (canonicalization !== undefined && typeof canonicalization !== "string") { | ||
| throw new Error("verification key canonicalization must be a string"); | ||
| } | ||
| return { | ||
| contract_version: "1", | ||
| key_id: keyId, | ||
| algorithm: "Ed25519", | ||
| use: "proof_verification", | ||
| ...(typeof canonicalization === "string" ? { canonicalization } : {}), | ||
| public_key: { | ||
| format: "jwk", | ||
| kty: "OKP", | ||
| crv: "Ed25519", | ||
| x, | ||
| }, | ||
| ...(input["lifecycle"] === undefined | ||
| ? {} | ||
| : { lifecycle: parseLifecycle(input["lifecycle"]) }), | ||
| }; | ||
| } | ||
| function entriesFromDocument(document) { | ||
| const input = record(document); | ||
| if (!input) | ||
| throw new Error("key document must be an object"); | ||
| if (Object.hasOwn(input, "key")) | ||
| return { entries: [input["key"]] }; | ||
| const keysetWrapper = Object.hasOwn(input, "keyset") | ||
| ? record(input["keyset"]) | ||
| : input; | ||
| if (!keysetWrapper || !Array.isArray(keysetWrapper["keys"])) { | ||
| return { entries: [input] }; | ||
| } | ||
| const canonicalization = keysetWrapper["canonicalization"]; | ||
| if (canonicalization !== undefined && typeof canonicalization !== "string") { | ||
| throw new Error("keyset canonicalization must be a string"); | ||
| } | ||
| return { | ||
| entries: keysetWrapper["keys"], | ||
| ...(typeof canonicalization === "string" | ||
| ? { inheritedCanonicalization: canonicalization } | ||
| : {}), | ||
| }; | ||
| } | ||
| /** Resolve one immutable key ID from a per-key document or a versioned keyset. */ | ||
| export function verificationKeyFromDocument(document, keyId) { | ||
| const { entries, inheritedCanonicalization } = entriesFromDocument(document); | ||
| let match = null; | ||
| const keyIdsByPublicKey = new Map(); | ||
| for (const entry of entries) { | ||
| const parsed = parseVerificationKey(entry, inheritedCanonicalization); | ||
| const previousKeyId = keyIdsByPublicKey.get(parsed.public_key.x); | ||
| if (previousKeyId !== undefined && previousKeyId !== parsed.key_id) { | ||
| throw new Error(`key document reuses one Ed25519 public key under ${previousKeyId} and ${parsed.key_id}`); | ||
| } | ||
| keyIdsByPublicKey.set(parsed.public_key.x, parsed.key_id); | ||
| if (parsed.key_id !== keyId) | ||
| continue; | ||
| if (match) | ||
| throw new Error(`key document contains duplicate key_id ${keyId}`); | ||
| match = parsed; | ||
| } | ||
| return match; | ||
| } | ||
| export function decodeCanonicalBase64Url(value, length, label) { | ||
| return Buffer.from(canonicalBase64Url(value, length, label), "base64url"); | ||
| } |
| export interface Rfc3339Instant { | ||
| epoch_milliseconds: number; | ||
| epoch_nanoseconds: bigint; | ||
| } | ||
| /** | ||
| * Parse a component-valid RFC 3339 instant without discarding sub-millisecond precision. | ||
| * | ||
| * This v1 profile accepts four-digit Gregorian years, seconds 00-59, one to nine fractional | ||
| * digits, `Z` or a numeric offset, and rejects RFC 3339's `-00:00` unknown-local-offset marker. | ||
| */ | ||
| export declare function parseRfc3339Instant(value: unknown): Rfc3339Instant | null; | ||
| /** Millisecond projection for Date interoperability. Exact comparisons must use nanoseconds. */ | ||
| export declare function rfc3339TimestampMilliseconds(value: unknown): number | null; | ||
| export declare function rfc3339TimestampNanoseconds(value: unknown): bigint | null; | ||
| export declare function isRfc3339Timestamp(value: unknown): value is string; |
| const RFC3339_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/u; | ||
| function leapYear(year) { | ||
| return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); | ||
| } | ||
| function daysInMonth(year, month) { | ||
| if (month === 2) | ||
| return leapYear(year) ? 29 : 28; | ||
| return [4, 6, 9, 11].includes(month) ? 30 : 31; | ||
| } | ||
| /** | ||
| * Parse a component-valid RFC 3339 instant without discarding sub-millisecond precision. | ||
| * | ||
| * This v1 profile accepts four-digit Gregorian years, seconds 00-59, one to nine fractional | ||
| * digits, `Z` or a numeric offset, and rejects RFC 3339's `-00:00` unknown-local-offset marker. | ||
| */ | ||
| export function parseRfc3339Instant(value) { | ||
| if (typeof value !== "string") | ||
| return null; | ||
| const match = RFC3339_TIMESTAMP.exec(value); | ||
| if (!match) | ||
| return null; | ||
| const year = Number(match[1]); | ||
| const month = Number(match[2]); | ||
| const day = Number(match[3]); | ||
| const hour = Number(match[4]); | ||
| const minute = Number(match[5]); | ||
| const second = Number(match[6]); | ||
| const fractionalNanoseconds = BigInt((match[7] ?? "").padEnd(9, "0") || "0"); | ||
| const zone = match[8]; | ||
| const offsetSign = match[9]; | ||
| const offsetHour = Number(match[10] ?? "0"); | ||
| const offsetMinute = Number(match[11] ?? "0"); | ||
| if (month < 1 || | ||
| month > 12 || | ||
| day < 1 || | ||
| day > daysInMonth(year, month) || | ||
| hour > 23 || | ||
| minute > 59 || | ||
| second > 59 || | ||
| offsetHour > 23 || | ||
| offsetMinute > 59 || | ||
| zone === "-00:00") { | ||
| return null; | ||
| } | ||
| // Date.UTC treats years 0-99 as 1900-1999. Setting the full year explicitly avoids that | ||
| // historical constructor behavior while retaining the full four-digit RFC 3339 range. | ||
| const utc = new Date(0); | ||
| utc.setUTCFullYear(year, month - 1, day); | ||
| utc.setUTCHours(hour, minute, second, 0); | ||
| const offset = zone === "Z" | ||
| ? 0 | ||
| : (offsetSign === "-" ? -1 : 1) * | ||
| (offsetHour * 60 + offsetMinute) * | ||
| 60_000; | ||
| const epochSecondMilliseconds = utc.getTime() - offset; | ||
| if (!Number.isFinite(epochSecondMilliseconds)) | ||
| return null; | ||
| return { | ||
| epoch_milliseconds: epochSecondMilliseconds + Number(fractionalNanoseconds / 1000000n), | ||
| epoch_nanoseconds: BigInt(epochSecondMilliseconds) * 1000000n + fractionalNanoseconds, | ||
| }; | ||
| } | ||
| /** Millisecond projection for Date interoperability. Exact comparisons must use nanoseconds. */ | ||
| export function rfc3339TimestampMilliseconds(value) { | ||
| return parseRfc3339Instant(value)?.epoch_milliseconds ?? null; | ||
| } | ||
| export function rfc3339TimestampNanoseconds(value) { | ||
| return parseRfc3339Instant(value)?.epoch_nanoseconds ?? null; | ||
| } | ||
| export function isRfc3339Timestamp(value) { | ||
| return parseRfc3339Instant(value) !== null; | ||
| } |
| export declare const KAVAL_CANONICALIZATION: "kaval-stable-json-v1"; | ||
| export type JsonPrimitive = null | boolean | number | string; | ||
| export type JsonValue = JsonPrimitive | JsonValue[] | { | ||
| [key: string]: JsonValue; | ||
| }; | ||
| export type KeyLifecycleStatus = "active" | "retired" | "revoked" | "compromised" | "unknown"; | ||
| export interface KeyLifecycle { | ||
| status: Exclude<KeyLifecycleStatus, "unknown">; | ||
| status_changed_at?: string; | ||
| reason?: string; | ||
| } | ||
| export interface VerificationKey { | ||
| contract_version: "1"; | ||
| key_id: string; | ||
| algorithm: "Ed25519"; | ||
| use: "proof_verification"; | ||
| canonicalization?: string; | ||
| public_key: { | ||
| format: "jwk"; | ||
| kty: "OKP"; | ||
| crv: "Ed25519"; | ||
| x: string; | ||
| }; | ||
| lifecycle?: KeyLifecycle; | ||
| } | ||
| export type FreshnessStatus = "fresh" | "recheck_due" | "expired" | "not_yet_issued" | "unknown"; | ||
| export interface VerificationResult { | ||
| contract_version: "1"; | ||
| scope: "signature_envelope"; | ||
| accepted: boolean; | ||
| format: { | ||
| valid: boolean; | ||
| error?: string; | ||
| }; | ||
| receipt: { | ||
| proof_id?: string; | ||
| algorithm?: string; | ||
| key_id?: string; | ||
| }; | ||
| canonicalization: { | ||
| algorithm: typeof KAVAL_CANONICALIZATION; | ||
| valid: boolean; | ||
| byte_length?: number; | ||
| sha256?: string; | ||
| error?: string; | ||
| }; | ||
| cryptographic: { | ||
| valid: boolean; | ||
| error?: string; | ||
| }; | ||
| key: { | ||
| found: boolean; | ||
| key_id?: string; | ||
| lifecycle_status: KeyLifecycleStatus; | ||
| canonicalization?: string; | ||
| trusted: boolean; | ||
| reason: string; | ||
| status_changed_at?: string; | ||
| }; | ||
| freshness: { | ||
| status: FreshnessStatus; | ||
| evaluated_at: string; | ||
| issued_at?: string; | ||
| recheck_at?: string; | ||
| expires_at?: string; | ||
| reason?: string; | ||
| }; | ||
| } | ||
| export interface VerifyOptions { | ||
| at?: Date | number | string; | ||
| } |
| export const KAVAL_CANONICALIZATION = "kaval-stable-json-v1"; |
| import { type VerificationResult, type VerifyOptions } from "./types.js"; | ||
| /** | ||
| * Accept either a bare ProofPacket or Kaval's public shared-receipt response (`run.packet`). | ||
| * Ambiguous wrappers fail closed instead of guessing which signed object the caller intended. | ||
| */ | ||
| export declare function extractReceipt(document: unknown): unknown; | ||
| /** | ||
| * Verify the exact Ed25519 signature bytes and evaluate key trust/freshness independently. | ||
| * | ||
| * This intentionally validates only the signature envelope. Full ProofPacket schema validation is | ||
| * the issuer/application's job and must not be confused with cryptographic verification. | ||
| */ | ||
| export declare function verifyReceipt(receiptValue: unknown, keyDocument: unknown, options?: VerifyOptions): VerificationResult; | ||
| export declare function verifyReceiptText(receiptJson: string, keyDocumentJson: string, options?: VerifyOptions): VerificationResult; |
| import { createHash, createPublicKey, verify as verifySignature, } from "node:crypto"; | ||
| import { canonicalUnsignedReceiptBytes, parseJsonStrict, } from "./canonicalize.js"; | ||
| import { decodeCanonicalBase64Url, verificationKeyFromDocument, } from "./key-document.js"; | ||
| import { parseRfc3339Instant } from "./rfc3339.js"; | ||
| import { KAVAL_CANONICALIZATION, } from "./types.js"; | ||
| function record(value) { | ||
| return value !== null && typeof value === "object" && !Array.isArray(value) | ||
| ? value | ||
| : null; | ||
| } | ||
| /** | ||
| * Accept either a bare ProofPacket or Kaval's public shared-receipt response (`run.packet`). | ||
| * Ambiguous wrappers fail closed instead of guessing which signed object the caller intended. | ||
| */ | ||
| export function extractReceipt(document) { | ||
| const input = record(document); | ||
| if (!input) | ||
| throw new Error("receipt document must be a JSON object"); | ||
| if (Object.hasOwn(input, "signature")) | ||
| return input; | ||
| const directPacket = record(input["packet"]); | ||
| const runPacket = record(record(input["run"])?.["packet"]); | ||
| const candidates = [directPacket, runPacket].filter((candidate) => candidate !== null); | ||
| if (candidates.length !== 1) { | ||
| throw new Error("receipt document must contain one bare receipt or exactly one packet wrapper"); | ||
| } | ||
| return candidates[0]; | ||
| } | ||
| /** | ||
| * The signature block Kaval issues, in both shapes a holder can legitimately be handed. | ||
| * | ||
| * A `ProofPacket` seals `{algorithm, key_id, signature}`. A `/v1/check` receipt adds `signed_at`. | ||
| * | ||
| * `signed_at` is NOT in the signed bytes — the canonicalizer strips the whole `signature` key, so | ||
| * nothing inside the block can be. It is authenticated indirectly instead, by being required to | ||
| * equal the receipt's `checked_at`, which is signed. That check is below; without it the field was | ||
| * freely rewritable on an otherwise-valid receipt. | ||
| * | ||
| * This is an ALLOWLIST, not a relaxation — the block is still closed, so an unrecognised member (the | ||
| * classic downgrade vector: an attacker appending an `algorithm`-shadowing or `key_id`-shadowing | ||
| * field a lax verifier might read) is still rejected. | ||
| */ | ||
| const REQUIRED_SIGNATURE_FIELDS = ["algorithm", "key_id", "signature"]; | ||
| const OPTIONAL_SIGNATURE_FIELDS = ["signed_at"]; | ||
| function signatureFieldSetIsValid(signature) { | ||
| if (REQUIRED_SIGNATURE_FIELDS.some((field) => !Object.hasOwn(signature, field))) | ||
| return false; | ||
| const permitted = new Set([ | ||
| ...REQUIRED_SIGNATURE_FIELDS, | ||
| ...OPTIONAL_SIGNATURE_FIELDS, | ||
| ]); | ||
| return Object.keys(signature).every((field) => permitted.has(field)); | ||
| } | ||
| function evaluatedAt(value) { | ||
| if (typeof value === "string") { | ||
| const parsed = parseRfc3339Instant(value); | ||
| if (parsed === null) | ||
| throw new Error("verification time is invalid"); | ||
| return { nanoseconds: parsed.epoch_nanoseconds, iso: value }; | ||
| } | ||
| const milliseconds = value === undefined | ||
| ? Date.now() | ||
| : value instanceof Date | ||
| ? value.getTime() | ||
| : value; | ||
| if (!Number.isFinite(milliseconds)) | ||
| throw new Error("verification time is invalid"); | ||
| const date = new Date(milliseconds); | ||
| const exactMilliseconds = date.getTime(); | ||
| if (!Number.isFinite(exactMilliseconds)) | ||
| throw new Error("verification time is invalid"); | ||
| return { | ||
| nanoseconds: BigInt(exactMilliseconds) * 1000000n, | ||
| iso: date.toISOString(), | ||
| }; | ||
| } | ||
| function timestamp(value) { | ||
| const parsed = parseRfc3339Instant(value); | ||
| return typeof value === "string" && parsed !== null | ||
| ? { value, nanoseconds: parsed.epoch_nanoseconds } | ||
| : null; | ||
| } | ||
| function freshness(receipt, at) { | ||
| const expiry = record(receipt["expiry"]); | ||
| const issuedAt = timestamp(expiry?.["issued_at"]); | ||
| const recheckAt = timestamp(expiry?.["recheck_at"]); | ||
| const expiresAt = timestamp(expiry?.["expires_at"]); | ||
| if (issuedAt === null || recheckAt === null || expiresAt === null) { | ||
| return { | ||
| status: "unknown", | ||
| evaluated_at: at.iso, | ||
| reason: "receipt expiry timestamps are missing or malformed", | ||
| }; | ||
| } | ||
| const issued = issuedAt.nanoseconds; | ||
| const recheck = recheckAt.nanoseconds; | ||
| const expires = expiresAt.nanoseconds; | ||
| if (recheck < issued || recheck >= expires || expires <= issued) { | ||
| return { | ||
| status: "unknown", | ||
| evaluated_at: at.iso, | ||
| issued_at: issuedAt.value, | ||
| recheck_at: recheckAt.value, | ||
| expires_at: expiresAt.value, | ||
| reason: "receipt expiry timestamps have an invalid ordering", | ||
| }; | ||
| } | ||
| let status; | ||
| if (at.nanoseconds < issued) | ||
| status = "not_yet_issued"; | ||
| else if (at.nanoseconds >= expires) | ||
| status = "expired"; | ||
| else if (at.nanoseconds >= recheck) | ||
| status = "recheck_due"; | ||
| else | ||
| status = "fresh"; | ||
| return { | ||
| status, | ||
| evaluated_at: at.iso, | ||
| issued_at: issuedAt.value, | ||
| recheck_at: recheckAt.value, | ||
| expires_at: expiresAt.value, | ||
| }; | ||
| } | ||
| function malformedResult(receipt, at, error) { | ||
| return { | ||
| contract_version: "1", | ||
| scope: "signature_envelope", | ||
| accepted: false, | ||
| format: { valid: false, error }, | ||
| receipt: { | ||
| ...(typeof receipt?.["proof_id"] === "string" | ||
| ? { proof_id: receipt["proof_id"] } | ||
| : {}), | ||
| }, | ||
| canonicalization: { | ||
| algorithm: KAVAL_CANONICALIZATION, | ||
| valid: false, | ||
| error: "receipt format is not canonicalizable", | ||
| }, | ||
| cryptographic: { valid: false, error: "receipt format is invalid" }, | ||
| key: { | ||
| found: false, | ||
| lifecycle_status: "unknown", | ||
| trusted: false, | ||
| reason: "no valid signature key ID was available", | ||
| }, | ||
| freshness: receipt | ||
| ? freshness(receipt, at) | ||
| : { status: "unknown", evaluated_at: at.iso }, | ||
| }; | ||
| } | ||
| /** | ||
| * Verify the exact Ed25519 signature bytes and evaluate key trust/freshness independently. | ||
| * | ||
| * This intentionally validates only the signature envelope. Full ProofPacket schema validation is | ||
| * the issuer/application's job and must not be confused with cryptographic verification. | ||
| */ | ||
| export function verifyReceipt(receiptValue, keyDocument, options = {}) { | ||
| const at = evaluatedAt(options.at); | ||
| const receipt = record(receiptValue); | ||
| if (!receipt) | ||
| return malformedResult(null, at, "receipt must be a JSON object"); | ||
| const signature = record(receipt["signature"]); | ||
| if (!signature) | ||
| return malformedResult(receipt, at, "receipt signature is missing"); | ||
| if (!signatureFieldSetIsValid(signature)) { | ||
| return malformedResult(receipt, at, "receipt signature has an invalid field set"); | ||
| } | ||
| if (Object.hasOwn(signature, "signed_at") && | ||
| timestamp(signature["signed_at"]) === null) { | ||
| return malformedResult(receipt, at, "receipt signature signed_at is not an RFC 3339 instant"); | ||
| } | ||
| /* | ||
| * `signed_at` lives INSIDE the signature block, and the canonicalizer strips that whole block | ||
| * before hashing — so the bytes never covered it. Until this check existed, rewriting `signed_at` | ||
| * to any other instant still returned `accepted: true`, on the one field a holder reads to answer | ||
| * "when was this attested?". | ||
| * | ||
| * Binding it to `checked_at` is what authenticates it, because `checked_at` IS in the signed | ||
| * bytes. The issuer passes one value for both (`pipeline.ts` hands `checkedAt` straight to | ||
| * `signCheckReceipt`), so this is a statement of an existing invariant rather than a new | ||
| * requirement, and every receipt ever issued satisfies it. Tampering now has to move `checked_at` | ||
| * too, which breaks the signature. | ||
| * | ||
| * Scoped to receipts that HAVE both fields: a ProofPacket seals `{algorithm, key_id, signature}` | ||
| * with no `signed_at` and no `checked_at`, and must keep verifying untouched. | ||
| */ | ||
| if (Object.hasOwn(signature, "signed_at") && | ||
| typeof receipt["checked_at"] === "string") { | ||
| if (signature["signed_at"] !== receipt["checked_at"]) { | ||
| return malformedResult(receipt, at, "receipt signature signed_at does not match the signed checked_at"); | ||
| } | ||
| } | ||
| if (signature["algorithm"] !== "Ed25519") { | ||
| return malformedResult(receipt, at, "only Ed25519 receipt signatures are supported"); | ||
| } | ||
| const keyId = signature["key_id"]; | ||
| if (typeof keyId !== "string" || !keyId.trim() || keyId.length > 128) { | ||
| return malformedResult(receipt, at, "receipt signature key_id is invalid"); | ||
| } | ||
| let signatureBytes; | ||
| try { | ||
| signatureBytes = decodeCanonicalBase64Url(signature["signature"], 64, "receipt Ed25519 signature"); | ||
| } | ||
| catch (error) { | ||
| return malformedResult(receipt, at, error.message); | ||
| } | ||
| let canonicalBytes; | ||
| try { | ||
| canonicalBytes = canonicalUnsignedReceiptBytes(receipt); | ||
| } | ||
| catch (error) { | ||
| return malformedResult(receipt, at, error.message); | ||
| } | ||
| const digest = createHash("sha256").update(canonicalBytes).digest("hex"); | ||
| const canonicalization = { | ||
| algorithm: KAVAL_CANONICALIZATION, | ||
| valid: true, | ||
| byte_length: canonicalBytes.byteLength, | ||
| sha256: `sha256:${digest}`, | ||
| }; | ||
| const receiptIdentity = { | ||
| ...(typeof receipt["proof_id"] === "string" | ||
| ? { proof_id: receipt["proof_id"] } | ||
| : {}), | ||
| algorithm: "Ed25519", | ||
| key_id: keyId, | ||
| }; | ||
| let key; | ||
| try { | ||
| key = verificationKeyFromDocument(keyDocument, keyId); | ||
| } | ||
| catch (error) { | ||
| return { | ||
| contract_version: "1", | ||
| scope: "signature_envelope", | ||
| accepted: false, | ||
| format: { valid: true }, | ||
| receipt: receiptIdentity, | ||
| canonicalization, | ||
| cryptographic: { | ||
| valid: false, | ||
| error: `verification key is malformed: ${error.message}`, | ||
| }, | ||
| key: { | ||
| found: false, | ||
| key_id: keyId, | ||
| lifecycle_status: "unknown", | ||
| trusted: false, | ||
| reason: `verification key document is malformed: ${error.message}`, | ||
| }, | ||
| freshness: freshness(receipt, at), | ||
| }; | ||
| } | ||
| if (!key) { | ||
| return { | ||
| contract_version: "1", | ||
| scope: "signature_envelope", | ||
| accepted: false, | ||
| format: { valid: true }, | ||
| receipt: receiptIdentity, | ||
| canonicalization, | ||
| cryptographic: { | ||
| valid: false, | ||
| error: `unknown verification key ${keyId}`, | ||
| }, | ||
| key: { | ||
| found: false, | ||
| key_id: keyId, | ||
| lifecycle_status: "unknown", | ||
| trusted: false, | ||
| reason: "the key document does not contain this immutable key ID", | ||
| }, | ||
| freshness: freshness(receipt, at), | ||
| }; | ||
| } | ||
| let cryptographicallyValid = false; | ||
| let cryptoError; | ||
| try { | ||
| const publicKey = createPublicKey({ | ||
| key: { | ||
| kty: "OKP", | ||
| crv: "Ed25519", | ||
| x: key.public_key.x, | ||
| }, | ||
| format: "jwk", | ||
| }); | ||
| if (publicKey.asymmetricKeyType !== "ed25519") { | ||
| throw new Error("resolved JWK is not an Ed25519 public key"); | ||
| } | ||
| cryptographicallyValid = verifySignature(null, canonicalBytes, publicKey, signatureBytes); | ||
| if (!cryptographicallyValid) | ||
| cryptoError = "Ed25519 signature does not match canonical bytes"; | ||
| } | ||
| catch (error) { | ||
| cryptoError = `Ed25519 verification failed: ${error.message}`; | ||
| } | ||
| const lifecycleStatus = key.lifecycle?.status ?? "unknown"; | ||
| const canonicalizationMatches = key.canonicalization === KAVAL_CANONICALIZATION; | ||
| const lifecycleTrusted = lifecycleStatus === "active" || lifecycleStatus === "retired"; | ||
| const trusted = canonicalizationMatches && lifecycleTrusted; | ||
| let trustReason; | ||
| if (!canonicalizationMatches) { | ||
| trustReason = | ||
| key.canonicalization === undefined | ||
| ? "key does not declare a canonicalization contract" | ||
| : `key declares unsupported canonicalization ${key.canonicalization}`; | ||
| } | ||
| else if (lifecycleStatus === "active") { | ||
| trustReason = "active key"; | ||
| } | ||
| else if (lifecycleStatus === "retired") { | ||
| trustReason = "retired key retained for historical verification"; | ||
| } | ||
| else if (lifecycleStatus === "revoked") { | ||
| trustReason = | ||
| "revoked keys remain cryptographically checkable but are not trusted"; | ||
| } | ||
| else if (lifecycleStatus === "compromised") { | ||
| trustReason = | ||
| "compromised keys remain cryptographically checkable but no self-asserted issuance time is trusted"; | ||
| } | ||
| else { | ||
| trustReason = "key lifecycle is unspecified"; | ||
| } | ||
| return { | ||
| contract_version: "1", | ||
| scope: "signature_envelope", | ||
| accepted: cryptographicallyValid && trusted, | ||
| format: { valid: true }, | ||
| receipt: receiptIdentity, | ||
| canonicalization, | ||
| cryptographic: { | ||
| valid: cryptographicallyValid, | ||
| ...(cryptoError === undefined ? {} : { error: cryptoError }), | ||
| }, | ||
| key: { | ||
| found: true, | ||
| key_id: keyId, | ||
| lifecycle_status: lifecycleStatus, | ||
| ...(key.canonicalization === undefined | ||
| ? {} | ||
| : { canonicalization: key.canonicalization }), | ||
| trusted, | ||
| reason: key.lifecycle?.reason | ||
| ? `${trustReason}: ${key.lifecycle.reason}` | ||
| : trustReason, | ||
| ...(key.lifecycle?.status_changed_at === undefined | ||
| ? {} | ||
| : { status_changed_at: key.lifecycle.status_changed_at }), | ||
| }, | ||
| freshness: freshness(receipt, at), | ||
| }; | ||
| } | ||
| export function verifyReceiptText(receiptJson, keyDocumentJson, options = {}) { | ||
| const at = evaluatedAt(options.at); | ||
| let receipt; | ||
| try { | ||
| receipt = parseJsonStrict(receiptJson); | ||
| } | ||
| catch (error) { | ||
| return malformedResult(null, at, `receipt JSON is invalid: ${error.message}`); | ||
| } | ||
| let keyDocument; | ||
| try { | ||
| keyDocument = parseJsonStrict(keyDocumentJson); | ||
| } | ||
| catch (error) { | ||
| const message = `verification key JSON is invalid: ${error.message}`; | ||
| const result = verifyReceipt(receipt, {}, { at: at.iso }); | ||
| return { | ||
| ...result, | ||
| cryptographic: { valid: false, error: message }, | ||
| key: { | ||
| ...result.key, | ||
| found: false, | ||
| lifecycle_status: "unknown", | ||
| trusted: false, | ||
| reason: message, | ||
| }, | ||
| accepted: false, | ||
| }; | ||
| } | ||
| return verifyReceipt(receipt, keyDocument, { at: at.iso }); | ||
| } |
+144
-179
| /** | ||
| * @usekaval/kaval — before an AI agent acts, Kaval verifies the facts the action relies on and | ||
| * returns a time-bounded signed proof your policy can enforce — ALLOW, REVIEW, or BLOCK. | ||
| * A typed, dependency-light HTTP client for the Kaval API. Mirrors the Python SDK | ||
| * (`pip install kaval`). Uses the global `fetch` (Node 18+, browsers, edge). | ||
| * @usekaval/kaval — before an AI agent acts, Kaval verifies the facts the action depends on and | ||
| * returns ALLOW, REVIEW, or BLOCK with a signed receipt. A typed, dependency-light HTTP client for | ||
| * the Kaval API. Mirrors the Python SDK (`pip install kaval`). Uses the global `fetch` | ||
| * (Node 18+, browsers, edge). | ||
| * | ||
| * One call does the work: `check()`. Register what Kaval should watch with `addSource()`, push your | ||
| * own documents with `sendEvent()`, and subscribe to `fact_state.delta` webhooks with | ||
| * `subscribeFactStateDeltas()` so you are told when a fact flips instead of polling for it. | ||
| */ | ||
| import type { AuditInput, ProofGateInput, ProofGateResult, ProofPacket, VerifyRequest, VerifyResponse } from "./proof.js"; | ||
| import type { AddSourceInput, AddSourceResult, CheckInput, CheckReceipt, CheckResult, CreateWebhookInput, CreateWebhookResult, RecompileSourceResult, RotateWebhookSigningKeyResult, SourceEventInput, SourceEventResult, WatchedSource, WebhookDeliveryPage, WebhookSubscription } from "./check.js"; | ||
| import type { IsoTimestamp, VerifyRequest, VerifyResponse } from "./proof.js"; | ||
| export type * from "./proof.js"; | ||
| export type VerdictStatus = "current" | "stale" | "contradicted" | "unsupported" | "conflicting" | "insufficient"; | ||
| /** Speed/depth tier for a legacy belief-freshness call. */ | ||
| export type VerifyMode = "instant" | "fast" | "auto" | "deep"; | ||
| export interface Evidence { | ||
| /** Canonical source signature (host/path). */ | ||
| source: string; | ||
| url?: string; | ||
| fetched_at: string; | ||
| http_status?: number; | ||
| content_hash?: string; | ||
| extracted: { | ||
| statement: string; | ||
| [k: string]: unknown; | ||
| }; | ||
| authority?: number | string; | ||
| } | ||
| /** A source backing a deep-tier explanation; `[n]` in the content refers to `citations[n-1]`. */ | ||
| export interface Citation { | ||
| url: string; | ||
| title?: string; | ||
| } | ||
| /** The deep tier's cited synthesis: markdown `content` with `[n]` citations + an overall grounding band. */ | ||
| export interface Explanation { | ||
| content: string; | ||
| citations: Citation[]; | ||
| confidence: "high" | "medium" | "low"; | ||
| } | ||
| /** A typed freshness verdict for a belief. */ | ||
| export interface Verdict { | ||
| id: string; | ||
| status: VerdictStatus; | ||
| /** Calibrated 0–1. */ | ||
| confidence: number; | ||
| reason: string; | ||
| /** ISO timestamp — the freshness guarantee. */ | ||
| checked_at: string; | ||
| evidence: Evidence[]; | ||
| /** Present iff `status !== "current"`. */ | ||
| discrepancy?: { | ||
| kind: string; | ||
| [k: string]: unknown; | ||
| }; | ||
| freshness_delta_s?: number; | ||
| /** The tier that produced this verdict (echoes the requested `mode`, default "auto"). */ | ||
| tier?: VerifyMode; | ||
| /** Deep tier only: a cited synthesis explaining the verdict. */ | ||
| explanation?: Explanation; | ||
| } | ||
| /** A verdict plus `act` — true only when the belief is `current` and confident enough to rely on. */ | ||
| export interface Decision extends Verdict { | ||
| act: boolean; | ||
| } | ||
| export interface CheckedBelief extends Verdict { | ||
| belief: string; | ||
| } | ||
| export interface ScanRisk { | ||
| id: string; | ||
| belief?: string; | ||
| status: VerdictStatus; | ||
| confidence: number; | ||
| reason: string; | ||
| source?: string; | ||
| } | ||
| export interface ScanResult { | ||
| total: number; | ||
| summary: Partial<Record<VerdictStatus, number>>; | ||
| /** The beliefs most likely to have drifted, worst first. */ | ||
| riskiest: ScanRisk[]; | ||
| /** The tier the sweep ran at (echoes `input.mode`, default "fast"). Always present. */ | ||
| tier: VerifyMode; | ||
| } | ||
| /** Cross-run memory so a monitor delivers only NEWLY-risky beliefs. Persist it between runs (cron) or | ||
| * pass the previous response's `state` straight back in. */ | ||
| export interface MonitorState { | ||
| riskyKeys: string[]; | ||
| } | ||
| export interface MonitorResult extends ScanResult { | ||
| checked_at: string; | ||
| /** How many newly-risky beliefs were delivered to the webhook. */ | ||
| delivered: number; | ||
| webhookOk?: boolean; | ||
| /** This sweep's risky keys — pass it back as `input.state` next run so a still-stale belief isn't | ||
| * re-delivered every sweep. */ | ||
| state: MonitorState; | ||
| } | ||
| export type * from "./check.js"; | ||
| export { DEFAULT_CHECK_MAX_WAIT_MS, FACT_STATE_DELTA_EVENT_TYPE, MAX_CHECK_MAX_WAIT_MS, MIN_CHECK_MAX_WAIT_MS, } from "./check.js"; | ||
| export type OutcomeKind = "current_later_contradicted" | "stale_caught_real" | "stale_was_false_alarm" | "relied_and_correct"; | ||
| /** LEGACY input for the belief-freshness fallback on /v1/verify. Prefer `VerifyRequest` | ||
| * (a conclusion + evidence_refs) via `verify()` for new integrations. */ | ||
| export interface VerifyBeliefInput { | ||
| belief: string; | ||
| context?: string; | ||
| url?: string; | ||
| held_at?: string; | ||
| held_content_hash?: string; | ||
| held_evidence?: string[]; | ||
| freshness_sla?: string; | ||
| proof_standard?: string; | ||
| /** Act only if confidence ≥ this (default 0.7). */ | ||
| minConfidence?: number; | ||
| /** Speed/depth tier: instant (cache/prior only, no LLM) | fast (cheap model) | auto (default) | | ||
| * deep (strongest model, max accuracy + a cited `explanation`). The response echoes `tier`. */ | ||
| mode?: VerifyMode; | ||
| } | ||
| export interface CheckInput { | ||
| belief: string; | ||
| context?: string; | ||
| held_evidence?: string[]; | ||
| freshness_sla?: string; | ||
| proof_standard?: string; | ||
| } | ||
| export interface ScanInput { | ||
| beliefs: string[]; | ||
| freshness_sla?: string; | ||
| concurrency?: number; | ||
| /** Speed/depth tier for the whole sweep (default "fast"). */ | ||
| mode?: VerifyMode; | ||
| } | ||
| export interface MonitorInput extends ScanInput { | ||
| /** URL that receives a POST with the newly-risky beliefs. */ | ||
| webhook?: string; | ||
| /** Last sweep's risky keys (from the previous response's `state`) → deliver only newly-risky beliefs. */ | ||
| state?: MonitorState; | ||
| } | ||
| /** Thrown on any non-2xx response. */ | ||
@@ -142,8 +27,13 @@ export declare class KavalError extends Error { | ||
| } | ||
| /** Thrown when POST /v1/gate returns HTTP 404 `proof_not_found`: no durable proof matches the | ||
| * supplied `proof_id`/`proof_key` in this workspace. Build one with `audit()` before gating — | ||
| * a missing proof is never a 200 gate state. */ | ||
| export declare class ProofNotFoundError extends KavalError { | ||
| readonly code = "proof_not_found"; | ||
| constructor(payload: unknown, idempotencyKey?: string); | ||
| /** | ||
| * Thrown when the API answers `410 tool_retired`. Every pre-0.6 verification endpoint | ||
| * (`/v1/audit`, `/v1/gate`, `/v1/kaval`, `/v1/scan-store`, `/v1/extract-and-check`, `/v1/monitor`, | ||
| * and the belief routes) collapsed into `POST /v1/check`. Call `check()` instead — the message says | ||
| * so explicitly rather than leaving an agent to guess at an unexplained HTTP error. | ||
| */ | ||
| export declare class KavalRetiredError extends KavalError { | ||
| readonly code = "tool_retired"; | ||
| /** The endpoint that replaced the one you called — always `/v1/check` today. */ | ||
| readonly replacement: string; | ||
| constructor(payload: unknown, path: string, idempotencyKey?: string); | ||
| } | ||
@@ -156,8 +46,9 @@ export interface KavalOptions { | ||
| fetch?: typeof fetch; | ||
| /** Default deadline for each HTTP operation. Defaults to 30 seconds; set null to disable. */ | ||
| /** Default deadline for each HTTP operation. Defaults to 150 seconds; set null to disable. */ | ||
| timeoutMs?: number | null; | ||
| } | ||
| /** Transport options for one billable API operation. Kaval generates a UUID by default. Supply the | ||
| * same key when coordinating a retry outside this client after an ambiguous/no-response failure. */ | ||
| /** Transport options for one API operation. */ | ||
| export interface RequestOptions { | ||
| /** Billable operations only. Kaval generates a UUID by default; supply the same key when | ||
| * coordinating a retry outside this client after an ambiguous/no-response failure. */ | ||
| idempotencyKey?: string; | ||
@@ -169,7 +60,9 @@ /** Cancels the operation and every bounded retry. */ | ||
| } | ||
| export interface KavalBatchOptions extends RequestOptions { | ||
| concurrency?: number; | ||
| } | ||
| /** The Kaval client: build a signed proof with `audit()`, enforce it at act time with `gate()`, | ||
| * or verify one conclusion with `verify()`. */ | ||
| /** | ||
| * The Kaval client. | ||
| * | ||
| * `check()` is the whole product: send the action an agent is about to take (or the claims it | ||
| * rests on) and get ALLOW / REVIEW / BLOCK plus a signed receipt. Everything else configures what | ||
| * Kaval watches so that check stays a warm database read instead of a research run. | ||
| */ | ||
| export declare class Kaval { | ||
@@ -182,33 +75,100 @@ private readonly base; | ||
| private billablePost; | ||
| private post; | ||
| /** Build, sign, and persist a complete action-bound proof packet (the expensive research path). */ | ||
| audit(input: AuditInput, options?: RequestOptions): Promise<ProofPacket>; | ||
| /** Apply a current durable proof to the exact action at act time — no search, parsing, or model | ||
| * call. A missing proof is HTTP 404 `proof_not_found`, thrown as `ProofNotFoundError`. */ | ||
| gate(input: ProofGateInput, options?: RequestOptions): Promise<ProofGateResult>; | ||
| /** Alias for gate(), kept for callers of the previous method name. */ | ||
| gateAction(input: ProofGateInput, options?: RequestOptions): Promise<ProofGateResult>; | ||
| /** Compatibility surface: verify one load-bearing conclusion against its evidence references. | ||
| * Returns `valid` | `invalidated` | `could_not_verify` plus a signed proof receipt. Production | ||
| * actions should build proof with `audit()` and enforce it with `gate()`. */ | ||
| verify(request: VerifyRequest, options?: RequestOptions): Promise<VerifyResponse>; | ||
| /** LEGACY belief-freshness fallback (accepted on the same /v1/verify route): the verdict plus | ||
| * `act`. Treat `act === false` as "re-fetch before relying on it". New integrations should call | ||
| * `verify()` with a conclusion + evidence_refs, or `audit()`/`gate()` for production actions. */ | ||
| verifyBelief(input: string | VerifyBeliefInput, options?: RequestOptions): Promise<Decision>; | ||
| /** Re-ground a held belief → the raw freshness verdict (no act decision). */ | ||
| check(input: string | CheckInput, options?: RequestOptions): Promise<Verdict>; | ||
| /** Pull every factual belief out of a paragraph and check each. */ | ||
| extractAndCheck(input: { | ||
| text: string; | ||
| context?: string; | ||
| freshness_sla?: string; | ||
| }, options?: RequestOptions): Promise<{ | ||
| beliefs: CheckedBelief[]; | ||
| /** One request, no idempotency key. Used by reads and by routes the server treats as reads. */ | ||
| private request; | ||
| /** | ||
| * Verify the facts an action depends on, before acting on it. | ||
| * | ||
| * Send `action` (what the agent is about to do) and optionally `context`, or send `claims` | ||
| * directly when you already know which facts matter. Kaval compiles the action into atomic | ||
| * facts, answers each from watched-source state (warm: no model call, no fetch), falls back to | ||
| * bounded live research for anything stale or novel, and returns: | ||
| * | ||
| * - `decision` — **ALLOW** (every material fact still holds on a fresh basis), **REVIEW** | ||
| * (something is unknown, changed at low/medium materiality, or mid-re-evaluation), or | ||
| * **BLOCK** (a high/critical fact changed, or a critical fact is unknown). | ||
| * - `reason_codes` — why, from a closed eight-code taxonomy. | ||
| * - `facts` — one row per fact with its status and the sources it rests on. | ||
| * - `receipt` — the id + Ed25519 signature of a document that re-derives this verdict offline. | ||
| * | ||
| * Only ALLOW means "safe to act". REVIEW is never permission to act. | ||
| */ | ||
| check(input: CheckInput, options?: RequestOptions): Promise<CheckResult>; | ||
| /** Fetch a signed check receipt exactly as it was signed, by `result.receipt.id`. */ | ||
| getReceipt(receiptId: string, options?: RequestOptions): Promise<CheckReceipt>; | ||
| /** | ||
| * Register something for Kaval to watch. A URL is polled conditionally; an `entity` (a plain | ||
| * name plus what you care about, e.g. `{kind:"entity", name:"Aetna", intent:"payer policy | ||
| * bulletins"}`) is resolved to the URLs that publish it; a `push` source is a document you send | ||
| * to `sendEvent()`. Facts learned from a watched source stay warm, so checks on them are a | ||
| * database read. | ||
| */ | ||
| addSource(input: AddSourceInput, options?: RequestOptions): Promise<AddSourceResult>; | ||
| /** List the watched sources for this workspace, including any auto-discovered by a check. */ | ||
| listSources(options?: RequestOptions & { | ||
| includeInactive?: boolean; | ||
| }): Promise<WatchedSource[]>; | ||
| getSource(sourceId: string, options?: RequestOptions): Promise<WatchedSource>; | ||
| deleteSource(sourceId: string, options?: RequestOptions): Promise<{ | ||
| deleted: true; | ||
| id: string; | ||
| }>; | ||
| /** Sweep a belief store for drift, worst first. */ | ||
| scanStore(input: ScanInput, options?: RequestOptions): Promise<ScanResult>; | ||
| /** Sweep + POST the newly-risky beliefs to a `webhook` (server-side delivery). */ | ||
| monitor(input: MonitorInput, options?: RequestOptions): Promise<MonitorResult>; | ||
| /** Report what actually happened, to calibrate trust over time. */ | ||
| /** | ||
| * Re-derive a source's acquisition plan — how Kaval fetches and parses it. Enqueued rather than | ||
| * compiled inline, so this answers `202` with a `job_id` while the worker does the work. | ||
| * | ||
| * This is the recovery path when a plan breaks (the site moved its content, or the parser stopped | ||
| * matching), and the way to get a plan at all for a source registered directly as `kind: "url"`. | ||
| * Pressing it bypasses the per-source cooldown, which is what a human pressing a button means. | ||
| * `503 discovery_unavailable` means the deployment has no discovery worker configured. | ||
| */ | ||
| recompileSource(sourceId: string, options?: RequestOptions): Promise<RecompileSourceResult>; | ||
| /** Stop polling a source without forgetting it or the facts that depend on it. */ | ||
| pauseSource(sourceId: string, options?: RequestOptions): Promise<WatchedSource>; | ||
| resumeSource(sourceId: string, options?: RequestOptions): Promise<WatchedSource>; | ||
| /** | ||
| * Push a document you own. Kaval stores the version, diffs it against the previous one, marks | ||
| * the dependent facts stale, re-evaluates them in the background, and delivers a | ||
| * `fact_state.delta` webhook naming what flipped. Address the document by `source_id`, or by | ||
| * `namespace` + `document_id` (created on first sight). | ||
| */ | ||
| sendEvent(input: SourceEventInput, options?: RequestOptions): Promise<SourceEventResult>; | ||
| /** | ||
| * Subscribe to `fact_state.delta` — the outbound half of the whole mechanism. Without a | ||
| * subscription the background loops still keep fact state fresh, but nothing tells you a fact | ||
| * flipped until your next `check()`. `external_scope_ids` filters deliveries to the scope keys | ||
| * you care about. The returned `webhook_verification` is the only time the signing secret is | ||
| * shown; store it and verify every inbound delivery with it. | ||
| */ | ||
| subscribeFactStateDeltas(input: { | ||
| callback_url: string; | ||
| } & Omit<CreateWebhookInput, "subscription_kind" | "event_types" | "callback_url">, options?: RequestOptions): Promise<CreateWebhookResult>; | ||
| /** Register any webhook subscription. `POST /v1/webhooks` requires an Idempotency-Key. */ | ||
| createWebhook(input: CreateWebhookInput, options?: RequestOptions): Promise<CreateWebhookResult>; | ||
| listWebhooks(options?: RequestOptions): Promise<WebhookSubscription[]>; | ||
| /** Pause or resume deliveries without losing the subscription's signing key or history. */ | ||
| setWebhookEnabled(subscriptionId: string, enabled: boolean, options?: RequestOptions): Promise<WebhookSubscription>; | ||
| deleteWebhook(subscriptionId: string, options?: RequestOptions): Promise<WebhookSubscription>; | ||
| /** | ||
| * The delivery log for one subscription, newest first — what was sent, what the endpoint | ||
| * answered, and what is dead-lettered. This is the only place a `delivery_id` is published, so it | ||
| * is also how you find the argument for `replayWebhookDelivery()`. | ||
| * | ||
| * Page with `before` (an RFC 3339 timestamp; the response's `next_before` is the next cursor, and | ||
| * null on the last page). `limit` is 1–200 and defaults to 50 server-side. | ||
| */ | ||
| listWebhookDeliveries(subscriptionId: string, options?: RequestOptions & { | ||
| before?: IsoTimestamp; | ||
| limit?: number; | ||
| }): Promise<WebhookDeliveryPage>; | ||
| /** | ||
| * Roll the subscription's signing key. `overlap_until` (RFC 3339, in the future and within 30 | ||
| * days) keeps the previous generation verifying until then, so a receiver can accept both while | ||
| * it redeploys. The returned `webhook_verification.secret` is shown exactly once. | ||
| */ | ||
| rotateWebhookSigningKey(subscriptionId: string, input: { | ||
| overlap_until: IsoTimestamp; | ||
| }, options?: RequestOptions): Promise<RotateWebhookSigningKeyResult>; | ||
| /** Re-deliver one dead-lettered delivery after fixing the receiving endpoint. */ | ||
| replayWebhookDelivery(deliveryId: string, options?: RequestOptions): Promise<Record<string, unknown>>; | ||
| /** Report what actually happened for a prior check (by `result.receipt.id`), to calibrate. */ | ||
| reportOutcome(input: { | ||
@@ -218,12 +178,17 @@ id: string; | ||
| note?: string; | ||
| }): Promise<{ | ||
| }, options?: RequestOptions): Promise<{ | ||
| ok: true; | ||
| }>; | ||
| /** Lower-level structured passthrough: a `KavalRequest` in, the raw `Verdict` out. Prefer | ||
| * `verifyBelief`/`check` unless you need the structured fact-type form. Mirrors the Python `kaval()`. */ | ||
| kaval(request: Record<string, unknown>, options?: RequestOptions): Promise<Verdict>; | ||
| /** Batch of structured `KavalRequest`s → a `Verdict` per request (same order). Mirrors the Python | ||
| * `kaval_batch()`. */ | ||
| kavalBatch(requests: Record<string, unknown>[], opts?: KavalBatchOptions): Promise<Verdict[]>; | ||
| health(): Promise<{ | ||
| /** | ||
| * @deprecated Pilot compatibility only — use {@link check}. Verifies one load-bearing conclusion | ||
| * against explicit evidence references and returns a ProofPacket receipt. Kept while the Matey | ||
| * pilot migrates; it will be removed once both pilots are on `check()`. | ||
| */ | ||
| verify(request: VerifyRequest, options?: RequestOptions): Promise<VerifyResponse>; | ||
| /** | ||
| * Liveness probe. Goes through the same transport as everything else so the documented | ||
| * `{ signal, timeoutMs }` contract holds here too — a health check that could hang forever is the | ||
| * one call where hanging is least acceptable. | ||
| */ | ||
| health(options?: RequestOptions): Promise<{ | ||
| ok: boolean; | ||
@@ -230,0 +195,0 @@ name: string; |
+238
-82
| /** | ||
| * @usekaval/kaval — before an AI agent acts, Kaval verifies the facts the action relies on and | ||
| * returns a time-bounded signed proof your policy can enforce — ALLOW, REVIEW, or BLOCK. | ||
| * A typed, dependency-light HTTP client for the Kaval API. Mirrors the Python SDK | ||
| * (`pip install kaval`). Uses the global `fetch` (Node 18+, browsers, edge). | ||
| * @usekaval/kaval — before an AI agent acts, Kaval verifies the facts the action depends on and | ||
| * returns ALLOW, REVIEW, or BLOCK with a signed receipt. A typed, dependency-light HTTP client for | ||
| * the Kaval API. Mirrors the Python SDK (`pip install kaval`). Uses the global `fetch` | ||
| * (Node 18+, browsers, edge). | ||
| * | ||
| * One call does the work: `check()`. Register what Kaval should watch with `addSource()`, push your | ||
| * own documents with `sendEvent()`, and subscribe to `fact_state.delta` webhooks with | ||
| * `subscribeFactStateDeltas()` so you are told when a fact flips instead of polling for it. | ||
| */ | ||
| import { FACT_STATE_DELTA_EVENT_TYPE } from "./check.js"; | ||
| export { DEFAULT_CHECK_MAX_WAIT_MS, FACT_STATE_DELTA_EVENT_TYPE, MAX_CHECK_MAX_WAIT_MS, MIN_CHECK_MAX_WAIT_MS, } from "./check.js"; | ||
| /** Thrown on any non-2xx response. */ | ||
@@ -22,10 +28,22 @@ export class KavalError extends Error { | ||
| } | ||
| /** Thrown when POST /v1/gate returns HTTP 404 `proof_not_found`: no durable proof matches the | ||
| * supplied `proof_id`/`proof_key` in this workspace. Build one with `audit()` before gating — | ||
| * a missing proof is never a 200 gate state. */ | ||
| export class ProofNotFoundError extends KavalError { | ||
| code = "proof_not_found"; | ||
| constructor(payload, idempotencyKey) { | ||
| super(404, payload, idempotencyKey); | ||
| this.name = "ProofNotFoundError"; | ||
| /** | ||
| * Thrown when the API answers `410 tool_retired`. Every pre-0.6 verification endpoint | ||
| * (`/v1/audit`, `/v1/gate`, `/v1/kaval`, `/v1/scan-store`, `/v1/extract-and-check`, `/v1/monitor`, | ||
| * and the belief routes) collapsed into `POST /v1/check`. Call `check()` instead — the message says | ||
| * so explicitly rather than leaving an agent to guess at an unexplained HTTP error. | ||
| */ | ||
| export class KavalRetiredError extends KavalError { | ||
| code = "tool_retired"; | ||
| /** The endpoint that replaced the one you called — always `/v1/check` today. */ | ||
| replacement; | ||
| constructor(payload, path, idempotencyKey) { | ||
| const replacement = payload?.replacement ?? "/v1/check"; | ||
| super(410, payload, idempotencyKey); | ||
| this.name = "KavalRetiredError"; | ||
| this.replacement = | ||
| typeof replacement === "string" ? replacement : "/v1/check"; | ||
| this.message = | ||
| `kaval 410: ${path} was retired in v0.6 — use ${this.replacement} ` + | ||
| `(the \`check()\` method) instead. One call verifies the facts an action depends on and ` + | ||
| `returns ALLOW, REVIEW, or BLOCK with a signed receipt.`; | ||
| } | ||
@@ -49,2 +67,11 @@ } | ||
| const DEFAULT_BASE_URL = "https://api.usekaval.com"; | ||
| /** | ||
| * Matches the API's own handler deadline, which is `MAX_CHECK_MAX_WAIT_MS` plus headroom for | ||
| * compile and receipt signing. 30s was shorter than the research budget the server applies by | ||
| * default, so the client aborted the quickstart's very first cold `check()` — a client-side | ||
| * deadline below the server's is a guaranteed failure, not a safety margin. Callers who want a | ||
| * shorter wall clock should send `max_wait_ms` (or `mode: "fast"`), which returns a real verdict | ||
| * instead of an `AbortError`. | ||
| */ | ||
| const DEFAULT_TIMEOUT_MS = 150_000; | ||
| const MAX_BILLABLE_ATTEMPTS = 2; | ||
@@ -97,2 +124,8 @@ const AMBIGUOUS_IDEMPOTENCY_CODES = new Set([ | ||
| } | ||
| /** The retired-route body is a FLAT `{error:"tool_retired"}`, not the `{error:{code}}` envelope. */ | ||
| function isRetiredPayload(payload) { | ||
| return (!!payload && | ||
| typeof payload === "object" && | ||
| payload.error === "tool_retired"); | ||
| } | ||
| /** Fail fast on the wire-invalid evidence_refs shapes the server strictly rejects, before any | ||
@@ -144,4 +177,15 @@ * network call or idempotency-key spend. */ | ||
| } | ||
| /** The Kaval client: build a signed proof with `audit()`, enforce it at act time with `gate()`, | ||
| * or verify one conclusion with `verify()`. */ | ||
| function encodeId(id) { | ||
| if (typeof id !== "string" || id.trim().length === 0) { | ||
| throw new TypeError("an id is required"); | ||
| } | ||
| return encodeURIComponent(id.trim()); | ||
| } | ||
| /** | ||
| * The Kaval client. | ||
| * | ||
| * `check()` is the whole product: send the action an agent is about to take (or the claims it | ||
| * rests on) and get ALLOW / REVIEW / BLOCK plus a signed receipt. Everything else configures what | ||
| * Kaval watches so that check stays a warm database read instead of a research run. | ||
| */ | ||
| export class Kaval { | ||
@@ -155,3 +199,4 @@ base; | ||
| this.f = opts.fetch ?? fetch; | ||
| this.timeoutMs = opts.timeoutMs === undefined ? 30_000 : opts.timeoutMs; | ||
| this.timeoutMs = | ||
| opts.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : opts.timeoutMs; | ||
| if (this.timeoutMs !== null && | ||
@@ -204,2 +249,5 @@ (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0)) { | ||
| return payload; | ||
| if (res.status === 410 && isRetiredPayload(payload)) { | ||
| throw new KavalRetiredError(payload, path, idempotencyKey); | ||
| } | ||
| const code = apiErrorCode(payload); | ||
@@ -219,15 +267,20 @@ if (attempt + 1 < MAX_BILLABLE_ATTEMPTS && | ||
| } | ||
| async post(path, body, options = {}) { | ||
| /** One request, no idempotency key. Used by reads and by routes the server treats as reads. */ | ||
| async request(method, path, body, options = {}, extraHeaders = {}) { | ||
| const request = requestSignal(options.signal, options.timeoutMs === undefined ? this.timeoutMs : options.timeoutMs); | ||
| try { | ||
| const res = await this.f(`${this.base}${path}`, { | ||
| method: "POST", | ||
| headers: this.headers, | ||
| method, | ||
| headers: { ...this.headers, ...extraHeaders }, | ||
| signal: request.signal, | ||
| // JSON.stringify omits `undefined` keys, so optional params drop out automatically. | ||
| body: JSON.stringify(body), | ||
| ...(body === undefined ? {} : { body: JSON.stringify(body) }), | ||
| }); | ||
| const payload = await res.json().catch(() => null); | ||
| if (!res.ok) | ||
| if (!res.ok) { | ||
| if (res.status === 410 && isRetiredPayload(payload)) { | ||
| throw new KavalRetiredError(payload, path); | ||
| } | ||
| throw new KavalError(res.status, payload); | ||
| } | ||
| return payload; | ||
@@ -239,78 +292,181 @@ } | ||
| } | ||
| /** Build, sign, and persist a complete action-bound proof packet (the expensive research path). */ | ||
| audit(input, options) { | ||
| return this.billablePost("/v1/audit", input, options); | ||
| /* ------------------------------- the one call ------------------------------ */ | ||
| /** | ||
| * Verify the facts an action depends on, before acting on it. | ||
| * | ||
| * Send `action` (what the agent is about to do) and optionally `context`, or send `claims` | ||
| * directly when you already know which facts matter. Kaval compiles the action into atomic | ||
| * facts, answers each from watched-source state (warm: no model call, no fetch), falls back to | ||
| * bounded live research for anything stale or novel, and returns: | ||
| * | ||
| * - `decision` — **ALLOW** (every material fact still holds on a fresh basis), **REVIEW** | ||
| * (something is unknown, changed at low/medium materiality, or mid-re-evaluation), or | ||
| * **BLOCK** (a high/critical fact changed, or a critical fact is unknown). | ||
| * - `reason_codes` — why, from a closed eight-code taxonomy. | ||
| * - `facts` — one row per fact with its status and the sources it rests on. | ||
| * - `receipt` — the id + Ed25519 signature of a document that re-derives this verdict offline. | ||
| * | ||
| * Only ALLOW means "safe to act". REVIEW is never permission to act. | ||
| */ | ||
| check(input, options) { | ||
| if (input?.action === undefined && input?.claims === undefined) { | ||
| throw new TypeError("check requires at least one of action or claims"); | ||
| } | ||
| // A check is a read of current state, so the server deliberately does not replay it under an | ||
| // idempotency key — a retry is free to recompute. | ||
| return this.request("POST", "/v1/check", input, options); | ||
| } | ||
| /** Apply a current durable proof to the exact action at act time — no search, parsing, or model | ||
| * call. A missing proof is HTTP 404 `proof_not_found`, thrown as `ProofNotFoundError`. */ | ||
| async gate(input, options) { | ||
| try { | ||
| return await this.billablePost("/v1/gate", input, options); | ||
| /** Fetch a signed check receipt exactly as it was signed, by `result.receipt.id`. */ | ||
| async getReceipt(receiptId, options) { | ||
| const { receipt } = await this.request("GET", `/v1/receipts/${encodeId(receiptId)}`, undefined, options); | ||
| return receipt; | ||
| } | ||
| /* --------------------------------- sources --------------------------------- */ | ||
| /** | ||
| * Register something for Kaval to watch. A URL is polled conditionally; an `entity` (a plain | ||
| * name plus what you care about, e.g. `{kind:"entity", name:"Aetna", intent:"payer policy | ||
| * bulletins"}`) is resolved to the URLs that publish it; a `push` source is a document you send | ||
| * to `sendEvent()`. Facts learned from a watched source stay warm, so checks on them are a | ||
| * database read. | ||
| */ | ||
| addSource(input, options) { | ||
| if (input?.locator === undefined && input?.name === undefined) { | ||
| throw new TypeError("addSource requires locator (or name for kind: 'entity')"); | ||
| } | ||
| catch (error) { | ||
| if (error instanceof KavalError && | ||
| error.status === 404 && | ||
| apiErrorCode(error.payload) === "proof_not_found") { | ||
| throw new ProofNotFoundError(error.payload, error.idempotencyKey); | ||
| } | ||
| throw error; | ||
| } | ||
| return this.request("POST", "/v1/sources", input, options); | ||
| } | ||
| /** Alias for gate(), kept for callers of the previous method name. */ | ||
| gateAction(input, options) { | ||
| return this.gate(input, options); | ||
| /** List the watched sources for this workspace, including any auto-discovered by a check. */ | ||
| async listSources(options) { | ||
| const query = options?.includeInactive ? "?include_inactive=true" : ""; | ||
| const { sources } = await this.request("GET", `/v1/sources${query}`, undefined, options); | ||
| return sources; | ||
| } | ||
| /** Compatibility surface: verify one load-bearing conclusion against its evidence references. | ||
| * Returns `valid` | `invalidated` | `could_not_verify` plus a signed proof receipt. Production | ||
| * actions should build proof with `audit()` and enforce it with `gate()`. */ | ||
| async verify(request, options) { | ||
| assertEvidenceRefs(request.evidence_refs); | ||
| return this.billablePost("/v1/verify", request, options); | ||
| async getSource(sourceId, options) { | ||
| const { source } = await this.request("GET", `/v1/sources/${encodeId(sourceId)}`, undefined, options); | ||
| return source; | ||
| } | ||
| /** LEGACY belief-freshness fallback (accepted on the same /v1/verify route): the verdict plus | ||
| * `act`. Treat `act === false` as "re-fetch before relying on it". New integrations should call | ||
| * `verify()` with a conclusion + evidence_refs, or `audit()`/`gate()` for production actions. */ | ||
| verifyBelief(input, options) { | ||
| return this.billablePost("/v1/verify", typeof input === "string" ? { belief: input } : input, options); | ||
| deleteSource(sourceId, options) { | ||
| return this.request("DELETE", `/v1/sources/${encodeId(sourceId)}`, undefined, options); | ||
| } | ||
| /** Re-ground a held belief → the raw freshness verdict (no act decision). */ | ||
| check(input, options) { | ||
| return this.billablePost("/v1/check", typeof input === "string" ? { belief: input } : input, options); | ||
| /** | ||
| * Re-derive a source's acquisition plan — how Kaval fetches and parses it. Enqueued rather than | ||
| * compiled inline, so this answers `202` with a `job_id` while the worker does the work. | ||
| * | ||
| * This is the recovery path when a plan breaks (the site moved its content, or the parser stopped | ||
| * matching), and the way to get a plan at all for a source registered directly as `kind: "url"`. | ||
| * Pressing it bypasses the per-source cooldown, which is what a human pressing a button means. | ||
| * `503 discovery_unavailable` means the deployment has no discovery worker configured. | ||
| */ | ||
| recompileSource(sourceId, options) { | ||
| return this.request("POST", `/v1/sources/${encodeId(sourceId)}/recompile`, {}, options); | ||
| } | ||
| /** Pull every factual belief out of a paragraph and check each. */ | ||
| extractAndCheck(input, options) { | ||
| return this.billablePost("/v1/extract-and-check", input, options); | ||
| /** Stop polling a source without forgetting it or the facts that depend on it. */ | ||
| async pauseSource(sourceId, options) { | ||
| const { source } = await this.request("POST", `/v1/sources/${encodeId(sourceId)}/pause`, {}, options); | ||
| return source; | ||
| } | ||
| /** Sweep a belief store for drift, worst first. */ | ||
| scanStore(input, options) { | ||
| return this.billablePost("/v1/scan-store", input, options); | ||
| async resumeSource(sourceId, options) { | ||
| const { source } = await this.request("POST", `/v1/sources/${encodeId(sourceId)}/resume`, {}, options); | ||
| return source; | ||
| } | ||
| /** Sweep + POST the newly-risky beliefs to a `webhook` (server-side delivery). */ | ||
| monitor(input, options) { | ||
| return this.billablePost("/v1/monitor", input, options); | ||
| /* ---------------------------------- events --------------------------------- */ | ||
| /** | ||
| * Push a document you own. Kaval stores the version, diffs it against the previous one, marks | ||
| * the dependent facts stale, re-evaluates them in the background, and delivers a | ||
| * `fact_state.delta` webhook naming what flipped. Address the document by `source_id`, or by | ||
| * `namespace` + `document_id` (created on first sight). | ||
| */ | ||
| sendEvent(input, options) { | ||
| return this.request("POST", "/v1/events", input, options); | ||
| } | ||
| /** Report what actually happened, to calibrate trust over time. */ | ||
| reportOutcome(input) { | ||
| return this.post("/v1/report-outcome", input); | ||
| /* --------------------------------- webhooks -------------------------------- */ | ||
| /** | ||
| * Subscribe to `fact_state.delta` — the outbound half of the whole mechanism. Without a | ||
| * subscription the background loops still keep fact state fresh, but nothing tells you a fact | ||
| * flipped until your next `check()`. `external_scope_ids` filters deliveries to the scope keys | ||
| * you care about. The returned `webhook_verification` is the only time the signing secret is | ||
| * shown; store it and verify every inbound delivery with it. | ||
| */ | ||
| subscribeFactStateDeltas(input, options) { | ||
| return this.createWebhook({ | ||
| ...input, | ||
| subscription_kind: "fact_state", | ||
| event_types: [FACT_STATE_DELTA_EVENT_TYPE], | ||
| }, options); | ||
| } | ||
| /** Lower-level structured passthrough: a `KavalRequest` in, the raw `Verdict` out. Prefer | ||
| * `verifyBelief`/`check` unless you need the structured fact-type form. Mirrors the Python `kaval()`. */ | ||
| kaval(request, options) { | ||
| return this.billablePost("/v1/kaval", request, options); | ||
| /** Register any webhook subscription. `POST /v1/webhooks` requires an Idempotency-Key. */ | ||
| createWebhook(input, options) { | ||
| if (!input?.callback_url?.startsWith("https://")) { | ||
| throw new TypeError("callback_url must be an https URL"); | ||
| } | ||
| return this.request("POST", "/v1/webhooks", input, options, { | ||
| "idempotency-key": options?.idempotencyKey ?? generatedIdempotencyKey(), | ||
| }); | ||
| } | ||
| /** Batch of structured `KavalRequest`s → a `Verdict` per request (same order). Mirrors the Python | ||
| * `kaval_batch()`. */ | ||
| kavalBatch(requests, opts = {}) { | ||
| return this.billablePost("/v1/kaval-batch", { | ||
| requests, | ||
| concurrency: opts.concurrency, | ||
| }, opts); | ||
| async listWebhooks(options) { | ||
| const { subscriptions } = await this.request("GET", "/v1/webhooks", undefined, options); | ||
| return subscriptions; | ||
| } | ||
| async health() { | ||
| const res = await this.f(`${this.base}/health`); | ||
| const payload = await res.json().catch(() => null); | ||
| if (!res.ok) | ||
| throw new KavalError(res.status, payload); | ||
| return payload; | ||
| /** Pause or resume deliveries without losing the subscription's signing key or history. */ | ||
| setWebhookEnabled(subscriptionId, enabled, options) { | ||
| return this.request("PATCH", `/v1/webhooks/${encodeId(subscriptionId)}`, { enabled }, options); | ||
| } | ||
| deleteWebhook(subscriptionId, options) { | ||
| return this.request("DELETE", `/v1/webhooks/${encodeId(subscriptionId)}`, undefined, options); | ||
| } | ||
| /** | ||
| * The delivery log for one subscription, newest first — what was sent, what the endpoint | ||
| * answered, and what is dead-lettered. This is the only place a `delivery_id` is published, so it | ||
| * is also how you find the argument for `replayWebhookDelivery()`. | ||
| * | ||
| * Page with `before` (an RFC 3339 timestamp; the response's `next_before` is the next cursor, and | ||
| * null on the last page). `limit` is 1–200 and defaults to 50 server-side. | ||
| */ | ||
| listWebhookDeliveries(subscriptionId, options) { | ||
| const query = new URLSearchParams(); | ||
| if (options?.before !== undefined) | ||
| query.set("before", options.before); | ||
| if (options?.limit !== undefined) | ||
| query.set("limit", String(options.limit)); | ||
| const search = query.toString(); | ||
| return this.request("GET", `/v1/webhooks/${encodeId(subscriptionId)}/deliveries${search === "" ? "" : `?${search}`}`, undefined, options); | ||
| } | ||
| /** | ||
| * Roll the subscription's signing key. `overlap_until` (RFC 3339, in the future and within 30 | ||
| * days) keeps the previous generation verifying until then, so a receiver can accept both while | ||
| * it redeploys. The returned `webhook_verification.secret` is shown exactly once. | ||
| */ | ||
| rotateWebhookSigningKey(subscriptionId, input, options) { | ||
| if (!input?.overlap_until) { | ||
| throw new TypeError("rotateWebhookSigningKey requires overlap_until, the instant the previous signing key stops verifying"); | ||
| } | ||
| return this.request("POST", `/v1/webhooks/${encodeId(subscriptionId)}/rotate`, input, options); | ||
| } | ||
| /** Re-deliver one dead-lettered delivery after fixing the receiving endpoint. */ | ||
| replayWebhookDelivery(deliveryId, options) { | ||
| return this.request("POST", `/v1/webhook-deliveries/${encodeId(deliveryId)}/replay`, {}, options); | ||
| } | ||
| /* --------------------------------- outcomes -------------------------------- */ | ||
| /** Report what actually happened for a prior check (by `result.receipt.id`), to calibrate. */ | ||
| reportOutcome(input, options) { | ||
| return this.request("POST", "/v1/report-outcome", input, options); | ||
| } | ||
| /* ------------------------------- pilot alias -------------------------------- */ | ||
| /** | ||
| * @deprecated Pilot compatibility only — use {@link check}. Verifies one load-bearing conclusion | ||
| * against explicit evidence references and returns a ProofPacket receipt. Kept while the Matey | ||
| * pilot migrates; it will be removed once both pilots are on `check()`. | ||
| */ | ||
| async verify(request, options) { | ||
| assertEvidenceRefs(request.evidence_refs); | ||
| return this.billablePost("/v1/verify", request, options); | ||
| } | ||
| /** | ||
| * Liveness probe. Goes through the same transport as everything else so the documented | ||
| * `{ signal, timeoutMs }` contract holds here too — a health check that could hang forever is the | ||
| * one call where hanging is least acceptable. | ||
| */ | ||
| health(options) { | ||
| return this.request("GET", "/health", undefined, options); | ||
| } | ||
| } | ||
@@ -317,0 +473,0 @@ /** Convenience factory, for callers who prefer a function over `new`. */ |
+0
-59
@@ -468,41 +468,2 @@ /** Public proof-protocol types. Field names intentionally match the hosted REST JSON exactly. */ | ||
| } | ||
| /** POST /v1/audit body. `domain` is descriptive metadata; it never expands calibration support. */ | ||
| export interface AuditInput { | ||
| text: string; | ||
| as_of: IsoTimestamp; | ||
| materiality?: Materiality; | ||
| intended_action?: string; | ||
| reversibility?: ActionReversibility; | ||
| false_allow_cost_usd?: number; | ||
| false_block_cost_usd?: number; | ||
| wait_cost_usd?: number; | ||
| domain?: string; | ||
| subject_hint?: string; | ||
| jurisdiction?: string; | ||
| geography?: string; | ||
| units?: string; | ||
| context?: string; | ||
| aliases?: string[]; | ||
| origin_urls?: string[]; | ||
| record?: RecordRef; | ||
| record_field?: string; | ||
| } | ||
| interface ProofGateInputBase { | ||
| expected_dependency_versions?: Record<string, string>; | ||
| material_claim_ids: string[]; | ||
| threshold: DecisionThreshold; | ||
| action: ActionContext; | ||
| } | ||
| /** POST /v1/gate requires exactly one durable proof locator. */ | ||
| export type ProofGateInput = ProofGateInputBase & ({ | ||
| proof_id: string; | ||
| proof_key?: never; | ||
| } | { | ||
| proof_key: string; | ||
| proof_id?: never; | ||
| }); | ||
| /** Every state /v1/gate can return as a 200. A missing proof is HTTP 404 `proof_not_found` | ||
| * (thrown as `ProofNotFoundError`), never a 200 state. */ | ||
| export type ProofGateState = "current" | "not_yet_valid" | "expired" | "invalidated" | "dependency_changed" | "integrity_failed" | "policy_mismatch" | "operational_failure"; | ||
| export type ProofBillingClass = "action_gate" | "operational_failure"; | ||
| /** One evidence reference for POST /v1/verify: EITHER a plain https URL string OR a strict | ||
@@ -545,21 +506,1 @@ * `{ url, document_id }` pair. A bare `{ url }` object without `document_id` is invalid on the | ||
| } | ||
| export interface ProofEnforcementResult { | ||
| mode: "shadow" | "block_only" | "bounded"; | ||
| controlApplied: boolean; | ||
| executionAllowed: boolean | null; | ||
| wouldAllow: boolean; | ||
| reason: string; | ||
| } | ||
| export interface ProofGateResult { | ||
| proofId: string; | ||
| state: ProofGateState; | ||
| decision: ActionDecision; | ||
| billingClass: ProofBillingClass; | ||
| proofReused: boolean; | ||
| researchPerformed: false; | ||
| humanOverrideApplied?: true; | ||
| latencyMs: number; | ||
| reason?: string; | ||
| enforcement?: ProofEnforcementResult; | ||
| } | ||
| export {}; |
+18
-4
| { | ||
| "name": "@usekaval/kaval", | ||
| "version": "0.5.0", | ||
| "version": "0.6.0", | ||
| "license": "Apache-2.0", | ||
| "description": "Fact verification for AI agents: before an agent acts, Kaval verifies the facts the action relies on and returns a time-bounded, Ed25519-signed proof your policy can enforce.", | ||
| "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.", | ||
| "type": "module", | ||
@@ -13,4 +13,16 @@ "main": "./dist/index.js", | ||
| "default": "./dist/index.js" | ||
| } | ||
| }, | ||
| "./verify": { | ||
| "types": "./dist/verify/index.d.ts", | ||
| "default": "./dist/verify/index.js" | ||
| }, | ||
| "./verify/discovery": { | ||
| "types": "./dist/verify/discovery.d.ts", | ||
| "default": "./dist/verify/discovery.js" | ||
| }, | ||
| "./package.json": "./package.json" | ||
| }, | ||
| "bin": { | ||
| "kaval-receipt-verify": "./dist/verify/cli.js" | ||
| }, | ||
| "files": [ | ||
@@ -28,3 +40,5 @@ "dist" | ||
| "freshness", | ||
| "rag" | ||
| "rag", | ||
| "ed25519", | ||
| "offline-verification" | ||
| ], | ||
@@ -31,0 +45,0 @@ "engines": { |
+393
-106
| # @usekaval/kaval | ||
| Before an AI agent acts, Kaval verifies the facts the action relies on and returns a time-bounded | ||
| signed proof your policy can enforce — `ALLOW`, `REVIEW`, or `BLOCK`. | ||
| Before an AI agent acts, send Kaval the action. Kaval identifies the facts that action depends on, | ||
| checks them against the sources it watches, and answers `ALLOW`, `REVIEW`, or `BLOCK` with a signed | ||
| receipt. | ||
@@ -13,2 +14,7 @@ Policy engines decide whether an action is permitted under the rules; Kaval verifies whether the | ||
| > **0.6 is a breaking release.** `audit`, `gate`, `verifyBelief`, `extractAndCheck`, `scanStore`, | ||
| > `monitor`, `kaval`, and `kavalBatch` are gone — they all collapsed into `check()`. The old routes | ||
| > answer `410 tool_retired`, which this client raises as `KavalRetiredError`. See | ||
| > [Migrating from 0.5](#migrating-from-05). | ||
| ## Node and module format | ||
@@ -27,155 +33,431 @@ | ||
| ## Build a proof, then gate the action | ||
| Three entry points, one zero-dependency package: | ||
| `audit()` builds the proof — the expensive research path. `gate()` applies it at act time with no | ||
| search, parsing, or model call. | ||
| | Import | What it is | | ||
| | ----------------------------------- | --------------------------------------------------------------------- | | ||
| | `@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/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). | ||
| ## check() — the one call | ||
| ```ts | ||
| import { Kaval, ProofNotFoundError } from "@usekaval/kaval"; | ||
| import { Kaval } from "@usekaval/kaval"; | ||
| const kaval = new Kaval({ apiKey: process.env.KAVAL_API_KEY }); | ||
| // 1. Build, sign, and persist a complete action-bound proof packet. | ||
| const proof = await kaval.audit({ | ||
| text: "Acme is eligible for a $12,000 refund", | ||
| as_of: new Date().toISOString(), | ||
| intended_action: "Issue Acme a $12,000 refund", | ||
| const result = await kaval.check({ | ||
| action: "Issue Acme a $12,000 refund", | ||
| context: "billing record acme-2026 says the contract allows it", | ||
| materiality: "critical", | ||
| reversibility: "irreversible", | ||
| false_allow_cost_usd: 12_000, | ||
| record: { system: "billing", table: "refunds", id: "acme-2026" }, | ||
| }); | ||
| // 2. At the exact action boundary, apply the durable proof — cheap and research-free. | ||
| try { | ||
| const gate = await kaval.gate({ | ||
| proof_id: proof.proof_id, | ||
| material_claim_ids: proof.action_decision.material_claim_ids, | ||
| threshold: proof.action_decision.threshold, | ||
| action: proof.research_contract.action, | ||
| }); | ||
| if (gate.state !== "current" || gate.decision.decision !== "ALLOW") { | ||
| throw new Error("Kaval did not allow the action"); // fail closed | ||
| } | ||
| } catch (error) { | ||
| if (error instanceof ProofNotFoundError) { | ||
| // No durable proof matches this proof_id/proof_key — build one with audit() first. | ||
| } | ||
| throw error; | ||
| if (result.decision !== "ALLOW") { | ||
| // REVIEW is never permission to act. | ||
| const moved = result.facts.filter((fact) => fact.status !== "holds"); | ||
| throw new Error(`blocked on: ${moved.map((f) => f.text).join("; ")}`); | ||
| } | ||
| ``` | ||
| `audit()` returns the complete typed `ProofPacket`: atomic claims, policy bindings, immutable source | ||
| versions, exact evidence spans, lineage families, claim assessments, calibrated/withheld risk, | ||
| provenance, expiry, and an Ed25519 signature (`signature.algorithm: "Ed25519"`, key id like | ||
| `proof-ed25519-2026-07`). | ||
| Already know which facts matter? Name them and skip extraction entirely — structured claims | ||
| canonicalize straight to a fingerprint, so there is no model call at all on the compile step: | ||
| `gate()` returns `{ proofId, state, decision, billingClass, proofReused, researchPerformed: false, | ||
| latencyMs }`. `state` is one of `current`, `not_yet_valid`, `expired`, `invalidated`, | ||
| `dependency_changed`, `integrity_failed`, `policy_mismatch`, or `operational_failure`. A missing | ||
| proof is never a 200 state: the server returns HTTP 404 `proof_not_found`, which this client throws | ||
| as the typed `ProofNotFoundError` (a `KavalError` subclass with `code: "proof_not_found"`). | ||
| `gateAction()` remains as an alias for `gate()`. | ||
| ```ts | ||
| const result = await kaval.check({ | ||
| claims: [ | ||
| { | ||
| subject: "Acme", | ||
| predicate: "refund_eligibility_window_days", | ||
| object: 90, | ||
| scope: { contract: "acme-2026" }, | ||
| materiality: "critical", | ||
| }, | ||
| "Acme's vendor security attestation is unexpired", | ||
| ], | ||
| mode: "fast", | ||
| }); | ||
| ``` | ||
| ## Verify a single conclusion (compatibility surface) | ||
| ### What comes back | ||
| `verify()` checks one load-bearing conclusion against its evidence references and returns | ||
| `valid`, `invalidated`, or `could_not_verify` plus a signed proof receipt. Production actions | ||
| should build proof with `audit()` and enforce it with `gate()`. | ||
| ```ts | ||
| result.decision; // "ALLOW" | "REVIEW" | "BLOCK" | ||
| result.reason_codes; // ["ALL_FACTS_HOLD"] | ["FACT_CHANGED"] | … (a closed set of 8) | ||
| result.facts; // [{ fingerprint, text, status, materiality, served_from_state, | ||
| // last_verified_at, sources: [{ locator, version_sha256, fetched_at }] }] | ||
| result.receipt; // { id, signature, signed_at } | ||
| result.latency_ms; // { compile, lookup, live, total } | ||
| ``` | ||
| - **ALLOW** — every material fact still holds on a fresh basis. Safe to act. | ||
| - **REVIEW** — something is `unknown`, mid-re-evaluation, or `changed` at low/medium materiality. | ||
| Never permission to act. | ||
| - **BLOCK** — a high/critical fact `changed`, or a critical fact is `unknown`. | ||
| Per-fact `status` is `holds` | `changed` | `unknown`, so you can see exactly *which* belief moved | ||
| rather than just that something did. | ||
| ### Options | ||
| | option | meaning | | ||
| | --------------- | -------------------------------------------------------------------------------------------- | | ||
| | `action` | what you are about to do, in plain language (required unless `claims` is given) | | ||
| | `context` | what you already believe that bears on it — the retrieved chunk, the cached field | | ||
| | `claims` | check these facts directly: plain sentences or `{subject, predicate, object, scope}` (max 20) | | ||
| | `mode` | `"standard"` (default, may research) or `"fast"` (stored state only) | | ||
| | `max_wait_ms` | live-research budget, default 100000, max 100000; 0 disables research (what `mode: "fast"` sets) | | ||
| | `origin_urls` | authoritative sources for this action (max 20), merged with what the workspace watches | | ||
| | `materiality` | `low` \| `medium` \| `high` \| `critical` | | ||
| | `as_of` | RFC 3339 cutoff for what the action may rely on | | ||
| The default is "let the research finish": a cold check has to search, fetch and adjudicate several | ||
| novel facts, and that routinely takes 50–100s. Lower `max_wait_ms` only when you would rather have a | ||
| bounded `REVIEW` than an answer — a fact that misses the budget comes back `unknown`. Do not count | ||
| on the detached remainder warming the next call: the next check recompiles the action and asks about | ||
| different fingerprints. What makes a check warm is a **watched source** (below), and warm checks are | ||
| a database read: ~50ms, zero model calls, zero fetches. | ||
| The three numbers are exported, so you can bound your own inputs against the server's: | ||
| ```ts | ||
| const { status, receipt } = await kaval.verify({ | ||
| conclusion: "The 2024 International Building Code is the current IBC edition.", | ||
| evidence_refs: ["https://codes.iccsafe.org/content/IBC2024V2.0"], | ||
| }); | ||
| import { | ||
| MIN_CHECK_MAX_WAIT_MS, // 0 — disables research, same as mode: "fast" | ||
| DEFAULT_CHECK_MAX_WAIT_MS, // 100000 — what the server applies when you omit it | ||
| MAX_CHECK_MAX_WAIT_MS, // 100000 — equal to the default; you can only ask for less | ||
| } from "@usekaval/kaval"; | ||
| ``` | ||
| status; // "valid" | "invalidated" | "could_not_verify" | ||
| receipt.decision; // "ALLOW" | "BLOCK" | "REVIEW" | ||
| receipt.reason; // e.g. "All material claims verified against current evidence." | ||
| receipt.share_endpoint; // "/v1/proofs/<id>/share" | ||
| receipt.packet; // the full signed ProofPacket | ||
| receipt.packet.action_decision.expires_at; // expiry lives here, not on the receipt | ||
| ### The receipt | ||
| ```ts | ||
| const receipt = await kaval.getReceipt(result.receipt.id); | ||
| ``` | ||
| Each item in `evidence_refs` (1–20 entries) is **either** a plain https URL string **or** a strict | ||
| `{ url, document_id }` object; `document_id` values must be unique per request. A bare `{ url }` | ||
| object without `document_id` is invalid — pass the plain string instead. The client rejects these | ||
| wire-invalid shapes locally before spending a request. | ||
| Returned exactly as signed. Because the decision table is published, the receipt's own fact list | ||
| 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. | ||
| ## Ed25519 receipts, verifiable offline | ||
| Each fact carries its `basis`: the sources it was proved against. When a basis entry has a | ||
| `version_sha256`, it also names what that digest covers — `version_sha256_of: "canonical_text"` (the | ||
| extracted text, with `parser_name` / `parser_version` naming the extractor) or `"raw_bytes"` (the | ||
| document as fetched). A PDF has both and they differ, so re-hash the artifact the label names. All | ||
| three labels are inside the signed bytes, so a rewritten one fails verification. | ||
| Receipts are Ed25519-signed. Anyone can verify one offline with the open verifier | ||
| (`@kaval/receipt-verifier` in the main Kaval repo) against the published JWK at | ||
| `GET /v1/proof-verification-keys/:kid` — no Kaval account required. | ||
| ## `/verify` — the offline receipt verifier | ||
| ## Honest boundaries | ||
| ```ts | ||
| import { verifyReceipt } from "@usekaval/kaval/verify"; | ||
| ``` | ||
| Demo results carry no organizational authority. A production `ALLOW` requires a customer-bound | ||
| action policy and applicable empirical calibration; `REVIEW` is never permission. | ||
| The subpath is the whole verifier: zero dependencies, and **nothing in its import graph touches the | ||
| network** — no `fetch`, no `node:http`, no sockets, transitively. That is enforced by a test that | ||
| walks the real module graph of both the source and the shipped `dist`, so "offline" is a checked | ||
| property rather than a promise. It never reads an API key and never contacts Kaval, which is what | ||
| makes it something you can hand to a counterparty who does not trust us. | ||
| ## Safe retries and idempotency | ||
| It answers three questions **separately**, and conflating them is the mistake it exists to prevent: | ||
| Every billable call automatically sends a fresh UUID `Idempotency-Key`. If the connection fails | ||
| without a trustworthy response, or the API says the operation is still being finalized, the client | ||
| retries once with the same key. It does not retry ordinary API errors, rate limits, or terminal 5xx | ||
| responses. | ||
| 1. **Cryptographic validity** — does the Ed25519 signature cover the exact canonical unsigned bytes? | ||
| 2. **Key trust** — is the immutable `key_id` active or benignly retired, or revoked/compromised? | ||
| 3. **Freshness** — at the instant you name, is the receipt `fresh`, `recheck_due`, `expired`, or | ||
| `not_yet_issued`? | ||
| Pass your own key when an outer job/retry system needs to keep one logical operation stable: | ||
| A valid signature proves who sealed these exact bytes. It does not prove the claim is true, that its | ||
| evidence is still current, or that the key is still trusted. `accepted` means signature **and** key | ||
| trust; freshness is reported alongside and never gates it. | ||
| ```ts | ||
| const operationId = crypto.randomUUID(); | ||
| const proof = await kaval.audit( | ||
| { text: "Acme is eligible for a $12,000 refund", as_of: new Date().toISOString() }, | ||
| { idempotencyKey: operationId }, | ||
| import { | ||
| extractReceipt, | ||
| parseJsonStrict, | ||
| verifyReceipt, | ||
| } from "@usekaval/kaval/verify"; | ||
| // Accepts a bare receipt, `{ packet }`, or the `{ run: { packet } }` share wrapper. | ||
| const receipt = extractReceipt(parseJsonStrict(receiptText)); | ||
| const keyset = parseJsonStrict(keysetText); | ||
| const result = verifyReceipt(receipt, keyset, { at: "2026-07-20T12:00:00.000Z" }); | ||
| result.cryptographic.valid; // the bytes really were signed by this key | ||
| result.key.lifecycle_status; // "active" | "retired" | "revoked" | "compromised" | "unknown" | ||
| result.key.trusted; | ||
| result.freshness.status; // "fresh" | "recheck_due" | "expired" | "not_yet_issued" | "unknown" | ||
| result.accepted; // cryptographic.valid && key.trusted | ||
| ``` | ||
| Use `parseJsonStrict` (or `verifyReceiptText`, which does it for you) on anything that arrives as | ||
| untrusted JSON **text**. Plain `JSON.parse` silently discards duplicate-member and lossy-number | ||
| evidence before any object-level verifier can see it. | ||
| Exported: `verifyReceipt` · `verifyReceiptText` · `extractReceipt` · `parseJsonStrict` · | ||
| `stableCanonicalJson` · `canonicalUnsignedReceiptJson` · `canonicalUnsignedReceiptBytes` · | ||
| `parseVerificationKey` · `verificationKeyFromDocument` · `isRfc3339Timestamp` · | ||
| `parseRfc3339Instant` · `rfc3339TimestampMilliseconds` · `rfc3339TimestampNanoseconds` · | ||
| `KAVAL_CANONICALIZATION` · `MAX_JSON_NUMBER_CHARACTERS`. | ||
| Both documents Kaval signs verify here: a ProofPacket, whose signature block is | ||
| `{algorithm, key_id, signature}`, and a `/v1/check` receipt, which adds `signed_at`. That block is a | ||
| closed allowlist — those four members and nothing else — so an appended field fails closed instead of | ||
| shadowing the algorithm or key a lax verifier reads. Nothing *inside* the block is covered by the | ||
| signature (canonicalization strips the whole block before hashing), so `signed_at` is authenticated | ||
| indirectly, by being required to equal the signed `checked_at`. A check receipt carries no `expiry`, | ||
| so its freshness is honestly `unknown`. | ||
| ### Fetching keys | ||
| `verifyReceipt` takes a key document you already hold — archive the keyset next to the receipt and | ||
| the verification is reproducible forever. If you would rather fetch it live, that is a **separate** | ||
| subpath, precisely so choosing the network is explicit: | ||
| ```ts | ||
| import { discoverVerificationKeyDocument } from "@usekaval/kaval/verify/discovery"; | ||
| const keys = await discoverVerificationKeyDocument( | ||
| "https://api.usekaval.com/v1/proof-verification-keys", | ||
| keyId, | ||
| ); | ||
| ``` | ||
| Reuse a key only after an ambiguous/no-response failure. After receiving a terminal response, start | ||
| a new key for any new attempt. `reportOutcome()` and `health()` are not billable and do not send this | ||
| header. If both bounded attempts remain ambiguous, the thrown `KavalError` or transport error exposes | ||
| the generated key as `error.idempotencyKey`; pass it back explicitly after your own delay to resume | ||
| the same operation instead of starting and billing a new one. | ||
| Discovery is bounded to 5s and 256 KiB, requires HTTPS, refuses redirects and URL credentials, and | ||
| allows plain HTTP only for loopback when you pass `allow_http_loopback`. | ||
| All billable methods accept `{ idempotencyKey?, signal?, timeoutMs? }`. The constructor defaults to | ||
| a 30-second deadline; override per call or set `timeoutMs: null` to disable it. Cancellation and | ||
| timeout errors retain `error.idempotencyKey`, because an interrupted billable request can be | ||
| ambiguous. | ||
| ### CLI: `kaval-receipt-verify` | ||
| ## Legacy held-belief compatibility | ||
| Installing this package installs the verifier as a command, so a counterparty can check a receipt | ||
| without writing any code: | ||
| The original currentness API remains available under legacy names — the server still accepts a | ||
| belief-freshness body on the same `/v1/verify` route: | ||
| ```bash | ||
| npx --package @usekaval/kaval kaval-receipt-verify verify receipt.json --keyset keys.json | ||
| ``` | ||
| ``` | ||
| Usage: | ||
| kaval-receipt-verify verify <receipt.json|-> --keyset <keys.json> [options] | ||
| kaval-receipt-verify verify <receipt.json|-> --key-url <https-url> [options] | ||
| Options: | ||
| --keyset <path> Offline per-key document or keyset (recommended for reproducibility) | ||
| --key-url <url> HTTPS per-key endpoint or keyset endpoint | ||
| --at <RFC3339> Evaluate freshness at an explicit time | ||
| --require-fresh Exit non-zero unless freshness is "fresh" | ||
| --allow-http-loopback Permit http://localhost/127.0.0.0/8/::1 for local development | ||
| --compact Emit compact JSON | ||
| -h, --help Show this help | ||
| ``` | ||
| It prints the full `VerificationResult` as JSON and takes the receipt from a file or `-` (stdin). | ||
| **Exit `0`** means the signature is valid and the key is trusted — an expired receipt still exits | ||
| `0`, because freshness is a different question; add `--require-fresh` to make anything but `fresh` | ||
| exit non-zero. **Exit `1`** is a completed verification that was not accepted. **Exit `2`** is an | ||
| input, I/O, or discovery failure. | ||
| `--at` and every receipt/key timestamp must be a component-valid RFC 3339 instant. Date-only | ||
| strings, impossible calendar days (`2026-02-29`), leap-second `:60`, and the `-00:00` | ||
| unknown-local-offset marker fail closed rather than being normalized by `Date.parse`, and freshness | ||
| comparisons keep all nine fractional-second digits as exact epoch nanoseconds. | ||
| ### Security boundary | ||
| - Key IDs are immutable and never reused across keys or algorithms; one public key may not appear | ||
| under two IDs. | ||
| - Only canonical, unpadded base64url Ed25519 public keys (32 bytes) and signatures (64 bytes) pass. | ||
| - Duplicate JSON keys, unsafe integers, lossy decimal/exponent spellings, sparse or non-JSON values, | ||
| excessive nesting, oversized documents, redirects, unknown key IDs, and algorithm confusion all | ||
| fail closed. | ||
| - `retired` is benign rotation: historical signatures still verify. | ||
| - `revoked` and `compromised` stay mathematically checkable but never yield `key.trusted: true` — a | ||
| compromised signer can backdate its own `issued_at`, so no self-asserted timestamp rescues them. | ||
| ## Keep it warm: watched sources | ||
| ```ts | ||
| const decision = await kaval.verifyBelief("Acme's CEO is Jane Doe"); | ||
| if (!decision.act) { | ||
| // stale / contradicted — re-fetch before relying on it | ||
| // Registering the NAME of an authority is usually enough. | ||
| const { source, resolved, authority } = await kaval.addSource({ | ||
| kind: "entity", | ||
| name: "Aetna", | ||
| intent: "payer policy bulletins", | ||
| }); | ||
| await kaval.listSources(); // includes sources auto-registered by a check | ||
| await kaval.listSources({ includeInactive: true }); | ||
| await kaval.pauseSource(source.id); // stop polling without forgetting it | ||
| await kaval.resumeSource(source.id); | ||
| await kaval.deleteSource(source.id); | ||
| await kaval.getSource(source.id); | ||
| await kaval.recompileSource(source.id); // re-derive how Kaval fetches and parses it | ||
| ``` | ||
| `kind`: `url` (one page) · `entity` (a name to resolve) · `push` (a document you send in) · | ||
| `connection` (a configured system of record). Kaval polls adaptively — slower when nothing changes, | ||
| faster when it does — and re-evaluates the facts that depend on a source when it moves. | ||
| `authority` is the resolver's working — one `{url, outcome, reason}` per candidate it considered. | ||
| `accepted` and `discarded` are informational; **`ambiguous` is the one to act on**: a real page of | ||
| the real entity, governing a different product line with different rules. Kaval refuses to guess | ||
| which of those you meant, so add or reject them yourself: | ||
| ```ts | ||
| for (const decision of authority ?? []) { | ||
| if (decision.outcome === "ambiguous") { | ||
| console.warn(`ambiguous: ${decision.url} — ${decision.reason}`); | ||
| } | ||
| } | ||
| ``` | ||
| `verifyBelief()` returns the verdict plus `act` — `true` only when the belief is `current` and | ||
| confident (≥ 0.7 by default; override with `minConfidence`). `mode` selects a speed/depth tier | ||
| (`instant` | `fast` | `auto` | `deep`); the deep tier adds a cited `explanation`. The related legacy | ||
| surfaces also still work: `check`, `extractAndCheck`, `scanStore`, `monitor`, `kaval`, `kavalBatch`, | ||
| and `reportOutcome`. | ||
| `discovery_error` is set when the source registered but Kaval could not derive an acquisition plan | ||
| for it. `recompileSource(id)` re-runs that derivation — it is the recovery path from a broken plan, | ||
| and the way a directly-registered `kind: "url"` source gets a plan at all. It answers `202` with a | ||
| `job_id` because discovery runs on the worker, not in your request. | ||
| ## Close the loop: `fact_state.delta` webhooks | ||
| Watching only helps you if you hear about it. Subscribe once, at deploy time: | ||
| ```ts | ||
| const report = await kaval.scanStore({ | ||
| beliefs: ["Acme is on the Enterprise plan", "Jane Doe is VP Eng at Acme"], | ||
| const { subscription, webhook_verification } = await kaval.subscribeFactStateDeltas({ | ||
| callback_url: "https://your-app.example.com/hooks/kaval", | ||
| external_scope_ids: ["contract:acme-2026"], // optional scope filter | ||
| }); | ||
| report.riskiest.forEach((r) => console.log(r.belief, "→", r.status)); | ||
| // webhook_verification.secret is shown EXACTLY ONCE — store it; it is how you | ||
| // authenticate every inbound delivery (hmac-sha256 over the standard webhook headers). | ||
| ``` | ||
| // …or get pushed the newly-stale ones: | ||
| await kaval.monitor({ beliefs, webhook: "https://your-app.com/hooks/stale" }); | ||
| Each delivery is a `FactStateDeltaEvent`: the source, `old_version_sha256 → new_version_sha256`, a | ||
| diff summary, and the facts whose state changed (`{fingerprint, text, old_state → new_state, | ||
| basis}`), plus a pointer to the receipt covering the re-evaluation. | ||
| ```ts | ||
| await kaval.listWebhooks(); | ||
| await kaval.setWebhookEnabled(subscription.subscription_id, false); | ||
| await kaval.deleteWebhook(subscription.subscription_id); | ||
| // The delivery log is the only place a delivery id is published — start here, then replay. | ||
| const { items, next_before } = await kaval.listWebhookDeliveries( | ||
| subscription.subscription_id, | ||
| { limit: 100 }, // 1–200, default 50; page with `before` | ||
| ); | ||
| for (const delivery of items) { | ||
| if (delivery.state === "dead_letter") await kaval.replayWebhookDelivery(delivery.delivery_id); | ||
| } | ||
| // Roll the signing key; the old generation keeps verifying until `overlap_until`, so you can | ||
| // redeploy the receiver without dropping deliveries. The new secret is shown exactly once. | ||
| await kaval.rotateWebhookSigningKey(subscription.subscription_id, { | ||
| overlap_until: new Date(Date.now() + 24 * 3_600_000).toISOString(), | ||
| }); | ||
| // createWebhook() is the general form if you need a non-fact_state family. | ||
| ``` | ||
| ## Push your own documents | ||
| ```ts | ||
| const { changed, facts_pending_review } = await kaval.sendEvent({ | ||
| namespace: "contracts", | ||
| document_id: "acme-2026-msa", | ||
| content: extractedText, // or content_url | ||
| scope_keys: ["contract:acme-2026"], | ||
| }); | ||
| ``` | ||
| Kaval stores the version, diffs it against the previous one, marks the dependent facts stale, | ||
| re-evaluates them in the background, and fires the delta webhook. `changed: false` means the content | ||
| was byte-identical: no version row, no staleness, no delta. Checks that land mid-re-evaluation | ||
| honestly return `REVIEW`. | ||
| ## reportOutcome() | ||
| ```ts | ||
| await kaval.reportOutcome({ id: result.receipt.id, kind: "relied_and_correct" }); | ||
| ``` | ||
| Kinds: `relied_and_correct` · `current_later_contradicted` · `stale_caught_real` · | ||
| `stale_was_false_alarm`. | ||
| ## verify() — deprecated pilot alias | ||
| `verify()` checks one load-bearing conclusion against evidence references you supply and returns a | ||
| signed ProofPacket receipt. It is kept only while existing pilot integrations migrate and **will be | ||
| removed**. New code should call `check()`, which needs no evidence list, is answered from watched | ||
| state in milliseconds, and keeps monitoring the facts afterwards. | ||
| ```ts | ||
| const { status, receipt } = await kaval.verify({ | ||
| conclusion: "The 2024 International Building Code is the current IBC edition.", | ||
| evidence_refs: ["https://codes.iccsafe.org/content/IBC2024V2.0"], | ||
| }); | ||
| ``` | ||
| Each item in `evidence_refs` (1–20 entries) is **either** a plain https URL string **or** a strict | ||
| `{ url, document_id }` object; `document_id` values must be unique per request. A bare `{ url }` | ||
| object without `document_id` is invalid — pass the plain string instead. The client rejects these | ||
| wire-invalid shapes locally before spending a request. | ||
| ## Errors | ||
| | class | when | | ||
| | -------------------- | ---------------------------------------------------------------------------------------- | | ||
| | `KavalError` | any non-2xx. Carries `status`, `payload`, and `idempotencyKey` where one was spent. | | ||
| | `KavalRetiredError` | HTTP 410 `tool_retired` — you called a route that folded into `/v1/check`. Exposes `.replacement` and a message naming `check()`. | | ||
| | `TypeError` | locally-detected invalid input, thrown before any network call | | ||
| ## Idempotency and retries | ||
| `verify()` is the only method that spends an operation key: it sends a fresh UUID | ||
| `Idempotency-Key` and retries once with the same key when the connection fails without a | ||
| trustworthy response, or the API says the operation is still being finalized. It does not retry | ||
| ordinary API errors, rate limits, or terminal 5xx responses. If both bounded attempts stay | ||
| ambiguous, the thrown error exposes `error.idempotencyKey` — pass it back explicitly after your own | ||
| delay to resume the same operation instead of billing a new one. | ||
| `createWebhook()` (and `subscribeFactStateDeltas()`) send a key because the API requires one; they | ||
| generate it when you do not supply it. | ||
| `check()` deliberately sends none: it is a read of current state, so a retry recomputes rather than | ||
| replays and cannot double-bill. | ||
| Every method — `health()` included — accepts a final `{ idempotencyKey?, signal?, timeoutMs? }`. The | ||
| constructor defaults to a 150-second deadline; override per call or set `timeoutMs: null` to disable | ||
| it. That default matches the API's own handler deadline: a client-side deadline below the server's | ||
| research budget does not bound anything, it just aborts your own cold `check()`. To finish sooner, | ||
| send a smaller `max_wait_ms` (or `mode: "fast"`) and get a real verdict instead of an `AbortError`. | ||
| ## Migrating from 0.5 | ||
| | 0.5 | 0.6 | | ||
| | ---------------------------------- | ----------------------------------------------------------------------------- | | ||
| | `audit()` | `check()` — the receipt **is** the proof | | ||
| | `gate()` / `gateAction()` | `check()` — the warm path re-checks in ~50ms; there is nothing to re-apply | | ||
| | `check(belief)` | `check({ action })` | | ||
| | `verifyBelief()` | `check({ action, context })` — branch on `decision === "ALLOW"`, not `act` | | ||
| | `extractAndCheck({ text })` | `check({ action: text })` — Kaval compiles the facts itself | | ||
| | `scanStore({ beliefs })` | `check({ claims })` — up to 20 per call | | ||
| | `monitor({ beliefs, webhook })` | `addSource()` + `subscribeFactStateDeltas()` — deltas are pushed, not swept | | ||
| | `kaval()` / `kavalBatch()` | `check({ claims: [{ subject, predicate, object, scope }] })` | | ||
| | `verify()` | `verify()`, deprecated → `check()` | | ||
| | `ProofNotFoundError` | removed with `/v1/gate` | | ||
| | — | new: `getReceipt`, source registry, `sendEvent`, webhook subscriptions | | ||
| Status mapping: `current` + `act: true` → `decision: "ALLOW"` with every fact `holds`; | ||
| `stale`/`contradicted` → a fact `changed` (`REVIEW` or `BLOCK` by materiality); | ||
| `unsupported`/`insufficient`/`conflicting` → a fact `unknown` (`REVIEW`, or `BLOCK` if critical). | ||
| ## API | ||
| `audit` · `gate` (`gateAction` alias) · `verify` · `verifyBelief` · `check` · `extractAndCheck` · | ||
| `scanStore` · `monitor` · `reportOutcome` · `kaval` · `kavalBatch` · `health`. Billable methods | ||
| accept a final `{ idempotencyKey?, signal?, timeoutMs? }` request-options argument (`kavalBatch` | ||
| includes it alongside `concurrency`). Construct with `{ apiKey, baseUrl?, fetch?, timeoutMs? }` — | ||
| `baseUrl` defaults to `https://api.usekaval.com`. Works in Node 18+, browsers, and edge runtimes | ||
| (uses the global `fetch`). | ||
| `check` · `getReceipt` · `addSource` · `listSources` · `getSource` · `pauseSource` · `resumeSource` · | ||
| `recompileSource` · `deleteSource` · `sendEvent` · `subscribeFactStateDeltas` · `createWebhook` · | ||
| `listWebhooks` · `setWebhookEnabled` · `deleteWebhook` · `listWebhookDeliveries` · | ||
| `rotateWebhookSigningKey` · `replayWebhookDelivery` · `reportOutcome` · `verify` (deprecated) · | ||
| `health`. | ||
| Construct with `{ apiKey, baseUrl?, fetch?, timeoutMs? }` — `baseUrl` defaults to | ||
| `https://api.usekaval.com`. Works in Node 18+, browsers, and edge runtimes (uses the global `fetch`). | ||
| 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. | ||
| **Env vars:** this package does **not** read `KAVAL_BASE_URL` from the environment — pass | ||
@@ -185,2 +467,7 @@ `baseUrl` in the constructor (Python SDK and MCP use `KAVAL_BASE_URL`; the marketing-site proxy | ||
| ## Honest boundaries | ||
| Demo results carry no organizational authority. A production `ALLOW` requires a customer-bound | ||
| action policy and applicable empirical calibration; `REVIEW` is never permission to act. | ||
| The Python client mirrors this surface: `pip install kaval`. |
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.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
159689
148.54%25
257.14%2938
165.16%471
155.98%2
100%9
125%