Commit restored mcp.verae.ai site pages (app, register, macos, windows, explorer), hasher/download artifacts, index/sample/hasher updates, ledger and global_client improvements, NATS stamp helper, and TestFlight enroll service. Ignore debug-trace/ and editor backups. Standard Readme for download and enroll docs.
1151 lines
45 KiB
HTML
1151 lines
45 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="https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/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 = stamp digests via MCP and save a local receipt map in
|
|
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.
|
|
• Defaults + auth persist in localStorage (verae.client.defaults,
|
|
verae.auth). Browser storage is less safe than Keychain — useful for
|
|
demos; prefer a short-lived token in shared machines.
|
|
• MCP sends Authorization: Bearer <token> when a token is present.
|
|
|
|
MCP default: https://mcp.verae.ai/mcp
|
|
Auth API default: https://api.veraetime.net</pre>
|
|
</details>
|
|
</section>
|
|
|
|
<section class="panel" id="defaults">
|
|
<h2>Defaults & auth</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>
|
|
<label class="field">Auth API URL
|
|
<input type="text" id="authApiUrl" spellcheck="false" autocomplete="off">
|
|
</label>
|
|
</div>
|
|
<h3>Auth</h3>
|
|
<div class="row">
|
|
<label class="field">Username
|
|
<input type="text" id="username" autocomplete="username">
|
|
</label>
|
|
<label class="field">Password
|
|
<input type="password" id="password" autocomplete="current-password">
|
|
</label>
|
|
</div>
|
|
<div class="row" style="margin-top:12px">
|
|
<label class="field">Access token (JWT)
|
|
<textarea id="accessToken" spellcheck="false" placeholder="Paste existing JWT or generate below"></textarea>
|
|
</label>
|
|
</div>
|
|
<p class="meta">Token hint: <span class="token-hint" id="tokenHint">(none)</span>
|
|
· Expires: <span id="expiresAt">—</span>
|
|
· Session: <span id="sessionSummary">—</span></p>
|
|
<div class="btn-row">
|
|
<button type="button" id="saveDefaultsBtn">Save</button>
|
|
<button type="button" class="secondary" id="refreshTokenBtn">Generate / refresh token</button>
|
|
<button type="button" class="secondary" id="checkSessionBtn">Check session</button>
|
|
</div>
|
|
<p class="status" id="authStatus"></p>
|
|
<p class="warn">Warning: username, password, and token are stored in <code>localStorage</code>. That is convenient but less safe than the Mac Keychain / <code>~/.verae/auth.properties</code> (0600). Clear auth on shared browsers.</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 "https://cdn.jsdelivr.net/npm/qrcode@1.5.4/+esm";
|
|
import { jsPDF as JsPDFEsm } from "https://cdn.jsdelivr.net/npm/jspdf@2.5.2/+esm";
|
|
|
|
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 (CDN blocked?). Try a refresh or allow cdn.jsdelivr.net.");
|
|
}
|
|
|
|
const LS_DEFAULTS = "verae.client.defaults";
|
|
const LS_AUTH = "verae.auth";
|
|
const LS_RECEIPTS = "verae.client.receipts";
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const mcpUrlEl = $("mcpUrl");
|
|
const scopeEl = $("scope");
|
|
const authApiUrlEl = $("authApiUrl");
|
|
const usernameEl = $("username");
|
|
const passwordEl = $("password");
|
|
const accessTokenEl = $("accessToken");
|
|
const tokenHintEl = $("tokenHint");
|
|
const expiresAtEl = $("expiresAt");
|
|
const sessionSummaryEl = $("sessionSummary");
|
|
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";
|
|
const DEFAULT_AUTH_API = "https://api.veraetime.net";
|
|
|
|
/** @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 tokenHint(tok) {
|
|
const t = String(tok || "").trim();
|
|
if (!t) return "(none)";
|
|
if (t.length <= 16) return t.slice(0, 4) + "…";
|
|
return t.slice(0, 8) + "…" + t.slice(-6);
|
|
}
|
|
|
|
function loadDefaults() {
|
|
let d = {};
|
|
try { d = JSON.parse(localStorage.getItem(LS_DEFAULTS) || "{}") || {}; } catch (_) {}
|
|
mcpUrlEl.value = d.mcpUrl || defaultMcp;
|
|
scopeEl.value = d.scope || "global";
|
|
authApiUrlEl.value = d.authApiUrl || DEFAULT_AUTH_API;
|
|
}
|
|
|
|
function loadAuth() {
|
|
let a = {};
|
|
try { a = JSON.parse(localStorage.getItem(LS_AUTH) || "{}") || {}; } catch (_) {}
|
|
usernameEl.value = a.username || "";
|
|
passwordEl.value = a.password || "";
|
|
accessTokenEl.value = a.accessToken || a.access_token || "";
|
|
expiresAtEl.textContent = a.expiresAt || a.expires_at || "—";
|
|
sessionSummaryEl.textContent = a.sessionSummary || "—";
|
|
tokenHintEl.textContent = tokenHint(accessTokenEl.value);
|
|
}
|
|
|
|
function saveDefaultsAndAuth() {
|
|
const defaults = {
|
|
mcpUrl: mcpUrlEl.value.trim() || defaultMcp,
|
|
scope: scopeEl.value || "global",
|
|
authApiUrl: authApiUrlEl.value.trim() || DEFAULT_AUTH_API
|
|
};
|
|
const auth = {
|
|
username: usernameEl.value,
|
|
password: passwordEl.value,
|
|
accessToken: accessTokenEl.value.trim(),
|
|
expiresAt: expiresAtEl.textContent === "—" ? "" : expiresAtEl.textContent,
|
|
sessionSummary: sessionSummaryEl.textContent === "—" ? "" : sessionSummaryEl.textContent
|
|
};
|
|
localStorage.setItem(LS_DEFAULTS, JSON.stringify(defaults));
|
|
localStorage.setItem(LS_AUTH, JSON.stringify(auth));
|
|
tokenHintEl.textContent = tokenHint(auth.accessToken);
|
|
setAuthStatus("Saved defaults + auth to localStorage.", false);
|
|
}
|
|
|
|
function setAuthStatus(msg, isErr) {
|
|
authStatusEl.textContent = msg;
|
|
authStatusEl.className = "status" + (isErr ? " err" : " ok");
|
|
}
|
|
|
|
function getBearer() {
|
|
return (accessTokenEl.value || "").trim();
|
|
}
|
|
|
|
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) {
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json, text/event-stream"
|
|
};
|
|
if (session) headers["Mcp-Session-Id"] = session;
|
|
const tok = getBearer();
|
|
if (tok) headers["Authorization"] = "Bearer " + tok;
|
|
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) {
|
|
const init = await post(url, rpc(1, "initialize", {
|
|
protocolVersion: "2025-03-26",
|
|
capabilities: {},
|
|
clientInfo: { name: "verae-browser-app", version: "1.0.0" }
|
|
}));
|
|
await post(url, { jsonrpc: "2.0", method: "notifications/initialized" }, init.sessionId);
|
|
return init.sessionId;
|
|
}
|
|
|
|
async function mcpStamp(url, sha, scope) {
|
|
const sessionId = await mcpOpen(url);
|
|
const call = await post(url, rpc(2, "tools/call", {
|
|
name: "stamp",
|
|
arguments: { sha256: sha, scope }
|
|
}), sessionId);
|
|
if (call.parsed && call.parsed.error) {
|
|
throw new Error(call.parsed.error.message || JSON.stringify(call.parsed.error));
|
|
}
|
|
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);
|
|
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) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
})[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", () => saveDefaultsAndAuth());
|
|
|
|
$("refreshTokenBtn").addEventListener("click", async () => {
|
|
const base = (authApiUrlEl.value || DEFAULT_AUTH_API).replace(/\/$/, "");
|
|
const username = usernameEl.value.trim();
|
|
const password = passwordEl.value;
|
|
if (!username || !password) {
|
|
setAuthStatus("Username and password required to generate a token.", true);
|
|
return;
|
|
}
|
|
setAuthStatus("Logging in…", false);
|
|
try {
|
|
const res = await fetch(base + "/auth/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
const text = await res.text();
|
|
let parsed = {};
|
|
try { parsed = JSON.parse(text); } catch (_) {}
|
|
if (!res.ok) {
|
|
throw new Error("login HTTP " + res.status + ": " + (parsed.error || text.slice(0, 200)));
|
|
}
|
|
const token = parsed.token || parsed.access_token || "";
|
|
if (!token) throw new Error("login response missing token");
|
|
accessTokenEl.value = token;
|
|
const exp = parsed.expiresAt || parsed.expires_at || "";
|
|
expiresAtEl.textContent = exp || "—";
|
|
const user = parsed.user && typeof parsed.user === "object" ? parsed.user : {};
|
|
sessionSummaryEl.textContent = (user.username || username) + (user.role ? " · " + user.role : "");
|
|
tokenHintEl.textContent = tokenHint(token);
|
|
saveDefaultsAndAuth();
|
|
setAuthStatus("Token refreshed and saved.", false);
|
|
} catch (err) {
|
|
setAuthStatus(String(err && err.message ? err.message : err), true);
|
|
}
|
|
});
|
|
|
|
$("checkSessionBtn").addEventListener("click", async () => {
|
|
const base = (authApiUrlEl.value || DEFAULT_AUTH_API).replace(/\/$/, "");
|
|
const tok = getBearer();
|
|
if (!tok) {
|
|
setAuthStatus("No access token to validate.", true);
|
|
return;
|
|
}
|
|
setAuthStatus("Checking session…", false);
|
|
try {
|
|
const res = await fetch(base + "/auth/validate", {
|
|
method: "GET",
|
|
headers: { "Authorization": "Bearer " + tok, "Accept": "application/json" }
|
|
});
|
|
const text = await res.text();
|
|
let parsed = {};
|
|
try { parsed = JSON.parse(text); } catch (_) {}
|
|
if (!res.ok || !parsed.valid) {
|
|
sessionSummaryEl.textContent = "invalid / expired";
|
|
setAuthStatus("Session invalid" + (parsed.error ? ": " + parsed.error : "."), true);
|
|
return;
|
|
}
|
|
const line = (parsed.username || "") + (parsed.role ? " · " + parsed.role : "");
|
|
sessionSummaryEl.textContent = line || "valid";
|
|
if (parsed.expiresAt) expiresAtEl.textContent = parsed.expiresAt;
|
|
saveDefaultsAndAuth();
|
|
setAuthStatus("Session valid" + (line ? ": " + line : "."), false);
|
|
} catch (err) {
|
|
setAuthStatus(String(err && err.message ? err.message : err), true);
|
|
}
|
|
});
|
|
|
|
accessTokenEl.addEventListener("input", () => {
|
|
tokenHintEl.textContent = tokenHint(accessTokenEl.value);
|
|
});
|
|
|
|
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();
|
|
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("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";
|
|
} 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();
|
|
}
|
|
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 {
|
|
const url = mcpUrlEl.value.trim();
|
|
const scope = scopeEl.value || "global";
|
|
let rid = receipt;
|
|
if (!rid && sha) {
|
|
const local = getLocalReceipt(sha);
|
|
rid = (local && local.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();
|
|
loadAuth();
|
|
setMode("register");
|
|
</script>
|
|
</body>
|
|
</html>
|