diff --git a/index.mjs b/index.mjs index f7b2f3e..16709b5 100644 --- a/index.mjs +++ b/index.mjs @@ -33,8 +33,10 @@ export class AereCloud { 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), + method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual', }); if (raw) return r; let data = null; @@ -120,6 +122,25 @@ export class AereCloud { 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'); } diff --git a/package.json b/package.json index a3bccbb..d6517c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aere/cloud", - "version": "1.4.0", + "version": "1.5.1", "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", diff --git a/test/sdk.test.mjs b/test/sdk.test.mjs index 0d43d84..4daa2c4 100644 --- a/test/sdk.test.mjs +++ b/test/sdk.test.mjs @@ -131,6 +131,45 @@ await test('2026-09-18: auditExport(): octetii zilei + digestul din antet; verif await assert.rejects(() => rau.auditExport({ format: 'xml' }), (x) => x.status === 400); }); +await test('2026-09-18: auditRetention() citeste cu cheia; auditVerifyKept(): exportul PASTRAT se potriveste cu ziua tinuta (held) si cu PIATRA unei zile sterse (expired, 410); un octet schimbat NU se potriveste; un 410 ajunge si la auditExport cu piatra in body', async () => { + const { createHash } = await import('node:crypto'); + const corp = Buffer.from('{"t":"2026-05-01T10:00:00.000Z","m":"GET","p":"/v1/account","s":200}\n'); + const d = '0x' + createHash('sha256').update(corp).digest('hex'); + const vazute = []; + const ret = new AereCloud({ apiKey: 'ak2800.0xAAA.AERE-SINTETIC-s', baseUrl: BAZA_TEST, fetch: async (u, i) => { vazute.push({ u, m: i.method, cheie: !!i.headers['x-api-key'] }); return new Response(JSON.stringify({ retentionDays: 90, hold: false, pending: null }), { status: 200 }); } }); + assert.equal((await ret.auditRetention()).retentionDays, 90); assert.ok(vazute[0].u.endsWith('/account/audit/retention')); assert.deepEqual([vazute[0].m, vazute[0].cheie], ['GET', true]); + const sters = async () => new Response(JSON.stringify({ error: 'audit_day_expired', expired: { day: '2026-05-01', digest: d, entries: 1, bytes: corp.length, deletedAt: '2026-09-25T12:00:01.000Z', retentionDays: 90 } }), { status: 410 }); + const e = new AereCloud({ apiKey: 'ak2800.0xAAA.AERE-SINTETIC-s', baseUrl: BAZA_TEST, fetch: sters }); + const bun = await e.auditVerifyKept('2026-05-01', corp); + assert.deepEqual([bun.state, bun.matches, bun.digest, bun.deletedAt], ['expired', true, d, '2026-09-25T12:00:01.000Z']); + const stricat = Buffer.from(corp); stricat[5] ^= 1; + assert.equal((await e.auditVerifyKept('2026-05-01', stricat)).matches, false, 'un export modificat nu are voie sa se potriveasca cu piatra'); + assert.equal((await e.auditVerifyKept('2026-05-01', corp.toString('utf8'))).matches, true, 'textul si octetii aceluiasi export dau acelasi digest'); + await assert.rejects(() => e.auditExport({ day: '2026-05-01' }), (x) => x.status === 410 && x.body.expired.digest === d); + const tinut = new AereCloud({ apiKey: 'ak2800.0xAAA.AERE-SINTETIC-s', baseUrl: BAZA_TEST, fetch: async () => new Response(corp, { status: 200, headers: { 'x-aere-digest': d } }) }); + assert.deepEqual((({ state, matches }) => [state, matches])(await tinut.auditVerifyKept('2026-05-01', corp)), ['held', true]); + assert.equal((await tinut.auditVerifyKept('2026-05-01', stricat)).matches, false); + const cazut = new AereCloud({ apiKey: 'ak2800.0xAAA.AERE-SINTETIC-s', baseUrl: BAZA_TEST, fetch: async () => new Response(JSON.stringify({ error: 'rate_limit' }), { status: 429 }) }); + await assert.rejects(() => cazut.auditVerifyKept('2026-05-01', corp), (x) => x.status === 429, 'o eroare nu are voie sa iasa drept "nu se potriveste"'); +}); + +await test('1.5.1: o redirectare NU e urmata cu fetch-ul implicit (server local real): 302 ajunge la apelant ca AereCloudError, iar tinta din Location nu primeste nimic (deci nici cheia)', async () => { + const http = await import('node:http'); + const vazute = []; + const srv = http.createServer((req, res) => { + vazute.push({ url: req.url, cheie: req.headers['x-api-key'] || null }); + if (req.url.startsWith('/v1/')) { res.writeHead(302, { location: '/in-alta-parte' }); return res.end(); } + res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}'); + }); + await new Promise((r) => srv.listen(0, '127.0.0.1', r)); + try { + const c = new AereCloud({ apiKey: 'ak2800.0xAAA.AERE-SINTETIC-s', baseUrl: `http://127.0.0.1:${srv.address().port}/v1` }); + await assert.rejects(() => c.account(), (e) => e.status === 302, '302 trebuia intors ca eroare, nu urmat'); + await assert.rejects(() => c.auditExport({ day: '2026-05-01' }), (e) => e.status === 302); + assert.equal(vazute.filter((v) => v.url === '/in-alta-parte').length, 0, 'redirectarea a fost urmata: ' + JSON.stringify(vazute)); + } finally { await new Promise((r) => srv.close(r)); } +}); + 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 } });