csoai-gspc-mcp 0.2.0 → 0.2.2

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
@@ -23,45 +23,53 @@ are reported as two labelled numbers and never reconciled.
23
23
  | `get_root` | GET `https://councilof.ai/root.json`. Three states: VALID / UNREACHABLE / UNCHECKABLE. Separate from GSPC. Never a certificate. |
24
24
  | `get_card` | GET one card-v0 leaf by sha256. VALID / INVALID (not a leaf) / UNCHECKABLE (fetch failed). A 404 leaf is INVALID, not UNCHECKABLE. |
25
25
  | `verify_inclusion` | GET `/api/proof?sha=`. VALID (included) / INVALID (not a leaf) / UNCHECKABLE (proof endpoint unreachable). |
26
+ | `x402_trust` | Latest x402 catalog trust snapshot: counts of correct challenges and phantom resources. A 402 is a challenge, not delivery. |
26
27
 
27
- Seven free tools above; five metered ones below. `tools/list` returns all twelve, and
28
+ Eight free tools above; four metered ones below. `tools/list` returns all twelve, and
28
29
  `wired-tools.test.mjs` fails if a listed tool does not run or a running tool is not listed.
29
30
 
30
- The same seven free tools, from the same definitions file
31
+ The same eight free tools, from the same definitions file
31
32
  (`functions/mcp/gspc-tools.json`), are served over HTTP at
32
33
  `https://councilof.ai/mcp` (streamable HTTP, JSON-RPC 2.0 POST). Use whichever
33
34
  transport your client speaks; the contracts are identical.
34
35
 
35
- ### The five x402-metered tools — carried here since 0.2.0
36
+ ### The four x402-metered tools
36
37
 
37
38
  | tool | route | free path |
38
39
  |---|---|---|
39
40
  | `commission_card` | `/api/request-attestation` | — (a payment never mints a MEASURED cell) |
40
41
  | `art50_marking_evidence` | `/api/art50/marking-evidence` | `preview: true` |
41
42
  | `rwa_evidence` | `/api/rwa/evidence` | `preview: true` (unsigned state) |
42
- | `witness_hash` | `/api/witness` | — |
43
43
  | `receipts_batch` | `/api/receipts/batch` | `preview: true` (count, span, roots, batch sha256) |
44
44
 
45
45
  Payment travels as the **`x_payment` argument**, not as a transport header — so stdio carries these
46
46
  exactly as the HTTP door does. Up to 0.1.1 this README said the opposite ("stdio has no payment header to
47
47
  forward"); that was a statement about the transport, and it was wrong about the mechanism. The server
48
- forwards your `x_payment` verbatim as the `X-PAYMENT` header on one request to `councilof.ai` and never
49
- inspects, signs or invents a receipt. Settlement is the route's job, fail-closed.
48
+ forwards your `x_payment` verbatim as the `X-PAYMENT` header on one request to `councilof.ai`. It never
49
+ authenticates, signs or invents a receipt; it only classifies the opaque response's receipt shape.
50
+ Settlement is the route's job, fail-closed.
50
51
 
51
- Four honest statuses, and no fifth:
52
+ Top-level statuses describe delivery, not settlement:
52
53
 
53
54
  - **`PAYMENT_REQUIRED`** — the route answered 402. The full challenge (`accepts[]`, the `PAYMENT-REQUIRED`
54
- header) comes back as `structuredContent`. Nothing was charged. A challenge is an answer, not a failure.
55
- - **`DELIVERED`** — the route answered 2xx, with the settle echo when the route sent one.
55
+ header) comes back as `structuredContent`. With no `x_payment`, nothing was charged by that request. If
56
+ an authorization was presented, settlement remains `UNCONFIRMED`; inspect before signing or retrying.
57
+ A challenge is an answer, not a failure.
58
+ - **`DELIVERED`** — the route answered 2xx and returned a deliverable. Inspect `delivery_kind`:
59
+ `PREVIEW_OR_FREE`, `DELIVERED_SETTLEMENT_UNCONFIRMED`, `DELIVERED_RECEIPT_GAP`, or
60
+ `DELIVERED_WITH_ROUTE_RECEIPT`. `receipt_state: PRESENT_UNVERIFIED` means a JWS-shaped receipt was
61
+ present in the route's opaque response; this wrapper has not verified it.
56
62
  - **`NOT_DEPLOYED`** — the route answered 404 on this origin. Said plainly, never a fabricated result.
