#!/usr/bin/env node // Check before you book: batch, verify offline, read the fields, watch for change. // Usage: https://lodestarstamp.com/check-before-you-book. Node 22+; no dependencies. import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; const STATEMENT_SCHEMA = "https://lodestarstamp.com/schema/receipt-signature-v2"; const FIELDS = ["real", "at_that_address", "licensed"]; const KEY_ENV = "LODESTAR_KEY"; const KEY_SHAPE = /^[A-Za-z0-9._~-]{8,256}$/; const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]"]); // LODESTAR_KEY, if set. It goes to the Trust API only, over HTTPS (or plain HTTP to this // machine, for a local Worker), and never to the discovery card's host. function heldKey(api) { const key = (process.env[KEY_ENV] || "").trim(); if (!key) return ""; if (!KEY_SHAPE.test(key)) throw new Error(`${KEY_ENV} is set but is not an X-Lodestar-Key`); const u = new URL(api); if (u.protocol !== "https:" && !LOOPBACK.has(u.hostname)) throw new Error(`${KEY_ENV} is sent over HTTPS only, and ${api} is not`); return key; } function canonical(v) { if (v === null || typeof v !== "object") return JSON.stringify(v); if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]"; return "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canonical(v[k])).join(",") + "}"; } async function sha256Hex(text) { const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); return Array.from(new Uint8Array(d)).map((b) => b.toString(16).padStart(2, "0")).join(""); } function unb64url(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); return Uint8Array.from(atob(s + "=".repeat((4 - (s.length % 4)) % 4)), (c) => c.charCodeAt(0)); } async function getJson(url, consumer, key = "") { const headers = { Accept: "application/json", "X-Lodestar-Consumer": consumer, "User-Agent": `${consumer} (lodestar reference client)` }; if (key) headers["X-Lodestar-Key"] = key; const path = new URL(url).pathname; let res; try { // fetch copies headers onto a redirect, to any host; a keyed request never follows one. res = await fetch(url, { headers, redirect: key ? "error" : "follow" }); } catch (error) { throw new Error(`no answer from ${path}: ${(error.cause && error.cause.message) || error.message}`); } const text = await res.text(); let body = null; try { body = JSON.parse(text); } catch (_) { body = null; } if (!res.ok) throw new Error(`${res.status} from ${path}: ${(body && (body.message || body.error)) || text.slice(0, 200)}`); return body; } // /v1/changes?since=, every page joined: an answer holds at most `limit` events, and // its next_cursor, sent back as cursor, reads the next until it is null. async function getChanges(api, since, consumer, key = "", maxPages = 1000) { const url = `${api}/v1/changes?since=${encodeURIComponent(since)}`; // A page marked data_source "unavailable" could not be read, so it is not the end. const read = async (pageUrl) => { const page = await getJson(pageUrl, consumer, key); if (page.data_source === "unavailable") throw new Error("the change feed could not be read (data_source: unavailable); try again later"); return page; }; let page = await read(url); const events = [...(page.events || [])]; for (let n = 1; page.next_cursor; n++) { if (n === maxPages) throw new Error(`the change feed did not end within ${maxPages} pages`); page = await read(`${url}&cursor=${encodeURIComponent(page.next_cursor)}`); events.push(...(page.events || [])); } return { ...page, events }; } // What a statement names the business by: the licence number where business.id is // license: (a licence-keyed market), the domain otherwise. function statementSubject(b) { const id = String(b.id || ""); return id.toLowerCase().startsWith("license:") ? ["license", id.slice("license:".length)] : ["domain", String(b.domain || "")]; } async function verify(result, keys) { const sig = result.signature; if (!sig || !sig.signed) return ["unsigned", (sig && sig.reason) || "no signature on the answer"]; const served = await sha256Hex(canonical(result.receipt || {})); const st = sig.statement || {}; if (st.schema !== STATEMENT_SCHEMA) return ["INVALID", "unexpected statement schema"]; if (served !== st.receipt_hash || served !== result.receipt_hash) return ["INVALID", "receipt bytes do not hash to the signed receipt_hash"]; const b = result.business || {}; const [named, value] = statementSubject(b); if (!value || st[named] !== value || st.market !== b.market || st.edition !== result.edition) return ["INVALID", "statement names a different business, market or edition"]; const x = keys[sig.kid]; if (!x) return ["INVALID", `no published key with kid ${sig.kid}`]; const key = await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x }, { name: "Ed25519" }, false, ["verify"]); const ok = await crypto.subtle.verify({ name: "Ed25519" }, key, unb64url(sig.sig), new TextEncoder().encode(canonical(st))); return ok ? ["valid", `kid ${sig.kid}`] : ["INVALID", "Ed25519 signature does not verify"]; } function describe(r, status, reason) { const b = r.business || {}; const lines = [`${b.name || r.query} (${b.domain || r.query})`, ` signature: ${status} (${reason})`, ` edition: ${r.edition} as of ${r.edition_as_of}`]; const fields = (r.receipt && r.receipt.fields) || {}; for (const f of FIELDS) { const env = fields[f] || {}; const state = env.state || "not_published"; const when = state === "verified" ? `, checked ${env.verified_on}, expires ${env.expires_on}` : ""; lines.push(` ${f}: ${state}${when}${env.source_url ? " · " + env.source_url : ""}`); } return lines.join("\n"); } const argv = process.argv.slice(2); const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : dflt; }; const flagValues = new Set(argv.flatMap((a, i) => (a.startsWith("--") ? [argv[i + 1]] : []))); const domains = (argv.find((a) => !a.startsWith("--") && !flagValues.has(a)) || "").split(",").map((s) => s.trim()).filter(Boolean); let api = opt("--api", null); const consumer = opt("--consumer", "reference-client"); const since = opt("--since", null); const fixtureDir = opt("--fixture-dir", null); let batch, keysBody, changes = null; if (fixtureDir) { batch = JSON.parse(readFileSync(join(fixtureDir, "batch.json"), "utf8")); keysBody = JSON.parse(readFileSync(join(fixtureDir, "keys.json"), "utf8")); if (existsSync(join(fixtureDir, "changes.json"))) changes = JSON.parse(readFileSync(join(fixtureDir, "changes.json"), "utf8")); } else { try { if (!api) { const card = await getJson("https://lodestarstamp.com/.well-known/mcp.json", consumer); const endpoint = new URL(card.endpoints.trust); if (endpoint.protocol !== "https:") throw new Error("Discovery must name an HTTPS receipt endpoint"); api = endpoint.origin; } const key = heldKey(api); batch = await getJson(`${api}/v1/trust/batch?domains=${encodeURIComponent(domains.join(","))}`, consumer, key); keysBody = await getJson(`${api}/v1/keys`, consumer, key); if (since) changes = await getChanges(api, since, consumer, key); } catch (error) { console.error(`error: ${error.message}`); process.exit(2); } } const keys = Object.fromEntries((keysBody.keys || []).filter((k) => k.kid && k.x).map((k) => [k.kid, k.x])); console.log(`Lodestar Stamp · ${batch.count} asked · order: ${batch.order}`); console.log("We report source-backed facts as of the date shown. We do not approve the booking. The assistant decides.\n"); let invalid = 0; for (const r of batch.results || []) { if (r.status !== "ok") { console.log(`${r.query}\n ${r.status}: ${r.message} (not adverse: absence is coverage)\n`); continue; } const [status, reason] = await verify(r, keys); if (status === "INVALID") invalid += 1; console.log(describe(r, status, reason) + "\n"); } if (changes) { const wanted = new Set(domains); const moved = (changes.events || []).filter((e) => wanted.has(e.domain)); console.log(`Changes since ${since}: ${moved.length} event(s) on these domains${moved.length ? "" : "."}`); for (const e of moved) { const fields = (e.fields_changed || []).map((c) => `${c.field} ${c.from}→${c.to}`).join(", ") || "re-dated only"; console.log(` ${e.as_of} ${e.type} ${e.domain}: ${fields}`); } } process.exit(invalid ? 1 : 0);