Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Receipt proof reads a receipt's ledger rows by exact receipt_id (no substring scan).
CREATE INDEX IF NOT EXISTS idx_ledger_receipt_id ON ledger(json_extract(payload_json, '$.receipt_id'));
3 changes: 3 additions & 0 deletions workers/godlock-uk/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,6 @@ CREATE INDEX IF NOT EXISTS idx_rescores_receipt ON receipt_rescores(receipt_id,
INSERT OR IGNORE INTO metadata(key, value) VALUES ('current_score', '50');
INSERT OR IGNORE INTO metadata(key, value) VALUES ('views', '0');
INSERT OR IGNORE INTO metadata(key, value) VALUES ('uses', '0');

-- Receipt proof reads a receipt's ledger rows by exact receipt_id (no substring scan).
CREATE INDEX IF NOT EXISTS idx_ledger_receipt_id ON ledger(json_extract(payload_json, '$.receipt_id'));
8 changes: 4 additions & 4 deletions workers/godlock-uk/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import {
} from "./ui.js";
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 { appendLedger, verifyLedger, ledgerEntriesForId, sha256hex, publicEntry } from "./ledger.js";
import { receiptLedgerAndProof } 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,
Expand Down Expand Up @@ -1274,10 +1274,10 @@ export default {
if (wantsJson(request, url)) return json({ ok: false, error: "not found" }, 404);
return html(page("Receipt", receiptBody({ id, row: null, entries: [] }), { path: "/receipt/" + id, kind: "receipt", indexable: false }), { status: 404 });
}
const entries = await ledgerEntriesForId(env, id);
const { rows: ledgerRows, proof } = await receiptLedgerAndProof(env, id);
const entries = ledgerRows.map(publicEntry);
const stats = await gatherStats(env, { wrote, ctx });
if (wantsJson(request, url)) {
const proof = await receiptProofForId(env, id);
return json({
ok: true,
receipt: publicPayload(row),
Expand Down
2 changes: 1 addition & 1 deletion workers/godlock-uk/src/ledger.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export async function ledgerEntriesForId(env, id) {
return out;
}

function publicEntry(row) {
export function publicEntry(row) {
let payload = {};
try { payload = JSON.parse(row.payload_json || "{}"); } catch { payload = {}; }
const safe = { ...payload };
Expand Down
243 changes: 182 additions & 61 deletions workers/godlock-uk/src/receiptProof.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,90 +2,211 @@
* 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.
* D1 cost: a verified checkpoint {seq, hash, count, at} is cached per isolate
* and in the Cache API (no KV writes). Each request does a one-row head probe
* (ORDER BY sequence DESC LIMIT 1), verifies only rows after the checkpoint,
* and reads this receipt's rows by the indexed receipt_id expression.
* The checkpoint keeps the verified entry_hash for every seq (~64 B/row,
* ~4.5 KB today) so receipt rows are matched against verified hashes, not just
* re-hashed in isolation. TODO when the ledger grows large: replace the full
* hash list with a Merkle root plus per-row inclusion paths.
*
* Fails closed: "verified" only when the chain verifies up to the probed head,
* the row count read equals head.seq - checkpoint.seq, and every receipt row
* recomputes to its entry_hash inside the verified range. Otherwise
* "unverified" or "missing", with no seal. Nothing is invented.
* Author: Aziel Eliab.
*/
import { hashPayload, ZERO } from "./ledger.js";

export const PROOF_NOT_SIGNED = "Hash-linked and sealed. Not signed.";
export const VERIFY_PATH = "/verify";
export const CHECKPOINT_CACHE_URL = "https://godlock.uk/__ledger-checkpoint/v1";
export const RECEIPT_ID_EXPR = "json_extract(payload_json, '$.receipt_id')";
export const RECEIPT_ID_INDEX_SQL = "CREATE INDEX IF NOT EXISTS idx_ledger_receipt_id ON ledger(" + RECEIPT_ID_EXPR + ")";
const COLS = "sequence, timestamp_utc, action, payload_json, previous_hash, entry_hash";
export const HEAD_SQL = "SELECT sequence, entry_hash, timestamp_utc FROM ledger ORDER BY sequence DESC LIMIT 1";
export const TAIL_SQL = "SELECT " + COLS + " FROM ledger WHERE sequence > ? ORDER BY sequence ASC";
export const BY_RECEIPT_SQL = "SELECT " + COLS + " FROM ledger WHERE " + RECEIPT_ID_EXPR + " = ? ORDER BY sequence ASC";

const GENESIS = { seq: 0, hash: ZERO, count: 0, at: null, hashes: [] };
let isolateCheckpoint = null;
let indexEnsured = false;

export function _resetProofCache() { isolateCheckpoint = null; indexEnsured = false; }

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 : [];
export function rowHashOk(row) {
const p = parse(row.payload_json);
if (!p.ok) return false;
return hashPayload({
sequence: Number(row.sequence),
timestamp_utc: row.timestamp_utc,
action: row.action,
payload: p.value,
previous_hash: row.previous_hash,
}) === row.entry_hash;
}

/** Pure. Verify rows continuing from checkpoint cp up to head. */
export function verifyForward(cp, rows, head) {
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,
});
const list = Array.isArray(rows) ? rows : [];
let prev = cp.hash;
let seq = cp.seq;
let at = cp.at;
const hashes = (cp.hashes || []).slice();
for (const row of list) {
const s = Number(row.sequence);
if (s !== seq + 1) errors.push("sequence gap at " + s);
if (String(row.previous_hash) !== prev) errors.push("previous_hash mismatch at sequence " + s);
if (!rowHashOk(row)) errors.push("entry_hash mismatch at sequence " + s);
prev = String(row.entry_hash);
hashes.push(prev);
seq = s;
at = row.timestamp_utc;
}
const headSeq = head ? Number(head.sequence) : 0;
const headHash = head ? String(head.entry_hash) : ZERO;
if (list.length !== headSeq - cp.seq) errors.push("partial read: got " + list.length + " rows, head expects " + (headSeq - cp.seq));
if (seq !== headSeq || prev !== headHash) errors.push("chain does not end at probed head");
if (errors.length) return { ok: false, errors };
return { ok: true, errors, checkpoint: { seq: headSeq, hash: headHash, count: headSeq, at, hashes } };
}

function validCheckpoint(cp) {
return !!cp && Number.isInteger(cp.seq) && cp.seq >= 0 && typeof cp.hash === "string" && cp.hash.length === 64
&& cp.count === cp.seq && Array.isArray(cp.hashes) && cp.hashes.length === cp.seq
&& (cp.seq === 0 || cp.hashes[cp.seq - 1] === cp.hash);
}

async function loadCheckpoint() {
if (validCheckpoint(isolateCheckpoint)) return isolateCheckpoint;
try {
if (typeof caches !== "undefined" && caches.default) {
const hit = await caches.default.match(CHECKPOINT_CACHE_URL);
if (hit) {
const cp = await hit.json();
if (validCheckpoint(cp)) { isolateCheckpoint = cp; return cp; }
}
}
expectedPrev = String(row.entry_hash);
expectedSeq = seq + 1;
head = String(row.entry_hash);
headAt = row.timestamp_utc;
} catch { /* cache miss is fine */ }
return null;
}

async function storeCheckpoint(cp) {
isolateCheckpoint = cp;
try {
if (typeof caches !== "undefined" && caches.default) {
await caches.default.put(CHECKPOINT_CACHE_URL, new Response(JSON.stringify(cp), {
headers: { "Content-Type": "application/json", "Cache-Control": "max-age=86400" },
}));
}
} catch { /* isolate copy still holds */ }
}

/** Verified head: cached checkpoint forward to the probed head, else full re-verify. */
export async function verifiedHead(env) {
const head = await env.DB.prepare(HEAD_SQL).first();
const headSeq = head ? Number(head.sequence) : 0;
const headHash = head ? String(head.entry_hash) : ZERO;
let cp = await loadCheckpoint();
if (cp && (cp.seq > headSeq)) cp = null;
if (cp && cp.seq === headSeq) {
if (cp.hash === headHash) return { ok: true, checkpoint: cp, errors: [] };
cp = null;
}
const tryFrom = async (from) => {
const rows = (await env.DB.prepare(TAIL_SQL).bind(from.seq).all()).results || [];
return verifyForward(from, rows, head);
};
let res = cp ? await tryFrom(cp) : null;
if (!res || !res.ok) {
// Checkpoint stale or forked away: re-verify from genesis once.
isolateCheckpoint = null;
res = await tryFrom(GENESIS);
}
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"] };
if (!res.ok) return { ok: false, checkpoint: null, errors: res.errors };
await storeCheckpoint(res.checkpoint);
return { ok: true, checkpoint: res.checkpoint, errors: [] };
}

/**
* A receipt row counts only if its entry_hash equals the verified hash saved at
* its seq and its previous_hash equals the verified hash at seq-1. This catches
* a row below the checkpoint that was rewritten with a recomputed entry_hash.
*/
export function matchesCheckpoint(cp, r) {
if (!cp || !Array.isArray(cp.hashes)) return false;
const seq = Number(r.sequence);
if (!Number.isInteger(seq) || seq < 1 || seq > cp.hashes.length) return false;
const expectPrev = seq === 1 ? ZERO : cp.hashes[seq - 2];
return String(r.entry_hash) === cp.hashes[seq - 1] && String(r.previous_hash) === expectPrev;
}

/** Pure. Build the proof from the verified head and this receipt's rows. */
export function buildReceiptProof(headResult, receiptRows) {
const base = { not_signed: PROOF_NOT_SIGNED, verify_url: VERIFY_PATH };
const rows = Array.isArray(receiptRows) ? receiptRows : [];
const cp = headResult && headResult.ok ? headResult.checkpoint : null;
const entries = rows.map((r) => ({
sequence: Number(r.sequence),
action: String(r.action),
timestamp_utc: r.timestamp_utc,
entry_hash: String(r.entry_hash),
previous_hash: String(r.previous_hash),
hash_ok: rowHashOk(r),
in_verified_range: !!cp && Number(r.sequence) <= cp.seq,
matches_checkpoint: matchesCheckpoint(cp, r),
}));
const errors = (headResult && headResult.errors) ? headResult.errors.slice() : [];
if (!entries.length) {
return { ...base, entries, status: "missing", receipt_hash: null, prev_hash: null, seal: null, sealed_at: null, errors: errors.concat(["no ledger entry for this receipt"]) };
}
const first = mine[0];
const chainOk = errors.length === 0 && mine.every((e) => e.hash_ok && e.link_ok);
const first = entries[0];
const ok = !!cp && entries.every((e) => e.hash_ok && e.in_verified_range && e.matches_checkpoint);
if (!ok && !errors.length) errors.push("receipt entry not inside verified chain");
return {
...base,
status: chainOk ? "verified" : "unverified",
entries,
status: ok ? "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,
seal: ok ? cp.hash : null,
sealed_at: ok ? cp.at : null,
sealed_seq: ok ? cp.seq : null,
errors,
};
}

export async function receiptProofForId(env, id) {
// Fallback only. The real index ships as migrations/0001_ledger_receipt_id_index.sql.
async function ensureReceiptIndex(env) {
if (indexEnsured) return;
await env.DB.prepare(RECEIPT_ID_INDEX_SQL).run();
indexEnsured = true;
}

/** One indexed read of this receipt's ledger rows. Exact receipt_id match, never substring. */
export async function receiptLedgerRows(env, id) {
const needle = String(id || "");
if (!needle) return [];
try { await ensureReceiptIndex(env); } catch { /* query still exact without index */ }
return (await env.DB.prepare(BY_RECEIPT_SQL).bind(needle).all()).results || [];
}

/** Returns { rows, proof }. rows feed the public ledger list; proof fails closed. */
export async function receiptLedgerAndProof(env, id) {
let rows = [];
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"] };
rows = await receiptLedgerRows(env, id);
} catch {
return { rows: [], proof: { status: "missing", not_signed: PROOF_NOT_SIGNED, verify_url: VERIFY_PATH, entries: [], receipt_hash: null, prev_hash: null, seal: null, sealed_at: null, errors: ["ledger read failed"] } };
}
let head;
try { head = await verifiedHead(env); } catch { head = { ok: false, checkpoint: null, errors: ["ledger head read failed"] }; }
return { rows, proof: buildReceiptProof(head, rows) };
}
4 changes: 2 additions & 2 deletions workers/godlock-uk/src/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ export function threadView(j) {
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"]);
out.receipt.push(["Verify", "/verify"]);
return out;
}

Expand All @@ -555,7 +555,7 @@ export function threadExpandScript() {
(function(){
var threadView=${threadView.toString()};
function row(box,k,v,mono){var p=document.createElement("p");var b=document.createElement("b");b.textContent=k+": ";p.appendChild(b);
if(k==="Verify"){var a=document.createElement("a");a.href=v;a.textContent="Verify chain";p.appendChild(a);}
if(k==="Verify"){var a=document.createElement("a");a.href="/verify";a.textContent="Verify chain";p.appendChild(a);}
else{var s=document.createElement("span");s.textContent=String(v);if(mono)s.className="hash";p.appendChild(s);}box.appendChild(p);}
function render(body,j){var m=threadView(j);body.textContent="";
m.reasoning.forEach(function(x){row(body,x[0],x[1],false);});
Expand Down
Loading
Loading