57
- - **`UNREACHABLE`** / **`BAD_ARGUMENTS`** — the call could not be made. Nothing was charged.
58
-
59
- **Known limitation, stated rather than hidden (2026-09-04):** the free `preview` paths and the 402
60
- challenge work today, but **settlement on the live rail is failing** — a genuine signed EIP-3009
61
- authorization is rejected by the facilitator with HTTP 400 after the buyer signs, because the facilitator
62
- now advertises two x402 dialects for Base and the wrong one is being selected. The fix is written and
63
- tested but not merged. Until it is, treat the paid paths as: challenge yes, delivery no. This server
64
- reports what the route actually said and never converts a failed settlement into a result.
63
+ - **`UNREACHABLE`** / **`BAD_ARGUMENTS`** — the call could not be made. After an authorization is
64
+ presented, a transport failure makes delivery and settlement unknown; never retry blindly.
65
+
66
+ The package does not infer settlement from a challenge, a 2xx, or its own request. It reports delivery
67
+ and the route's settlement evidence separately: a 402 is `PAYMENT_REQUIRED`, a 2xx is `DELIVERED`,
68
+ and a transport failure remains `UNREACHABLE`. A settle echo is `REPORTED_BY_ROUTE`, not independent
69
+ chain verification; a missing or unreadable signed receipt is a named gap and never a silent success.
70
+ `settlement_state` is exactly `NOT_REQUESTED`, `UNCONFIRMED`, or `REPORTED_BY_ROUTE`.
71
+ `receipt_state` on a delivered result is exactly `NOT_REQUESTED`, `ABSENT`, `MISSING`, `UNREADABLE`,
72
+ or `PRESENT_UNVERIFIED`.
65
73
 
66
74
  Every paid deliverable is measurement, not certification; no tool on either transport carries a trust
67
75
  label; amounts appear only inside a 402 challenge.
package/gspc-tools.json CHANGED
@@ -114,6 +114,29 @@
114
114
  ],
115
115
  "additionalProperties": false
116
116
  }
117
+ },
118
+ {
119
+ "name": "x402_trust",
120
+ "description": "GET the latest x402 catalog trust snapshot: counts of how many catalogued x402 resources open a correct 402 challenge vs how many are phantom on the wire. Counts only by doctrine — host details withheld; a 402 is NOT delivery; measurement, never certification.",
121
+ "inputSchema": {
122
+ "type": "object",
123
+ "properties": {},
124
+ "additionalProperties": false
125
+ },
126
+ "outputSchema": {
127
+ "type": "object",
128
+ "properties": {
129
+ "counts": {
130
+ "type": "object"
131
+ },
132
+ "headline": {
133
+ "type": "string"
134
+ },
135
+ "as_of": {
136
+ "type": "string"
137
+ }
138
+ }
139
+ }
117
140
  }
118
141
  ]
119
142
  }
package/index.mjs CHANGED
@@ -32,7 +32,9 @@ import { createInterface } from "node:readline";
32
32
  import { readFileSync, existsSync } from "node:fs";
33
33
  import { fileURLToPath } from "node:url";
34
34
 
35
- const VERSION = "0.2.0";
35
+ const PACKAGE = JSON.parse(readFileSync(fileURLToPath(new URL("./package.json", import.meta.url)), "utf8"));
36
+ const VERSION = PACKAGE.version;
37
+ if (typeof VERSION !== "string" || !VERSION) throw new Error("package.json has no valid version");
36
38
  const ORIGIN = process.env.GSPC_ORIGIN || "https://councilof.ai";
37
39
  const FETCH_TIMEOUT_MS = 15000;
38
40
 
@@ -64,8 +66,9 @@ const FREE_TOOLS = JSON.parse(readFileSync(TOOLS_PATH, "utf8")).tools;
64
66
  *
65
67
  * Payment travels as the `x_payment` TOOL ARGUMENT, not as a transport header, so stdio carries
66
68
  * these exactly as the HTTP door does: the argument is forwarded verbatim as the X-PAYMENT header
