Sign in with Verae on register/app pages (DEC-657): free anonymous lookup first, new documents stamped with a per-tab key from the shared device sign-in (sessionStorage only), price + balance confirmed; receipt links no longer call stamp; app.html old localStorage login removed; jsPDF/qrcode self-hosted; index snippets send a key
This commit is contained in:
parent
1ca834900e
commit
8a532c6585
8 changed files with 948 additions and 212 deletions
292
site/app.html
292
site/app.html
|
|
@ -9,7 +9,7 @@
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 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">
|
<link rel="icon" href="assets/VeraeFullLogo.png">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/jspdf.umd.min.js"></script>
|
<script src="/assets/vendor/jspdf.umd.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--ink: oklch(0.21 0.020 310);
|
--ink: oklch(0.21 0.020 310);
|
||||||
|
|
@ -179,24 +179,25 @@
|
||||||
This page hashes files in your browser (SHA-256 WASM or Web Crypto), then
|
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.
|
calls the Verae MCP with digests only — file bytes never upload.
|
||||||
|
|
||||||
• Register = stamp digests via MCP and save a local receipt map in
|
• Register = look each digest up for free; documents already registered
|
||||||
localStorage (keyed by sha256), similar to ~/.verae/receipts on Mac.
|
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
|
• 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.
|
pasted receipt_id. Does NOT stamp. If no receipt is known → Not found.
|
||||||
• Green check = found / valid / registered · Red octagon = not found /
|
• Green check = found / valid / registered · Red octagon = not found /
|
||||||
invalid / error. SHA-256 is always shown when hashed.
|
invalid / error. SHA-256 is always shown when hashed.
|
||||||
• Defaults + auth persist in localStorage (verae.client.defaults,
|
• Sign-in keeps a per-tab Verae key in sessionStorage only (gone when the
|
||||||
verae.auth). Browser storage is less safe than Keychain — useful for
|
tab closes; Sign out revokes it). No password or key is stored in
|
||||||
demos; prefer a short-lived token in shared machines.
|
localStorage or cookies. Lookups and Verify never send the key.
|
||||||
• MCP sends Authorization: Bearer <token> when a token is present.
|
|
||||||
|
|
||||||
MCP default: https://mcp.verae.ai/mcp
|
MCP default: https://mcp.verae.ai/mcp</pre>
|
||||||
Auth API default: https://api.veraetime.net</pre>
|
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="defaults">
|
<section class="panel" id="defaults">
|
||||||
<h2>Defaults & auth</h2>
|
<h2>Defaults & Verae account</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="field">MCP URL
|
<label class="field">MCP URL
|
||||||
<input type="text" id="mcpUrl" spellcheck="false" autocomplete="off">
|
<input type="text" id="mcpUrl" spellcheck="false" autocomplete="off">
|
||||||
|
|
@ -208,34 +209,13 @@ Auth API default: https://api.veraetime.net</pre>
|
||||||
<option value="local">local</option>
|
<option value="local">local</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">Auth API URL
|
|
||||||
<input type="text" id="authApiUrl" spellcheck="false" autocomplete="off">
|
|
||||||
</label>
|
|
||||||
</div>
|
</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">
|
<div class="btn-row">
|
||||||
<button type="button" id="saveDefaultsBtn">Save</button>
|
<button type="button" id="saveDefaultsBtn">Save defaults</button>
|
||||||
<button type="button" class="secondary" id="refreshTokenBtn">Generate / refresh token</button>
|
|
||||||
<button type="button" class="secondary" id="checkSessionBtn">Check session</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3>Verae account</h3>
|
||||||
|
<div id="veraeSignIn"></div>
|
||||||
<p class="status" id="authStatus"></p>
|
<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>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
|
|
@ -298,8 +278,9 @@ Auth API default: https://api.veraetime.net</pre>
|
||||||
<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>
|
<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>
|
</footer>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import QRCodeDefault, { toDataURL as qrToDataURL } from "https://cdn.jsdelivr.net/npm/qrcode@1.5.4/+esm";
|
import QRCodeDefault, { toDataURL as qrToDataURL } from "/assets/vendor/qrcode.esm.js";
|
||||||
import { jsPDF as JsPDFEsm } from "https://cdn.jsdelivr.net/npm/jspdf@2.5.2/+esm";
|
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 = {
|
const QRCode = {
|
||||||
toDataURL: (typeof QRCodeDefault?.toDataURL === "function")
|
toDataURL: (typeof QRCodeDefault?.toDataURL === "function")
|
||||||
|
|
@ -310,23 +291,16 @@ const QRCode = {
|
||||||
function getJsPDF() {
|
function getJsPDF() {
|
||||||
if (window.jspdf && window.jspdf.jsPDF) return window.jspdf.jsPDF;
|
if (window.jspdf && window.jspdf.jsPDF) return window.jspdf.jsPDF;
|
||||||
if (typeof JsPDFEsm === "function") return JsPDFEsm;
|
if (typeof JsPDFEsm === "function") return JsPDFEsm;
|
||||||
throw new Error("jsPDF failed to load (CDN blocked?). Try a refresh or allow cdn.jsdelivr.net.");
|
throw new Error("jsPDF failed to load. Try a refresh.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const LS_DEFAULTS = "verae.client.defaults";
|
const LS_DEFAULTS = "verae.client.defaults";
|
||||||
const LS_AUTH = "verae.auth";
|
const LS_AUTH_LEGACY = "verae.auth"; // old username/password/JWT store: removed on load (DEC-657)
|
||||||
const LS_RECEIPTS = "verae.client.receipts";
|
const LS_RECEIPTS = "verae.client.receipts";
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const mcpUrlEl = $("mcpUrl");
|
const mcpUrlEl = $("mcpUrl");
|
||||||
const scopeEl = $("scope");
|
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 authStatusEl = $("authStatus");
|
||||||
const drop = $("drop");
|
const drop = $("drop");
|
||||||
const fileInput = $("fileInput");
|
const fileInput = $("fileInput");
|
||||||
|
|
@ -344,7 +318,6 @@ const host = location.hostname || "mcp.verae.ai";
|
||||||
const defaultMcp = (host && host !== "localhost" && host !== "127.0.0.1")
|
const defaultMcp = (host && host !== "localhost" && host !== "127.0.0.1")
|
||||||
? ("https://" + host + "/mcp")
|
? ("https://" + host + "/mcp")
|
||||||
: "https://mcp.verae.ai/mcp";
|
: "https://mcp.verae.ai/mcp";
|
||||||
const DEFAULT_AUTH_API = "https://api.veraetime.net";
|
|
||||||
|
|
||||||
/** @type {"register"|"verify"} */
|
/** @type {"register"|"verify"} */
|
||||||
let mode = "register";
|
let mode = "register";
|
||||||
|
|
@ -364,49 +337,18 @@ function lookupUrl(sha, receipt) {
|
||||||
}
|
}
|
||||||
lookupHint.textContent = publicBase() + "/app.html?sha256=…&receipt=…";
|
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() {
|
function loadDefaults() {
|
||||||
let d = {};
|
let d = {};
|
||||||
try { d = JSON.parse(localStorage.getItem(LS_DEFAULTS) || "{}") || {}; } catch (_) {}
|
try { d = JSON.parse(localStorage.getItem(LS_DEFAULTS) || "{}") || {}; } catch (_) {}
|
||||||
mcpUrlEl.value = d.mcpUrl || defaultMcp;
|
mcpUrlEl.value = d.mcpUrl || defaultMcp;
|
||||||
scopeEl.value = d.scope || "global";
|
scopeEl.value = d.scope || "global";
|
||||||
authApiUrlEl.value = d.authApiUrl || DEFAULT_AUTH_API;
|
try { localStorage.removeItem(LS_AUTH_LEGACY); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadAuth() {
|
function saveDefaults() {
|
||||||
let a = {};
|
const defaults = { mcpUrl: mcpUrlEl.value.trim() || defaultMcp, scope: scopeEl.value || "global" };
|
||||||
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_DEFAULTS, JSON.stringify(defaults));
|
||||||
localStorage.setItem(LS_AUTH, JSON.stringify(auth));
|
setAuthStatus("Saved defaults (MCP URL and scope only).", false);
|
||||||
tokenHintEl.textContent = tokenHint(auth.accessToken);
|
|
||||||
setAuthStatus("Saved defaults + auth to localStorage.", false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAuthStatus(msg, isErr) {
|
function setAuthStatus(msg, isErr) {
|
||||||
|
|
@ -414,10 +356,6 @@ function setAuthStatus(msg, isErr) {
|
||||||
authStatusEl.className = "status" + (isErr ? " err" : " ok");
|
authStatusEl.className = "status" + (isErr ? " err" : " ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBearer() {
|
|
||||||
return (accessTokenEl.value || "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadReceiptMap() {
|
function loadReceiptMap() {
|
||||||
try { return JSON.parse(localStorage.getItem(LS_RECEIPTS) || "{}") || {}; }
|
try { return JSON.parse(localStorage.getItem(LS_RECEIPTS) || "{}") || {}; }
|
||||||
catch (_) { return {}; }
|
catch (_) { return {}; }
|
||||||
|
|
@ -510,14 +448,13 @@ function rpc(id, method, params) {
|
||||||
return { jsonrpc: "2.0", id, method, params };
|
return { jsonrpc: "2.0", id, method, params };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(url, body, session) {
|
async function post(url, body, session, key) {
|
||||||
const headers = {
|
const headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "application/json, text/event-stream"
|
"Accept": "application/json, text/event-stream"
|
||||||
};
|
};
|
||||||
if (session) headers["Mcp-Session-Id"] = session;
|
if (session) headers["Mcp-Session-Id"] = session;
|
||||||
const tok = getBearer();
|
if (key) headers["Authorization"] = "Bearer " + key; // only for stamp (DEC-655/657); lookups stay anonymous
|
||||||
if (tok) headers["Authorization"] = "Bearer " + tok;
|
|
||||||
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const sessionId = res.headers.get("mcp-session-id") || session;
|
const sessionId = res.headers.get("mcp-session-id") || session;
|
||||||
|
|
@ -546,25 +483,53 @@ function firstReceipt(payload) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mcpOpen(url) {
|
async function mcpOpen(url, key) {
|
||||||
const init = await post(url, rpc(1, "initialize", {
|
const init = await post(url, rpc(1, "initialize", {
|
||||||
protocolVersion: "2025-03-26",
|
protocolVersion: "2025-03-26",
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
clientInfo: { name: "verae-browser-app", version: "1.0.0" }
|
clientInfo: { name: "verae-browser-app", version: "1.1.0" }
|
||||||
}));
|
}), undefined, key);
|
||||||
await post(url, { jsonrpc: "2.0", method: "notifications/initialized" }, init.sessionId);
|
await post(url, { jsonrpc: "2.0", method: "notifications/initialized" }, init.sessionId, key);
|
||||||
return init.sessionId;
|
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) {
|
async function mcpStamp(url, sha, scope) {
|
||||||
const sessionId = await mcpOpen(url);
|
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", {
|
const call = await post(url, rpc(2, "tools/call", {
|
||||||
name: "stamp",
|
name: "stamp",
|
||||||
arguments: { sha256: sha, scope }
|
arguments: { sha256: sha, scope }
|
||||||
}), sessionId);
|
}), sessionId, key);
|
||||||
if (call.parsed && call.parsed.error) {
|
const err = toolError(call);
|
||||||
throw new Error(call.parsed.error.message || JSON.stringify(call.parsed.error));
|
if (err) throw new Error(err);
|
||||||
}
|
|
||||||
const payload = toolPayload(call.parsed);
|
const payload = toolPayload(call.parsed);
|
||||||
const receipt = firstReceipt(payload);
|
const receipt = firstReceipt(payload);
|
||||||
if (!receipt) throw new Error("No receipt in stamp response");
|
if (!receipt) throw new Error("No receipt in stamp response");
|
||||||
|
|
@ -572,7 +537,7 @@ async function mcpStamp(url, sha, scope) {
|
||||||
const v = await post(url, rpc(3, "tools/call", {
|
const v = await post(url, rpc(3, "tools/call", {
|
||||||
name: "verify",
|
name: "verify",
|
||||||
arguments: { receipt_id: receipt.receipt_id, want_scope: scope }
|
arguments: { receipt_id: receipt.receipt_id, want_scope: scope }
|
||||||
}), sessionId);
|
}), sessionId, key);
|
||||||
const vp = toolPayload(v.parsed);
|
const vp = toolPayload(v.parsed);
|
||||||
const vr = firstReceipt(vp) || (vp && vp.valid !== undefined ? Object.assign({}, receipt, vp) : null);
|
const vr = firstReceipt(vp) || (vp && vp.valid !== undefined ? Object.assign({}, receipt, vp) : null);
|
||||||
if (vr) {
|
if (vr) {
|
||||||
|
|
@ -722,78 +687,9 @@ clearBtn.addEventListener("click", () => {
|
||||||
setStatus("Cleared.");
|
setStatus("Cleared.");
|
||||||
});
|
});
|
||||||
|
|
||||||
$("saveDefaultsBtn").addEventListener("click", () => saveDefaultsAndAuth());
|
$("saveDefaultsBtn").addEventListener("click", () => saveDefaults());
|
||||||
|
|
||||||
$("refreshTokenBtn").addEventListener("click", async () => {
|
const signin = mountSignIn($("veraeSignIn"));
|
||||||
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 () => {
|
runBtn.addEventListener("click", async () => {
|
||||||
const url = mcpUrlEl.value.trim();
|
const url = mcpUrlEl.value.trim();
|
||||||
|
|
@ -802,6 +698,7 @@ runBtn.addEventListener("click", async () => {
|
||||||
runBtn.disabled = true;
|
runBtn.disabled = true;
|
||||||
exportBtn.disabled = true;
|
exportBtn.disabled = true;
|
||||||
const pastedRid = (receiptPasteEl.value || "").trim();
|
const pastedRid = (receiptPasteEl.value || "").trim();
|
||||||
|
const fresh = [];
|
||||||
try {
|
try {
|
||||||
setStatus("Loading local WASM hasher…");
|
setStatus("Loading local WASM hasher…");
|
||||||
try { await loadHasher(); } catch (e) {
|
try { await loadHasher(); } catch (e) {
|
||||||
|
|
@ -820,13 +717,18 @@ runBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
if (!it.sha256) it.sha256 = await hashFile(it.file);
|
if (!it.sha256) it.sha256 = await hashFile(it.file);
|
||||||
if (mode === "register") {
|
if (mode === "register") {
|
||||||
setStatus("Registering " + it.name + " (scope=" + scope + ")…");
|
setStatus("Looking up " + it.name + "…");
|
||||||
it.receipt = await mcpStamp(url, it.sha256, scope);
|
const found = await mcpLookup(url, it.sha256, scope);
|
||||||
if (!it.receipt.hash) it.receipt.hash = it.sha256;
|
if (found) {
|
||||||
saveReceiptForSha(it.sha256, it.receipt, it.name);
|
it.receipt = found;
|
||||||
it.state = "done";
|
saveReceiptForSha(it.sha256, it.receipt, it.name);
|
||||||
it.kind = "registered";
|
it.state = "done";
|
||||||
it.statusLabel = "Registered";
|
it.kind = "found";
|
||||||
|
it.statusLabel = "Already registered";
|
||||||
|
} else {
|
||||||
|
it.state = "queued";
|
||||||
|
fresh.push(it);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Verify: never stamp
|
// Verify: never stamp
|
||||||
setStatus("Verifying " + it.name + "…");
|
setStatus("Verifying " + it.name + "…");
|
||||||
|
|
@ -866,6 +768,36 @@ runBtn.addEventListener("click", async () => {
|
||||||
}
|
}
|
||||||
renderTable();
|
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 ok = items.filter((x) => x.state === "done").length;
|
||||||
const nf = items.filter((x) => x.state === "not_found").length;
|
const nf = items.filter((x) => x.state === "not_found").length;
|
||||||
const bad = items.filter((x) => x.state === "error").length;
|
const bad = items.filter((x) => x.state === "error").length;
|
||||||
|
|
@ -1097,12 +1029,17 @@ exportBtn.addEventListener("click", async () => {
|
||||||
items = [fake];
|
items = [fake];
|
||||||
renderTable();
|
renderTable();
|
||||||
try {
|
try {
|
||||||
const url = mcpUrlEl.value.trim();
|
loadDefaults(); // this runs before the bottom-of-script loadDefaults()
|
||||||
|
const url = mcpUrlEl.value.trim() || defaultMcp;
|
||||||
const scope = scopeEl.value || "global";
|
const scope = scopeEl.value || "global";
|
||||||
let rid = receipt;
|
let rid = receipt;
|
||||||
if (!rid && sha) {
|
if (!rid && sha) {
|
||||||
const local = getLocalReceipt(sha);
|
const local = getLocalReceipt(sha);
|
||||||
rid = (local && local.receipt_id) || "";
|
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) {
|
if (!rid) {
|
||||||
fake.state = "not_found";
|
fake.state = "not_found";
|
||||||
|
|
@ -1144,7 +1081,6 @@ exportBtn.addEventListener("click", async () => {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
loadDefaults();
|
loadDefaults();
|
||||||
loadAuth();
|
|
||||||
setMode("register");
|
setMode("register");
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
7
site/assets/vendor/dijkstrajs.esm.js
vendored
Normal file
7
site/assets/vendor/dijkstrajs.esm.js
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* Bundled by jsDelivr using Rollup v4.62.2 and esbuild v0.28.1.
|
||||||
|
* Original file: /npm/dijkstrajs@1.0.3/dijkstra.js
|
||||||
|
*
|
||||||
|
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||||
|
*/
|
||||||
|
var c={exports:{}},m;function j(){return m||(m=1,(function(x){var u={single_source_shortest_paths:function(s,r,t){var e={},i={};i[r]=0;var n=u.PriorityQueue.make();n.push(r,0);for(var p,h,o,v,a,d,f,l,y;!n.empty();){p=n.pop(),h=p.value,v=p.cost,a=s[h]||{};for(o in a)a.hasOwnProperty(o)&&(d=a[o],f=v+d,l=i[o],y=typeof i[o]>"u",(y||l>f)&&(i[o]=f,n.push(o,f),e[o]=h))}if(typeof t<"u"&&typeof i[t]>"u"){var k=["Could not find a path from ",r," to ",t,"."].join("");throw new Error(k)}return e},extract_shortest_path_from_predecessor_list:function(s,r){for(var t=[],e=r;e;)t.push(e),s[e],e=s[e];return t.reverse(),t},find_path:function(s,r,t){var e=u.single_source_shortest_paths(s,r,t);return u.extract_shortest_path_from_predecessor_list(e,t)},PriorityQueue:{make:function(s){var r=u.PriorityQueue,t={},e;s=s||{};for(e in r)r.hasOwnProperty(e)&&(t[e]=r[e]);return t.queue=[],t.sorter=s.sorter||r.default_sorter,t},default_sorter:function(s,r){return s.cost-r.cost},push:function(s,r){var t={value:s,cost:r};this.queue.push(t),this.queue.sort(this.sorter)},pop:function(){return this.queue.shift()},empty:function(){return this.queue.length===0}}};x.exports=u})(c)),c.exports}var _=j(),q=_.PriorityQueue,P=_.extract_shortest_path_from_predecessor_list,w=_.find_path,g=_.single_source_shortest_paths;export{q as PriorityQueue,_ as default,P as extract_shortest_path_from_predecessor_list,w as find_path,g as single_source_shortest_paths};
|
||||||
397
site/assets/vendor/jspdf.umd.min.js
vendored
Normal file
397
site/assets/vendor/jspdf.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
14
site/assets/vendor/qrcode.esm.js
vendored
Normal file
14
site/assets/vendor/qrcode.esm.js
vendored
Normal file
File diff suppressed because one or more lines are too long
177
site/assets/verae-signin.js
Normal file
177
site/assets/verae-signin.js
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
// Sign in with Verae for the mcp.verae.ai pages (DEC-657).
|
||||||
|
//
|
||||||
|
// Uses the shared Verae sign-in (portal device grant, RFC 8628 - the same flow as `verae login` and the apps):
|
||||||
|
// POST zap /portal/api/device/authorize -> the user approves the code at zap.verae.ai/portal/device.html
|
||||||
|
// POST zap /portal/api/device/token (poll) -> an xVerae- API key for this browser tab.
|
||||||
|
// The key is kept in sessionStorage only (this tab, this origin; gone when the tab closes). Never localStorage,
|
||||||
|
// never a cookie. Stamps send it as Authorization: Bearer to /mcp and are charged to the signed-in account.
|
||||||
|
// Verify and lookups never need it.
|
||||||
|
|
||||||
|
const ZAP = "https://zap.verae.ai";
|
||||||
|
const SS_KEY = "verae.signin.key";
|
||||||
|
|
||||||
|
export function currentKey() {
|
||||||
|
try { return sessionStorage.getItem(SS_KEY) || ""; } catch (_) { return ""; }
|
||||||
|
}
|
||||||
|
function setKey(k) {
|
||||||
|
try { k ? sessionStorage.setItem(SS_KEY, k) : sessionStorage.removeItem(SS_KEY); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zap(path, init = {}, key = currentKey()) {
|
||||||
|
const headers = Object.assign({ "Accept": "application/json" }, init.headers || {});
|
||||||
|
if (init.body) headers["Content-Type"] = "application/json";
|
||||||
|
if (key) headers["Authorization"] = "Bearer " + key;
|
||||||
|
const res = await fetch(ZAP + path, { method: init.method || "GET", headers, body: init.body, credentials: "omit", cache: "no-store" });
|
||||||
|
let body = {};
|
||||||
|
try { body = await res.json(); } catch (_) {}
|
||||||
|
return { status: res.status, ok: res.ok, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Price, balance and account for the signed-in key; null (and the key dropped) when it is no longer valid. */
|
||||||
|
export async function account() {
|
||||||
|
const key = currentKey();
|
||||||
|
if (!key) return null;
|
||||||
|
const r = await zap("/v1/account");
|
||||||
|
if (r.status === 401 || r.status === 403) { setKey(""); return null; }
|
||||||
|
if (!r.ok) throw new Error("Could not read your Verae account (" + r.status + ")");
|
||||||
|
return r.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the device sign-in. onCode({user_code, url}) is called once the code exists so the page can show it with a
|
||||||
|
* link (a popup opened after an await is often blocked). Resolves with the account, or throws on deny/expiry/cancel.
|
||||||
|
*/
|
||||||
|
export async function signIn({ onCode, signal, clientName } = {}) {
|
||||||
|
const start = await zap("/portal/api/device/authorize", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ client_name: clientName || ("Verae web (" + location.host + ")"), device_name: "Web browser" }),
|
||||||
|
}, "");
|
||||||
|
if (!start.ok) throw new Error((start.body && (start.body.error_description || start.body.error)) || ("Sign-in could not start (" + start.status + ")"));
|
||||||
|
const { device_code, user_code, verification_uri_complete, interval, expires_in } = start.body;
|
||||||
|
if (onCode) onCode({ user_code, url: verification_uri_complete });
|
||||||
|
let wait = Math.max(5, Number(interval) || 5) * 1000;
|
||||||
|
const until = Date.now() + (Number(expires_in) || 600) * 1000;
|
||||||
|
while (Date.now() < until) {
|
||||||
|
await sleep(wait);
|
||||||
|
if (signal && signal.aborted) throw new Error("Sign-in cancelled");
|
||||||
|
const t = await zap("/portal/api/device/token", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code }),
|
||||||
|
}, "");
|
||||||
|
if (t.ok && t.body.api_key) {
|
||||||
|
setKey(t.body.api_key);
|
||||||
|
return account();
|
||||||
|
}
|
||||||
|
const e = t.body && t.body.error;
|
||||||
|
if (e === "authorization_pending") continue;
|
||||||
|
if (e === "slow_down") { wait = Math.max(wait + 5000, (Number(t.body.interval) || 0) * 1000); continue; }
|
||||||
|
if (e === "access_denied") throw new Error("Sign-in was denied");
|
||||||
|
if (e === "expired_token") throw new Error("Sign-in code expired - try again");
|
||||||
|
throw new Error("Sign-in failed (" + (e || t.status) + ")");
|
||||||
|
}
|
||||||
|
throw new Error("Sign-in code expired - try again");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke this tab's key and forget it. */
|
||||||
|
export async function signOut() {
|
||||||
|
const key = currentKey();
|
||||||
|
setKey("");
|
||||||
|
if (key) { try { await zap("/v1/tokens/self", { method: "DELETE" }, key); } catch (_) {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, attrs = {}, text) {
|
||||||
|
const e = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
|
||||||
|
if (text !== undefined) e.textContent = text;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount the sign-in box into `host`. Returns { ensureSignedIn(), refresh(), account() }.
|
||||||
|
* ensureSignedIn() resolves with the account once signed in (starting the sign-in if needed).
|
||||||
|
*/
|
||||||
|
export function mountSignIn(host, opts = {}) {
|
||||||
|
if (!document.getElementById("verae-signin-css")) {
|
||||||
|
const css = el("style", { id: "verae-signin-css" });
|
||||||
|
css.textContent = ".verae-signin [hidden]{display:none!important}" +
|
||||||
|
".verae-code{margin:10px 0;padding:10px 14px;border-radius:10px;background:rgba(127,127,127,.12);display:flex;flex-wrap:wrap;gap:6px;align-items:center}" +
|
||||||
|
"#veraeUserCode{font-family:ui-monospace,Menlo,monospace;font-size:18px;letter-spacing:.08em}";
|
||||||
|
document.head.appendChild(css);
|
||||||
|
}
|
||||||
|
host.textContent = "";
|
||||||
|
host.classList.add("verae-signin");
|
||||||
|
const line = el("p", { class: "meta", "data-testid": "signin-status" });
|
||||||
|
const btnIn = el("button", { type: "button", id: "veraeSignInBtn" }, "Sign in with Verae");
|
||||||
|
const btnOut = el("button", { type: "button", class: "secondary", id: "veraeSignOutBtn" }, "Sign out");
|
||||||
|
const codeBox = el("div", { class: "verae-code", hidden: "" });
|
||||||
|
const row = el("div", { class: "btn-row" });
|
||||||
|
row.append(btnIn, btnOut);
|
||||||
|
host.append(line, codeBox, row);
|
||||||
|
let acct = null;
|
||||||
|
let pending = null;
|
||||||
|
let abort = null;
|
||||||
|
|
||||||
|
function fmt(n) { return (typeof n === "number" ? n : 0).toLocaleString() + " credits"; }
|
||||||
|
function render() {
|
||||||
|
btnIn.hidden = !!acct;
|
||||||
|
btnOut.hidden = !acct;
|
||||||
|
if (acct) {
|
||||||
|
line.textContent = "Signed in as " + (acct.email || acct.name || acct.customer_id) +
|
||||||
|
" · price per new document " + (acct.stamp_price_credits == null ? "n/a" : fmt(acct.stamp_price_credits)) +
|
||||||
|
" · balance " + fmt(acct.balance_credits) +
|
||||||
|
" · this month " + fmt(acct.period_spend_credits);
|
||||||
|
} else if (!pending) {
|
||||||
|
line.textContent = "Checking and looking up documents is free and needs no account. Registering a new document needs a Verae account; it is charged to that account.";
|
||||||
|
}
|
||||||
|
if (opts.onChange) opts.onChange(acct);
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
try { acct = await account(); } catch (e) { line.textContent = String(e.message || e); }
|
||||||
|
render();
|
||||||
|
return acct;
|
||||||
|
}
|
||||||
|
function start() {
|
||||||
|
if (pending) return pending;
|
||||||
|
abort = new AbortController();
|
||||||
|
btnIn.disabled = true;
|
||||||
|
line.textContent = "Starting Verae sign-in…";
|
||||||
|
pending = signIn({
|
||||||
|
signal: abort.signal,
|
||||||
|
onCode: ({ user_code, url }) => {
|
||||||
|
codeBox.hidden = false;
|
||||||
|
codeBox.textContent = "";
|
||||||
|
const a = el("a", { href: url, target: "_blank", rel: "noopener noreferrer", id: "veraeSignInLink" }, "Open the Verae sign-in page");
|
||||||
|
const cancel = el("button", { type: "button", class: "secondary" }, "Cancel");
|
||||||
|
cancel.addEventListener("click", () => abort && abort.abort());
|
||||||
|
codeBox.append(el("span", {}, "Confirm code "), el("strong", { id: "veraeUserCode" }, user_code), el("span", {}, " at zap.verae.ai: "), a, el("span", {}, " "), cancel);
|
||||||
|
line.textContent = "Waiting for you to approve the sign-in in the Verae portal…";
|
||||||
|
try { window.open(url, "_blank", "noopener"); } catch (_) {}
|
||||||
|
},
|
||||||
|
}).then((a) => { acct = a; return a; }).finally(() => {
|
||||||
|
pending = null; abort = null; btnIn.disabled = false; codeBox.hidden = true; render();
|
||||||
|
});
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
btnIn.addEventListener("click", () => { start().catch((e) => { line.textContent = String(e.message || e); }); });
|
||||||
|
btnOut.addEventListener("click", async () => { await signOut(); acct = null; render(); });
|
||||||
|
refresh();
|
||||||
|
return {
|
||||||
|
account: () => acct,
|
||||||
|
refresh,
|
||||||
|
async ensureSignedIn() { return acct || (await refresh()) || start(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm a charge before stamping. Returns true when the user agrees. */
|
||||||
|
export function confirmCharge(acct, count, scope = "global") {
|
||||||
|
const price = scope !== "global" ? 0 : (acct && typeof acct.stamp_price_credits === "number" ? acct.stamp_price_credits : null);
|
||||||
|
const total = price == null ? null : price * count;
|
||||||
|
const msg = "Register " + count + " new document" + (count === 1 ? "" : "s") + " with Verae?\n\n" +
|
||||||
|
(price == null ? "Price: see your Verae plan" : ("Price: " + price + " credits each, " + total + " credits in total")) +
|
||||||
|
"\nBalance: " + ((acct && acct.balance_credits) || 0) + " credits" +
|
||||||
|
"\nCharged to: " + ((acct && (acct.email || acct.name || acct.customer_id)) || "your Verae account") +
|
||||||
|
"\n\nDocuments already registered are not charged again.";
|
||||||
|
return window.confirm(msg);
|
||||||
|
}
|
||||||
|
|
@ -427,12 +427,14 @@ curl -sS -D - -X POST https://mcp.verae.ai/mcp \
|
||||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"my-app","version":"1.0.0"}}}'
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"my-app","version":"1.0.0"}}}'
|
||||||
|
|
||||||
# 2) notifications/initialized (include Mcp-Session-Id from step 1)
|
# 2) notifications/initialized (include Mcp-Session-Id from step 1)
|
||||||
# 3) tools/call stamp
|
# 3) tools/call stamp (a write: needs your Verae API key and is charged to your account)
|
||||||
curl -sS -X POST https://mcp.verae.ai/mcp \
|
curl -sS -X POST https://mcp.verae.ai/mcp \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-H 'Accept: application/json, text/event-stream' \
|
-H 'Accept: application/json, text/event-stream' \
|
||||||
|
-H 'Authorization: Bearer YOUR_VERAE_API_KEY' \
|
||||||
-H 'Mcp-Session-Id: SESSION' \
|
-H 'Mcp-Session-Id: SESSION' \
|
||||||
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"stamp","arguments":{"sha256":"DIGEST_HEX","scope":"global"}}}'</pre>
|
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"stamp","arguments":{"sha256":"DIGEST_HEX","scope":"global"}}}'</pre>
|
||||||
|
<p class="meta"><strong>Stamping needs your own Verae API key</strong> (<code>Authorization: Bearer xVerae-…</code>): get one with <code>verae login</code>, in the <a href="https://zap.verae.ai/portal/">Verae portal</a>, or with <strong>Sign in with Verae</strong> on the <a href="register.html">register page</a>. Each new document is charged to that account; a document you already registered is free. <code>verify</code> and <code>hash_report</code> stay anonymous (rate limited).</p>
|
||||||
<p class="meta">Python SDK shape (same as the official client): <code>Client(url)</code> then <code>await client.call_tool("stamp", {"sha256": digest, "scope": "global"})</code>.</p>
|
<p class="meta">Python SDK shape (same as the official client): <code>Client(url)</code> then <code>await client.call_tool("stamp", {"sha256": digest, "scope": "global"})</code>.</p>
|
||||||
|
|
||||||
<h3 style="margin-top:36px;font-size:20px;">Embed in a web page (browser JavaScript)</h3>
|
<h3 style="margin-top:36px;font-size:20px;">Embed in a web page (browser JavaScript)</h3>
|
||||||
|
|
@ -445,6 +447,7 @@ async function post(body, session) {
|
||||||
"Accept": "application/json, text/event-stream"
|
"Accept": "application/json, text/event-stream"
|
||||||
};
|
};
|
||||||
if (session) headers["Mcp-Session-Id"] = session;
|
if (session) headers["Mcp-Session-Id"] = session;
|
||||||
|
headers["Authorization"] = "Bearer " + VERAE_API_KEY; // stamp needs your key; omit it for verify / hash_report
|
||||||
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const sessionId = res.headers.get("mcp-session-id") || session;
|
const sessionId = res.headers.get("mcp-session-id") || session;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 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">
|
<link rel="icon" href="assets/VeraeFullLogo.png">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/jspdf.umd.min.js"></script>
|
<script src="/assets/vendor/jspdf.umd.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--ink: oklch(0.21 0.020 310);
|
--ink: oklch(0.21 0.020 310);
|
||||||
|
|
@ -142,12 +142,18 @@
|
||||||
<h2>How it works</h2>
|
<h2>How it works</h2>
|
||||||
<ol class="steps">
|
<ol class="steps">
|
||||||
<li>No assistant required — this is an embedded browser client of the MCP. Files stay on your device; only the SHA-256 digest is sent to <code>/mcp</code>.</li>
|
<li>No assistant required — this is an embedded browser client of the MCP. Files stay on your device; only the SHA-256 digest is sent to <code>/mcp</code>.</li>
|
||||||
<li>Default scope is <strong>global</strong>. Calling <code>stamp</code> is idempotent: an existing certificate is returned with its original time.</li>
|
<li>Default scope is <strong>global</strong>. Each digest is first looked up for free (no account): a document that is already registered shows its original time and receipt.</li>
|
||||||
|
<li>Registering a <em>new</em> document needs a Verae account: <strong>Sign in with Verae</strong> (the same sign-in as the Verae apps). The price and your balance are shown before anything is charged. The sign-in key stays in this browser tab only.</li>
|
||||||
<li>Each row shows the original <code>created_at</code>, receipt id (CID), and optional merkle / block hash.</li>
|
<li>Each row shows the original <code>created_at</code>, receipt id (CID), and optional merkle / block hash.</li>
|
||||||
<li>Export selected formats. PDF is a formal <em>Verae Timestamp Registration Document</em> (one page per file) with logo, ledger location, and a QR code that opens the public lookup for that digest.</li>
|
<li>Export selected formats. PDF is a formal <em>Verae Timestamp Registration Document</em> (one page per file) with logo, ledger location, and a QR code that opens the public lookup for that digest.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Verae account</h2>
|
||||||
|
<div id="veraeSignIn"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Options</h2>
|
<h2>Options</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -210,8 +216,9 @@
|
||||||
<span><a href="index.html">Install & connectors</a> · <a href="app.html">Full browser app</a> · <a href="sample.html">Raw MCP sample</a> · <a href="https://api.veraetime.net/docs/swagger/">API docs</a></span>
|
<span><a href="index.html">Install & connectors</a> · <a href="app.html">Full browser app</a> · <a href="sample.html">Raw MCP sample</a> · <a href="https://api.veraetime.net/docs/swagger/">API docs</a></span>
|
||||||
</footer>
|
</footer>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import QRCodeDefault, { toDataURL as qrToDataURL } from "https://cdn.jsdelivr.net/npm/qrcode@1.5.4/+esm";
|
import QRCodeDefault, { toDataURL as qrToDataURL } from "/assets/vendor/qrcode.esm.js";
|
||||||
import { jsPDF as JsPDFEsm } from "https://cdn.jsdelivr.net/npm/jspdf@2.5.2/+esm";
|
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 = {
|
const QRCode = {
|
||||||
toDataURL: (typeof QRCodeDefault?.toDataURL === "function")
|
toDataURL: (typeof QRCodeDefault?.toDataURL === "function")
|
||||||
|
|
@ -222,7 +229,7 @@ const QRCode = {
|
||||||
function getJsPDF() {
|
function getJsPDF() {
|
||||||
if (window.jspdf && window.jspdf.jsPDF) return window.jspdf.jsPDF;
|
if (window.jspdf && window.jspdf.jsPDF) return window.jspdf.jsPDF;
|
||||||
if (typeof JsPDFEsm === "function") return JsPDFEsm;
|
if (typeof JsPDFEsm === "function") return JsPDFEsm;
|
||||||
throw new Error("jsPDF failed to load (CDN blocked?). Try a refresh or allow cdn.jsdelivr.net.");
|
throw new Error("jsPDF failed to load. Try a refresh.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
@ -255,6 +262,7 @@ function lookupUrl(sha, receipt) {
|
||||||
return u.toString();
|
return u.toString();
|
||||||
}
|
}
|
||||||
lookupHint.textContent = publicBase() + "/register.html?sha256=…&receipt=…";
|
lookupHint.textContent = publicBase() + "/register.html?sha256=…&receipt=…";
|
||||||
|
const signin = mountSignIn($("veraeSignIn"));
|
||||||
|
|
||||||
/** @type {{name:string,size:number,file:File,sha256?:string,receipt?:object,error?:string,state:string}[]} */
|
/** @type {{name:string,size:number,file:File,sha256?:string,receipt?:object,error?:string,state:string}[]} */
|
||||||
let items = [];
|
let items = [];
|
||||||
|
|
@ -333,12 +341,13 @@ function rpc(id, method, params) {
|
||||||
return { jsonrpc: "2.0", id, method, params };
|
return { jsonrpc: "2.0", id, method, params };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(url, body, session) {
|
async function post(url, body, session, key) {
|
||||||
const headers = {
|
const headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "application/json, text/event-stream"
|
"Accept": "application/json, text/event-stream"
|
||||||
};
|
};
|
||||||
if (session) headers["Mcp-Session-Id"] = session;
|
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 res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const sessionId = res.headers.get("mcp-session-id") || session;
|
const sessionId = res.headers.get("mcp-session-id") || session;
|
||||||
|
|
@ -367,20 +376,54 @@ function firstReceipt(payload) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mcpStamp(url, sha, scope) {
|
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 "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mcpInit(url, key, name) {
|
||||||
const init = await post(url, rpc(1, "initialize", {
|
const init = await post(url, rpc(1, "initialize", {
|
||||||
protocolVersion: "2025-03-26",
|
protocolVersion: "2025-03-26",
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
clientInfo: { name: "verae-register-page", version: "1.0.0" }
|
clientInfo: { name: name || "verae-register-page", version: "1.1.0" }
|
||||||
}));
|
}), undefined, key);
|
||||||
await post(url, { jsonrpc: "2.0", method: "notifications/initialized" }, init.sessionId);
|
await post(url, { jsonrpc: "2.0", method: "notifications/initialized" }, init.sessionId, key);
|
||||||
|
return init.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 mcpInit(url, "", "verae-register-lookup");
|
||||||
|
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 sid = await mcpInit(url, key);
|
||||||
const call = await post(url, rpc(2, "tools/call", {
|
const call = await post(url, rpc(2, "tools/call", {
|
||||||
name: "stamp",
|
name: "stamp",
|
||||||
arguments: { sha256: sha, scope }
|
arguments: { sha256: sha, scope }
|
||||||
}), init.sessionId);
|
}), sid, key);
|
||||||
if (call.parsed && call.parsed.error) {
|
const err = toolError(call);
|
||||||
throw new Error(call.parsed.error.message || JSON.stringify(call.parsed.error));
|
if (err) throw new Error(err);
|
||||||
}
|
const init = { sessionId: sid };
|
||||||
const payload = toolPayload(call.parsed);
|
const payload = toolPayload(call.parsed);
|
||||||
const receipt = firstReceipt(payload);
|
const receipt = firstReceipt(payload);
|
||||||
if (!receipt) throw new Error("No receipt in stamp response");
|
if (!receipt) throw new Error("No receipt in stamp response");
|
||||||
|
|
@ -389,7 +432,7 @@ async function mcpStamp(url, sha, scope) {
|
||||||
const v = await post(url, rpc(3, "tools/call", {
|
const v = await post(url, rpc(3, "tools/call", {
|
||||||
name: "verify",
|
name: "verify",
|
||||||
arguments: { receipt_id: receipt.receipt_id, want_scope: scope }
|
arguments: { receipt_id: receipt.receipt_id, want_scope: scope }
|
||||||
}), init.sessionId);
|
}), init.sessionId, key);
|
||||||
const vp = toolPayload(v.parsed);
|
const vp = toolPayload(v.parsed);
|
||||||
const vr = firstReceipt(vp) || (vp && vp.valid !== undefined ? Object.assign({}, receipt, vp) : null);
|
const vr = firstReceipt(vp) || (vp && vp.valid !== undefined ? Object.assign({}, receipt, vp) : null);
|
||||||
if (vr) {
|
if (vr) {
|
||||||
|
|
@ -479,30 +522,65 @@ runBtn.addEventListener("click", async () => {
|
||||||
exportBtn.disabled = true;
|
exportBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
setStatus("Loading local WASM hasher…");
|
setStatus("Loading local WASM hasher…");
|
||||||
await loadHasher();
|
await loadHasher().catch(() => {});
|
||||||
|
// 1) hash + free lookup (no account needed)
|
||||||
|
const sid = await mcpInit(url, "", "verae-register-lookup");
|
||||||
|
const fresh = [];
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const it = items[i];
|
const it = items[i];
|
||||||
if (it.state === "done" && it.receipt) continue;
|
if (it.state === "done" && it.receipt) continue;
|
||||||
it.state = "working";
|
it.state = "working";
|
||||||
it.error = undefined;
|
it.error = undefined;
|
||||||
renderTable();
|
renderTable();
|
||||||
setStatus("Hashing " + it.name + " (" + (i + 1) + "/" + items.length + ")…");
|
|
||||||
try {
|
try {
|
||||||
it.sha256 = await hashFile(it.file);
|
setStatus("Hashing " + it.name + " (" + (i + 1) + "/" + items.length + ")…");
|
||||||
setStatus("Stamping " + it.name + " (scope=" + scope + ")…");
|
if (!it.sha256) it.sha256 = await hashFile(it.file);
|
||||||
it.receipt = await mcpStamp(url, it.sha256, scope);
|
setStatus("Looking up " + it.name + "…");
|
||||||
it.state = "done";
|
const found = await mcpLookup(url, it.sha256, scope, sid);
|
||||||
|
if (found) {
|
||||||
|
it.receipt = found;
|
||||||
|
it.state = "done";
|
||||||
|
} else {
|
||||||
|
it.state = "queued";
|
||||||
|
fresh.push(it);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
it.state = "error";
|
it.state = "error";
|
||||||
it.error = String(err && err.message ? err.message : err);
|
it.error = String(err && err.message ? err.message : err);
|
||||||
}
|
}
|
||||||
renderTable();
|
renderTable();
|
||||||
}
|
}
|
||||||
|
// 2) new documents: signed-in, price and balance confirmed, then stamped with the user's key
|
||||||
|
if (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)) {
|
||||||
|
for (const it of fresh) { it.state = "queued"; }
|
||||||
|
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);
|
||||||
|
it.state = "done";
|
||||||
|
} catch (err) {
|
||||||
|
it.state = "error";
|
||||||
|
it.error = String(err && err.message ? err.message : err);
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
await signin.refresh();
|
||||||
|
}
|
||||||
const ok = items.filter((x) => x.state === "done").length;
|
const ok = items.filter((x) => x.state === "done").length;
|
||||||
const bad = items.filter((x) => x.state === "error").length;
|
const bad = items.filter((x) => x.state === "error").length;
|
||||||
setStatus("Finished: " + ok + " ok" + (bad ? ", " + bad + " failed" : "") + ". Download exports when ready.");
|
setStatus("Finished: " + ok + " ok" + (bad ? ", " + bad + " failed" : "") + ". Download exports when ready.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(String(err), true);
|
setStatus(String(err && err.message ? err.message : err), true);
|
||||||
} finally {
|
} finally {
|
||||||
runBtn.disabled = items.length === 0;
|
runBtn.disabled = items.length === 0;
|
||||||
}
|
}
|
||||||
|
|
@ -730,19 +808,10 @@ exportBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const url = mcpUrlEl.value.trim();
|
const url = mcpUrlEl.value.trim();
|
||||||
const scope = "global";
|
const scope = "global";
|
||||||
const init = await post(url, rpc(1, "initialize", {
|
const init = { sessionId: await mcpInit(url, "", "verae-register-lookup") };
|
||||||
protocolVersion: "2025-03-26",
|
|
||||||
capabilities: {},
|
|
||||||
clientInfo: { name: "verae-register-lookup", version: "1.0.0" }
|
|
||||||
}));
|
|
||||||
await post(url, { jsonrpc: "2.0", method: "notifications/initialized" }, init.sessionId);
|
|
||||||
let rec = null;
|
let rec = null;
|
||||||
if (sha) {
|
if (sha) {
|
||||||
const call = await post(url, rpc(2, "tools/call", {
|
rec = await mcpLookup(url, sha, scope, init.sessionId); // free lookup, never a stamp
|
||||||
name: "stamp",
|
|
||||||
arguments: { sha256: sha, scope }
|
|
||||||
}), init.sessionId);
|
|
||||||
rec = firstReceipt(toolPayload(call.parsed));
|
|
||||||
}
|
}
|
||||||
if (receipt) {
|
if (receipt) {
|
||||||
const v = await post(url, rpc(3, "tools/call", {
|
const v = await post(url, rpc(3, "tools/call", {
|
||||||
|
|
|
||||||
133
tests/web/signin-e2e.mjs
Normal file
133
tests/web/signin-e2e.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
// E2E for DEC-657 against live mcp.verae.ai + zap.verae.ai, test customer cust_verae_test_billing.
|
||||||
|
// PRE=1 serves the new pages/assets from ../site via request interception (before deploying them).
|
||||||
|
import puppeteer from "puppeteer-core";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import crypto from "crypto";
|
||||||
|
const SITE = path.resolve("../site");
|
||||||
|
const PRE = process.env.PRE === "1";
|
||||||
|
const SESSION = process.env.PORTAL_SESSION; // temp portal session for the test customer (deleted afterwards)
|
||||||
|
const KNOWN = { sha: "e1dcc8e28577df3282d0d3f04038df1ff23fa462b7a6c29a9697e48393a4579c", rid: "QmS6NoQjR42YgRAmZJAwzQ2BQSycL2pKTATfr6dX1X2TTp" };
|
||||||
|
const out = { checks: [], keys: [] };
|
||||||
|
const ok = (name, cond, extra) => { out.checks.push({ name, ok: !!cond, extra }); console.log((cond ? "ok " : "FAIL ") + name + (extra ? " :: " + String(extra).slice(0, 200) : "")); if (!cond) process.exitCode = 1; };
|
||||||
|
const types = { ".html": "text/html", ".js": "text/javascript" };
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({ executablePath: "/usr/bin/google-chrome", headless: "new", args: ["--no-sandbox"] });
|
||||||
|
browser.on("targetcreated", async (t) => { if (t.type() === "page") { const p = await t.page(); if (p && p.url().includes("/portal/device.html")) { out.popup = p.url(); await p.close().catch(() => {}); } } });
|
||||||
|
|
||||||
|
async function page(url) {
|
||||||
|
const p = await browser.newPage();
|
||||||
|
const ext = [];
|
||||||
|
if (PRE) {
|
||||||
|
await p.setRequestInterception(true);
|
||||||
|
p.on("request", (r) => {
|
||||||
|
const u = new URL(r.url());
|
||||||
|
if (u.host === "mcp.verae.ai" && /^\/(register|app|index)\.html$|^\/assets\/(verae-signin\.js|vendor\/.+)$/.test(u.pathname)) {
|
||||||
|
const f = path.join(SITE, u.pathname);
|
||||||
|
return r.respond({ status: 200, contentType: types[path.extname(f)] || "application/octet-stream", body: fs.readFileSync(f) });
|
||||||
|
}
|
||||||
|
r.continue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
p.on("request", (r) => { const h = new URL(r.url()).host; if (r.resourceType() === "script" && !/(^|\.)verae\.ai$/.test(h)) ext.push(r.url()); });
|
||||||
|
p.on("console", (m) => { if (m.type() === "error") console.log(" [console]", m.text().slice(0, 200)); });
|
||||||
|
p.dialogs = [];
|
||||||
|
p.on("dialog", async (d) => { p.dialogs.push(d.message()); await d.accept(); });
|
||||||
|
await p.goto(url, { waitUntil: "networkidle2" });
|
||||||
|
p.ext = ext;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
const status = (p) => p.$eval("#status", (e) => e.textContent);
|
||||||
|
async function waitStatus(p, re, ms = 180000) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (Date.now() - t0 < ms) { const s = await status(p); if (re.test(s)) return s; await new Promise((r) => setTimeout(r, 500)); }
|
||||||
|
return "TIMEOUT: " + (await status(p));
|
||||||
|
}
|
||||||
|
async function approveWhenShown(p) {
|
||||||
|
await p.waitForSelector("#veraeUserCode", { visible: true, timeout: 60000 });
|
||||||
|
const code = await p.$eval("#veraeUserCode", (e) => e.textContent);
|
||||||
|
const href = await p.$eval("#veraeSignInLink", (e) => e.href);
|
||||||
|
ok("sign-in shows code + portal link", /^[A-Z]{4}-[A-Z]{4}$/.test(code) && href.startsWith("https://zap.verae.ai/portal/device.html?user_code="), code + " " + href);
|
||||||
|
const r = await fetch("https://zap.verae.ai/portal/api/device/approve", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer " + SESSION }, body: JSON.stringify({ user_code: code }) });
|
||||||
|
ok("approved in the portal as the test customer", r.ok, r.status);
|
||||||
|
}
|
||||||
|
async function storage(p) {
|
||||||
|
return p.evaluate(() => ({ ss: Object.assign({}, sessionStorage), ls: Object.assign({}, localStorage), cookie: document.cookie }));
|
||||||
|
}
|
||||||
|
function tmpFile(tag) {
|
||||||
|
const f = `/tmp/verae-e2e-${tag}-${Date.now()}.txt`;
|
||||||
|
fs.writeFileSync(f, `DEC-657 e2e ${tag} ${crypto.randomUUID()}\n`);
|
||||||
|
return { f, sha: crypto.createHash("sha256").update(fs.readFileSync(f)).digest("hex") };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) register.html deep link: anonymous lookup, no stamp, no key
|
||||||
|
let p = await page(`https://mcp.verae.ai/register.html?sha256=${KNOWN.sha}`);
|
||||||
|
let s = await waitStatus(p, /Lookup complete|Error|error|No receipt/i, 60000);
|
||||||
|
const row = await p.$eval("#tbody", (e) => e.textContent);
|
||||||
|
ok("register.html receipt link: anonymous lookup works", /Lookup complete/.test(s) && row.includes(KNOWN.rid), s);
|
||||||
|
ok("no key used for the lookup", Object.keys((await storage(p)).ss).length === 0);
|
||||||
|
ok("signed-out hint shown", (await p.$eval("[data-testid=signin-status]", (e) => e.textContent)).includes("needs a Verae account"));
|
||||||
|
|
||||||
|
// 2) register a new document: sign in (device flow), price + balance confirmed, stamped with the key
|
||||||
|
const A = tmpFile("register");
|
||||||
|
await p.goto("https://mcp.verae.ai/register.html", { waitUntil: "networkidle2" });
|
||||||
|
await (await p.$("#fileInput")).uploadFile(A.f);
|
||||||
|
await p.click("#runBtn");
|
||||||
|
await approveWhenShown(p);
|
||||||
|
s = await waitStatus(p, /^Finished|Not registered|Error/);
|
||||||
|
ok("register.html: new document registered", /^Finished: 1 ok/.test(s), s);
|
||||||
|
ok("price + balance confirmed before charging", p.dialogs.length === 1 && /10 credits each, 10 credits in total/.test(p.dialogs[0]) && /Balance: \d+ credits/.test(p.dialogs[0]), p.dialogs[0]);
|
||||||
|
let st = await storage(p);
|
||||||
|
const keyA = st.ss["verae.signin.key"]; out.keys.push(keyA);
|
||||||
|
ok("key in sessionStorage only", /^xVerae-/.test(keyA || "") && !JSON.stringify(st.ls).includes("xVerae-") && !st.cookie.includes("xVerae"), Object.keys(st.ls).join(","));
|
||||||
|
ok("account line shows price and balance", /price per new document 10 credits · balance [\d,]+ credits/.test(await p.$eval("[data-testid=signin-status]", (e) => e.textContent)));
|
||||||
|
const ridA = await p.$eval("#tbody", (e) => e.textContent);
|
||||||
|
out.newA = A.sha;
|
||||||
|
// 3) same document again: found by the free lookup, no dialog, no charge
|
||||||
|
const d0 = p.dialogs.length;
|
||||||
|
await p.click("#clearBtn");
|
||||||
|
await (await p.$("#fileInput")).uploadFile(A.f);
|
||||||
|
await p.click("#runBtn");
|
||||||
|
s = await waitStatus(p, /^Finished|Error/);
|
||||||
|
ok("same document again: found, no confirmation, no stamp", /^Finished: 1 ok/.test(s) && p.dialogs.length === d0, s);
|
||||||
|
// 4) sign out revokes the key
|
||||||
|
await p.click("#veraeSignOutBtn");
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
st = await storage(p);
|
||||||
|
const who = await fetch("https://zap.verae.ai/v1/whoami", { headers: { authorization: "Bearer " + keyA } });
|
||||||
|
ok("sign out: key forgotten and revoked", !st.ss["verae.signin.key"] && who.status === 401, who.status);
|
||||||
|
ok("register.html loads no third-party scripts (fonts CSS only)", p.ext.length === 0, p.ext.join(" "));
|
||||||
|
await p.close();
|
||||||
|
|
||||||
|
// 5) app.html: legacy localStorage credentials are wiped; register flow with its own sign-in
|
||||||
|
p = await page("https://mcp.verae.ai/app.html");
|
||||||
|
await p.evaluate(() => localStorage.setItem("verae.auth", JSON.stringify({ username: "x", password: "y", accessToken: "z" })));
|
||||||
|
await p.reload({ waitUntil: "networkidle2" });
|
||||||
|
ok("app.html removes the old localStorage username/password/JWT", (await p.evaluate(() => localStorage.getItem("verae.auth"))) === null);
|
||||||
|
ok("app.html has no password/JWT fields", (await p.$("#password")) === null && (await p.$("#accessToken")) === null);
|
||||||
|
const B = tmpFile("app");
|
||||||
|
await (await p.$("#fileInput")).uploadFile(B.f);
|
||||||
|
await p.click("#runBtn");
|
||||||
|
await approveWhenShown(p);
|
||||||
|
s = await waitStatus(p, /^Finished|Not registered|Error/);
|
||||||
|
ok("app.html: new document registered", /^Finished: 1 ok/.test(s), s);
|
||||||
|
ok("app.html: price + balance confirmed", p.dialogs.length === 1 && /10 credits each/.test(p.dialogs[0]), p.dialogs[0]);
|
||||||
|
st = await storage(p);
|
||||||
|
const keyB = st.ss["verae.signin.key"]; out.keys.push(keyB);
|
||||||
|
ok("app.html key in sessionStorage only", /^xVerae-/.test(keyB || "") && !JSON.stringify(st.ls).includes("xVerae-") && !st.cookie.includes("xVerae"));
|
||||||
|
// verify mode stays anonymous
|
||||||
|
await p.click("#veraeSignOutBtn"); await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
await p.goto(`https://mcp.verae.ai/app.html?sha256=${B.sha}`, { waitUntil: "networkidle2" });
|
||||||
|
s = await waitStatus(p, /Lookup complete|Not found|Error|Invalid/i, 60000);
|
||||||
|
ok("app.html receipt link (signed out): anonymous lookup + verify", /Lookup complete/.test(s), s);
|
||||||
|
ok("app.html loads no third-party scripts", p.ext.length === 0, p.ext.join(" "));
|
||||||
|
out.newB = B.sha;
|
||||||
|
await p.close();
|
||||||
|
|
||||||
|
// 6) index.html docs mention the key for stamp
|
||||||
|
p = await page("https://mcp.verae.ai/index.html");
|
||||||
|
ok("index.html stamp snippet sends a Verae API key", (await p.content()).includes("Authorization: Bearer YOUR_VERAE_API_KEY"));
|
||||||
|
await p.close();
|
||||||
|
await browser.close();
|
||||||
|
fs.writeFileSync("/tmp/verae-e2e-result.json", JSON.stringify(out, null, 2));
|
||||||
|
console.log(process.exitCode ? "E2E FAIL" : "E2E OK", "new digests:", out.newA, out.newB);
|
||||||
Loading…
Reference in a new issue