177 lines
8.6 KiB
JavaScript
177 lines
8.6 KiB
JavaScript
// 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);
|
|
}
|