// The runtime a claim handler runs in: what its code can use, and the limits. // This file is the single source of truth for the handler API. The sandbox // (src/quickjs.ts) implements it and test/handler-runtime.test.ts checks the // limits written here against the sandbox constants. // // A handler is a plain script (not a module) that Adorably runs in a fresh // QuickJS sandbox, one run per claim. It defines a top-level function named // handle: // // async function handle(input: HandlerInput): Promise<{ url: string; cta_label?: string }> // // It may also be `const handle = async (input) => { ... }`. Type a JS handler // with JSDoc against this file: // // /** @param {HandlerInput} input @returns {Promise} */ // async function handle(input) { ... } // // and check it with only the globals a handler has (the DOM's console clashes // with the one declared here): a tsconfig.json with "target": "es2022", // "lib": ["es2022"], "types": [], "strict": true, "allowJs": true, // "checkJs": true, "noEmit": true and "files": ["handler-runtime.d.ts", // "handler.js"], then `npx tsc -p .`. // // Limits per run. The time, memory, source and fetch limits are Adorably's // settings (the claim-handler resource of the seller MCP shows the current // values); the defaults are 2000 ms, 16 MiB of memory, a 32 KiB source, and 3 // fetches of at most 1500 ms and 64 KiB each. Running out of time or memory // ends the run (error 'timeout' or 'memory'). The adorably helpers and regex // work count against the same time and memory as your own code. // // A handler may also define explain (a sponsored explanation, run when a // developer presses Explain on your perk): // // async function explain(input: ExplainInput): Promise // // or set globalThis.explain from a bundle. Explain runs have their own // limits (defaults: 10000 ms of the run's own time, a 45000 ms wall clock // that counts model waits, 32 MiB of memory, a 1 MiB source, 5 fetches, 8 // model calls of at most 16000 input and 1024 output tokens each, model // responses of at most 262144 bytes). The clock of the run's own time stops // while a model request or stream read is pending. Explain runs also get an // OpenRouter-shaped model API at https://openrouter.adorably.local/api/v1 // (reached with fetch, XMLHttpRequest, an OpenAI-compatible client or // adorably.model.complete) and the web globals clients need, declared below: // XMLHttpRequest, Headers, URL, URLSearchParams, TextEncoder, TextDecoder, // AbortController, AbortSignal, setTimeout and friends, queueMicrotask, // performance.now, structuredClone, ReadableStream, Response, crypto. At most // 100 timers may be pending at once; one more fails the run. // Claim runs (handle) have none of them. // // Nothing else exists in a claim run: no timers, no storage, no modules, no // network beyond fetch. Any failure (an exception, a timeout, too much memory, a bad return // value, adorably.fallback) sends the developer to the offer's plain landing // URL, so a claim never breaks. /** What handle receives. */ interface HandlerInput { /** The offer being claimed. url is its plain landing URL. */ offer: { id: string; vendor: string; value: string; url: string } /** The claim's id, as list_claims shows it. In test runs (test_claim_handler, set_claim_handler) 'clm_test', or the claim_id passed. */ claim_id: string /** * The email the developer chose to share. Always there: a claim without one * never runs the handler, and a test run has test_email. In error * messages, fallback reasons and logs it is replaced by [email]. */ email: string /** * true in claims: the developer confirmed they own the email with a code * Adorably mailed to it (an unconfirmed email never reaches a handler). * Absent in test runs. */ email_verified?: true /** * true in test runs (test_claim_handler, set_claim_handler), false in * claims. Test runs call your real API unless a fetch is mocked: tell it * the request is a test, and pass a fresh claim_id per test if it caches * answers by idempotency key. */ test: boolean } /** What handle resolves to. Other fields are ignored. */ interface HandlerOutput { /** https, on one of your approved hosts (or a subdomain), no user or password, at most 1024 characters. */ url: string /** * The claim button's label: plain text, at most 24 characters, not blank, * no URL or domain (dotted names like "Node.js" read as one: write "Node"), * nothing in a prohibited category (the adorably://prohibited resource). * Trimmed and moderated: a claim drops a label that fails and shows the * default one; a test run fails instead. */ cta_label?: string } /** The type of handle: `async function handle(input: HandlerInput): Promise`. */ type ClaimHandler = (input: HandlerInput) => Promise /** * The secrets saved with set_claim_handler (or passed to test_claim_handler * for one run), by name (e.g. SECRETS.API_KEY). Frozen. A name that is not * set reads as undefined; a test run logs a warning the first time. Anyone * who can run handlers for your seller account can read the saved ones. */ declare const SECRETS: Readonly> /** fetch's options. */ interface HandlerRequestInit { /** GET (the default), POST, PUT or PATCH, in any case. Anything else rejects with 'method not allowed'. */ method?: string /** * Values are converted to strings. Host, Connection, Transfer-Encoding, * Forwarded, X-Real-IP, True-Client-IP, CF-* and X-Forwarded-* are dropped. */ headers?: Readonly> /** A string (JSON.stringify an object); anything else rejects with a TypeError. Ignored for GET. */ body?: string | null /** Give up sooner than the per-fetch limit (never later): rejects with 'fetch timed out'. A positive number of ms. */ timeoutMs?: number } /** fetch's response. From your hosts the body is read whole first; a model stream is read as you read it. */ interface HandlerResponse { readonly status: number /** status is 200-299. */ readonly ok: boolean /** Response headers: a plain object with lower-case names (res.headers['x-request-id']), with a non-enumerable get(name), has(name), forEach and entries. */ readonly headers: Readonly> & { get(name: string): string | null; has(name: string): boolean } /** The body as UTF-8 bytes: body.getReader().read() or `for await (const chunk of res.body)`. Read once. */ readonly body: HandlerBody text(): Promise /** Rejects when the body is not JSON. */ json(): Promise } interface HandlerBody extends AsyncIterable { getReader(): { read(): Promise<{ done: boolean; value?: Uint8Array }>; cancel(): Promise; releaseLock(): void } } /** * A subset of fetch: https only, to your approved hosts (the landing page * hosts of your offers Adorably approved, and their subdomains; test runs * too, so an offer on a new host gets its handler once approved). * Redirects are not followed (a 3xx is returned as it is). * Rejects with an Error whose message is one of: * - 'host not allowed': not https, a user or password in the url, or the host is not on your approved hosts * - 'method not allowed': not GET, POST, PUT or PATCH * - 'too many fetches': over the per-run count (default 3) * - 'fetch timed out': over the per-fetch time (default 1500 ms) * - 'response too large': a body over the per-fetch size (default 65536 bytes) * - 'fetch failed': a network error or a header the Fetch API refuses * - 'the run already fell back': called after adorably.fallback */ declare function fetch(url: string, init?: HandlerRequestInit): Promise /** * A no-op in claims: production runs never collect logs. In test runs * (test_claim_handler, set_claim_handler) each call becomes one line of the * result's logs, "[level] " and the arguments (strings as they are, errors as "Name: message", other values * as JSON), at most 50 lines of at most 500 characters. Secret values and the * email, plain or URL-encoded, are replaced by [secret] and [email]; a value * your code changed (upper case, a slice, base64) is not caught. */ declare const console: Readonly<{ log(...args: unknown[]): void info(...args: unknown[]): void warn(...args: unknown[]): void error(...args: unknown[]): void debug(...args: unknown[]): void }> /** Values adorably.url writes as query parameters; null and undefined are skipped. */ type HandlerUrlParam = string | number | boolean | null | undefined /** * Helpers for building signed links and codes. Frozen. Every helper throws a * TypeError ("adorably.: ") on an argument it cannot use. * Strings are hashed, signed and encoded as UTF-8 (an unpaired surrogate as * U+FFFD). The helpers run inside the sandbox under the run's time and memory * limits: with the default 2000 ms, sha256 and hmac handle a few megabytes * per run. */ declare const adorably: Readonly<{ /** * base plus params as an encoded query string, appended to any query base * has and kept before its #fragment. Keys and values are * percent-encoded (encodeURIComponent); null and undefined values are * skipped; numbers must be finite. Throws when base is not an absolute * https:// URL with a host, has a user or password, or has spaces or * control characters, or when a value is another type. * @example adorably.url('https://example.com/signup', { claim: input.claim_id, ref: 'adorably' }) * // 'https://example.com/signup?claim=clm_...&ref=adorably' */ url(base: string, params?: Readonly>): string /** SHA-256 of data, as 64 lower-case hex characters. */ sha256(data: string): string /** HMAC-SHA256 of message with key: hex (the default) or base64url without padding. */ hmac(key: string, message: string, encoding?: 'hex' | 'base64url'): string /** base64url (RFC 4648 section 5) of data, without padding. */ base64url(data: string): string /** The text data encodes. Padding is optional; throws on other characters or bytes that are not UTF-8. */ base64urlDecode(data: string): string /** * An HS256 JSON Web Token of payload signed with key. Adds iat (seconds * since the epoch) unless payload has one, and with expiresIn (seconds, * positive) sets exp to now + expiresIn. */ jwt(payload: Readonly>, key: string, opts?: { expiresIn?: number }): string /** * A random code, every character equally likely, from host cryptographic * randomness. length 1 to 1024 (default 12); alphabet 2 to 256 distinct * characters (default ABCDEFGHJKLMNPQRSTUVWXYZ23456789: upper case without * I, O, 0 or 1). A run may draw at most 65536 random bytes (one per * character, a few more for alphabets that do not divide 256). */ code(length?: number, alphabet?: string): string /** * true when the run may return url: https, on one of your approved hosts * (or a subdomain), no user or password, no control characters, at most 1024 * characters. Check any url your API returns with it. * @example if (!adorably.allowedUrl(body.url)) adorably.fallback('API answered a url we cannot use') */ allowedUrl(url: unknown): boolean /** * The ms left before the run's time limit, by the clock. The clock moves * while fetches wait, not while your code runs: read it after a fetch to * decide whether another one fits. */ remainingMs(): number /** * Ends the run now: the developer gets the offer's plain landing URL and * the result is error 'fallback' with reason as its message (at most 200 * characters, the email replaced by [email]). Use it when there is nothing * better to offer (e.g. your API is down or refused the claim). It * cannot be caught: code after it, even in a catch, does not change the * outcome, and later fetches reject. */ fallback(reason: string): never /** * Explain runs only (absent in claim runs): one model call through the * OpenRouter-shaped API. A non-2xx answer rejects with an Error whose * message is "model: " and whose status is the HTTP * status (402: your balance or budget; 429: the call limit or the daily * cap; 400: a bad request or a model you may not use; 503: no provider). */ model: Readonly<{ complete(req: ModelRequest): Promise }> }> // ---------- explain runs (sponsored explanations) ---------- /** A brief fact, with the URL it comes from. */ interface ExplainFact { kind: 'product' | 'pricing' | 'migration'; text: string; source: string } /** Your saved explanation brief (set_explanation_brief). */ interface ExplainBrief { facts: ExplainFact[] /** The stack tags the brief targets. */ target_tags: string[] /** Products you may name, each with the has: tag that says the developer uses it. */ competitors: { name: string; tag: string }[] /** The model the brief asks for, when set. */ model?: string } /** What explain receives. */ interface ExplainInput { offer: { id: string; vendor: string; value: string; url: string } /** The stack tags the developer sent: only the brief's target tags their project has, like has:vercel. */ tags: string[] /** The brief competitor whose tag is among tags (the first, in brief order), else null. */ competitor: string | null /** Your brief; null in a test run of an offer without one. */ brief: ExplainBrief | null /** true in test runs (test_claim_handler with explain, set_claim_handler). */ test: boolean } /** * What explain resolves to: the message, or null (or undefined) to show * nothing. The developer sees it inside "From (sponsored)" with your * landing page's host, the sources and your claim button, only after * Adorably's checks pass: at most 1200 characters, no URL or domain in the * text (sources go in sources), 1 to 5 https sources on your approved hosts * or in your brief, no disparagement, never calling itself neutral or unbiased, and a * model check that every claim is in your facts. A message that fails is not * shown; the inference is billed either way. */ interface ExplainOutput { message: string sources: string[] /** The frame's claim button, like cta_label. */ cta_label?: string } /** The type of explain. */ type ExplainHandler = (input: ExplainInput) => Promise /** A chat message in the OpenAI format the model API takes. */ type ChatMessage = | { role: 'system' | 'developer' | 'user'; content: string } | { role: 'assistant'; content: string | null; tool_calls?: ChatToolCall[] } | { role: 'tool'; tool_call_id: string; content: string } interface ChatToolCall { id: string; type: 'function'; function: { name: string; arguments: string } } interface ChatTool { type: 'function'; function: { name: string; description?: string; parameters?: Record } } /** adorably.model.complete's request. Left out, model is the default (Claude Haiku 5.5). */ interface ModelRequest { model?: string messages: ChatMessage[] /** Clamped to the per-call output limit (default 1024), which is also the default. */ max_tokens?: number tools?: ChatTool[] tool_choice?: 'auto' | 'none' | 'required' | { type: 'function'; function: { name: string } } reasoning?: { effort?: 'low' | 'medium' | 'high' } response_format?: { type: 'json_schema'; json_schema: { name: string; schema: Record } } stop?: string | string[] } interface ModelAnswer { /** The text, or null when the model only called tools. */ text: string | null tool_calls: ChatToolCall[] /** stop, length, tool_calls or content_filter. */ finish_reason: string /** cost: what this call costs you, in USD (live runs at cost plus Adorably's margin, test runs at cost). */ usage: { prompt_tokens: number; completion_tokens: number; cost: number } } // Explain runs only: the web globals OpenAI- and OpenRouter-compatible // clients use (the subset they need; claim runs have none of them). declare class XMLHttpRequest { readyState: number; status: number; statusText: string; responseText: string; responseType: '' | 'text' | 'json'; readonly response: any; timeout: number onreadystatechange: ((ev: { type: string }) => void) | null; onprogress: ((ev: { type: string }) => void) | null; onload: ((ev: { type: string }) => void) | null onerror: ((ev: { type: string }) => void) | null; onabort: ((ev: { type: string }) => void) | null; onloadend: ((ev: { type: string }) => void) | null; ontimeout: ((ev: { type: string }) => void) | null open(method: string, url: string): void setRequestHeader(name: string, value: string): void send(body?: string | null): void abort(): void getResponseHeader(name: string): string | null getAllResponseHeaders(): string addEventListener(type: string, fn: (ev: { type: string }) => void): void removeEventListener(type: string, fn: (ev: { type: string }) => void): void } declare function setTimeout(fn: (...args: any[]) => void, ms?: number, ...args: any[]): number declare function clearTimeout(id: number | undefined): void declare function setInterval(fn: (...args: any[]) => void, ms?: number, ...args: any[]): number declare function clearInterval(id: number | undefined): void declare function queueMicrotask(fn: () => void): void declare function structuredClone(value: T): T declare const performance: Readonly<{ now(): number; timeOrigin: number }> declare class TextEncoder { encode(s?: string): Uint8Array } declare class TextDecoder { constructor(label?: string, options?: { fatal?: boolean; ignoreBOM?: boolean }); decode(input?: ArrayBufferView | ArrayBuffer, options?: { stream?: boolean }): string } declare class AbortSignal { readonly aborted: boolean; readonly reason: unknown; static timeout(ms: number): AbortSignal; static abort(reason?: unknown): AbortSignal; addEventListener(type: 'abort', fn: () => void): void; throwIfAborted(): void } declare class AbortController { readonly signal: AbortSignal; abort(reason?: unknown): void } declare const crypto: Readonly<{ randomUUID(): string; getRandomValues(a: T): T }>