@crawlcheck/sdk 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,11 +18,17 @@ const { proceed, receipt } = await cc.guard("example.com", "read", { agent: "MyA
18
18
 
19
19
  Every verification returns `integrity_valid`, `issuer_trusted` and `accepted`. Act on `accepted`: it needs the signing key to be one CrawlCheck publishes, so a document someone signed with their own key is `verified` offline but never `accepted`. The client methods (`verifyDocument`, `verifyOffline`, `verifyReceiptOffline`, `guard`) check the published key directory for you.
20
20
 
21
+ Since 1.8.0 an MCP connect decision names the server and, with a lockfile, its approved `tools_sha256` (`preflight(domain, "connect", { mcp: { url, tools_sha256 } })`; `withPreflight` sends it for you). CrawlCheck reads the live tools from its own network: a changed tool list comes back as `block`, and the signed receipt's `assessed` block says exactly which endpoint and tool state it covered.
22
+
23
+ Since 1.8.0 they also apply the signing policy (https://crawlcheck.io/keys#policy): which key purpose may sign which object at which time (decision receipts move to the decision key at 2026-11-09 17:00 UTC), retired keys (valid only for documents signed before they stopped) and compromised keys (never valid on their signature alone). The result carries `key_policy_valid`, and `accepted` needs it. Offline, build the same input from copies you saved: `keyPolicyFrom(savedDirectory, savedRotationRecord)`, then `applyKeyPolicy(result, doc, kp)` or `guard(domain, action, { onlineKeys: false, keyPolicy })`. A saved copy cannot know a key retired or compromised after you saved it.
24
+
25
+ `accepted` says who signed a document. It is not permission to act: a valid receipt can say `block`, `warn` or `require_confirmation`. `guard()` decides that part.
26
+
21
27
  `preflight(domain, action, { agent, template, policy })` returns the signed receipt without deciding for you; `verifyDocument(doc)` verifies any signed resolve answer or decision receipt offline. `resolve(domain)` returns the full signed ResolveV1 answer.
22
28
 
23
29
  ## Give your agent the check as a tool
24
30
 
25
- Two tools your agent can call itself: `crawlcheck_check_site` (the signed, verified decision for an action on a site) and `crawlcheck_fetch` (fetches a page only after its domain, and every redirect hop, is allowed). Both fail closed.
31
+ Three tools your agent can call itself: `crawlcheck_check_site` (the signed, verified decision for an action on a site, including the business facts the site publishes and which AI engines get them wrong), `crawlcheck_verify_fact` (one signed verdict on a phone number, address, hours or service area you hold: does the business publish it, did it once, or whose is it) and `crawlcheck_fetch` (fetches a page only after its domain, and every redirect hop, is allowed). All fail closed.
26
32
 
27
33
  ```js
28
34
  import { CrawlCheck, agentTools, toolsFor } from "@crawlcheck/sdk";
package/dist/index.d.ts CHANGED
@@ -16,8 +16,50 @@ export interface VerifyCheck {
16
16
  export interface Trust {
17
17
  integrity_valid: boolean;
18
18
  issuer_trusted: boolean | null;
19
- accepted: boolean;
19
+ accepted: boolean; /** conf2: the signing policy accepts this key for this object at its signing time (purpose, retired, compromised). null: not decided. */
20
+ key_policy_valid?: boolean | null; /** conf2: stable reason: ok, ok_bound, key_wrong_purpose, key_not_yet_active, key_retired, key_compromised, key_unknown, no_signing_time, or related_<code> for a key the document leans on. */
21
+ key_policy_code?: string | null;
22
+ }
23
+ /** conf2: the key directory and rotation record as the signing policy reads them (keyPolicyFrom / publishedKeyPolicy). */
24
+ export interface KeyPolicyInput {
25
+ v: 2;
26
+ kids: string[];
27
+ keys: Record<string, {
28
+ purpose: string;
29
+ start: number | null;
30
+ }>;
31
+ retired: Record<string, {
32
+ purpose: string;
33
+ stopped: number | null;
34
+ }>;
35
+ compromised: Record<string, number>;
36
+ rotation: boolean;
37
+ read_at?: string;
38
+ source?: string;
39
+ }
40
+ export interface KeyPolicyResult {
41
+ ok: boolean | null;
42
+ code: string;
43
+ known: boolean;
44
+ purpose: string | null;
45
+ expected: string | null;
46
+ why: string;
20
47
  }
48
+ /** The signing policy as data (https://crawlcheck.io/keys#policy): object purposes, purpose chains, start times. */
49
+ export declare const KEY_POLICY: {
50
+ v: 2;
51
+ object_purpose: Record<string, string>;
52
+ chains: Record<string, string[]>;
53
+ starts: Record<string, string | null>;
54
+ bound: Record<string, string>;
55
+ purpose_aliases: Record<string, string>;
56
+ };
57
+ /** Build the policy input from a key directory you hold and, optionally, the rotation record saved with it. */
58
+ export declare const keyPolicyFrom: (directory: unknown, rotation?: unknown) => Promise<KeyPolicyInput>;
59
+ /** Does the signing policy accept this document's signing key for this object at its signing time? */
60
+ export declare const keyPolicyCheck: (doc: unknown, kp: KeyPolicyInput) => KeyPolicyResult;
61
+ /** Apply the signing policy to a verification: key_published (retired keys included), key_purpose, and the trust fields. */
62
+ export declare const applyKeyPolicy: <T>(res: T, doc: unknown, kp: KeyPolicyInput) => T & Trust;
21
63
  export interface BundleVerification extends Partial<Trust> {
22
64
  verified: boolean;
23
65
  summary: string;
@@ -197,7 +239,11 @@ export interface PreflightOptions {
197
239
  template?: string;
198
240
  policy?: PreflightPolicy; /** 8-128 characters of A-Z a-z 0-9 . _ : - signed into the receipt (replay binding). guard() sends a fresh one when you give none. */
199
241
  nonce?: string; /** The exact operation this decision is for (method + url, or an MCP tool); signed into the receipt. */
200
- operation?: Operation;
242
+ operation?: Operation; /** An MCP server on this domain and the tool-list hash you approved (mcp-lockfile tools_sha256). The decision then reads the live tools, blocks when they differ, and its signed `assessed` block names exactly what it covered. https://crawlcheck.io/spec/mcp-lockfile */
243
+ mcp?: {
244
+ url: string;
245
+ tools_sha256?: string;
246
+ };
201
247
  }
202
248
  /** What a receipt can be scoped to. Send only the keys that apply; CrawlCheck signs them back exactly as sent. https://crawlcheck.io/spec/guard#operation */
203
249
  export interface Operation {
@@ -236,6 +282,8 @@ export interface GuardOptions extends PreflightOptions {
236
282
  onlineKeys?: boolean;
237
283
  /** Pinned CrawlCheck key ids, for offline use with onlineKeys: false. */
238
284
  publishedKids?: string[];
285
+ /** conf2: a pinned key policy (keyPolicyFrom(savedDirectory, savedRotation)), for offline use with onlineKeys: false. Checks key purpose too. */
286
+ keyPolicy?: KeyPolicyInput;
239
287
  /** Testing clock (ms since epoch). Default Date.now(). */
240
288
  now?: number;
241
289
  }
@@ -619,7 +667,10 @@ export declare class CrawlCheck {
619
667
  }): Promise<RevocationSync>;
620
668
  /** The policy engine: a signed decision receipt (allow | warn | require_confirmation | block | unsupported). A policy can only make the decision stricter. */
621
669
  preflight(domain: string, action?: Action, opts?: PreflightOptions): Promise<DecisionReceipt>;
622
- /** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
670
+ private kpCache;
671
+ /** conf2: the key directory and rotation record, read now (cached 10 minutes), as the signing policy reads them. */
672
+ publishedKeyPolicy(): Promise<KeyPolicyInput>;
673
+ /** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory and the signing policy must accept it. */
623
674
  verifyDocument(doc: unknown, onlineKeys?: boolean): Promise<DocumentVerification & Trust>;
624
675
  /**
625
676
  * Preflight, verify the receipt, check it is for THIS operation, and decide. Proceeds on allow; warn only with allowWarn;
@@ -637,6 +688,8 @@ export declare class CrawlCheck {
637
688
  guardedFetch(url: string, opts?: GuardedFetchOptions): Promise<GuardedFetchResult>;
638
689
  /** Look a domain up without sending it: only the first prefixLen hex characters of sha256(domain) leave this process (one more character per 413, up to 8). found false with ready true means no public measurement. */
639
690
  /** Make a signed lockfile for an MCP server (CrawlCheck reads tools/list; no tool is called). Save it; check with checkLock before every connect. */
691
+ /** One signed verdict on a business fact (phone, address, hours, service_area) against what the business publishes: GET /api/v1/field-check. */
692
+ fieldCheck(domain: string, field: FactField, value: string): Promise<JsonObject>;
640
693
  mcpLock(url: string): Promise<McpLock & JsonObject>;
641
694
  /** Read the server's tools now (through CrawlCheck) and compare with a lockfile; the answer is signed. For a fully offline check, list the tools yourself and call checkLock. */
642
695
  mcpLockCheck(lock: McpLock, url?: string): Promise<McpLockCheck & JsonObject>;
@@ -687,11 +740,12 @@ export declare class CrawlCheck {
687
740
  private req;
688
741
  }
689
742
  /**
690
- * Agent tools (1.6.0): CrawlCheck as two tools any agent framework can register, so the check happens inside the agent's
691
- * own tool loop. crawlcheck_check_site returns the signed, verified decision for an action on a site; crawlcheck_fetch
743
+ * Agent tools (1.6.0; crawlcheck_verify_fact and decision facts 1.7.0): CrawlCheck as tools any agent framework can register, so the check happens inside the agent's
744
+ * own tool loop. crawlcheck_check_site returns the signed, verified decision for an action on a site (with the business facts the site publishes); crawlcheck_verify_fact checks one phone, address or hours value; crawlcheck_fetch
692
745
  * fetches a page only after its domain (and every redirect hop) proceeds. Both fail closed, exactly like guard().
693
746
  * toolsFor() shapes them for OpenAI function calling, Anthropic tool use, the Vercel AI SDK or an MCP server.
694
747
  */
748
+ export type FactField = "phone" | "address" | "hours" | "service_area";
695
749
  export interface AgentTool {
696
750
  name: string;
697
751
  description: string;
package/dist/index.js CHANGED
@@ -3,6 +3,14 @@ import * as L from "./log.js";
3
3
  import * as S from "./staple.js";
4
4
  import * as M from "./mcp.js";
5
5
  import * as R from "./revocations.js";
6
+ /** The signing policy as data (https://crawlcheck.io/keys#policy): object purposes, purpose chains, start times. */
7
+ export const KEY_POLICY = V.KEY_POLICY;
8
+ /** Build the policy input from a key directory you hold and, optionally, the rotation record saved with it. */
9
+ export const keyPolicyFrom = V.keyPolicyFrom;
10
+ /** Does the signing policy accept this document's signing key for this object at its signing time? */
11
+ export const keyPolicyCheck = V.keyPolicyCheck;
12
+ /** Apply the signing policy to a verification: key_published (retired keys included), key_purpose, and the trust fields. */
13
+ export const applyKeyPolicy = V.applyKeyPolicy;
6
14
  function withTrust(v, kid, published, related = []) {
7
15
  let checks = v.checks;
8
16
  let issuer = null;
@@ -302,11 +310,34 @@ export class CrawlCheck {
302
310
  b.nonce = opts.nonce;
303
311
  if (opts.operation)
304
312
  b.operation = opts.operation;
313
+ if (opts.mcp && opts.mcp.url)
314
+ b.mcp = (opts.mcp.tools_sha256 ? { url: opts.mcp.url, tools_sha256: opts.mcp.tools_sha256 } : { url: opts.mcp.url });
305
315
  return this.post("/api/v1/preflight", b);
306
316
  }
307
- /** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
317
+ kpCache = null;
318
+ /** conf2: the key directory and rotation record, read now (cached 10 minutes), as the signing policy reads them. */
319
+ async publishedKeyPolicy() {
320
+ if (this.kpCache && Date.now() - this.kpCache.at < 600e3)
321
+ return this.kpCache.kp;
322
+ const d = await this.req("GET", "/.well-known/http-message-signatures-directory");
323
+ let rot = null;
324
+ try {
325
+ const r = await this.f(this.base + "/.well-known/security-key-rotation.json", { headers: { accept: "application/json" } });
326
+ if (r.ok)
327
+ rot = await r.json();
328
+ }
329
+ catch {
330
+ rot = null;
331
+ }
332
+ const kp = Object.assign(await keyPolicyFrom(d, rot), { read_at: new Date().toISOString(), source: "online" });
333
+ this.kpCache = { at: Date.now(), kp };
334
+ return kp;
335
+ }
336
+ /** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory and the signing policy must accept it. */
308
337
  async verifyDocument(doc, onlineKeys = true) {
309
- return withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, onlineKeys ? await this.publishedKeyIds() : null, V.relatedKids(doc));
338
+ const kp = onlineKeys ? await this.publishedKeyPolicy() : null;
339
+ const v = withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, kp ? kp.kids : null, V.relatedKids(doc));
340
+ return kp && doc?.signature ? applyKeyPolicy(v, doc, kp) : v;
310
341
  }
311
342
  /**
312
343
  * Preflight, verify the receipt, check it is for THIS operation, and decide. Proceeds on allow; warn only with allowWarn;
@@ -333,11 +364,14 @@ export class CrawlCheck {
333
364
  if (!receipt || typeof receipt !== "object")
334
365
  return no(scope.why);
335
366
  const online = opts.onlineKeys ?? true;
336
- if (!online && !(Array.isArray(opts.publishedKids) && opts.publishedKids.length))
337
- return no(["onlineKeys is false and no publishedKids were pinned: the signer cannot be checked"], receipt);
367
+ if (!online && !opts.keyPolicy && !(Array.isArray(opts.publishedKids) && opts.publishedKids.length))
368
+ return no(["onlineKeys is false and no keyPolicy or publishedKids were pinned: the signer cannot be checked"], receipt);
338
369
  let verification;
339
370
  try {
340
- verification = withTrust(await V.verifyDataDoc(receipt), receipt?.signature?.kid, online ? await this.publishedKeyIds() : opts.publishedKids, V.relatedKids(receipt));
371
+ const kp = online ? await this.publishedKeyPolicy() : opts.keyPolicy || null; // conf2: purpose, retired and compromised keys, not only "published"
372
+ verification = withTrust(await V.verifyDataDoc(receipt), receipt?.signature?.kid, kp ? kp.kids : opts.publishedKids, V.relatedKids(receipt));
373
+ if (kp)
374
+ verification = applyKeyPolicy(verification, receipt, kp);
341
375
  }
342
376
  catch (e) {
343
377
  return no(["the receipt could not be verified: " + (e?.message || e)].concat(scope.why), receipt);
@@ -347,6 +381,8 @@ export class CrawlCheck {
347
381
  why.push("integrity failed: " + verification.checks.filter((c) => c.ok === false).map((c) => c.id).join(", "));
348
382
  if (verification.issuer_trusted !== true)
349
383
  why.push("signed by a key that is not in CrawlCheck's published key directory");
384
+ else if (verification.key_policy_valid === false)
385
+ why.push("the signing policy rejects this key for a decision receipt: " + (verification.checks.find((c) => c.id === "key_purpose") || {}).why);
350
386
  if (why.length)
351
387
  return no(why, receipt, verification);
352
388
  const d = receipt.decision;
@@ -423,6 +459,8 @@ export class CrawlCheck {
423
459
  }
424
460
  /** Look a domain up without sending it: only the first prefixLen hex characters of sha256(domain) leave this process (one more character per 413, up to 8). found false with ready true means no public measurement. */
425
461
  /** Make a signed lockfile for an MCP server (CrawlCheck reads tools/list; no tool is called). Save it; check with checkLock before every connect. */
462
+ /** One signed verdict on a business fact (phone, address, hours, service_area) against what the business publishes: GET /api/v1/field-check. */
463
+ async fieldCheck(domain, field, value) { return this.req("GET", "/api/v1/field-check", { domain: normalizeDomain(domain), field, value }); }
426
464
  async mcpLock(url) { return this.req("GET", "/api/v1/mcp/lock", { url }); }
427
465
  /** Read the server's tools now (through CrawlCheck) and compare with a lockfile; the answer is signed. For a fully offline check, list the tools yourself and call checkLock. */
428
466
  async mcpLockCheck(lock, url) { return this.req("POST", "/api/v1/mcp/lock/check", undefined, { lock, url: url ?? lock?.server?.url ?? undefined }); }
@@ -586,7 +624,31 @@ export function agentTools(cc, opts = {}) {
586
624
  return { proceed: false, why: ["not a public domain name: " + String(a?.site ?? "")] };
587
625
  const g = await cc.guard(domain, action, opts);
588
626
  return { domain, action, proceed: g.proceed, decision: g.receipt ? String(g.receipt.decision) : null, why: g.why, reasons: Array.isArray(g.receipt?.steps) ? g.receipt.steps.map((x) => String(x?.why ?? "")).filter(Boolean) : [], reason_codes: g.receipt?.reason_codes ?? [],
589
- receipt_sha256: g.receipt?.sha256 ?? null, verified: g.verification ? g.verification.accepted : false, alternatives: g.receipt?.alternatives ?? null };
627
+ receipt_sha256: g.receipt?.sha256 ?? null, verified: g.verification ? g.verification.accepted : false, alternatives: g.receipt?.alternatives ?? null,
628
+ facts: g.receipt?.facts ?? null };
629
+ } },
630
+ { name: "crawlcheck_verify_fact",
631
+ description: "Check one fact you hold about a business against what the business itself publishes: its phone number, street address, opening hours or a place it serves. Returns CrawlCheck's signed verdict (published now, published before, or not this business, and who does publish it). Use it before giving someone a phone number, address or hours, especially ones that came from an AI answer or a directory.",
632
+ parameters: { type: "object", additionalProperties: false, required: ["site", "field", "value"], properties: {
633
+ site: { type: "string", description: "The business's website domain or any URL on it" },
634
+ field: { type: "string", enum: ["phone", "address", "hours", "service_area"], description: "Which fact the value is" },
635
+ value: { type: "string", description: "The value you hold, e.g. (720) 555-0100, 1200 Main St, Mon 8am, or a city name" }
636
+ } },
637
+ execute: async (a) => {
638
+ const domain = atDomain(a?.site), field = String(a?.field ?? ""), value = String(a?.value ?? "").trim().slice(0, 200);
639
+ if (!domain)
640
+ return { ok: false, why: ["not a public domain name: " + String(a?.site ?? "")] };
641
+ if (!["phone", "address", "hours", "service_area"].includes(field) || !value)
642
+ return { ok: false, why: ["give field (phone, address, hours or service_area) and value"] };
643
+ let doc;
644
+ try {
645
+ doc = await cc.fieldCheck(domain, field, value);
646
+ }
647
+ catch (e) {
648
+ return { ok: false, domain, field, value, why: [String(e?.message ?? e)] };
649
+ }
650
+ const v = await cc.verifyDocument(doc).catch(() => null);
651
+ return Object.assign({}, doc, { ok: true, verified: v ? (v.accepted ?? v.verified) === true : false, verification: v ? v.summary : "could not verify" });
590
652
  } },
591
653
  { name: "crawlcheck_fetch",
592
654
  description: "Fetch a web page only if CrawlCheck allows reading that site; every redirect to a new domain is checked again before it is followed. Returns the page text (truncated) or, when refused, why. Prefer this over a plain fetch for sites you have not checked.",
package/dist/mcp.js CHANGED
@@ -60,8 +60,12 @@ export function mcpPreflight(cc, opts = {}) {
60
60
  return false;
61
61
  return true;
62
62
  };
63
+ // tscope1: send the server URL and, with a lockfile, its approved tools_sha256, so CrawlCheck reads the live tools from its own
64
+ // vantage and the signed receipt names the endpoint and tool state it covered (a changed list comes back as block).
65
+ const mcpScope = function () { if (!state.url || !/^https:/i.test(state.url))
66
+ return undefined; const h = opts.lock && typeof opts.lock.tools_sha256 === "string" ? opts.lock.tools_sha256 : null; return h ? { url: state.url, tools_sha256: h } : { url: state.url }; };
63
67
  const decide = async function (host) {
64
- const g = await cc.guard(host, "connect", { template, policy: opts.policy, agent: opts.agent, confirm: opts.confirm, allowWarn: !!opts.allowWarn, onlineKeys: opts.onlineKeys });
68
+ const g = await cc.guard(host, "connect", { template, policy: opts.policy, agent: opts.agent, confirm: opts.confirm, allowWarn: !!opts.allowWarn, onlineKeys: opts.onlineKeys, mcp: mcpScope() });
65
69
  state.receipt = g.receipt;
66
70
  state.verification = g.verification;
67
71
  state.decidedAt = Date.now();
package/dist/verify.d.ts CHANGED
@@ -20,6 +20,42 @@ export function verifyThresholdSigners(x: any): Promise<{
20
20
  why: never[];
21
21
  }[]>;
22
22
  export function relatedKids(x: any): any[];
23
+ export function keyPolicyFrom(directory: any, rotation: any): Promise<{
24
+ v: number;
25
+ kids: string[];
26
+ keys: {};
27
+ retired: {};
28
+ compromised: {};
29
+ rotation: boolean;
30
+ }>;
31
+ export function signedAtOf(x: any): number | null;
32
+ export function keyPolicyCheck(x: any, kp: any): {
33
+ ok: false | null;
34
+ code: string | undefined;
35
+ known: boolean;
36
+ purpose: any;
37
+ expected: null;
38
+ why: string | undefined;
39
+ } | {
40
+ ok: boolean;
41
+ code: string;
42
+ known: boolean;
43
+ purpose: any;
44
+ expected: any;
45
+ why: string;
46
+ };
47
+ export function applyKeyPolicy(res: any, x: any, kp: any): any;
48
+ export function publishedKeyPolicy(base: any): Promise<{
49
+ v: number;
50
+ kids: string[];
51
+ keys: {};
52
+ retired: {};
53
+ compromised: {};
54
+ rotation: boolean;
55
+ } & {
56
+ read_at: string;
57
+ source: string;
58
+ }>;
23
59
  export function trustOf(res: any, kid: any, publishedKids: any, related: any): any;
24
60
  export function publishedKeyIds(base: any): Promise<string[]>;
25
61
  export function verifyBundle(b: any, opts: any): Promise<any>;
@@ -28,3 +64,29 @@ export function verifyExport(x: any, opts: any): Promise<any>;
28
64
  export function verifyResolution(x: any, opts: any): Promise<any>;
29
65
  export function verifyDnsTxt(txt: any, opts: any): Promise<any>;
30
66
  export function verifyDataDoc(x: any, opts: any): Promise<any>;
67
+ export namespace KEY_POLICY {
68
+ let v: number;
69
+ let object_purpose: {
70
+ "crawlcheck-decision": string;
71
+ "crawlcheck-log-sth": string;
72
+ "*": string;
73
+ };
74
+ let chains: {
75
+ documents: string[];
76
+ decisions: string[];
77
+ "log heads": string[];
78
+ };
79
+ let starts: {
80
+ requests: null;
81
+ documents: string;
82
+ decisions: string;
83
+ "log heads": string;
84
+ };
85
+ let bound: {
86
+ "crawlcheck-resolve": string;
87
+ "crawlcheck-decision": string;
88
+ };
89
+ let purpose_aliases: {
90
+ "requests and documents": string;
91
+ };
92
+ }
package/dist/verify.js CHANGED
@@ -612,8 +612,10 @@ async function verifyDataDoc0(x) {
612
612
  throw new Error("this runtime has no WebCrypto (crypto.subtle)");
613
613
  const checks = [];
614
614
  const C = (id, ok, why) => checks.push({ id, ok, why });
615
- if (!x || !/^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision|snapshot|visitor|conduct|traffic-index|anchor|anchor-check|content-clock|origin|claims|read-equivalence|rights|rights-feed|mcp-scan|mcp-lock|mcp-lock-check|mcp-shadow|mcp-auth|impersonation|task-canaries|agent-safety-benchmark|compliance-pack|share-of-preflight|incident|revocations|high-risk|log-sth|threshold-verdict|resolve-as-of)$/.test(String(x.kind)) || !x.signature)
616
- throw new Error("not a CrawlCheck data inventory, export part, deletion record, resolve answer, decision receipt, snapshot manifest, visitor verdict, conduct record, traffic index, citation anchor, content clock, origin decision, claim ledger, read-equivalence certificate, rights answer, rights-feed manifest, MCP tool scan, MCP lockfile or lockfile check");
615
+ // 1.7.0: every kind the service signs with this scheme verifies (field checks, opt-out records, AI-visibility and what-moved
616
+ // readings were refused by the old fixed list though their signatures were valid); trust still rests on key_published.
617
+ if (!x || !/^crawlcheck-[a-z0-9]+(-[a-z0-9]+){0,6}$/.test(String(x.kind)) || !x.signature || !x.signature.message || String(x.signature.message).indexOf(DATA_SIG_PREFIX) !== 0)
618
+ throw new Error("not a signed CrawlCheck document (kind crawlcheck-*, signed with the crawlcheck-data-v1 scheme)");
617
619
  const body = {};
618
620
  for (const k of Object.keys(x))
619
621
  if (k !== "sha256" && k !== "signature")
@@ -749,6 +751,152 @@ export function relatedKids(x) {
749
751
  catch (e) { }
750
752
  return out.filter((k, i) => typeof k === "string" && out.indexOf(k) === i);
751
753
  }
754
+ // conf2: the signing policy (https://crawlcheck.io/keys#policy, machine copy spec/key-policy/policy.json) as code. Which key
755
+ // PURPOSE may sign which object at which time, retired keys, compromised keys. Same rule in the Python package.
756
+ export const KEY_POLICY = { v: 2,
757
+ object_purpose: { "crawlcheck-decision": "decisions", "crawlcheck-log-sth": "log heads", "*": "documents" },
758
+ chains: { documents: ["documents", "requests"], decisions: ["decisions", "documents", "requests"], "log heads": ["log heads", "documents", "requests"] },
759
+ starts: { requests: null, documents: "2026-10-11T00:00:00Z", decisions: "2026-11-09T17:00:00Z", "log heads": "2026-11-09T17:00:00Z" },
760
+ bound: { "crawlcheck-resolve": "transparency.sth", "crawlcheck-decision": "resolve" },
761
+ purpose_aliases: { "requests and documents": "requests" } };
762
+ const kpPurpose = (p) => { p = String(p || "requests"); return KEY_POLICY.purpose_aliases[p] || p; };
763
+ const kpMs = (v) => { if (v === null || v === undefined || v === "")
764
+ return null; if (typeof v === "number")
765
+ return v < 1e11 ? v * 1000 : v; const t = Date.parse(String(v)); return isFinite(t) ? t : null; };
766
+ // The key directory (and, when you have it, the rotation record) as the input every key-policy check reads.
767
+ export async function keyPolicyFrom(directory, rotation) {
768
+ const keys = {}, retired = {}, compromised = {};
769
+ for (const k of (directory && directory.keys) || []) {
770
+ if (!k || !k.x)
771
+ continue;
772
+ const kid = b64u(await sha256(enc.encode(JSON.stringify({ crv: k.crv || "Ed25519", kty: k.kty || "OKP", x: k.x }))));
773
+ const purpose = kpPurpose(k.crawlcheck_purpose);
774
+ keys[kid] = { purpose, start: kpMs(k.crawlcheck_signs_from) ?? kpMs(k.nbf) ?? kpMs(KEY_POLICY.starts[purpose]) };
775
+ }
776
+ for (const r of (rotation && rotation.retired) || [])
777
+ if (r && r.thumbprint)
778
+ retired[r.thumbprint] = { purpose: kpPurpose(r.purpose), stopped: kpMs(r.stopped_signing_at) ?? kpMs(r.retired_at) };
779
+ for (const c of (rotation && rotation.compromised) || [])
780
+ if (c && c.thumbprint)
781
+ compromised[c.thumbprint] = kpMs(c.compromised_since) ?? 0;
782
+ return { v: 2, kids: Object.keys(keys), keys, retired, compromised, rotation: !!rotation };
783
+ }
784
+ export function signedAtOf(x) {
785
+ const s = x && x.signature, c = [s && s.signed_at, x && x.decided_at, x && x.answered_at, x && x.sealed_at, x && x.generated_at, x && x.issued_at, x && x.at];
786
+ for (const v of c) {
787
+ const t = kpMs(v);
788
+ if (t !== null)
789
+ return t;
790
+ }
791
+ return null;
792
+ }
793
+ function kpBoundKid(x) {
794
+ const path = KEY_POLICY.bound[x && x.kind];
795
+ if (!path)
796
+ return null;
797
+ const o = path.split(".").reduce((a, p) => (a && typeof a === "object" ? a[p] : undefined), x);
798
+ return (o && o.signature && o.signature.kid) || (o && typeof o.kid === "string" ? o.kid : null);
799
+ }
800
+ // One key against the policy at time t. Returns { known, ok, purpose, why } (ok null: could not be decided).
801
+ function kpKey(kid, t, kp) {
802
+ if (!kid)
803
+ return { known: false, ok: false, code: "key_unknown", why: "no signing key named" };
804
+ if (kp.compromised[kid] !== undefined)
805
+ return { known: false, ok: false, code: "key_compromised", why: "key " + kid + " is listed as compromised since " + new Date(kp.compromised[kid]).toISOString() + ": its signature alone proves nothing (" + (t !== null && t >= kp.compromised[kid] ? "signed after that moment" : "only an outside anchor still proves documents signed before it") + ")" };
806
+ const k = kp.keys[kid];
807
+ if (k) {
808
+ if (t !== null && k.start !== null && t < k.start)
809
+ return { known: true, ok: false, purpose: k.purpose, code: "key_not_yet_active", why: "key " + kid + " (" + k.purpose + ") signs nothing before " + new Date(k.start).toISOString() };
810
+ return { known: true, ok: true, purpose: k.purpose, start: k.start };
811
+ }
812
+ const r = kp.retired[kid];
813
+ if (r) {
814
+ if (t === null)
815
+ return { known: true, ok: null, purpose: r.purpose, code: "no_signing_time", why: "retired key, and the document carries no signing time" };
816
+ return r.stopped !== null && t <= r.stopped ? { known: true, ok: true, purpose: r.purpose, retired: true, why: "retired key, signed before it stopped signing (" + new Date(r.stopped).toISOString() + ")" } : { known: false, ok: false, code: "key_retired", why: "key " + kid + " stopped signing at " + (r.stopped !== null ? new Date(r.stopped).toISOString() : "an unknown time") + "; this was signed after" };
817
+ }
818
+ return { known: false, ok: false, code: "key_unknown", why: "key " + kid + " is not in the published key directory" + (kp.rotation ? " or the rotation record" : "") };
819
+ }
820
+ function kpExpected(objPurpose, t, kp) {
821
+ for (const p of KEY_POLICY.chains[objPurpose]) {
822
+ if (p === "requests")
823
+ return p;
824
+ if (Object.keys(kp.keys).some((kid) => kp.keys[kid].purpose === p && kp.compromised[kid] === undefined && (t === null || kp.keys[kid].start === null || kp.keys[kid].start <= t)))
825
+ return p;
826
+ }
827
+ return "requests";
828
+ }
829
+ // Does the policy accept this signature on this document? { ok: true | false | null, code, purpose, expected, why }
830
+ // code (stable): ok, ok_bound, key_wrong_purpose, key_not_yet_active, key_retired, key_compromised, key_unknown, no_signing_time;
831
+ // applyKeyPolicy adds related_<code> when the key a document leans on (its answer, its log head) fails.
832
+ export function keyPolicyCheck(x, kp) {
833
+ const kid = x && x.signature && x.signature.kid, t = signedAtOf(x);
834
+ const objPurpose = KEY_POLICY.object_purpose[x && x.kind] || KEY_POLICY.object_purpose["*"];
835
+ const k = kpKey(kid, t, kp);
836
+ if (k.ok !== true)
837
+ return { ok: k.ok, code: k.code, known: k.known, purpose: k.purpose || null, expected: null, why: k.why };
838
+ if (t === null)
839
+ return { ok: null, code: "no_signing_time", known: true, purpose: k.purpose, expected: null, why: "the document carries no signing time, so the key's purpose cannot be checked against the policy" };
840
+ const expected = kpExpected(objPurpose, t, kp), chain = KEY_POLICY.chains[objPurpose], at = new Date(t).toISOString();
841
+ if (k.purpose === expected)
842
+ return { ok: true, code: "ok", known: true, purpose: k.purpose, expected, why: "a " + k.purpose + " key signs " + objPurpose + " at " + at + (k.retired ? " (" + k.why + ")" : "") };
843
+ const later = chain.indexOf(k.purpose) > chain.indexOf(expected);
844
+ const bound = kpBoundKid(x), decisionKeyLive = objPurpose === "decisions" && expected === "decisions";
845
+ if (later && bound && bound === kid && !decisionKeyLive)
846
+ return { ok: true, code: "ok_bound", known: true, purpose: k.purpose, expected, why: "signed by the " + k.purpose + " key that signed the object it is bound to (" + KEY_POLICY.bound[x.kind] + "), as the policy allows" };
847
+ return { ok: false, code: "key_wrong_purpose", known: true, purpose: k.purpose, expected, why: "a " + k.purpose + " key does not sign " + objPurpose + " at " + at + ": the " + expected + " key does" + (chain.indexOf(k.purpose) < 0 ? " (" + k.purpose + " keys never sign them)" : "") };
848
+ }
849
+ // Apply the policy to a verification result: key_published (retired keys included), key_purpose, and the trust fields.
850
+ export function applyKeyPolicy(res, x, kp) {
851
+ if (!res || !kp)
852
+ return res;
853
+ const main = keyPolicyCheck(x, kp), t = signedAtOf(x), rel = [];
854
+ let relCode = null;
855
+ if (x && x.kind === "crawlcheck-decision" && x.resolve && x.resolve.kid) {
856
+ const r = kpKey(x.resolve.kid, t, kp);
857
+ if (r.ok !== true) {
858
+ rel.push("the Resolve answer it names: " + r.why);
859
+ relCode = relCode || "related_" + (r.code || "key_unknown");
860
+ }
861
+ }
862
+ const sth = x && x.transparency && x.transparency.sth;
863
+ if (sth && sth.signature) {
864
+ const r = keyPolicyCheck(sth, kp);
865
+ if (r.ok === false) {
866
+ rel.push("its log head: " + r.why);
867
+ relCode = relCode || "related_" + r.code;
868
+ }
869
+ }
870
+ const kpub = { id: "key_published", ok: main.known && !rel.length, why: main.known && !rel.length ? "key " + (x.signature && x.signature.kid) + " is " + (kp.keys[x.signature && x.signature.kid] ? "in the published key directory" : "a retired key listed in the rotation record") + ((x && (x.kind === "crawlcheck-decision" || sth)) ? ", and so is every key it leans on" : "") : (main.known ? "it leans on a key the policy rejects: " + rel.join("; ") : main.why) + ": reject this document" };
871
+ const kpur = { id: "key_purpose", ok: rel.length ? false : main.ok, code: rel.length && main.ok !== false ? relCode : main.code, why: rel.length ? rel.join("; ") : main.why };
872
+ let checks = (res.checks || []).filter((c) => c.id !== "key_published" && c.id !== "key_purpose").concat([kpub, kpur]);
873
+ const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
874
+ const own = checks.filter((c) => c.id !== "key_published" && c.id !== "key_purpose");
875
+ const integrity_valid = own.some((c) => c.ok !== null) && own.every((c) => c.ok !== false);
876
+ const unavailable = checks.filter((c) => c.ok === null).map((c) => c.id);
877
+ const accepted = integrity_valid && kpub.ok === true && kpur.ok !== false;
878
+ return Object.assign({}, res, { checks, verified: ran.length > 0 && failed.length === 0, summary: failed.length ? failed.length + " check(s) FAILED" : ran.length + " of " + checks.length + " checks ran and passed",
879
+ integrity_valid, issuer_trusted: kpub.ok, key_policy_valid: kpur.ok, key_policy_code: kpur.code, accepted, accepted_strict: accepted && unavailable.length === 0, assurance: unavailable.length ? "partial" : "complete", unavailable });
880
+ }
881
+ // The directory and the rotation record, read now (online). The rotation record is optional: without it retired and
882
+ // compromised keys are unknown, and only keys still in the directory are accepted.
883
+ export async function publishedKeyPolicy(base) {
884
+ const b = String(base || "https://crawlcheck.io").replace(/\/+$/, "");
885
+ const r = await fetch(b + "/.well-known/http-message-signatures-directory");
886
+ if (!r.ok)
887
+ throw new Error("key directory answered HTTP " + r.status);
888
+ const d = await r.json();
889
+ let rot = null;
890
+ try {
891
+ const q = await fetch(b + "/.well-known/security-key-rotation.json");
892
+ if (q.ok)
893
+ rot = await q.json();
894
+ }
895
+ catch (e) {
896
+ rot = null;
897
+ }
898
+ return Object.assign(await keyPolicyFrom(d, rot), { read_at: new Date().toISOString(), source: "online" });
899
+ }
752
900
  export function trustOf(res, kid, publishedKids, related) {
753
901
  let checks = Array.isArray(res && res.checks) ? res.checks : [];
754
902
  let issuer = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlcheck/sdk",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Typed client for the CrawlCheck API (generated from its OpenAPI document) and the zero-dependency offline verifier for evidence bundles and remediation receipts.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",