import { escapeHtml } from "@intentic/base/escape"; import { FetchJsonError, fetchJson } from "@intentic/base/fetch-json"; // The zod-free half of an embed's wire and look, shared by the two embeds (webchat-widget, issue-sdk) and the daemon's // doors behind them. Nothing imported but base's dependency-free subpaths: this bundles into someone else's page, and // the contract's barrel would drag in zod (measured at 1.1 MB for two strings). // Where an embed talks to: the daemon it came from, and the automation it is the public face of. export interface EmbedEndpoint { readonly base: string; readonly automationId: string; } // The server's own refusal sentence ({"error": ...}), shown verbatim: it names exactly what to fix (e.g. "origin not // allowed"). export class EmbedError extends Error { constructor( message: string, readonly status: number, ) { super(message); this.name = "EmbedError"; } } export const embedFailure = async (response: Response): Promise => { const body = (await response.json().catch(() => undefined)) as { error?: unknown } | undefined; return new EmbedError(typeof body?.error === "string" ? body.error : `request failed (${response.status})`, response.status); }; // One route of a public door: `///`, the daemon's own spelling. export const embedUrl = ({ base, automationId }: EmbedEndpoint, slug: string, path: string): string => `${base}/${slug}/${encodeURIComponent(automationId)}/${path}`; // One GET against a public door, answered as JSON or refused with the server's own sentence. Base's transport gives it // a deadline, so a door that stalls fails in 30 s instead of leaving the visitor's widget waiting on it for minutes, // and names a timeout as one. The label is what a visitor reads when the server cannot be reached. export const fetchEmbedJson = async (url: string): Promise => { try { // SAFETY: the caller names the shape its door answers; the embed bundles no schema library to check it with. return (await fetchJson(url, { label: "The server" })) as T; } catch (error) { // A refusal's body arrives as the start of its text; embedFailure reads the sentence out of it as for any response. throw error instanceof FetchJsonError && error.kind === "status" && error.status !== undefined ? await embedFailure(new Response(error.body ?? "", { status: error.status })) : error; } }; // Find a nonce whose SHA-256 of `salt:nonce` has `difficulty` leading zero bits, signed to one caller's id so it can't // be replayed elsewhere. Solved on the main thread, not a Worker: a host's CSP may forbid the blob: URL a Worker needs. export interface PowChallenge { readonly salt: string; readonly difficulty: number; } export const fetchEmbedChallenge = (endpoint: EmbedEndpoint, slug: string, param: string, id: string): Promise => fetchEmbedJson(`${embedUrl(endpoint, slug, "challenge")}?${param}=${encodeURIComponent(id)}`); const BATCH = 512; // Leading zero bits of a digest, up to `wanted`. Stops at the first non-zero byte, so a miss costs one byte. const leadingZeroBits = (digest: Uint8Array, wanted: number): number => { let bits = 0; for (const byte of digest) { if (byte !== 0) { // Math.clz32 counts 32-bit leading zeros; the byte sits in the low 8, so 24 are structural. return bits + Math.clz32(byte) - 24; } bits += 8; if (bits >= wanted) { return bits; } } return bits; }; // Resolves to `:`, so the daemon can re-derive the challenge without storing one. insecure is for an // http:// page: crypto.subtle needs a secure context, and the fix is the site's TLS. export const solveProofOfWork = async (challenge: PowChallenge, insecure: string, onProgress?: (attempts: number) => void): Promise => { if (crypto.subtle === undefined) { throw new Error(insecure); } const encoder = new TextEncoder(); for (let nonce = 0; ; nonce += 1) { const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(`${challenge.salt}:${nonce}`))); if (leadingZeroBits(digest, challenge.difficulty) >= challenge.difficulty) { return `${challenge.salt}:${nonce}`; } if (nonce % BATCH === BATCH - 1) { onProgress?.(nonce + 1); // Hands the main thread back so the page (and the "checking..." line) keeps painting. // allow(primitive-tiers): a yield to the page's event loop, not a wait; this bundle imports no async helpers. await new Promise((resolve) => setTimeout(resolve, 0)); } } }; // localStorage can throw (Safari private mode, blocked storage); the embed still works, just minting a fresh id per // load. Silent by design: an embed must never put an error in the console. export const readStored = (name: string): string | undefined => { try { return window.localStorage.getItem(name) ?? undefined; } catch { return undefined; } }; export const writeStored = (name: string, value: string): void => { try { window.localStorage.setItem(name, value); } catch { // No storage: this load keeps the value in memory, the next one mints another. } }; // A per-browser id, not identity and not a secret: it threads a visitor's messages into one conversation, or bounds a // rate window per tab. export const storedId = (name: string): string => { const existing = readStored(name); if (existing !== undefined && existing !== "") { return existing; } const minted = crypto.randomUUID(); writeStored(name, minted); return minted; }; // The daemon to talk to is this script's own origin, the one thing a copy-paste can't get wrong; data-base overrides it // behind a reverse proxy. currentScript is read at module scope, valid only synchronously; querySelector is the // fallback. export const embedScript = (srcMatch: string): HTMLScriptElement | null => (document.currentScript as HTMLScriptElement | null) ?? document.querySelector(`script[src*="${srcMatch}"]`); // Undefined with no automation id, the one mistake worth flagging: otherwise the embed is silently absent. export const embedEndpointOf = (script: HTMLScriptElement): EmbedEndpoint | undefined => { const automationId = script.dataset["automation"]; if (automationId === undefined || automationId === "") { return undefined; } return { automationId, base: (script.dataset["base"] ?? new URL(script.src, window.location.href).origin).replace(/\/$/, "") }; }; // The frames of a Visitor chat reply, streamed as server-sent events on the POST that sent the message: a chunk of the // answer, the notice that the owner reviews it first, the one failure sentence a visitor sees, and the end of the turn. export const WEBCHAT_EVENT = { delta: "delta", pending: "pending", error: "error", done: "done" } as const; // Intentic's brand orange (`--color-brand-600`): the accent of an embed nobody configured, so it still looks like the // product, and what an embed paints when it cannot read the configured one. export const DEFAULT_ACCENT = "#e47100"; // The red, green and blue of a `#rgb` or `#rrggbb` colour, undefined for anything else. The embeds do their colour maths // here rather than in CSS: an unsupported color-mix() is invalid at computed-value time and would paint nothing. export const parseHex = (value: string): [number, number, number] | undefined => { const digits = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.trim())?.[1]; if (digits === undefined) { return undefined; } const full = digits.length === 3 ? [...digits].map((char) => char + char).join("") : digits; const channel = (at: number): number => Number.parseInt(full.slice(at, at + 2), 16); return [channel(0), channel(2), channel(4)]; }; // One sRGB channel on WCAG's linear scale. const linear = (channel: number): number => { const unit = channel / 255; return unit <= 0.03928 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4; }; // WCAG relative luminance; an unreadable colour scores 0, which only ever picks a label and is never shown. const luminance = (color: string): number => { const [r, g, b] = parseHex(color) ?? [0, 0, 0]; return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); }; const contrast = (a: number, b: number): number => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); // The label on a solid accent: the embed's own dark ink or white, whichever contrasts more. Measured, not assumed: // white on the default orange is 3.15:1, under AA, where either embed's dark ink clears 5:1, and comparing the two works // for any accent a customer picks. export const onAccent = (accent: string, dark: string): string => { const field = luminance(accent); return contrast(field, luminance(dark)) >= contrast(field, luminance("#ffffff")) ? dark : "#ffffff"; }; // Text for innerHTML, safe both between tags and inside a quoted attribute: neither an owner's config nor what a // visitor typed is trusted with markup. Base's one escaper, re-exported for the embeds that import it from here. export { escapeHtml };