POST /v1/identity/verify: an AERE Identity presentation judged by the published command line -> VALID / PARTIAL / INVALID with every row, the claims shown and the subject when no row failed, and the command that reproduces the verdict. POST /v1/compliance/check: your policy judged on a presentation -> COMPLIANT / NOT_COMPLIANT with the reasons and, with record, the decision as an AIP-23 envelope without personal data. In Go the presentation, keys, lists and policy travel as json.RawMessage: a map or float64 would change numbers above 2^53 and the canonical text the signatures cover. The .NET negative control now builds clean for each planted defect (an incremental build could run the tests against the previous planted library). A negative control for the JavaScript and Python routes is added. Tests: JavaScript 13, Python 14, Go ok, Java 10/10 (and the Java 8 API gate); negative controls Go 9/9, Java 10/10, .NET 12/12, JavaScript and Python 4/4.
212 lines
14 KiB
JavaScript
212 lines
14 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;
|
|
// 1.5.1: fara redirectari urmate. fetch le urmeaza implicit si duce x-api-key la orice gazda numita in Location;
|
|
// un 3xx e un raspuns (AereCloudError cu statusul lui), nu un drum.
|
|
const r = await this._fetch(this.baseUrl + path, {
|
|
method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual',
|
|
});
|
|
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 }); }
|
|
|
|
/**
|
|
* the Trust API (1.6.0): one AERE Proof Protocol (AIP-23) envelope of any kind -> VALID / INVALID / PARTIAL with the levels checked and the command that reproduces the verdict; the judgement is the published reference verifier. Needs a gateway that serves POST /v1/verify.
|
|
* @param {object} envelope the envelope as produced (statement, statementHash, signatures, notarization...)
|
|
* @param {{chain?: number}} [opts] 2800 or 28001; omitted = the chain the envelope declares
|
|
* @returns {Promise<{verdict:'VALID'|'INVALID'|'PARTIAL', levels:object[], verifier:object, reproduce:string}>}
|
|
*/
|
|
verify(envelope, { chain } = {}) {
|
|
return this._req('POST', '/verify' + (chain ? `?chain=${encodeURIComponent(chain)}` : ''), { body: envelope });
|
|
}
|
|
|
|
/**
|
|
* identity verification (1.7.0): an AERE Identity presentation judged by the published command line -> VALID / PARTIAL / INVALID with every
|
|
* row, the claims shown and the subject when no row failed, and the command (with the moment) that reproduces the verdict. Read `verdict`:
|
|
* PARTIAL means some rows could not be judged (no trusted issuers, no status list). Needs a gateway that serves POST /v1/identity/verify.
|
|
* @param {{presentation:object, audience:string, nonce:string, trustedIssuers?:(string|object)[], statusLists?:object[], revocations?:object[], maxAgeSeconds?:number}} request
|
|
* @returns {Promise<{verdict:'VALID'|'PARTIAL'|'INVALID', notJudged:number, subject:object|null, claims:object|null, rows:object[], judgedAt:string, verifier:object, reproduce:string}>}
|
|
*/
|
|
identityVerify(request) { return this._req('POST', '/identity/verify', { body: request }); }
|
|
|
|
/**
|
|
* compliance check (1.7.0): your policy judged on an AERE Identity presentation -> COMPLIANT / NOT_COMPLIANT with the reasons, and with
|
|
* `record: true` the record of the decision as an AIP-23 envelope without personal data (notarize it with notarize()). No claims come back.
|
|
* @param {{presentation:object, policy:object, audience:string, nonce:string, statusLists?:object[], revocations?:object[], record?:boolean}} request
|
|
* @returns {Promise<{verdict:'COMPLIANT'|'NOT_COMPLIANT', compliant:boolean, reasons:string[], policyId:string, policyHash:string, presentationHash:string, record?:object, judgedAt:string, verifier:object, reproduce:string}>}
|
|
*/
|
|
complianceCheck(request) { return this._req('POST', '/compliance/check', { body: request }); }
|
|
|
|
// ---- 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)}`); }
|
|
// 2026-09-26: the post-quantum finality point (finalized/safe = the parent of the last certified anchor), the tag nodes do not serve yet
|
|
finality() { return this._req('GET', '/data/finality'); }
|
|
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)}`); }
|
|
// 2026-09-17: the proof with its post-quantum finality (first appearance, covering anchor, verdict, verification steps)
|
|
proof(hash) { return this._req('GET', `/proof/${encodeURIComponent(hash)}`); }
|
|
|
|
// quantum readiness scan of a domain's public TLS edge: free without a key; { attest: true } notarizes the report digest
|
|
// on chain 2800 and needs the key (the response then carries `attestation`)
|
|
readiness(domain, opts = {}) {
|
|
const body = { domain, ...(opts.fresh ? { fresh: true } : {}), ...(opts.attest ? { attest: true } : {}) };
|
|
return this._req('POST', '/pq/readiness', { body, auth: !!opts.attest });
|
|
}
|
|
readinessReport(domain) { return this._req('GET', `/pq/readiness/${encodeURIComponent(domain)}`, { auth: false }); }
|
|
// the whole public perimeter of a domain in one report (keyed): the hosts you name (labels or names under the domain) plus common
|
|
// prefixes found through DNS, each measured with the same handshakes. The request has a deadline: while summary.complete is false,
|
|
// call again (finished scans are cached). { attest: true } notarizes the digest of a COMPLETE report on chain 2800.
|
|
readinessPerimeter(domain, opts = {}) {
|
|
const body = { domain, ...(opts.hosts ? { hosts: opts.hosts } : {}), ...(opts.discover === false ? { discover: false } : {}), ...(opts.attest ? { attest: true } : {}) };
|
|
return this._req('POST', '/pq/readiness/perimeter', { body });
|
|
}
|
|
|
|
// AIP-21 (2026-09-18): the per-block post-quantum finality record (seals heard by a validator, re-verified, quorum
|
|
// verdict). Free, no key. Testnet 28001 first; `network` is reserved for chain 2800 once the method reaches its validators.
|
|
// `client`: 'besu' (default), 'nethermind', or 'both' (two independent implementations, with `agreement` computed by the gateway).
|
|
pqFinality(block = 'latest', network = 'testnet', client = 'besu') {
|
|
return this._req('GET', `/pq/finality/${encodeURIComponent(block)}?network=${encodeURIComponent(network)}&client=${encodeURIComponent(client)}`, { auth: false });
|
|
}
|
|
|
|
// audit log of every keyed request of this account for one UTC day (append-only, paged), with the day file's sha256
|
|
auditLog({ day, offset = 0, limit = 1000 } = {}) {
|
|
const q = new URLSearchParams({ offset: String(offset), limit: String(limit) });
|
|
if (day) q.set('day', day);
|
|
return this._req('GET', `/account/audit?${q}`);
|
|
}
|
|
// the same day as a file (1.4.0): format 'jsonl' = the EXACT bytes of the day file, whose sha256 is the digest you notarize
|
|
// (`verified` says the bytes you received hash to the digest the gateway states); 'cef' = one ArcSight CEF line per request
|
|
// for a SIEM, carrying the digest of the source file. Returns { day, format, bytes, text, entries, digest, verified }.
|
|
async auditExport({ day, format = 'jsonl' } = {}) {
|
|
const q = new URLSearchParams({ format }); if (day) q.set('day', day);
|
|
const r = await this._req('GET', `/account/audit?${q}`, { raw: true });
|
|
if (!r.ok) { let d = null; try { d = await r.json(); } catch { /* non-JSON */ } throw new AereCloudError(r.status, d, '/account/audit'); }
|
|
const bytes = new Uint8Array(await r.arrayBuffer());
|
|
const digest = r.headers.get('x-aere-digest');
|
|
const local = '0x' + [...new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes))].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
return { day: r.headers.get('x-aere-day'), format, bytes, text: new TextDecoder().decode(bytes), entries: Number(r.headers.get('x-aere-entries') || 0), digest,
|
|
verified: format === 'jsonl' ? local === digest : null };
|
|
}
|
|
// retention of the audit log (1.5.0), READ-ONLY with the API key: what is in force, what is pending and until when, what we
|
|
// hold, and the ledger of every change and of every day deleted (each with its digest). Retention is SET with a wallet
|
|
// signature in the console, never with the key: a stolen key must not be able to shorten the trail of its own use.
|
|
auditRetention() { return this._req('GET', '/account/audit/retention'); }
|
|
// does an export you KEPT still match the day on our side, even after we deleted it under your retention? Hashes YOUR bytes
|
|
// locally and compares with the digest of the day we hold ('held') or with the tombstone the deleted day left ('expired').
|
|
// Returns { day, state, digest, local, matches, deletedAt }.
|
|
async auditVerifyKept(day, bytes) {
|
|
const u8 = typeof bytes === 'string' ? new TextEncoder().encode(bytes) : new Uint8Array(bytes);
|
|
const local = '0x' + [...new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', u8))].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
const r = await this._req('GET', `/account/audit?${new URLSearchParams({ format: 'jsonl', day })}`, { raw: true });
|
|
let d = null;
|
|
if (!r.ok) { try { d = await r.json(); } catch { /* non-JSON */ } }
|
|
if (r.status === 410 && d && d.expired) return { day, state: 'expired', digest: d.expired.digest, local, matches: local === d.expired.digest, deletedAt: d.expired.deletedAt };
|
|
if (!r.ok) throw new AereCloudError(r.status, d, '/account/audit');
|
|
await r.arrayBuffer();
|
|
const digest = r.headers.get('x-aere-digest');
|
|
return { day, state: 'held', digest, local, matches: local === digest, deletedAt: null };
|
|
}
|
|
|
|
// ---- 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 }); }
|
|
/**
|
|
* Cont inteligent cu proprietar POST-CUANTIC (Falcon-512), gazul platit de releu.
|
|
* @param {string} falconPubKey 0x-hex, 897 octeti, antetul 0x09
|
|
* @param {number} [salt=0] idempotent pe (cheie, salt): a doua chemare intoarce contul, fara tx
|
|
*/
|
|
sponsorCreatePqAccount(falconPubKey, salt = 0) {
|
|
return this._req('POST', '/sponsor/createPqAccount', { body: { falconPubKey, salt } });
|
|
}
|
|
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;
|
|
}
|