67
- * on one same-origin request, and this server never inspects, signs or invents a receipt.
68
- * Settlement is the route's job, fail-closed. Without `x_payment` the tool returns the route's own
69
+ * on one same-origin request. This server never authenticates, signs or invents a receipt; it only
70
+ * classifies the opaque response's receipt shape. Settlement is the route's job, fail-closed.
71
+ * Without `x_payment` the tool returns the route's own
69
72
  * 402 challenge, which is an answer and not a failure — and `preview` is free where a tool offers it.
70
73
  */
71
74
  const PAID_TOOLS_PATH = firstExisting([
@@ -368,6 +371,29 @@ async function verifyInclusion(args) {
368
371
  }
369
372
  }
370
373
 
374
+ /**
375
+ * Mirror the HTTP MCP tool's public x402-trust contract. The snapshot is the
376
+ * canonical measured artefact; this package delegates to it instead of
377
+ * copying counts or manufacturing a trust verdict locally.
378
+ */
379
+ async function x402Trust() {
380
+ const path = "/interop/x402-trust/latest.json";
381
+ try {
382
+ const d = await fetchJson(path);
383
+ return {
384
+ state: "VALID",
385
+ source: `${ORIGIN}${path}`,
386
+ kind: d.kind ?? null,
387
+ as_of: d.as_of ?? null,
388
+ counts: d.counts ?? null,
389
+ headline: d.headline ?? null,
390
+ not_a_certification: true,
391
+ };
392
+ } catch (e) {
393
+ return { ...unreachable(path, e), state: "UNREACHABLE" };
394
+ }
395
+ }
396
+
371
397
  /* ---------------------------------------------------------------- paid tools */
372
398
 
373
399
  const PAID_DOCTRINE =
@@ -413,12 +439,6 @@ function buildPaidRequest(name, args) {
413
439
  u.searchParams.set("asset", str("asset"));
414
440
  if (flag("preview")) u.searchParams.set("preview", "1");
415
441
  break;
416
- case "witness_hash":
417
- if (!str("sha256") && !str("url")) return { error: "sha256 or url is required" };
418
- if (str("sha256")) u.searchParams.set("sha256", str("sha256").toLowerCase());
419
- if (str("url")) u.searchParams.set("url", str("url"));
420
- if (str("label")) u.searchParams.set("label", str("label"));
421
- break;
422
442
  case "receipts_batch":
423
443
  if (!str("from")) return { error: "from is required (ISO-8601)" };
424
444
  u.searchParams.set("from", str("from"));
@@ -431,17 +451,73 @@ function buildPaidRequest(name, args) {
431
451
  return { url: u.toString(), init: { method, headers, ...(body ? { body } : {}) }, route };
432
452
  }
433
453
 
454
+ function inspectReceipt(paymentResponse) {
455
+ if (!paymentResponse) return "ABSENT";
456
+ try {
457
+ const normalized = paymentResponse.replace(/-/g, "+").replace(/_/g, "/");
458
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
459
+ const decoded = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(padded), (c) => c.charCodeAt(0))));
460
+ const receipt = decoded?.extensions?.["offer-receipt"]?.info?.receipt;
461
+ return receipt &&
462
+ typeof receipt === "object" &&
463
+ !Array.isArray(receipt) &&
464
+ receipt.format === "jws" &&
465
+ typeof receipt.signature === "string" &&
466
+ /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(receipt.signature)
467
+ ? "PRESENT_UNVERIFIED"
468
+ : "MISSING";
469
+ } catch {
470
+ return "UNREADABLE";
471
+ }
472
+ }
473
+
474
+ function settlementFields(paymentPresented, paymentResponse) {
475
+ if (paymentResponse) {
476
+ return {
477
+ settlement_state: "REPORTED_BY_ROUTE",
478
+ payment_response_header: paymentResponse,
479
+ };
480
+ }
481
+ if (paymentPresented) return { settlement_state: "UNCONFIRMED" };
482
+ return { settlement_state: "NOT_REQUESTED", nothing_charged: true };
483
+ }
484
+
485
+ function deliveryFields(paymentPresented, paymentResponse) {
486
+ const receiptState = paymentResponse
487
+ ? inspectReceipt(paymentResponse)
488
+ : paymentPresented
489
+ ? "ABSENT"
490
+ : "NOT_REQUESTED";
491
+ if (!paymentPresented) {
492
+ return { delivery_kind: "PREVIEW_OR_FREE", receipt_state: receiptState };
493
+ }
494
+ if (!paymentResponse) {
495
+ return { delivery_kind: "DELIVERED_SETTLEMENT_UNCONFIRMED", receipt_state: receiptState };
496
+ }
497
+ if (receiptState === "PRESENT_UNVERIFIED") {
498
+ return { delivery_kind: "DELIVERED_WITH_ROUTE_RECEIPT", receipt_state: receiptState };
499
+ }
500
+ return {
501
+ delivery_kind: "DELIVERED_RECEIPT_GAP",
502
+ receipt_state: receiptState,
503
+ receipt_gap:
504
+ "The route reported settlement, but its opaque response did not carry a readable offer-receipt JWS. Inspect the route, chain and facilitator; do not retry blindly.",
505
+ };
506
+ }
507
+
434
508
  /**
435
509
  * Forward one paid tool to its route and report what came back, in the route's own words.
436
510
  * 402 is PAYMENT_REQUIRED and not an error; 404 is NOT_DEPLOYED and never a fabricated result.
437
511
  */
