aere-cloud-sdk/index.mjs

110 lines
5.2 KiB
JavaScript

// @aere/cloud — clientul oficial al Aere Cloud. Zero dependinte: doar fetch si crypto,
// disponibile in Node 18+ si in browser. Impacheteaza fiecare ruta a gateway-ului intr-o
// metoda, cu erorile ridicate ca AereCloudError (cod HTTP + corpul serverului), ca integratorul
// sa nu mai construiasca cereri de mana. Aceeasi filozofie ca API-ul: nimic ascuns, totul
// verificabil — de aceea verifyWebhook e in SDK, ca primitorul de webhook-uri sa nu-si scrie
// singur verificarea HMAC (locul unde integrarile gresesc cel mai des si primesc date false).
export class AereCloudError extends Error {
constructor(status, body, path) {
super(`Aere Cloud ${status} on ${path}: ${body && body.error ? body.error : 'request failed'}`);
this.name = 'AereCloudError';
this.status = status;
this.body = body;
this.path = path;
}
}
export class AereCloud {
/**
* @param {object} opts
* @param {string} opts.apiKey cheia in forma ak2800.<adresa>.<secret>
* @param {string} [opts.baseUrl='https://cloud.aere.network/v1']
* @param {function} [opts.fetch] injectabil pentru teste/Node vechi
*/
constructor(opts = {}) {
if (!opts.apiKey) throw new Error('AereCloud: apiKey is required (ak2800.<address>.<secret>)');
this.apiKey = opts.apiKey;
this.baseUrl = (opts.baseUrl || 'https://cloud.aere.network/v1').replace(/\/$/, '');
this._fetch = opts.fetch || globalThis.fetch;
if (!this._fetch) throw new Error('AereCloud: no fetch available; pass opts.fetch on old Node');
}
async _req(method, path, { body, auth = true, raw = false } = {}) {
const headers = { 'content-type': 'application/json' };
if (auth) headers['x-api-key'] = this.apiKey;
const r = await this._fetch(this.baseUrl + path, {
method, headers, body: body === undefined ? undefined : JSON.stringify(body),
});
if (raw) return r;
let data = null;
try { data = await r.json(); } catch { /* corp non-JSON */ }
if (!r.ok) throw new AereCloudError(r.status, data, path);
return data;
}
// ---- health & account ----
health() { return this._req('GET', '/health', { auth: false }); }
account() { return this._req('GET', '/account'); }
// ---- JSON-RPC ----
/** Un apel JSON-RPC brut; @returns rezultatul sau arunca la eroarea RPC. */
async rpc(method, params = []) {
const d = await this._req('POST', '/rpc', { body: { jsonrpc: '2.0', id: 1, method, params } });
if (d && d.error) throw new AereCloudError(200, d, '/rpc');
return d && d.result;
}
async blockNumber() { return parseInt(await this.rpc('eth_blockNumber'), 16); }
// ---- post-quantum verification ----
/**
* @param {object} p { scheme, publicKey, signature?, message?, signedMessage? } (0x-hex)
* @returns {Promise<{valid:boolean, scheme, precompile, block, chainId}>}
*/
pqVerify(p) { return this._req('POST', '/pq/verify', { body: p }); }
// ---- chain data ----
dataHead() { return this._req('GET', '/data/head'); }
validators() { return this._req('GET', '/data/validators'); }
anchors(limit = 10) { return this._req('GET', `/data/anchors?limit=${encodeURIComponent(limit)}`); }
anchor(height) { return this._req('GET', `/data/anchors/${encodeURIComponent(height)}`); }
transfers(address, limit = 50) {
return this._req('GET', `/data/transfers?address=${encodeURIComponent(address)}&limit=${encodeURIComponent(limit)}`);
}
// ---- notarization ----
/** Notarizeaza un digest de 32 de octeti (0x + 64 hex); noi platim gazul. */
notarize(hash) { return this._req('POST', '/notarize', { body: { hash } }); }
proofOf(hash) { return this._req('GET', `/notarize/${encodeURIComponent(hash)}`); }
// ---- webhooks ----
listWebhooks() { return this._req('GET', '/webhooks'); }
createWebhook(spec) { return this._req('POST', '/webhooks', { body: spec }); }
deleteWebhook(id) { return this._req('DELETE', `/webhooks/${encodeURIComponent(id)}`); }
// ---- gas sponsorship ----
sponsorHealth() { return this._req('GET', '/sponsor/health'); }
sponsorCreateAccount(body) { return this._req('POST', '/sponsor/createAccount', { body }); }
sponsorExecute(body) { return this._req('POST', '/sponsor/execute', { body }); }
}
/**
* Verifica semnatura unei livrari de webhook. A NU crede un webhook fara asta.
* @param {string} rawBody corpul brut al cererii, EXACT ca octeti (nu re-serializat)
* @param {string} signatureHeader antetul x-aere-signature
* @param {string} secret secretul webhook-ului, primit o data la creare
* @returns {boolean} true doar daca semnatura se potriveste
*/
export async function verifyWebhook(rawBody, signatureHeader, secret) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const mac = await crypto.subtle.sign('HMAC', key, typeof rawBody === 'string' ? enc.encode(rawBody) : rawBody);
const asteptat = Array.from(new Uint8Array(mac), (b) => b.toString(16).padStart(2, '0')).join('');
const primit = String(signatureHeader || '');
// comparatie in timp constant, pe lungimi egale
if (asteptat.length !== primit.length) return false;
let dif = 0;
for (let i = 0; i < asteptat.length; i++) dif |= asteptat.charCodeAt(i) ^ primit.charCodeAt(i);
return dif === 0;
}