/* global globalThis */ // Node.js 24+ helper. This client signs API requests; it never sends a blockchain payment. const crypto = require('node:crypto'); const hash = (value) => crypto.createHash('sha256').update(value).digest('hex'); function canonicalJson(value) { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; if (value && typeof value === 'object') { const fields = Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`); return `{${fields.join(',')}}`; } return JSON.stringify(value); } function createAgentClient({ apiKey, payerWallet, signMessage, baseUrl = 'https://us-central1-audio-at-play.cloudfunctions.net', getFirebaseIdToken = async () => '', fetch: fetchImplementation = globalThis.fetch }) { if (!apiKey || !payerWallet || typeof signMessage !== 'function') { throw new Error('apiKey, payerWallet and an Ed25519 signMessage function are required.'); } const root = baseUrl.replace(/\/$/, ''); let catalogPromise; const serverCodes = new Set(['cancelled', 'unknown', 'invalid-argument', 'deadline-exceeded', 'not-found', 'already-exists', 'permission-denied', 'resource-exhausted', 'failed-precondition', 'aborted', 'out-of-range', 'unimplemented', 'internal', 'unavailable', 'data-loss', 'unauthenticated']); function failure(message, code, status = null) { const error = new Error(message); error.name = 'AgentApiError'; error.code = code; error.status = status; return error; } function retryMetadata(error, response) { const value = response.headers?.get?.('retry-after')?.trim(); if (!value || value.length > 100) return; if (/^\d{1,9}$/.test(value)) { error.retryAfter = value; error.retryAfterSeconds = Number(value); } else if (/^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(value) && Number.isFinite(Date.parse(value))) { error.retryAfter = value; error.retryAt = new Date(value).toISOString(); } } async function decode(response) { const status = Number.isInteger(response.status) ? response.status : null; let body; try { // Preserve HTTP context even when a proxy returns HTML or an empty/malformed body. body = typeof response.text === 'function' ? JSON.parse(await response.text()) : await response.json(); } catch { /* Do not expose response bodies or parser errors. */ } if (!response.ok) { const code = serverCodes.has(body?.error?.code) ? body.error.code : 'http-error'; const error = failure('Agent API returned HTTP ' + status + '. Reconcile the saved operation before retrying.', code, status); retryMetadata(error, response); throw error; } if (!body || typeof body !== 'object' || Array.isArray(body)) { throw failure('Agent API returned an unreadable response. Reconcile the saved operation before retrying.', 'invalid-response', status); } return body; } async function request(url, options) { let response; try { response = await fetchImplementation(url, options); } catch (cause) { const timeout = cause?.name === 'AbortError' || cause?.name === 'TimeoutError'; throw failure(timeout ? 'Agent API request timed out or was cancelled. Reconcile the saved operation.' : 'Agent API request failed on the network. Reconcile the saved operation.', timeout ? 'request-timeout' : 'network-error'); } return decode(response); } function catalog() { if (!catalogPromise) { catalogPromise = request(`${root}/agentListNativeProducts`, { headers: { Authorization: `Bearer ${apiKey}` }, }).catch((error) => { catalogPromise = undefined; throw error; }); } return catalogPromise; } async function call(action, fields, requestId = crypto.randomUUID()) { const discovery = await catalog(); if (discovery.auth?.version !== 2 || !discovery.auth.audience) { throw new Error('The endpoint does not advertise the v2 agent authentication protocol.'); } const token = await getFirebaseIdToken(); const timestamp = String(Date.now()); const body = { ...fields, requestId, payerWallet }; const message = ['Audio at Play agent API v2', discovery.auth.audience, hash(apiKey), 'POST', action, timestamp, hash(canonicalJson(body)), hash(token)].join('\n'); const signature = Buffer.from(await signMessage(Buffer.from(message, 'utf8'))); if (signature.length !== 64) throw new Error('signMessage must return a 64-byte Ed25519 signature.'); return request(`${root}/${action}`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'x-agent-timestamp': timestamp, 'x-agent-wallet-signature': signature.toString('base64'), ...(token ? { 'x-firebase-id-token': token } : {}) }, body: JSON.stringify(body) }); } function mutation(action, fields) { if (!fields?.requestId) throw new Error('Persist a unique requestId and reuse it when retrying this operation.'); const { requestId, ...body } = fields; return call(action, body, requestId); } return { catalog, acquireFree: (fields) => mutation('agentAcquireFreeProduct', fields), prepare: (fields) => mutation('agentPrepareSolanaPurchase', fields), verify: ({ requestId, ...fields }) => call('agentVerifySolanaPurchase', fields, requestId), library: ({ requestId, ...fields } = {}) => call('agentListLibrary', fields, requestId), download: (fields) => mutation('agentRequestProductDownload', fields), }; } module.exports = { createAgentClient };