438
512
  async function callPaidTool(name, args) {
439
513
  const tool = PAID_BY_NAME.get(name);
514
+ const paymentPresented = typeof args.x_payment === "string" && args.x_payment.trim().length > 0;
440
515
  const base = {
441
516
  tool: name,
442
517
  route: tool.csoai.route,
443
518
  sku: tool.csoai.sku,
444
519
  rail: tool.csoai.rail,
520
+ payment_presented: paymentPresented,
445
521
  doctrine: PAID_DOCTRINE,
446
522
  not_a_certification: true,
447
523
  };
@@ -456,11 +532,30 @@ async function callPaidTool(name, args) {
456
532
  ...base,
457
533
  status: "UNREACHABLE",
458
534
  reason: e instanceof Error ? e.message : String(e),
459
- note: "The route could not be fetched. Nothing was charged, and no result is invented.",
535
+ delivery_state: "UNKNOWN",
536
+ ...settlementFields(paymentPresented),
537
+ note: paymentPresented
538
+ ? "The route could not be fetched. Delivery and settlement are unknown; inspect the wallet, chain and facilitator before signing or retrying."
539
+ : "The route could not be fetched. No payment authorization was presented, and no result is invented.",
460
540
  };
461
541
  }
462
542
 
463
- const text = await res.text();
543
+ let text;
544
+ try {
545
+ text = await res.text();
546
+ } catch {
547
+ return {
548
+ ...base,
549
+ status: "UNREADABLE_RESPONSE",
550
+ http_status: res.status,
551
+ reason: "the evidence route response could not be read",
552
+ delivery_state: "UNKNOWN",
553
+ ...settlementFields(paymentPresented),
554
+ note: paymentPresented
555
+ ? "Delivery and settlement are unknown; inspect the wallet, chain and facilitator before signing or retrying."
556
+ : "No payment authorization was presented, and no result is invented.",
557
+ };
558
+ }
464
559
  let body = null;
465
560
  try {
466
561
  body = text ? JSON.parse(text) : null;
@@ -475,19 +570,57 @@ async function callPaidTool(name, args) {
475
570
  http_status: 402,
476
571
  payment_required: body,
477
572
  payment_required_header: res.headers.get("payment-required"),
573
+ delivery_state: "NOT_DELIVERED",
574
+ ...settlementFields(paymentPresented),
478
575
  note:
479
- "A challenge is an answer, not a failure. Pay from your own wallet against accepts[] and " +
480
- "call again with x_payment. Nothing was charged by this call.",
576
+ "A challenge is an answer, not a failure. " +
577
+ (paymentPresented
578
+ ? "A payment authorization was presented, but this response does not prove settlement. Inspect the wallet, chain and facilitator before signing or retrying."
579
+ : "Pay from your own wallet against accepts[] and call again with x_payment. No payment authorization was presented; nothing was charged by this request."),
481
580
  };
482
581
  }
