verae-mcp-timestamping-server/site/app.html

1087 lines
43 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verae Timestamping — Browser App</title>
<meta name="description" content="Browser WASM Verae timestamping client: hash locally, Register or Verify via MCP, export receipts. Digests only — file bytes never upload.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="icon" href="assets/VeraeFullLogo.png">
<script src="/assets/vendor/jspdf.umd.min.js"></script>
<style>
:root {
--ink: oklch(0.21 0.020 310);
--ink-soft: oklch(0.42 0.018 310);
--ink-mute: oklch(0.60 0.014 310);
--paper: oklch(0.992 0.004 90);
--soft: oklch(0.972 0.008 85);
--hair: oklch(0.90 0.010 80);
--purple: oklch(0.32 0.110 310);
--purple-mid: oklch(0.42 0.130 312);
--purple-wash: oklch(0.96 0.020 312);
--gold: oklch(0.80 0.090 85);
--ok: oklch(0.55 0.12 145);
--err: oklch(0.55 0.18 25);
--ease: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--ink);
background: var(--paper);
line-height: 1.55;
}
a { color: var(--purple); }
header {
display: flex; align-items: center; justify-content: space-between;
max-width: 1100px; margin: 0 auto; padding: 28px 28px 0;
}
.brand img { height: 46px; width: auto; display: block; }
nav { display: flex; gap: 22px; font-size: 14px; font-weight: 600; flex-wrap: wrap; }
nav a { color: var(--ink-soft); text-decoration: none; }
nav a:hover, nav a.active { color: var(--purple); }
.wrap { max-width: 1100px; margin: 0 auto; padding: 36px 28px 80px; }
.eyebrow {
font-size: 13px; font-weight: 700; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--purple-mid); margin: 0 0 8px;
}
h1 {
font-family: Fraunces, Georgia, serif; font-weight: 560;
font-size: clamp(32px, 5vw, 48px); line-height: 1.1;
letter-spacing: -0.03em; margin: 0 0 12px; max-width: 24ch;
}
.lede { max-width: 48rem; color: var(--ink-soft); margin: 0 0 28px; font-size: 17px; }
.panel {
background: white; border: 1px solid var(--hair); border-radius: 16px;
padding: 22px; margin-bottom: 18px;
}
.panel h2 { margin: 0 0 10px; font-size: 18px; }
.panel h3 { margin: 16px 0 8px; font-size: 14px; color: var(--ink-soft); text-transform: uppercase; letter-spacing: 0.06em; }
.steps { margin: 0; padding-left: 1.2rem; color: var(--ink-soft); }
.steps li { margin: 0.35rem 0; }
.row { display: flex; flex-wrap: wrap; gap: 14px 22px; align-items: flex-end; margin-top: 14px; }
label.field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 600; color: var(--ink-soft); flex: 1 1 12rem; }
select, input[type=text], input[type=password], textarea {
font: inherit; font-weight: 500; color: var(--ink);
border: 1px solid var(--hair); border-radius: 10px; padding: 10px 12px;
min-width: 0; width: 100%; background: var(--soft);
}
textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; min-height: 72px; resize: vertical; }
.formats { display: flex; flex-wrap: wrap; gap: 10px 16px; }
.formats label {
display: inline-flex; align-items: center; gap: 8px;
font-size: 14px; font-weight: 600; color: var(--ink);
background: var(--purple-wash); border-radius: 999px; padding: 8px 14px;
cursor: pointer; border: 1px solid transparent;
}
.formats input { accent-color: var(--purple); }
.mode-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
.mode-btn {
font: inherit; font-weight: 700; border: 1px solid var(--hair); border-radius: 999px;
padding: 11px 20px; cursor: pointer; background: white; color: var(--ink-soft);
}
.mode-btn.active { background: var(--purple); color: white; border-color: var(--purple); }
.drop {
margin-top: 8px; border: 2px dashed var(--hair); border-radius: 16px;
padding: 36px 20px; text-align: center; background: var(--soft);
transition: border-color .2s var(--ease), background .2s var(--ease);
cursor: pointer;
}
.drop.drag { border-color: var(--purple-mid); background: var(--purple-wash); }
.drop strong { display: block; font-size: 17px; margin-bottom: 6px; }
.drop span { color: var(--ink-mute); font-size: 14px; }
.drop input { display: none; }
.btn-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
button, .btn {
font: inherit; font-weight: 700; border: 0; border-radius: 999px;
padding: 11px 18px; cursor: pointer; background: var(--purple); color: white;
}
button.secondary, .btn.secondary { background: white; color: var(--purple); border: 1px solid var(--hair); }
button:disabled { opacity: 0.55; cursor: not-allowed; }
.status { font-size: 14px; color: var(--ink-soft); margin: 12px 0 0; }
.status.err { color: var(--err); }
.status.ok { color: var(--ok); }
.warn {
font-size: 13px; color: var(--ink-mute); background: var(--soft);
border-radius: 10px; padding: 10px 12px; margin-top: 12px;
}
table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 13px; }
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--hair); vertical-align: top; }
th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-mute); }
td.hash, td.receipt { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; word-break: break-all; font-size: 12px; }
.light {
display: inline-flex; align-items: center; gap: 8px; font-weight: 700; font-size: 13px;
}
.light svg { width: 22px; height: 22px; flex-shrink: 0; }
.light.ok { color: var(--ok); }
.light.fail { color: var(--err); }
.light.wait { color: var(--ink-mute); }
.badge {
display: inline-block; font-size: 11px; font-weight: 700; letter-spacing: 0.04em;
text-transform: uppercase; padding: 3px 8px; border-radius: 999px;
}
.badge.ok { background: color-mix(in oklch, var(--ok) 18%, white); color: var(--ok); }
.badge.wait { background: var(--soft); color: var(--ink-mute); }
.badge.fail { background: color-mix(in oklch, var(--err) 16%, white); color: var(--err); }
.meta { font-size: 13px; color: var(--ink-mute); margin-top: 10px; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.92em; }
details.docs summary {
cursor: pointer; font-weight: 700; font-size: 15px; color: var(--purple);
list-style: none;
}
details.docs summary::-webkit-details-marker { display: none; }
details.docs pre {
white-space: pre-wrap; font-size: 13px; line-height: 1.45;
background: var(--soft); border-radius: 12px; padding: 14px 16px;
color: var(--ink-soft); margin: 12px 0 0; overflow-x: auto;
}
.token-hint { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--ink-mute); }
footer {
max-width: 1100px; margin: 0 auto; padding: 0 28px 48px;
display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap;
font-size: 13px; color: var(--ink-mute);
}
@media (max-width: 640px) {
header, .wrap, footer { padding-left: 16px; padding-right: 16px; }
nav { gap: 12px; font-size: 13px; }
.drop { padding: 28px 14px; }
}
</style>
</head>
<body>
<header>
<a class="brand" href="https://www.verae.com/"><img src="assets/VeraeFullLogo.png" alt="Verae"></a>
<nav>
<a href="index.html">Install</a>
<a href="ways.html">Ways to use</a>
<a href="explorer.html">Explorer</a>
<a href="macos.html">macOS</a>
<a href="windows.html">Windows</a>
<a href="app.html" class="active">Browser app</a>
<a href="register.html">Register docs</a>
<a href="sample.html">Sample</a>
<a href="hasher.html">Hasher</a>
</nav>
</header>
<main class="wrap">
<p class="eyebrow">Browser client · WASM hash · Mac-parity</p>
<h1>Verae timestamping</h1>
<p class="lede">Full browser client matching the Mac app: hash files locally (WASM, WebCrypto fallback), <strong>Register</strong> or <strong>Verify</strong> digests via MCP, green/red status lights, auth defaults, and CSV / JSON / PDF / text export. File bytes never leave this device.</p>
<section class="panel">
<details class="docs">
<summary>Docs (brief — same ideas as Mac Docs)</summary>
<pre>Verae Timestamping (Browser)
This page hashes files in your browser (SHA-256 WASM or Web Crypto), then
calls the Verae MCP with digests only — file bytes never upload.
• Register = look each digest up for free; documents already registered
show their original time. New documents need Sign in with Verae (the
same sign-in as the Verae apps); the price and your balance are shown
before anything is charged. Receipts are saved in a local map
(localStorage, keyed by sha256), similar to ~/.verae/receipts on Mac.
• Verify = look up local receipt → MCP verify by receipt_id; or use a
pasted receipt_id. Does NOT stamp. If no receipt is known → Not found.
• Green check = found / valid / registered · Red octagon = not found /
invalid / error. SHA-256 is always shown when hashed.
• Sign-in keeps a per-tab Verae key in sessionStorage only (gone when the
tab closes; Sign out revokes it). No password or key is stored in
localStorage or cookies. Lookups and Verify never send the key.
MCP default: https://mcp.verae.ai/mcp</pre>
</details>
</section>
<section class="panel" id="defaults">
<h2>Defaults &amp; Verae account</h2>
<div class="row">
<label class="field">MCP URL
<input type="text" id="mcpUrl" spellcheck="false" autocomplete="off">
</label>
<label class="field" style="flex:0 1 10rem">Scope
<select id="scope">
<option value="global" selected>global</option>
<option value="shared">shared</option>
<option value="local">local</option>
</select>
</label>
</div>
<div class="btn-row">
<button type="button" id="saveDefaultsBtn">Save defaults</button>
</div>
<h3>Verae account</h3>
<div id="veraeSignIn"></div>
<p class="status" id="authStatus"></p>
</section>
<section class="panel">
<h2>Mode</h2>
<div class="mode-row" role="group" aria-label="Register or Verify">
<button type="button" class="mode-btn active" id="modeRegister" data-mode="register">Register</button>
<button type="button" class="mode-btn" id="modeVerify" data-mode="verify">Verify</button>
</div>
<div class="row" id="receiptPasteRow" hidden>
<label class="field">Optional receipt ID (verify when no local receipt)
<input type="text" id="receiptPaste" spellcheck="false" placeholder="bafy… or receipt CID">
</label>
</div>
<div class="row" style="margin-top:18px">
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink-soft);margin-bottom:8px">Export formats</div>
<div class="formats">
<label><input type="checkbox" name="fmt" value="json" checked> JSON</label>
<label><input type="checkbox" name="fmt" value="csv" checked> CSV</label>
<label><input type="checkbox" name="fmt" value="text" checked> Text</label>
<label><input type="checkbox" name="fmt" value="pdf" checked> PDF certificate</label>
</div>
</div>
</div>
</section>
<section class="panel">
<h2>Documents</h2>
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Drop files here">
<strong>Drop files here</strong>
<span>or click to choose · multiple files supported · digests only leave the browser</span>
<input type="file" id="fileInput" multiple>
</div>
<div class="btn-row">
<button type="button" id="runBtn" disabled>Register</button>
<button type="button" class="secondary" id="exportBtn" disabled>Download exports</button>
<button type="button" class="secondary" id="clearBtn">Clear</button>
</div>
<p class="status" id="status">Waiting for files…</p>
<div style="overflow-x:auto;margin-top:12px">
<table id="results" hidden>
<thead>
<tr>
<th>Status</th>
<th>File</th>
<th>Result</th>
<th>Original timestamp</th>
<th>SHA-256</th>
<th>Receipt</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<p class="meta">Lookup URL (PDF QR): <code id="lookupHint"></code></p>
</section>
</main>
<footer>
<span>Files never leave this browser. Digests only.</span>
<span><a href="index.html">Install</a> · <a href="ways.html">Ways to use</a> · <a href="ways.html#testflight">TestFlight</a> · <a href="windows.html">Windows</a> · <a href="register.html">Register docs</a> · <a href="https://api.veraetime.net/docs/swagger/">API docs</a></span>
</footer>
<script type="module">
import QRCodeDefault, { toDataURL as qrToDataURL } from "/assets/vendor/qrcode.esm.js";
import { mountSignIn, currentKey, confirmCharge } from "/assets/verae-signin.js";
const JsPDFEsm = undefined; // jsPDF is self-hosted (UMD, window.jspdf); no third-party scripts on pages that hold a key
const QRCode = {
toDataURL: (typeof QRCodeDefault?.toDataURL === "function")
? QRCodeDefault.toDataURL.bind(QRCodeDefault)
: qrToDataURL
};
function getJsPDF() {
if (window.jspdf && window.jspdf.jsPDF) return window.jspdf.jsPDF;
if (typeof JsPDFEsm === "function") return JsPDFEsm;
throw new Error("jsPDF failed to load. Try a refresh.");
}
const LS_DEFAULTS = "verae.client.defaults";
const LS_AUTH_LEGACY = "verae.auth"; // old username/password/JWT store: removed on load (DEC-657)
const LS_RECEIPTS = "verae.client.receipts";
const $ = (id) => document.getElementById(id);
const mcpUrlEl = $("mcpUrl");
const scopeEl = $("scope");
const authStatusEl = $("authStatus");
const drop = $("drop");
const fileInput = $("fileInput");
const runBtn = $("runBtn");
const exportBtn = $("exportBtn");
const clearBtn = $("clearBtn");
const statusEl = $("status");
const results = $("results");
const tbody = $("tbody");
const lookupHint = $("lookupHint");
const receiptPasteRow = $("receiptPasteRow");
const receiptPasteEl = $("receiptPaste");
const host = location.hostname || "mcp.verae.ai";
const defaultMcp = (host && host !== "localhost" && host !== "127.0.0.1")
? ("https://" + host + "/mcp")
: "https://mcp.verae.ai/mcp";
/** @type {"register"|"verify"} */
let mode = "register";
/** @type {{name:string,size:number,file:File,sha256?:string,receipt?:object,error?:string,state:string,kind?:string,statusLabel?:string}[]} */
let items = [];
let hasherMod = null;
function publicBase() {
if (location.origin && location.origin !== "null") return location.origin;
return "https://mcp.verae.ai";
}
function lookupUrl(sha, receipt) {
const u = new URL(publicBase() + "/app.html");
if (sha) u.searchParams.set("sha256", sha);
if (receipt) u.searchParams.set("receipt", receipt);
return u.toString();
}
lookupHint.textContent = publicBase() + "/app.html?sha256=…&receipt=…";
function loadDefaults() {
let d = {};
try { d = JSON.parse(localStorage.getItem(LS_DEFAULTS) || "{}") || {}; } catch (_) {}
mcpUrlEl.value = d.mcpUrl || defaultMcp;
scopeEl.value = d.scope || "global";
try { localStorage.removeItem(LS_AUTH_LEGACY); } catch (_) {}
}
function saveDefaults() {
const defaults = { mcpUrl: mcpUrlEl.value.trim() || defaultMcp, scope: scopeEl.value || "global" };
localStorage.setItem(LS_DEFAULTS, JSON.stringify(defaults));
setAuthStatus("Saved defaults (MCP URL and scope only).", false);
}
function setAuthStatus(msg, isErr) {
authStatusEl.textContent = msg;
authStatusEl.className = "status" + (isErr ? " err" : " ok");
}
function loadReceiptMap() {
try { return JSON.parse(localStorage.getItem(LS_RECEIPTS) || "{}") || {}; }
catch (_) { return {}; }
}
function saveReceiptForSha(sha, receipt, sourceName) {
if (!sha || !receipt) return;
const map = loadReceiptMap();
map[sha.toLowerCase()] = {
sha256: sha.toLowerCase(),
receipt,
sourceName: sourceName || "",
saved_at: new Date().toISOString()
};
localStorage.setItem(LS_RECEIPTS, JSON.stringify(map));
}
function getLocalReceipt(sha) {
if (!sha) return null;
const entry = loadReceiptMap()[sha.toLowerCase()];
return entry && entry.receipt ? entry.receipt : null;
}
async function loadHasher() {
if (hasherMod) return hasherMod;
const origin = (location.origin && location.origin !== "null") ? location.origin : "https://mcp.verae.ai";
const bases = [
origin + "/hasher",
"https://mcp.verae.ai/hasher",
"https://mcp.georgelambert.org/hasher"
];
const errors = [];
for (const b of bases) {
try {
const latestRes = await fetch(b + "/latest.json", { cache: "no-cache" });
if (!latestRes.ok) throw new Error(b + "/latest.json -> " + latestRes.status);
const latest = await latestRes.json();
let manUrl = latest.manifest || (b + "/" + latest.version + "/manifest.json");
if (latest.version) {
const same = b + "/" + latest.version + "/manifest.json";
try {
const r = await fetch(same, { cache: "no-cache" });
if (r.ok) manUrl = same;
} catch (_) {}
}
const manRes = await fetch(manUrl, { cache: "no-cache" });
if (!manRes.ok) throw new Error(manUrl + " -> " + manRes.status);
const man = await manRes.json();
const base = (b + "/" + (latest.version || "0.1.2")).replace(/\/$/, "");
const jsName = (man.wasm && man.wasm.js) || "verae_sha256.js";
const mod = await import(base + "/" + jsName);
await mod.default(base + "/" + ((man.wasm && man.wasm.module) || "verae_sha256_bg.wasm"));
hasherMod = mod;
return mod;
} catch (e) {
errors.push(String(e && e.message ? e.message : e));
}
}
hasherMod = null;
throw new Error("Could not load WASM hasher (" + errors.join("; ") + ")");
}
async function hashFileSubtle(file) {
const buf = await file.arrayBuffer();
const dig = await crypto.subtle.digest("SHA-256", buf);
return Array.from(new Uint8Array(dig)).map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function hashFile(file) {
try {
const mod = await loadHasher();
if (mod && mod.Sha256Stream) {
const stream = new mod.Sha256Stream();
const reader = file.stream().getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
stream.update(value);
}
return stream.finish();
}
} catch (e) {
console.warn("WASM hasher unavailable, using Web Crypto", e);
}
if (!(window.crypto && crypto.subtle)) {
throw new Error("No WASM hasher and no Web Crypto SHA-256 in this browser");
}
return hashFileSubtle(file);
}
function rpc(id, method, params) {
return { jsonrpc: "2.0", id, method, params };
}
async function post(url, body, session, key) {
const headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream"
};
if (session) headers["Mcp-Session-Id"] = session;
if (key) headers["Authorization"] = "Bearer " + key; // only for stamp (DEC-655/657); lookups stay anonymous
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
const text = await res.text();
const sessionId = res.headers.get("mcp-session-id") || session;
let parsed = text;
try { parsed = JSON.parse(text); } catch (_) {}
if (!res.ok) throw new Error(res.status + " " + text.slice(0, 400));
return { parsed, sessionId, text };
}
function toolPayload(parsed) {
const r = parsed && parsed.result;
if (!r) return null;
if (r.structuredContent) return r.structuredContent;
const t = r.content && r.content[0] && r.content[0].text;
if (typeof t === "string") {
try { return JSON.parse(t); } catch (_) { return { raw: t }; }
}
return r;
}
function firstReceipt(payload) {
if (!payload) return null;
if (Array.isArray(payload.receipts) && payload.receipts[0]) return payload.receipts[0];
if (payload.receipt) return payload.receipt;
if (payload.receipt_id || payload.hash) return payload;
return null;
}
async function mcpOpen(url, key) {
const init = await post(url, rpc(1, "initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "verae-browser-app", version: "1.1.0" }
}), undefined, key);
await post(url, { jsonrpc: "2.0", method: "notifications/initialized" }, init.sessionId, key);
return init.sessionId;
}
function toolError(call) {
const p = call && call.parsed;
if (p && p.error) return p.error.message || JSON.stringify(p.error);
const r = p && p.result;
if (r && r.isError) return (r.content && r.content[0] && r.content[0].text) || "tool error";
return "";
}
/** Free, anonymous lookup (hash_report): the earliest receipt for the digest, or null. Never stamps. */
async function mcpLookup(url, sha, scope, sessionId) {
const sid = sessionId || await mcpOpen(url);
const call = await post(url, rpc(4, "tools/call", {
name: "hash_report",
arguments: { sha256: sha, want_scope: scope }
}), sid);
const err = toolError(call);
if (err) throw new Error(err);
const p = toolPayload(call.parsed);
const list = (p && Array.isArray(p.receipts)) ? p.receipts.filter((r) => r && r.receipt_id) : [];
if (!list.length) return null;
list.sort((a, b) => String(a.created_at || "~").localeCompare(String(b.created_at || "~")));
const r = Object.assign({}, list[0]);
if (!r.hash) r.hash = sha;
if (!r.scope) r.scope = scope;
return r;
}
async function mcpStamp(url, sha, scope) {
const key = currentKey();
if (!key) throw new Error("Sign in with Verae to register new documents");
const sessionId = await mcpOpen(url, key);
const call = await post(url, rpc(2, "tools/call", {
name: "stamp",
arguments: { sha256: sha, scope }
}), sessionId, key);
const err = toolError(call);
if (err) throw new Error(err);
const payload = toolPayload(call.parsed);
const receipt = firstReceipt(payload);
if (!receipt) throw new Error("No receipt in stamp response");
try {
const v = await post(url, rpc(3, "tools/call", {
name: "verify",
arguments: { receipt_id: receipt.receipt_id, want_scope: scope }
}), sessionId, key);
const vp = toolPayload(v.parsed);
const vr = firstReceipt(vp) || (vp && vp.valid !== undefined ? Object.assign({}, receipt, vp) : null);
if (vr) {
if (vr.merkle && !receipt.merkle) receipt.merkle = vr.merkle;
if (vr.created_at && !receipt.created_at) receipt.created_at = vr.created_at;
if (vr.cube_id) receipt.cube_id = vr.cube_id;
if (vr.org_id) receipt.org_id = vr.org_id;
receipt._verified = true;
}
} catch (_) {}
return receipt;
}
/** MCP verify by receipt_id only — never stamps. */
async function mcpVerify(url, receiptId, scope) {
const sessionId = await mcpOpen(url);
const call = await post(url, rpc(2, "tools/call", {
name: "verify",
arguments: { receipt_id: receiptId, want_scope: scope }
}), sessionId);
if (call.parsed && call.parsed.error) {
const msg = call.parsed.error.message || JSON.stringify(call.parsed.error);
return { ok: false, error: msg, receipt: null, payload: null };
}
const payload = toolPayload(call.parsed);
const receipt = firstReceipt(payload) || {};
const valid = payload && (payload.valid === true || payload.valid === "true"
|| payload.status === "valid" || payload.found === true
|| !!(receipt.receipt_id || receipt.hash || receipt.created_at));
const invalid = payload && (payload.valid === false || payload.status === "invalid"
|| payload.found === false);
if (invalid) {
return { ok: false, error: (payload.error || payload.message || "Invalid"), receipt, payload };
}
if (!valid && !receipt.receipt_id) {
return { ok: false, error: "Not found", receipt: null, payload };
}
if (!receipt.receipt_id) receipt.receipt_id = receiptId;
if (payload && payload.merkle && !receipt.merkle) receipt.merkle = payload.merkle;
if (payload && payload.created_at && !receipt.created_at) receipt.created_at = payload.created_at;
if (payload && payload.hash && !receipt.hash) receipt.hash = payload.hash;
return { ok: true, receipt, payload };
}
function setStatus(msg, isErr) {
statusEl.textContent = msg;
statusEl.className = "status" + (isErr ? " err" : "");
}
function iconOk() {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="currentColor" opacity="0.15"/><path fill="currentColor" d="M9.2 16.2 5.8 12.8l1.4-1.4 2 2 5.6-5.6 1.4 1.4z"/></svg>';
}
function iconFail() {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 2 22 12 12 22 2 12 12 2zm3.5 13.1L14.1 16.5 12 14.4 9.9 16.5 8.5 15.1 10.6 13 8.5 10.9 9.9 9.5 12 11.6 14.1 9.5 15.5 10.9 13.4 13z"/></svg>';
}
function iconWait() {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path fill="currentColor" d="M11 7h2v6h-2zm0 8h2v2h-2z"/></svg>';
}
function renderTable() {
results.hidden = items.length === 0;
tbody.innerHTML = "";
for (const it of items) {
const tr = document.createElement("tr");
let light = '<span class="light wait">' + iconWait() + " queued</span>";
let badge = '<span class="badge wait">queued</span>';
if (it.state === "working") {
light = '<span class="light wait">' + iconWait() + " working</span>";
badge = '<span class="badge wait">working</span>';
} else if (it.state === "done" || it.kind === "registered" || it.kind === "valid" || it.kind === "found") {
const label = it.statusLabel || (it.kind === "registered" ? "Registered" : "Valid");
light = '<span class="light ok">' + iconOk() + " " + escapeHtml(label) + "</span>";
badge = '<span class="badge ok">ok</span>';
} else if (it.state === "not_found" || it.kind === "not_found") {
light = '<span class="light fail">' + iconFail() + " Not found</span>";
badge = '<span class="badge fail">not found</span>';
} else if (it.state === "error" || it.kind === "invalid" || it.kind === "error") {
const label = it.statusLabel || "Error";
light = '<span class="light fail">' + iconFail() + " " + escapeHtml(label) + "</span>";
badge = '<span class="badge fail">error</span>';
}
const ts = (it.receipt && it.receipt.created_at) || "—";
const sha = it.sha256 || "—";
const rid = (it.receipt && it.receipt.receipt_id) || (it.state === "not_found" ? "—" : (it.error || "—"));
tr.innerHTML =
"<td>" + light + "</td>" +
"<td>" + escapeHtml(it.name) + "<div class=meta>" + it.size + " bytes</div></td>" +
"<td>" + badge + "</td>" +
"<td>" + escapeHtml(String(ts)) + "</td>" +
"<td class=hash>" + escapeHtml(sha) + "</td>" +
"<td class=receipt>" + escapeHtml(String(rid)) + "</td>";
tbody.appendChild(tr);
}
const exportable = items.some((i) => i.receipt && (i.state === "done" || i.kind === "registered" || i.kind === "valid" || i.kind === "found"));
exportBtn.disabled = !exportable;
runBtn.disabled = items.length === 0;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[c]);
}
function addFiles(fileList) {
const arr = Array.from(fileList || []);
for (const f of arr) {
items.push({ name: f.name, size: f.size, file: f, state: "queued" });
}
renderTable();
setStatus(items.length + " file(s) ready. Click " + (mode === "verify" ? "Verify" : "Register") + ".");
}
function setMode(next) {
mode = next === "verify" ? "verify" : "register";
$("modeRegister").classList.toggle("active", mode === "register");
$("modeVerify").classList.toggle("active", mode === "verify");
receiptPasteRow.hidden = mode !== "verify";
runBtn.textContent = mode === "verify" ? "Verify" : "Register";
if (items.length) {
setStatus(items.length + " file(s) ready. Click " + runBtn.textContent + ".");
}
}
$("modeRegister").addEventListener("click", () => setMode("register"));
$("modeVerify").addEventListener("click", () => setMode("verify"));
drop.addEventListener("click", () => fileInput.click());
drop.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileInput.click(); }
});
fileInput.addEventListener("change", () => {
addFiles(fileInput.files);
fileInput.value = "";
});
["dragenter", "dragover"].forEach((ev) => {
drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add("drag"); });
});
["dragleave", "drop"].forEach((ev) => {
drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove("drag"); });
});
drop.addEventListener("drop", (e) => addFiles(e.dataTransfer.files));
clearBtn.addEventListener("click", () => {
items = [];
renderTable();
setStatus("Cleared.");
});
$("saveDefaultsBtn").addEventListener("click", () => saveDefaults());
const signin = mountSignIn($("veraeSignIn"));
runBtn.addEventListener("click", async () => {
const url = mcpUrlEl.value.trim();
const scope = scopeEl.value || "global";
if (!url) { setStatus("Set an MCP URL.", true); return; }
runBtn.disabled = true;
exportBtn.disabled = true;
const pastedRid = (receiptPasteEl.value || "").trim();
const fresh = [];
try {
setStatus("Loading local WASM hasher…");
try { await loadHasher(); } catch (e) {
console.warn(e);
setStatus("WASM unavailable — using Web Crypto fallback…");
}
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (mode === "register" && it.state === "done" && it.receipt) continue;
it.state = "working";
it.error = undefined;
it.kind = undefined;
it.statusLabel = undefined;
renderTable();
setStatus("Hashing " + it.name + " (" + (i + 1) + "/" + items.length + ")…");
try {
if (!it.sha256) it.sha256 = await hashFile(it.file);
if (mode === "register") {
setStatus("Looking up " + it.name + "…");
const found = await mcpLookup(url, it.sha256, scope);
if (found) {
it.receipt = found;
saveReceiptForSha(it.sha256, it.receipt, it.name);
it.state = "done";
it.kind = "found";
it.statusLabel = "Already registered";
} else {
it.state = "queued";
fresh.push(it);
}
} else {
// Verify: never stamp
setStatus("Verifying " + it.name + "…");
const local = getLocalReceipt(it.sha256);
const rid = (local && local.receipt_id) || pastedRid || "";
if (!rid) {
it.state = "not_found";
it.kind = "not_found";
it.statusLabel = "Not found";
it.receipt = undefined;
it.error = "Not found";
} else {
const v = await mcpVerify(url, rid, scope);
if (v.ok && v.receipt) {
it.receipt = v.receipt;
if (!it.receipt.hash) it.receipt.hash = it.sha256;
if (local) saveReceiptForSha(it.sha256, it.receipt, it.name);
it.state = "done";
it.kind = "valid";
it.statusLabel = "Valid";
} else {
const err = (v.error || "Not found").toString();
const isNf = /not\s*found/i.test(err) || err === "Not found";
it.state = isNf ? "not_found" : "error";
it.kind = isNf ? "not_found" : "invalid";
it.statusLabel = isNf ? "Not found" : ("Invalid");
it.error = err;
if (v.receipt) it.receipt = v.receipt;
}
}
}
} catch (err) {
it.state = "error";
it.kind = "error";
it.statusLabel = "Error";
it.error = String(err && err.message ? err.message : err);
}
renderTable();
}
if (mode === "register" && fresh.length) {
setStatus(fresh.length + " new document(s) to register. Sign in with Verae to continue…");
const acct = await signin.ensureSignedIn();
if (!acct) throw new Error("Sign in with Verae to register new documents");
if (!confirmCharge(acct, fresh.length, scope)) {
renderTable();
setStatus("Not registered: " + fresh.length + " new document(s) left unstamped (nothing was charged).");
return;
}
for (const it of fresh) {
it.state = "working";
renderTable();
try {
setStatus("Registering " + it.name + " (scope=" + scope + ")…");
it.receipt = await mcpStamp(url, it.sha256, scope);
if (!it.receipt.hash) it.receipt.hash = it.sha256;
saveReceiptForSha(it.sha256, it.receipt, it.name);
it.state = "done";
it.kind = "registered";
it.statusLabel = "Registered";
} catch (err) {
it.state = "error";
it.kind = "error";
it.statusLabel = "Error";
it.error = String(err && err.message ? err.message : err);
}
renderTable();
}
await signin.refresh();
}
const ok = items.filter((x) => x.state === "done").length;
const nf = items.filter((x) => x.state === "not_found").length;
const bad = items.filter((x) => x.state === "error").length;
setStatus("Finished: " + ok + " ok" + (nf ? ", " + nf + " not found" : "") + (bad ? ", " + bad + " failed" : "") + ".");
} catch (err) {
setStatus(String(err), true);
} finally {
runBtn.disabled = items.length === 0;
}
});
function selectedFormats() {
return Array.from(document.querySelectorAll('input[name="fmt"]:checked')).map((el) => el.value);
}
function rowsForExport() {
return items.filter((i) => i.receipt && (i.state === "done" || i.kind === "registered" || i.kind === "valid" || i.kind === "found")).map((i) => ({
file: i.name,
size_bytes: i.size,
sha256: i.sha256,
scope: i.receipt.scope || scopeEl.value,
receipt_id: i.receipt.receipt_id || "",
created_at: i.receipt.created_at || "",
merkle: i.receipt.merkle || "",
cube_id: i.receipt.cube_id || "",
org_id: i.receipt.org_id || "",
restamp_of: i.receipt.restamp_of || "",
lookup_url: lookupUrl(i.sha256, i.receipt.receipt_id),
blockchain_hint: i.receipt.merkle
? ("merkle/block " + i.receipt.merkle)
: ("CID " + (i.receipt.receipt_id || "") + " on VeraeTime global ledger")
}));
}
function downloadBlob(filename, blob) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}
function toCsv(rows) {
const cols = ["file","size_bytes","sha256","scope","receipt_id","created_at","merkle","cube_id","org_id","lookup_url"];
const esc = (v) => {
const s = String(v ?? "");
if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
return s;
};
return cols.join(",") + "\n" + rows.map((r) => cols.map((c) => esc(r[c])).join(",")).join("\n") + "\n";
}
function toText(rows) {
return rows.map((r) => {
return [
"Verae Timestamp Registration",
"File: " + r.file,
"SHA-256: " + r.sha256,
"Scope: " + r.scope,
"Original timestamp: " + r.created_at,
"Receipt ID: " + r.receipt_id,
"Merkle / block: " + (r.merkle || "(pending / not returned)"),
"Ledger location: VeraeTime global (api.veraetime.net) · CID on content-addressed store",
"Lookup: " + r.lookup_url,
"Explorer: https://explorer.verae.ai/",
""
].join("\n");
}).join("\n");
}
let logoDataUrl = null;
async function getLogoDataUrl() {
if (logoDataUrl) return logoDataUrl;
const res = await fetch("assets/VeraeFullLogo.png");
const blob = await res.blob();
logoDataUrl = await new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = reject;
fr.readAsDataURL(blob);
});
return logoDataUrl;
}
async function qrDataUrl(text) {
if (!QRCode || typeof QRCode.toDataURL !== "function") {
throw new Error("QRCode library failed to load");
}
return await QRCode.toDataURL(text, {
errorCorrectionLevel: "M",
margin: 1,
width: 280,
color: { dark: "#2a1838", light: "#ffffff" }
});
}
async function buildPdf(rows) {
const jsPDF = getJsPDF();
const doc = new jsPDF({ unit: "pt", format: "letter" });
const logo = await getLogoDataUrl();
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const margin = 48;
for (let i = 0; i < rows.length; i++) {
if (i > 0) doc.addPage();
const r = rows[i];
doc.setDrawColor(42, 24, 56);
doc.setLineWidth(2);
doc.rect(28, 28, pageW - 56, pageH - 56);
doc.setLineWidth(0.6);
doc.setDrawColor(196, 168, 90);
doc.rect(34, 34, pageW - 68, pageH - 68);
try { doc.addImage(logo, "PNG", margin, 48, 120, 36); } catch (_) {}
doc.setFont("times", "bold");
doc.setFontSize(18);
doc.setTextColor(42, 24, 56);
doc.text("Verae Timestamp Registration Document", pageW / 2, 110, { align: "center" });
doc.setFont("helvetica", "normal");
doc.setFontSize(10);
doc.setTextColor(90, 80, 100);
doc.text("Official certificate of digital content registration on the VeraeTime global ledger", pageW / 2, 128, { align: "center" });
doc.setDrawColor(196, 168, 90);
doc.setLineWidth(1);
doc.line(margin, 142, pageW - margin, 142);
const fields = [
["Document name", r.file],
["Content SHA-256", r.sha256],
["Registration scope", (r.scope || "global").toUpperCase()],
["Original timestamp (UTC)", r.created_at || "—"],
["Receipt ID (CID)", r.receipt_id || "—"],
["Merkle / block hash", r.merkle || "See ledger lookup via receipt CID"],
["Ledger location", "VeraeTime global ledger · api.veraetime.net"],
["Blockchain explorer", "https://explorer.verae.ai/"],
["Public lookup URL", r.lookup_url]
];
let y = 168;
doc.setFontSize(10);
for (const [label, value] of fields) {
doc.setFont("helvetica", "bold");
doc.setTextColor(42, 24, 56);
doc.text(label, margin, y);
doc.setFont("helvetica", "normal");
doc.setTextColor(40, 40, 50);
const lines = doc.splitTextToSize(String(value), pageW - margin * 2 - 10);
doc.text(lines, margin, y + 14);
y += 14 + lines.length * 12 + 10;
if (y > pageH - 220) break;
}
const qr = await qrDataUrl(r.lookup_url);
const qrSize = 110;
const qrX = pageW - margin - qrSize;
const qrY = pageH - margin - qrSize - 36;
doc.addImage(qr, "PNG", qrX, qrY, qrSize, qrSize);
doc.setFontSize(8);
doc.setTextColor(90, 80, 100);
doc.text("Scan to look up this registration", qrX + qrSize / 2, qrY + qrSize + 14, { align: "center" });
doc.setFontSize(8);
doc.text(
"This document certifies that the SHA-256 digest above was registered with Verae. File bytes were not uploaded; only the digest was submitted via MCP.",
margin, pageH - 52, { maxWidth: pageW - margin * 2 - qrSize - 16 }
);
doc.setFont("helvetica", "bold");
doc.setTextColor(42, 24, 56);
doc.text("Verae · verae.com · mcp.verae.ai", margin, pageH - 36);
}
return doc.output("blob");
}
exportBtn.addEventListener("click", async () => {
const rows = rowsForExport();
if (!rows.length) { setStatus("Nothing to export yet.", true); return; }
const fmts = selectedFormats();
if (!fmts.length) { setStatus("Select at least one export format.", true); return; }
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
try {
if (fmts.includes("json")) {
downloadBlob("verae-registrations-" + stamp + ".json",
new Blob([JSON.stringify({ generated_at: new Date().toISOString(), scope_default: scopeEl.value || "global", receipts: rows }, null, 2)], { type: "application/json" }));
}
if (fmts.includes("csv")) {
downloadBlob("verae-registrations-" + stamp + ".csv",
new Blob([toCsv(rows)], { type: "text/csv" }));
}
if (fmts.includes("text")) {
downloadBlob("verae-registrations-" + stamp + ".txt",
new Blob([toText(rows)], { type: "text/plain" }));
}
if (fmts.includes("pdf")) {
setStatus("Building PDF certificate(s)…");
getJsPDF();
await qrDataUrl("https://mcp.verae.ai/app.html");
const blob = await buildPdf(rows);
if (!blob || blob.size < 100) throw new Error("PDF blob empty");
downloadBlob("Verae-Timestamp-Registration-" + stamp + ".pdf", blob);
}
setStatus("Exports downloaded for " + rows.length + " document(s).");
} catch (err) {
setStatus("Export failed: " + err, true);
}
});
// Deep-link: ?sha256=&receipt= — verify-only lookup (never stamps)
(async function hydrateFromQuery() {
const q = new URLSearchParams(location.search);
const sha = (q.get("sha256") || "").trim().toLowerCase();
const receipt = (q.get("receipt") || "").trim();
if (!sha && !receipt) return;
setMode("verify");
if (receipt) receiptPasteEl.value = receipt;
setStatus("Looking up from URL parameters…");
results.hidden = false;
const fake = {
name: "(lookup)",
size: 0,
file: null,
sha256: sha || undefined,
state: "working"
};
items = [fake];
renderTable();
try {
loadDefaults(); // this runs before the bottom-of-script loadDefaults()
const url = mcpUrlEl.value.trim() || defaultMcp;
const scope = scopeEl.value || "global";
let rid = receipt;
if (!rid && sha) {
const local = getLocalReceipt(sha);
rid = (local && local.receipt_id) || "";
if (!rid) {
const found = await mcpLookup(url, sha, scope).catch(() => null); // free lookup, never a stamp
rid = (found && found.receipt_id) || "";
}
}
if (!rid) {
fake.state = "not_found";
fake.kind = "not_found";
fake.statusLabel = "Not found";
fake.error = "Not found";
renderTable();
setStatus("Not found — no receipt in URL, paste field, or localStorage.", true);
return;
}
const v = await mcpVerify(url, rid, scope);
if (!v.ok || !v.receipt) {
const err = (v.error || "Not found").toString();
const isNf = /not\s*found/i.test(err);
fake.state = isNf ? "not_found" : "error";
fake.kind = isNf ? "not_found" : "invalid";
fake.statusLabel = isNf ? "Not found" : "Invalid";
fake.error = err;
renderTable();
setStatus(err, true);
return;
}
fake.receipt = v.receipt;
fake.sha256 = v.receipt.hash || sha || "";
fake.state = "done";
fake.kind = "valid";
fake.statusLabel = "Valid";
fake.name = "Lookup " + (fake.sha256 || rid).slice(0, 16) + "…";
renderTable();
setStatus("Lookup complete. Use Download exports for PDF/CSV/JSON/text.");
} catch (err) {
fake.state = "error";
fake.kind = "error";
fake.statusLabel = "Error";
fake.error = String(err && err.message ? err.message : err);
renderTable();
setStatus(String(err), true);
}
})();
loadDefaults();
setMode("register");
</script>
</body>
</html>