@aere/cloud 1.5.1: audit retention (auditRetention, auditVerifyKept) and no redirects followed
auditRetention() reads the retention of the audit log (read-only with the API key; it is set with a wallet signature in the console). auditVerifyKept(day, bytes) checks an export you kept against the day we hold or against the tombstone a deleted day left (its digest), hashing your bytes locally. The client no longer follows redirects: fetch followed them and carried the x-api-key header to whatever host a Location named; a 3xx is now an AereCloudError with its status.
This commit is contained in:
parent
53fd47b23b
commit
84cc9493f2
23
index.mjs
23
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'); }
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 } });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user