483
582
  if (res.status === 404) {
484
- return { ...base, status: "NOT_DEPLOYED", http_status: 404, body, note: `${tool.csoai.route} is not on ${ORIGIN}.` };
583
+ return {
584
+ ...base,
585
+ status: "NOT_DEPLOYED",
586
+ http_status: 404,
587
+ body,
588
+ delivery_state: "NOT_DELIVERED",
589
+ ...settlementFields(paymentPresented),
590
+ note: paymentPresented
591
+ ? `${tool.csoai.route} is not on ${ORIGIN}. Settlement is unconfirmed; inspect the wallet, chain and facilitator before signing or retrying.`
592
+ : `${tool.csoai.route} is not on ${ORIGIN}. No payment authorization was presented; nothing was charged by this request.`,
593
+ };
485
594
  }
486
595
  if (res.ok) {
487
596
  const settle = res.headers.get("x-payment-response");
488
- return { ...base, status: "DELIVERED", http_status: res.status, body, ...(settle ? { x_payment_response: settle } : {}) };
597
+ return {
598
+ ...base,
599
+ status: "DELIVERED",
600
+ http_status: res.status,
601
+ body,
602
+ deliverable: body,
603
+ delivery_state: "DELIVERED",
604
+ ...settlementFields(paymentPresented, settle),
605
+ ...deliveryFields(paymentPresented, settle),
606
+ payment_response_header: settle,
607
+ ...(settle ? { x_payment_response: settle } : {}),
608
+ };
489
609
  }
490
- return { ...base, status: String(res.status), http_status: res.status, body };
610
+ const settle = res.headers.get("x-payment-response");
611
+ return {
612
+ ...base,
613
+ status: `HTTP_${res.status}`,
614
+ http_status: res.status,
615
+ body,
616
+ delivery_state: "NOT_DELIVERED",
617
+ ...settlementFields(paymentPresented, settle),
618
+ note: settle
619
+ ? "Settlement was reported by the route; inspect the receipt before retrying."
620
+ : paymentPresented
621
+ ? "Settlement is unconfirmed; inspect the wallet, chain and facilitator before signing or retrying."
622
+ : "No payment authorization was presented; nothing was charged by this request.",
623
+ };
491
624
  }
492
625
 
493
626
  const HANDLERS = {
@@ -498,6 +631,7 @@ const HANDLERS = {
498
631
  get_root: getRoot,
499
632
  get_card: getCard,
500
633
  verify_inclusion: verifyInclusion,
634
+ x402_trust: x402Trust,
501
635
  };
502
636
 
503
637
  /* ----------------------------------------------------------------- transport */
@@ -539,13 +673,14 @@ function summaryLine(name, payload) {
539
673
  return `${payload.state ?? "?"} — card-v0 leaf ${String(payload.sha256 || "").slice(0, 16) || "?"}.`;
540
674
  case "verify_inclusion":
541
675
  return `${payload.state ?? "?"} — inclusion against live merkle.`;
676
+ case "x402_trust":
677
+ return `${payload.state ?? "?"} — ${payload.headline || "catalog trust counts"}.`;
542
678
  case "commission_card":
543
679
  case "art50_marking_evidence":
544
680
  case "rwa_evidence":
545
- case "witness_hash":
546
681
  case "receipts_batch":
547
682
  return `${payload.status ?? "?"} — ${payload.route ?? name}${
548
- payload.status === "PAYMENT_REQUIRED" ? "; nothing charged" : ""
683
+ payload.status === "PAYMENT_REQUIRED" && payload.payment_presented === false ? "; nothing charged" : ""
549
684
  }${payload.reason ? " — " + payload.reason : ""}. ${PAID_DOCTRINE}.`;
550
685
  default:
551
686
  return name;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "csoai-gspc-mcp",
3
- "version": "0.2.0",
4
- "description": "Stdio MCP server for the live GSPC board, signed measurement cards, and public-root merkle at councilof.ai. Twelve tools: seven free readers (board_totals, get_axis, verify_card with a pinned key and a three-state verdict, list_cards, get_root, get_card, verify_inclusion) and five x402-metered ones (commission_card, art50_marking_evidence, rwa_evidence, witness_hash, receipts_batch) that return the route's own 402 challenge until you pay. We measure, never certify; unmeasured is first-class; verdicts are VALID / INVALID / UNCHECKABLE, never two-state. A 404 leaf is INVALID. Zero dependencies.",
3
+ "version": "0.2.2",
4
+ "description": "Stdio MCP server for Council of AI's GSPC board and signed measurement evidence. Twelve tools: eight free readers and four x402-metered evidence tools. Measurement only, never certification. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "csoai-gspc-mcp": "index.mjs"
@@ -50,5 +50,6 @@
50
50
  },
51
51
  "engines": {
52
52
  "node": ">=20"
53
- }
53
+ },
54
+ "mcpName": "io.github.CSOAI-ORG/gspc"
54
55
  }
