@aere/cloud v1.0.0: clientul oficial al Aere Cloud, zero dependinte (RPC, PQ verify, date, transferuri, notarizare, webhooks, gaz), 7 teste inclusiv verifyWebhook cu control negativ si proba vie
This commit is contained in:
commit
fa8478c816
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
*.log
|
||||
15
LICENSE
Normal file
15
LICENSE
Normal file
@ -0,0 +1,15 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Aere Network
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
||||
77
README.md
Normal file
77
README.md
Normal file
@ -0,0 +1,77 @@
|
||||
# @aere/cloud
|
||||
|
||||
The official client for the [Aere Cloud API](https://aere.network/cloud.html) — one keyed
|
||||
endpoint for RPC, post-quantum signature verification, chain data, notarization, webhooks and
|
||||
gas sponsorship on Aere Network (chain 2800). Zero dependencies; runs in Node 18+ and modern
|
||||
browsers.
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
npm install @aere/cloud
|
||||
```
|
||||
|
||||
Get a key: the free trial is self-serve at [aere.network/cloud.html#subscribe](https://aere.network/cloud.html#subscribe).
|
||||
A key looks like `ak2800.<your-0x-address>.<secret>`.
|
||||
|
||||
## Use
|
||||
|
||||
```js
|
||||
import { AereCloud } from '@aere/cloud';
|
||||
|
||||
const aere = new AereCloud({ apiKey: process.env.AERE_KEY });
|
||||
|
||||
// chain data
|
||||
const head = await aere.dataHead();
|
||||
const anchors = await aere.anchors(5); // post-quantum anchor certificates
|
||||
const set = await aere.validators(); // live QBFT validator set
|
||||
|
||||
// JSON-RPC
|
||||
const block = await aere.blockNumber();
|
||||
|
||||
// post-quantum verification, computed by the chain's own precompiles
|
||||
const { valid } = await aere.pqVerify({
|
||||
scheme: 'ml-dsa-44', publicKey: '0x…', signature: '0x…', message: '0x…',
|
||||
});
|
||||
|
||||
// notarize a document hash — Aere pays the gas
|
||||
const receipt = await aere.notarize('0x' + sha256Hex);
|
||||
const proof = await aere.proofOf('0x' + sha256Hex); // { notarized, firstSeenAt, … }
|
||||
|
||||
// transfer history for an address (token from genesis, native from launch)
|
||||
const tx = await aere.transfers('0xYourAddress', 50);
|
||||
```
|
||||
|
||||
Every method throws `AereCloudError` on a non-2xx response, carrying `.status` and the server's
|
||||
JSON `.body` so you can act on the exact error.
|
||||
|
||||
## Webhooks
|
||||
|
||||
Create a webhook and verify every delivery — never trust a webhook payload without checking its
|
||||
signature:
|
||||
|
||||
```js
|
||||
import { AereCloud, verifyWebhook } from '@aere/cloud';
|
||||
|
||||
const aere = new AereCloud({ apiKey: process.env.AERE_KEY });
|
||||
const wh = await aere.createWebhook({ type: 'pq-anchors', url: 'https://you.example/hook' });
|
||||
// store wh.secret now — it is shown once
|
||||
|
||||
// in your webhook handler (raw body, not re-serialized):
|
||||
app.post('/hook', async (req, res) => {
|
||||
const ok = await verifyWebhook(req.rawBody, req.headers['x-aere-signature'], WEBHOOK_SECRET);
|
||||
if (!ok) return res.status(401).end();
|
||||
handle(JSON.parse(req.rawBody));
|
||||
res.status(200).end();
|
||||
});
|
||||
```
|
||||
|
||||
Webhook types: `pq-anchors` (every 32nd-block certificate), `address-activity` (transactions
|
||||
touching a watched address, pass `watch`), `subscription-events` (your billing events).
|
||||
|
||||
## Reference
|
||||
|
||||
Full API reference and OpenAPI spec: [aere.network/cloud-docs.html](https://aere.network/cloud-docs.html)
|
||||
· live status you compute in your own browser: [aere.network/cloud-status.html](https://aere.network/cloud-status.html)
|
||||
|
||||
MIT licensed.
|
||||
109
index.mjs
Normal file
109
index.mjs
Normal file
@ -0,0 +1,109 @@
|
||||
// @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;
|
||||
}
|
||||
16
package.json
Normal file
16
package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@aere/cloud",
|
||||
"version": "1.0.0",
|
||||
"description": "Official client for the Aere Cloud API: keyed RPC, post-quantum verification, chain data, notarization, webhooks and gas sponsorship on Aere Network (chain 2800).",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"exports": { ".": "./index.mjs" },
|
||||
"files": ["index.mjs", "README.md"],
|
||||
"scripts": { "test": "node test/sdk.test.mjs" },
|
||||
"keywords": ["aere", "blockchain", "evm", "post-quantum", "falcon", "rpc", "web3", "chain-2800"],
|
||||
"license": "MIT",
|
||||
"homepage": "https://aere.network/cloud.html",
|
||||
"repository": { "type": "git", "url": "https://git.aere.network/aere-network/aere-cloud-sdk" },
|
||||
"engines": { "node": ">=18" },
|
||||
"dependencies": {}
|
||||
}
|
||||
101
test/sdk.test.mjs
Normal file
101
test/sdk.test.mjs
Normal file
@ -0,0 +1,101 @@
|
||||
// Testele SDK-ului: rutele contra unui server FALS (deterministe, offline) + verifyWebhook cu
|
||||
// perechea negativa + o proba VIE optionala contra productiei daca AERE_CHEIE e in mediu.
|
||||
import assert from 'node:assert';
|
||||
import crypto from 'node:crypto';
|
||||
import { AereCloud, AereCloudError, verifyWebhook } from '../index.mjs';
|
||||
|
||||
let treceri = 0;
|
||||
async function test(nume, fn) {
|
||||
try { await fn(); treceri++; console.log(' OK ' + nume); }
|
||||
catch (e) { console.log(' ESEC ' + nume + ' — ' + e.message); process.exitCode = 1; }
|
||||
}
|
||||
|
||||
// ---- server fals: raspunde dupa cale, ca sa testam constructia cererii si maparea erorilor ----
|
||||
function fetchFals(rute) {
|
||||
return async (url, opts = {}) => {
|
||||
const u = new URL(url);
|
||||
const cale = u.pathname.replace(/^\/v1/, ''); // baseUrl de test include /v1
|
||||
const cheie = opts.method + ' ' + cale + u.search;
|
||||
const r = rute[cheie] || rute[opts.method + ' ' + cale];
|
||||
if (!r) return { ok: false, status: 404, json: async () => ({ error: 'not_found' }) };
|
||||
r._vazut = { headers: opts.headers, body: opts.body ? JSON.parse(opts.body) : undefined };
|
||||
return { ok: r.status < 400, status: r.status, json: async () => r.corp };
|
||||
};
|
||||
}
|
||||
// AERE-SINTETIC: cheile de mai jos (ak2800.0xAAA.secret, secretul-meu) sunt FICTIVE, pentru
|
||||
// testul offline; nu sunt chei ale retelei. Marcajul spune portii de secrete exact asta.
|
||||
const BAZA_TEST = 'https://x.test/v1';
|
||||
|
||||
await test('constructorul cere cheia', () => {
|
||||
assert.throws(() => new AereCloud({}), /apiKey is required/);
|
||||
});
|
||||
|
||||
await test('cheia merge in antetul x-api-key, health e fara cheie', async () => {
|
||||
const rute = { 'GET /health': { status: 200, corp: { ok: true, chainId: 2800, block: 5 } },
|
||||
'GET /account': { status: 200, corp: { address: '0xabc', planId: 5 } } };
|
||||
const c = new AereCloud({ apiKey: 'ak2800.0xAAA.AERE-SINTETIC-secret', baseUrl: BAZA_TEST, fetch: fetchFals(rute) });
|
||||
const h = await c.health();
|
||||
assert.equal(h.block, 5);
|
||||
assert.equal(rute['GET /health']._vazut.headers['x-api-key'], undefined, 'health nu trimite cheie');
|
||||
await c.account();
|
||||
assert.equal(rute['GET /account']._vazut.headers['x-api-key'], 'ak2800.0xAAA.AERE-SINTETIC-secret');
|
||||
});
|
||||
|
||||
await test('rpc impacheteaza corect si ridica eroarea RPC', async () => {
|
||||
const rute = {
|
||||
'POST /rpc': { status: 200, corp: { jsonrpc: '2.0', id: 1, result: '0x10' } },
|
||||
};
|
||||
const c = new AereCloud({ apiKey: 'ak2800.0xAAA.AERE-SINTETIC-s', baseUrl: BAZA_TEST, fetch: fetchFals(rute) });
|
||||
assert.equal(await c.blockNumber(), 16);
|
||||
assert.deepEqual(rute['POST /rpc']._vazut.body, { jsonrpc: '2.0', id: 1, method: 'eth_blockNumber', params: [] });
|
||||
// eroare RPC in plic -> AereCloudError
|
||||
const rute2 = { 'POST /rpc': { status: 200, corp: { error: { code: -32601, message: 'nope' } } } };
|
||||
const c2 = new AereCloud({ apiKey: 'ak2800.0xAAA.AERE-SINTETIC-s', baseUrl: BAZA_TEST, fetch: fetchFals(rute2) });
|
||||
await assert.rejects(() => c2.rpc('qbft_proposeValidatorVote'), (e) => e instanceof AereCloudError && e.body.error.code === -32601);
|
||||
});
|
||||
|
||||
await test('un status HTTP de eroare devine AereCloudError cu corpul serverului', async () => {
|
||||
const rute = { 'POST /pq/verify': { status: 400, corp: { error: 'bad_publicKey', hint: '0x hex' } } };
|
||||
const c = new AereCloud({ apiKey: 'ak2800.0xAAA.AERE-SINTETIC-s', baseUrl: BAZA_TEST, fetch: fetchFals(rute) });
|
||||
await assert.rejects(() => c.pqVerify({ scheme: 'ml-dsa-44', publicKey: 'nu-e-hex' }),
|
||||
(e) => e instanceof AereCloudError && e.status === 400 && e.body.error === 'bad_publicKey');
|
||||
});
|
||||
|
||||
await test('rutele de date construiesc corect query-ul', async () => {
|
||||
const rute = {
|
||||
'GET /data/anchors?limit=3': { status: 200, corp: { anchors: [] } },
|
||||
'GET /data/transfers?address=0xBeef&limit=25': { status: 200, corp: { tokenTransfers: [], nativeTransfers: [] } },
|
||||
};
|
||||
const c = new AereCloud({ apiKey: 'ak2800.0xAAA.AERE-SINTETIC-s', baseUrl: BAZA_TEST, fetch: fetchFals(rute) });
|
||||
await c.anchors(3);
|
||||
await c.transfers('0xBeef', 25);
|
||||
// daca reperele nu s-ar potrivi, fetchFals ar da 404 si _req ar arunca
|
||||
});
|
||||
|
||||
await test('verifyWebhook: semnatura buna trece, una gresita pica, o litera in plus pica', async () => {
|
||||
const secret = 'AERE-SINTETIC-secretul-meu';
|
||||
const corp = JSON.stringify({ event: 'pq-anchor', data: { height: 42 } });
|
||||
const bun = crypto.createHmac('sha256', secret).update(corp).digest('hex'); // ca serverul
|
||||
assert.equal(await verifyWebhook(corp, bun, secret), true);
|
||||
assert.equal(await verifyWebhook(corp, bun.slice(0, -1) + (bun.slice(-1) === 'a' ? 'b' : 'a'), secret), false);
|
||||
assert.equal(await verifyWebhook(corp, bun + 'x', secret), false);
|
||||
assert.equal(await verifyWebhook(corp + ' ', bun, secret), false, 'corp modificat = alta semnatura');
|
||||
});
|
||||
|
||||
// ---- proba VIE optionala ----
|
||||
if (process.env.AERE_CHEIE) {
|
||||
await test('LIVE: health + account + anchors prin productie', async () => {
|
||||
const c = new AereCloud({ apiKey: process.env.AERE_CHEIE });
|
||||
const h = await c.health();
|
||||
assert.equal(h.chainId, 2800);
|
||||
assert.ok(h.block > 0);
|
||||
const a = await c.anchors(1);
|
||||
assert.ok(a.anchors[0].falconSeals >= 6, 'ancora vie sub cvorum');
|
||||
const acc = await c.account();
|
||||
assert.ok(acc.usageLast31Days);
|
||||
});
|
||||
} else {
|
||||
console.log(' (sar proba LIVE: AERE_CHEIE nu e in mediu)');
|
||||
}
|
||||
|
||||
console.log('\n' + treceri + ' teste trecute');
|
||||
Loading…
Reference in New Issue
Block a user