#!/usr/bin/env node // Fifth Domain Notary: 公证工单系统 (canonical tool) // Canon: FIFTH-DOMAIN-BROADCAST-TOWER/TCS-PROTOCOL-FIFTH-DOMAIN-NOTARY-0001.tcs // Append-only hash ledger; tickets immutable except status transitions and appended notes/receipts. import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; const TOWER = process.env.GH_FDT_ROOT ?? "/Volumes/JZAO/铸渊-ICE-GL-ZY001/FIFTH-DOMAIN-BROADCAST-TOWER"; const NOTARY = path.join(TOWER, "notary"); const LEDGER = path.join(NOTARY, "ledger.json"); const TICKETS = path.join(NOTARY, "tickets"); function sha256(value) { return crypto.createHash("sha256").update(value).digest("hex"); } function readJson(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } } function atomicWriteJson(file, value) { fs.mkdirSync(path.dirname(file), { recursive: true }); const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); fs.renameSync(tmp, file); } function canonical(entry) { return JSON.stringify(entry); } function fail(msg) { process.stderr.write(`FDT_NOTARY FAIL ${msg}\n`); process.exit(1); } function safeToken(value, fallback = "unknown") { const token = String(value ?? "").trim().replaceAll(/[^a-zA-Z0-9._-]/gu, "-"); return token || fallback; } const KINDS = new Set(["COLLABORATION", "QUESTION", "AUTHORIZATION_REQUEST", "NOTARIZED_STATEMENT", "HANDOFF"]); function loadLedger() { const ledger = readJson(LEDGER); if (ledger && ledger.schema !== "guanghu.fifth-domain-notary-ledger/v1") fail("LEDGER_CORRUPT"); return ledger ?? { schema: "guanghu.fifth-domain-notary-ledger/v1", entries: [], head: null }; } function appendLedger(action, ticketId, actor, body) { const ledger = loadLedger(); const entry = { seq: ledger.entries.length + 1, ts: new Date().toISOString(), action, ticket_id: ticketId, actor: safeToken(actor), prev_hash: ledger.head, body, }; entry.entry_hash = sha256(canonical(entry)); ledger.entries.push(entry); ledger.head = entry.entry_hash; atomicWriteJson(LEDGER, ledger); return entry; } function ticketPath(id) { return path.join(TICKETS, `${id}.json`); } function loadTicket(id) { const t = readJson(ticketPath(id)); if (!t || t.schema !== "guanghu.fifth-domain-notary-ticket/v1") fail(`TICKET_MISSING_OR_CORRUPT:${id}`); return t; } function saveTicket(t) { atomicWriteJson(ticketPath(t.ticket_id), t); } function todaySeq() { const day = new Date().toISOString().slice(0, 10).replaceAll("-", ""); const ledger = loadLedger(); const ids = new Set(ledger.entries.map((e) => e.ticket_id).filter((id) => id?.startsWith(`FDT-${day}`))); let n = ids.size + 1; let id = `FDT-${day}-${String(n).padStart(3, "0")}`; while (ids.has(id) || fs.existsSync(ticketPath(id))) { n += 1; id = `FDT-${day}-${String(n).padStart(3, "0")}`; } return id; } function valueAfter(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; } function create(argv) { const by = valueAfter(argv, "--by"); const kind = valueAfter(argv, "--kind"); const subject = valueAfter(argv, "--subject"); const bodyFile = valueAfter(argv, "--body-file"); const audience = valueAfter(argv, "--audience") ?? "ANY_INSTANCE"; if (!by || !kind || !subject || !bodyFile) fail("usage: create --by SESSION --kind KIND --subject TEXT --body-file FILE [--audience A]"); if (!KINDS.has(kind)) fail(`INVALID_KIND:${kind}`); let bodyText; try { bodyText = fs.readFileSync(bodyFile, "utf8"); } catch { fail(`BODY_FILE_UNREADABLE:${bodyFile}`); } const id = todaySeq(); const bodyPath = path.join(TICKETS, `${id}.body.md`); fs.mkdirSync(TICKETS, { recursive: true }); fs.writeFileSync(bodyPath, bodyText, { encoding: "utf8", mode: 0o600 }); const ticket = { schema: "guanghu.fifth-domain-notary-ticket/v1", ticket_id: id, kind, subject, audience, creator: safeToken(by), created_at: new Date().toISOString(), status: "OPEN", body_path: bodyPath, body_sha256: sha256(bodyText), history: [], }; const entry = appendLedger("CREATE", id, by, { kind, subject, audience, body_sha256: ticket.body_sha256 }); ticket.history.push({ ts: entry.ts, action: "CREATE", actor: entry.actor, entry_hash: entry.entry_hash }); saveTicket(ticket); process.stdout.write(`FDT CREATED ${id} kind=${kind} creator=${safeToken(by)} ledger_seq=${entry.seq} head=${entry.entry_hash.slice(0, 12)}\n`); } function transition(argv, action) { const id = valueAfter(argv, "--id"); const by = valueAfter(argv, "--by"); const text = valueAfter(argv, "--text") ?? ""; if (!id || !by) fail(`usage: ${action.toLowerCase()} --id FDT-x --by SESSION [--text TEXT]`); const t = loadTicket(id); const allowed = { CLAIM: ["OPEN"], RECEIPT: ["CLAIMED", "OPEN"], CLOSE: ["RECEIPTED", "CLAIMED"], NOTE: ["OPEN", "CLAIMED", "RECEIPTED", "CLOSED"] }; if (!allowed[action].includes(t.status)) fail(`INVALID_TRANSITION:${t.status}->${action}`); const entry = appendLedger(action, id, by, text ? { text } : {}); t.history.push({ ts: entry.ts, action, actor: entry.actor, text: text || undefined, entry_hash: entry.entry_hash }); if (action === "CLAIM") { t.status = "CLAIMED"; t.claimed_by = entry.actor; } if (action === "RECEIPT") t.status = "RECEIPTED"; if (action === "CLOSE") t.status = "CLOSED"; saveTicket(t); process.stdout.write(`FDT ${action} ${id} by=${entry.actor} status=${t.status} head=${entry.entry_hash.slice(0, 12)}\n`); } function list(argv) { const status = valueAfter(argv, "--status"); let ids = []; try { ids = fs.readdirSync(TICKETS).filter((n) => n.endsWith(".json")).sort(); } catch { /* empty */ } for (const name of ids) { const t = readJson(path.join(TICKETS, name)); if (!t || (status && t.status !== status)) continue; process.stdout.write(`${t.ticket_id} [${t.status}] ${t.kind} "${t.subject.slice(0, 40)}" by=${t.creator} -> ${t.audience}\n`); } } function verify() { const ledger = readJson(LEDGER); if (!ledger || ledger.schema !== "guanghu.fifth-domain-notary-ledger/v1") fail("LEDGER_MISSING_OR_CORRUPT"); let prev = null; for (const entry of ledger.entries) { const { entry_hash, ...rest } = entry; if (entry.prev_hash !== prev) fail(`CHAIN_BREAK_AT_SEQ:${entry.seq}`); if (sha256(canonical(rest)) !== entry_hash) fail(`HASH_MISMATCH_AT_SEQ:${entry.seq}`); prev = entry_hash; } if (ledger.head !== prev) fail("HEAD_MISMATCH"); for (const name of fs.readdirSync(TICKETS).filter((n) => n.endsWith(".json"))) { const t = readJson(path.join(TICKETS, name)); if (!t || !Array.isArray(t.history)) fail(`TICKET_CORRUPT:${name}`); const body = fs.readFileSync(t.body_path, "utf8"); if (sha256(body) !== t.body_sha256) fail(`TICKET_BODY_TAMPERED:${t.ticket_id}`); for (const h of t.history) { if (!ledger.entries.some((e) => e.entry_hash === h.entry_hash)) fail(`TICKET_HISTORY_UNANCHORED:${t.ticket_id}:${h.action}`); } } process.stdout.write(`FDT VERIFY PASS entries=${ledger.entries.length} head=${ledger.head.slice(0, 12)}\n`); } function main() { const [command, ...argv] = process.argv.slice(2); switch (command) { case "create": create(argv); break; case "claim": transition(argv, "CLAIM"); break; case "receipt": transition(argv, "RECEIPT"); break; case "note": transition(argv, "NOTE"); break; case "close": transition(argv, "CLOSE"); break; case "list": list(argv); break; case "verify": verify(); break; default: process.stderr.write("usage: fifth-domain-notary.mjs create|claim|receipt|note|close|list|verify\n"); process.exit(2); } } main();