From e57212eaf1804b28fa357f2d1fe2b8ff534c6ab7 Mon Sep 17 00:00:00 2001 From: Aziel Eliab Date: Sat, 10 Oct 2026 23:57:32 -0400 Subject: [PATCH] Thread drop-down: show reasoning plus hash-linked, sealed receipt (not signed), fail closed --- workers/godlock-uk/package.json | 2 +- workers/godlock-uk/src/index.js | 3 + workers/godlock-uk/src/receiptProof.js | 91 +++++++++++++++++++++++ workers/godlock-uk/src/ui.js | 67 ++++++++++++++++- workers/godlock-uk/thread-expand.test.mjs | 71 ++++++++++++++++++ 5 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 workers/godlock-uk/src/receiptProof.js create mode 100644 workers/godlock-uk/thread-expand.test.mjs diff --git a/workers/godlock-uk/package.json b/workers/godlock-uk/package.json index 9bc3b89..626514f 100644 --- a/workers/godlock-uk/package.json +++ b/workers/godlock-uk/package.json @@ -5,7 +5,7 @@ "license": "Apache-2.0", "type": "module", "scripts": { - "test": "node --test steer.test.mjs presence.test.mjs pages.test.mjs engine.test.mjs runtime.test.mjs mesh.test.mjs site-presence.test.mjs donate.test.mjs receipts.test.mjs identity.test.mjs ingest.test.mjs shelves.test.mjs redline.test.mjs submit.test.mjs survival.test.mjs help.test.mjs softwares-ssot.test.mjs sot-outlet.test.mjs debate-score.test.mjs indexnow.test.mjs" + "test": "node --test steer.test.mjs presence.test.mjs pages.test.mjs engine.test.mjs runtime.test.mjs mesh.test.mjs site-presence.test.mjs donate.test.mjs receipts.test.mjs identity.test.mjs ingest.test.mjs shelves.test.mjs redline.test.mjs submit.test.mjs survival.test.mjs help.test.mjs softwares-ssot.test.mjs sot-outlet.test.mjs debate-score.test.mjs indexnow.test.mjs thread-expand.test.mjs" }, "dependencies": { "wrangler": "^4.127.1" diff --git a/workers/godlock-uk/src/index.js b/workers/godlock-uk/src/index.js index d5a8191..d89d527 100644 --- a/workers/godlock-uk/src/index.js +++ b/workers/godlock-uk/src/index.js @@ -23,6 +23,7 @@ import { import { donateDoc, DONATE_RAILS, donateQrIdFromPath } from "./donate.js"; import { handleRuntimeRoot, isRuntimeRequest, runtimeCors } from "./runtimeRoot.js"; import { appendLedger, verifyLedger, ledgerEntriesForId, sha256hex } from "./ledger.js"; +import { receiptProofForId } from "./receiptProof.js"; import { robotsTxt, sitemapXml, citeDoc, llmsDoc, aiDoc, siteOpenApi, mcpDiscoveryDoc, BANNER, DOWNLOAD, DOWNLOAD_STATS, DOWNLOAD_COUNT, GITHUB, AUTHOR, indexNowKeyBody, INDEXNOW_KEY_PATH, INDEXNOW_WELL_KNOWN_PATH, @@ -1276,10 +1277,12 @@ export default { const entries = await ledgerEntriesForId(env, id); const stats = await gatherStats(env, { wrote, ctx }); if (wantsJson(request, url)) { + const proof = await receiptProofForId(env, id); return json({ ok: true, receipt: publicPayload(row), ledger: entries, + proof, current_score: stats.current_score, residual: stats.residual, triad_display: stats.triad_display, diff --git a/workers/godlock-uk/src/receiptProof.js b/workers/godlock-uk/src/receiptProof.js new file mode 100644 index 0000000..5dec3d6 --- /dev/null +++ b/workers/godlock-uk/src/receiptProof.js @@ -0,0 +1,91 @@ +/** + * Receipt proof for the expand (drop-down) view of a public thread. + * Hash-linked and sealed. NOT signed: there is no signature anywhere here. + * + * Fails closed: status is "verified" only when every ledger entry for the + * receipt recomputes to its entry_hash, links to the entry before it, and the + * whole ledger verifies from genesis to the head that is reported as the seal. + * Anything else is "unverified" or "missing". Nothing is invented. + * Author: Aziel Eliab. + */ +import { hashPayload, ZERO } from "./ledger.js"; + +export const PROOF_NOT_SIGNED = "Hash-linked and sealed. Not signed."; + +function parse(blob) { + try { return { ok: true, value: JSON.parse(blob || "{}") }; } catch { return { ok: false, value: {} }; } +} + +/** + * Pure. rows = every ledger row (sequence ASC, raw payload_json), id = receipt id. + * Returns { status, receipt_hash, prev_hash, seal, sealed_at, entries, errors, not_signed }. + */ +export function buildReceiptProof(rows, id) { + const needle = String(id || ""); + const all = Array.isArray(rows) ? rows : []; + const errors = []; + let expectedPrev = ZERO; + let expectedSeq = 1; + let head = ZERO; + let headAt = null; + const mine = []; + for (const row of all) { + const seq = Number(row.sequence); + if (seq !== expectedSeq) errors.push("sequence gap at " + seq); + if (String(row.previous_hash) !== expectedPrev) errors.push("previous_hash mismatch at sequence " + seq); + const p = parse(row.payload_json); + if (!p.ok) errors.push("payload_json not JSON at sequence " + seq); + const recomputed = hashPayload({ + sequence: seq, + timestamp_utc: row.timestamp_utc, + action: row.action, + payload: p.value, + previous_hash: row.previous_hash, + }); + const hashOk = p.ok && recomputed === row.entry_hash; + if (!hashOk) errors.push("entry_hash mismatch at sequence " + seq); + if (needle && String(row.payload_json || "").indexOf(needle) >= 0) { + mine.push({ + sequence: seq, + action: String(row.action), + timestamp_utc: row.timestamp_utc, + entry_hash: String(row.entry_hash), + previous_hash: String(row.previous_hash), + hash_ok: hashOk, + link_ok: String(row.previous_hash) === expectedPrev, + }); + } + expectedPrev = String(row.entry_hash); + expectedSeq = seq + 1; + head = String(row.entry_hash); + headAt = row.timestamp_utc; + } + const base = { not_signed: PROOF_NOT_SIGNED, verify_url: "/verify", entries: mine }; + if (!mine.length) { + return { ...base, status: "missing", receipt_hash: null, prev_hash: null, seal: null, sealed_at: null, errors: ["no ledger entry for this receipt"] }; + } + const first = mine[0]; + const chainOk = errors.length === 0 && mine.every((e) => e.hash_ok && e.link_ok); + return { + ...base, + status: chainOk ? "verified" : "unverified", + receipt_hash: first.entry_hash, + prev_hash: first.previous_hash, + timestamp_utc: first.timestamp_utc, + // The seal is the ledger head the chain verified up to. Only reported when it verified. + seal: chainOk ? head : null, + sealed_at: chainOk ? headAt : null, + errors, + }; +} + +export async function receiptProofForId(env, id) { + try { + const rows = (await env.DB.prepare( + "SELECT sequence, timestamp_utc, action, payload_json, previous_hash, entry_hash FROM ledger ORDER BY sequence ASC" + ).all()).results || []; + return buildReceiptProof(rows, id); + } catch (err) { + return { status: "missing", not_signed: PROOF_NOT_SIGNED, verify_url: "/verify", entries: [], receipt_hash: null, prev_hash: null, seal: null, sealed_at: null, errors: ["ledger read failed"] }; + } +} diff --git a/workers/godlock-uk/src/ui.js b/workers/godlock-uk/src/ui.js index afd113e..609b006 100644 --- a/workers/godlock-uk/src/ui.js +++ b/workers/godlock-uk/src/ui.js @@ -507,13 +507,76 @@ export function priorReceiptItems(rows, { fullChallenge = false } = {}) { return (rows || []).map((r) => { const challenge = fullChallenge ? challengeBlockText(r) : challengePreview(r); return `
  • -
    ${esc(challenge)}
    +
    + ${esc(challenge)} +

    Loading reasoning and receipt…

    +
    ${rescoreLine(r)}
    ${esc(when(r.created_utc))}
    Receipt
  • `; }).join(""); } +/** + * Pure view model for an expanded thread. Input is the /receipt/:id?format=json + * body (or null on fetch failure). Fails closed: only status "verified" from the + * server proof is shown as verified. Never invents reasoning or a receipt. + * Kept free of outer references so it can be inlined via toString(). + */ +export function threadView(j) { + var out = { reasoning: [], receipt: [], status: "missing", note: "Hash-linked and sealed. Not signed." }; + if (!j || j.ok !== true || !j.receipt) { + out.receipt.push(["Receipt", "missing (could not be fetched)"]); + return out; + } + var r = j.receipt; + if (typeof r.summary === "string" && r.summary) out.reasoning.push(["Summary", r.summary]); + if (typeof r.explanation === "string" && r.explanation) out.reasoning.push(["Reasoning", r.explanation]); + if (!out.reasoning.length) out.reasoning.push(["Reasoning", "No reasoning was recorded for this receipt."]); + var p = j.proof; + if (!p || typeof p !== "object") { + out.receipt.push(["Receipt", "missing (no proof returned)"]); + return out; + } + var st = p.status === "verified" ? "verified" : (p.status === "unverified" ? "unverified" : "missing"); + if (st === "verified" && !(p.receipt_hash && p.prev_hash && p.seal)) st = "unverified"; + out.status = st; + out.receipt.push(["Status", st]); + out.receipt.push(["Receipt hash", p.receipt_hash || "missing"]); + out.receipt.push(["Prev hash", p.prev_hash || "missing"]); + out.receipt.push(["Seal (verified ledger head)", st === "verified" ? p.seal : "missing"]); + out.receipt.push(["Timestamp", p.timestamp_utc || "missing"]); + out.receipt.push(["Verify", p.verify_url || "/verify"]); + return out; +} + +export function threadExpandScript() { + return ``; +} + export function nodesLiveFromStats(stats) { const s = stats && typeof stats === "object" ? stats : {}; const mesh = s.mesh && typeof s.mesh === "object" ? s.mesh : {}; @@ -641,6 +704,7 @@ ${latestHtml}

    Prior receipts

    Newest ${esc(HOME_PRIOR_LIMIT)}. Every receipt is kept. Full receipts chain.

    +${threadExpandScript()} `; } @@ -677,6 +741,7 @@ ${steerPanel(s.steer, s)}

    How it is scored · Ask a question.

    ${receiptsPager({ page, pages, total: total != null ? total : receipts })} +${threadExpandScript()} ${ingestTipSection()} ${actReceiptsSection(rows)} `; diff --git a/workers/godlock-uk/thread-expand.test.mjs b/workers/godlock-uk/thread-expand.test.mjs new file mode 100644 index 0000000..b0ccde2 --- /dev/null +++ b/workers/godlock-uk/thread-expand.test.mjs @@ -0,0 +1,71 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { hashPayload, canonicalJson, ZERO } from "./src/ledger.js"; +import { buildReceiptProof } from "./src/receiptProof.js"; +import { threadView, threadExpandScript, priorReceiptItems } from "./src/ui.js"; + +function chain(payloads) { + const rows = []; let prev = ZERO; + payloads.forEach((payload, i) => { + const e = { sequence: i + 1, timestamp_utc: "2026-10-0" + (i + 1) + "T00:00:00.000Z", action: "submit", payload, previous_hash: prev }; + const entry_hash = hashPayload(e); + rows.push({ ...e, payload_json: canonicalJson(payload), entry_hash }); + prev = entry_hash; + }); + return rows; +} + +describe("receipt proof", () => { + const rows = chain([{ id: "aaa" }, { id: "bbb111" }, { id: "ccc" }]); + it("verifies and seals at the ledger head", () => { + const p = buildReceiptProof(rows, "bbb111"); + assert.equal(p.status, "verified"); + assert.equal(p.receipt_hash, rows[1].entry_hash); + assert.equal(p.prev_hash, rows[0].entry_hash); + assert.equal(p.seal, rows[2].entry_hash); + assert.match(p.not_signed, /Not signed/); + }); + it("missing receipt fails closed", () => { + const p = buildReceiptProof(rows, "zzz999"); + assert.equal(p.status, "missing"); assert.equal(p.seal, null); + }); + it("tampered payload is unverified with no seal", () => { + const bad = rows.map((r) => ({ ...r })); + bad[1].payload_json = canonicalJson({ id: "bbb111", x: 1 }); + const p = buildReceiptProof(bad, "bbb111"); + assert.equal(p.status, "unverified"); assert.equal(p.seal, null); + }); + it("tamper elsewhere in the chain also blocks the seal", () => { + const bad = rows.map((r) => ({ ...r })); bad[2].previous_hash = ZERO; + assert.equal(buildReceiptProof(bad, "bbb111").status, "unverified"); + }); +}); + +describe("thread expand view", () => { + it("fetch failure shows receipt missing, no reasoning invented", () => { + const v = threadView(null); + assert.equal(v.status, "missing"); assert.equal(v.reasoning.length, 0); + }); + it("verified proof renders hashes, seal and verify link", () => { + const v = threadView({ ok: true, receipt: { summary: "S", explanation: "E" }, proof: { status: "verified", receipt_hash: "h1", prev_hash: "h0", seal: "h9", timestamp_utc: "t", verify_url: "/verify" } }); + assert.equal(v.status, "verified"); + assert.deepEqual(v.reasoning, [["Summary", "S"], ["Reasoning", "E"]]); + const m = Object.fromEntries(v.receipt); + assert.equal(m["Seal (verified ledger head)"], "h9"); assert.equal(m.Verify, "/verify"); + }); + it("verified without a seal is downgraded", () => { + const v = threadView({ ok: true, receipt: { summary: "S" }, proof: { status: "verified", receipt_hash: "h1", prev_hash: "h0", seal: null } }); + assert.equal(v.status, "unverified"); + }); + it("unverified proof never shows a seal", () => { + const v = threadView({ ok: true, receipt: {}, proof: { status: "unverified", receipt_hash: "h1", prev_hash: "h0", seal: "h9" } }); + assert.equal(Object.fromEntries(v.receipt)["Seal (verified ledger head)"], "missing"); + }); + it("prior rows render a drop-down and the page script parses", () => { + const html = priorReceiptItems([{ id: "abc", created_utc: "2026-10-01T00:00:00Z", challenge_text: "q" }]); + assert.match(html, /
    /); + const js = threadExpandScript().replace(/^