package/paid-tools.json CHANGED
@@ -49,21 +49,6 @@
49
49
  },
50
50
  "csoai": { "paid": true, "rail": "x402", "route": "/api/rwa/evidence", "sku": "rwa_evidence", "free_preview": "preview=true", "deliverable": "one card-v0 leaf, surface public.notice, kind csoai.eater.xrpl-issuer/0.1", "deployed_by": "live on councilof.ai (verified 2026-09-03)" }
51
51
  },
52
- {
53
- "name": "witness_hash",
54
- "description": "PAID (x402 or CSOAI LTD invoice). Witness a SHA-256 digest — 'attest what you're shown' — via https://councilof.ai/api/witness: one public.notice leaf (csoai.witness.hash/0.1) in the next hourly signed public root, an RFC-3161 timestamp over the digest, and the root's Rekor + OpenTimestamps anchors. Hash-only: name a digest, or a public URL fetched once (robots honoured, never past a login, paywall or bot check → UNCHECKABLE, nothing charged). Attests existence of the digest at the root's as_of — nothing about content, legality or provenance; a self-signed card carries no legal presumption. Measurement, not certification. Status is free at /api/witness/status. Without x_payment the tool returns the 402 challenge. If the route is not deployed on this origin the tool says NOT_DEPLOYED.",
55
- "inputSchema": {
56
- "type": "object",
57
- "properties": {
58
- "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "The digest to witness (64 lowercase hex). Or pass url." },
59
- "url": { "type": "string", "description": "Optional public https URL, fetched once with our UA, hashed, never stored." },
60
- "label": { "type": "string", "description": "Optional label (≤120 chars, no verdict words); appears on the leaf." },
61
- "x_payment": { "type": "string", "description": "The X-PAYMENT header value signed against accepts[] from the previous 402. Omit to receive the challenge." }
62
- },
63
- "additionalProperties": false
64
- },
65
- "csoai": { "paid": true, "rail": "x402-or-invoice", "route": "/api/witness", "sku": "witness_hash", "free_status": "/api/witness/status?sha256=", "deliverable": "one public.notice leaf in the next hourly root + RFC-3161 reply + Rekor/OTS anchors", "deployed_by": "live on councilof.ai (verified 2026-09-03)" }
66
- },
67
52
  {
68
53
  "name": "receipts_batch",
69
54
  "description": "PAID (x402 or CSOAI LTD invoice). A historical batch of the estate's measurement receipts via https://councilof.ai/api/receipts/batch: every signed card-v0 leaf whose as_of falls in [from,to] (≤200), each with its Merkle inclusion path and the public root(s) that carried it, plus the root index for the window and one signed manifest card citing the batch sha256. preview=true is free and returns count, span, root count and the sha256 of the exact bytes the paid call returns. Recent leaves are free at /root.json, /cards/ and /api/proof?sha= — the batch sells assembly across history, never a conclusion. No settlement-receipt stream exists (/api/receipts/latest is UNPUBLISHED) and this tool never claims one. Without x_payment the tool returns the 402 challenge.",
package/verify-card.mjs CHANGED
@@ -30,8 +30,13 @@ const ORIGIN = "https://councilof.ai";
30
30
  // that the file is self-consistent — anyone can alter a body and sign it with a key they
31
31
  // just generated. Authenticity requires pinning to the key published in our DID document:
32
32
  // did:web:csoai.org#card-attestation-1
33
+ const CARD_KEY_ID = "did:web:csoai.org#card-attestation-1";
33
34
  const PINNED_PUBKEY_HEX =
34
35
  "d4cb0eaa16d5f50bf7633a36aa34fe09a55e124b9316ded2abdb122bb9c37e38";
36
+ const PINNED_DID_KEYS = Object.freeze({
37
+ [CARD_KEY_ID]: PINNED_PUBKEY_HEX,
38
+ "did:web:csoai.org#board-attestation-1": "9367cf59be9cb72bbc9796adf056201ec1c58adfeaa13f83b2c5b754d6c20170",
39
+ });
35
40
 
36
41
  // Fields whose values are floats in our schema. See "THE HONEST LIMIT" above.
37
42
  const FLOAT_FIELDS = new Set(["accuracy", "ci_low", "ci_high", "recall", "precision", "f1"]);
@@ -142,18 +147,35 @@ function jcsString(s) {
142
147
  export async function verifyCard(card) {
143
148
  if (!card || typeof card !== "object" || !card.body)
144
149
  return { state: "UNCHECKABLE", reason: "not a card: no body" };
145
- if (!card.pubkey || !card.signature || !card.id)
146
- return { state: "UNCHECKABLE", reason: "missing pubkey, signature or id" };
150
+ if (!card.signature || !card.id)
151
+ return { state: "UNCHECKABLE", reason: "missing signature or id" };
152
+ const hasPubkey = typeof card.pubkey === "string" && !!card.pubkey;
153
+ const hasDid = typeof card.did === "string" && !!card.did;
154
+ if (!hasPubkey && !hasDid)
155
+ return { state: "UNCHECKABLE", reason: "card names neither an inline public key nor a DID key reference" };
156
+ if (hasPubkey && hasDid)
157
+ return { state: "UNCHECKABLE", reason: "card names two key authorities; verifier will not guess" };
147
158
 
148
- if (card.pubkey !== PINNED_PUBKEY_HEX)
149
- return { state: "INVALID", reason: "pubkey is not the published card-attestation key" };
159
+ let keyHex;
160
+ let keyId;
161
+ if (hasDid) {
162
+ keyHex = PINNED_DID_KEYS[card.did];
163
+ keyId = card.did;
164
+ if (!keyHex)
165
+ return { state: "UNCHECKABLE", reason: `DID key reference ${card.did} is not pinned by this verifier` };
166
+ } else {
167
+ if (card.pubkey !== PINNED_PUBKEY_HEX)
168
+ return { state: "INVALID", reason: "pubkey is not the published card-attestation key" };
169
+ keyHex = card.pubkey;
170
+ keyId = CARD_KEY_ID;
171
+ }
150
172
 
151
173
  let preimage;
152
174
  try {
153
175
  // Preimage-rule dispatch (roadmap item 1): absent = legacy CPython v1; "jcs-rfc8785" = JCS v2.
154
176
  // Never re-sign v1 cards — the verifier dispatches on the field.
155
177
  const rule = card.preimage_rule || card.canon || "cpython-v1";
156
- const fn = rule === "jcs-rfc8785" ? canonicalJcs : canonical;
178
+ const fn = rule === "jcs-rfc8785" || rule === "sha256(canonical body)" ? canonicalJcs : canonical;
157
179
  preimage = new TextEncoder().encode(fn(card.body));
158
180
  } catch (e) {
159
181
  return { state: "UNCHECKABLE", reason: `cannot canonicalise: ${e.message}` };
@@ -165,13 +187,13 @@ export async function verifyCard(card) {
165
187
 
166
188
  let key;
167
189
  try {
168
- key = await crypto.subtle.importKey("raw", unhex(card.pubkey), "Ed25519", false, ["verify"]);
190
+ key = await crypto.subtle.importKey("raw", unhex(keyHex), "Ed25519", false, ["verify"]);
169
191
  } catch {
170
192
  return { state: "UNCHECKABLE", reason: "this runtime has no Ed25519 (needs Node 19+)" };
171
193
  }
172
194
  const ok = await crypto.subtle.verify("Ed25519", key, unhex(card.signature), preimage);
173
195
  return ok
174
- ? { state: "VALID", id: card.id, axis: card.body.axis }
196
+ ? { state: "VALID", id: card.id, axis: card.body.axis, keyId }
175
197
  : { state: "INVALID", reason: "signature does not verify under the pinned key" };
176
198
  }
177
199