110 lines
5.2 KiB
JavaScript
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;
|
|
}
|