From e7ebc288976d1e885919b76dbb5e53a3aface78e Mon Sep 17 00:00:00 2001 From: Monserrat Mendoza <210256392+M0nsxx@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:03:23 -0600 Subject: [PATCH] feat(cli): nirium report summary/export - thin client for the Reporting node Adds `nirium report summary` and `nirium report export anchors|payments|payroll` against GET /api/reporting/summary and GET /api/reporting/export. `export anchors` prints exactly one CID per line on stdout, pipeable straight into `nirium verify` (xargs -n1 or a while-read loop); the row count and the "not certified regulatory reporting" disclaimer print to stderr so neither lands in that pipe. --csv writes the server's CSV instead of printing rows; --json prints the full response (mutually exclusive with --csv). 15 new tests against a local HTTP server standing in for the Reporting node, full suite green. Closes #136 Co-Authored-By: Claude Sonnet 5 --- packages/cli/CHANGELOG.md | 4 + packages/cli/README.md | 47 ++++++ packages/cli/bin/nirium.js | 179 +++++++++++++++++++++++ packages/cli/package.json | 2 +- packages/cli/test/report.test.js | 238 +++++++++++++++++++++++++++++++ 5 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/report.test.js diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 3b07e19..2963f5c 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the `nirium-cli` package are documented here. ## Unreleased +### Added + +- `nirium report summary` and `nirium report export anchors|payments|payroll` ([#136](https://github.com/nirium-protocol/nirium/issues/136)): thin client for the Reporting node. `export anchors` prints exactly one CID per line on stdout (nothing else), pipeable straight into `nirium verify`; the row count and the "not certified regulatory reporting" disclaimer print to stderr so they never land in that pipe. `--csv ` writes the server's CSV instead; `--json` prints the full response. + ## 3.0.1 - 2026-10-24 ### Fixed diff --git a/packages/cli/README.md b/packages/cli/README.md index 0db7131..f36d9ec 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -239,6 +239,53 @@ of the two checks actually ran and succeeded, a matching `--hash`/ was verified, so `verify` prints `NOTHING VERIFIED`, exits non-zero, and sets `verified: false` in `--json`, instead of reporting a pass. +## Reporting: summaries and exports + +```bash +nirium report summary [--from ] [--to ] [--network ] [--json] +nirium report export anchors|payments|payroll [--from ] [--to ] [--limit ] [--csv out.csv] [--json] +``` + +Thin client for the Reporting node's `GET /api/reporting/summary` and +`GET /api/reporting/export` - an aggregation layer over what the +protocol has already anchored (payroll runs, x402/MPP receipts, IPFS +anchors). **This is institutional-format output, not certified +regulatory reporting** - anything filed with an authority remains the +caller's own responsibility (the server's own disclaimer is reprinted in +both commands' output). + +`report summary` prints settled payroll runs and totals, payment +receipts by gateway, and anchor counts for the period. `report export +` pulls the underlying rows: + +- Without `--csv`, rows print one per line on stdout. For `export + anchors` specifically, that line is **just the CID** - nothing else - + so it pipes straight into `nirium verify`: + + ```bash + nirium report export anchors --from 2026-10-01 | xargs -n1 nirium verify + ``` + + The row count and disclaimer print to **stderr**, not stdout, so they + never end up in that pipe. +- With `--csv `, the server's CSV is written to that file instead + (nothing on stdout but a confirmation). +- `--json` prints the full response (`type`, `period`, `rowCount`, + `rows`, `disclaimer`) instead of one row per line; mutually exclusive + with `--csv`. + +| Option (both subcommands) | Purpose | Default | +|---|---|---| +| `--from ` / `--to ` | ISO date period | 30-day window | +| `--network ` | restrict to one network | all | +| `-u, --url ` | agent URL | `http://localhost:3002` | +| `--json` | machine-readable full response | off | + +| Option (`export` only) | Purpose | Default | +|---|---|---| +| `--limit ` | max rows (server caps at 1000) | server default | +| `--csv ` | write the CSV export here instead of printing rows | off | + ## Manage stored credentials ```bash diff --git a/packages/cli/bin/nirium.js b/packages/cli/bin/nirium.js index da04530..d6f222d 100755 --- a/packages/cli/bin/nirium.js +++ b/packages/cli/bin/nirium.js @@ -251,6 +251,38 @@ program await executeConfigCommand(action, key, value); }); +// --- COMMAND: report --- +const reportCommand = program + .command('report') + .description('πŸ“Š Institutional-format summaries and exports from the Reporting node - not certified regulatory reporting'); + +reportCommand + .command('summary') + .description('Aggregate payroll, payments, and anchors for a period') + .option('--from ', 'ISO date, start of period (default: a 30-day window)') + .option('--to ', 'ISO date, end of period') + .option('--network ', 'testnet or mainnet (default: all)') + .option('-u, --url ', 'Agent URL', 'http://localhost:3002') + .option('--json', 'Output as JSON') + .action(async (options) => { + await executeReportSummaryCommand(options); + }); + +reportCommand + .command('export') + .argument('', 'anchors, payments, or payroll') + .description('Export rows for a period - "export anchors" prints one CID per line, pipeable to `nirium verify`') + .option('--from ', 'ISO date, start of period (default: a 30-day window)') + .option('--to ', 'ISO date, end of period') + .option('--limit ', 'Max rows (server caps at 1000)') + .option('--network ', 'testnet or mainnet (default: all)') + .option('--csv ', 'Write the CSV export to this file instead of printing rows') + .option('-u, --url ', 'Agent URL', 'http://localhost:3002') + .option('--json', 'Output the full JSON response instead of one row per line') + .action(async (type, options) => { + await executeReportExportCommand(type, options); + }); + // Un servidor que COBRA, no uno que escucha. Es el camino corto de // "instalΓ© algo" a "me pagaron": levantas esto, le pegas con un cliente // x402 y el pago se liquida on-chain antes de que salga la respuesta. @@ -1650,4 +1682,151 @@ function formatVerifyOutput(result) { return lines.join('\n'); } +// ═══════════════════════════════════════════════════════════════ +// report β€” summaries and exports from the Reporting node +// ═══════════════════════════════════════════════════════════════ + +function buildQueryString(params) { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') qs.set(k, String(v)); + } + const str = qs.toString(); + return str ? `?${str}` : ''; +} + +async function fetchReportJson(url, fail) { + let response; + try { + response = await fetch(url, { signal: AbortSignal.timeout(20_000) }); + } catch (err) { + const msg = (err?.name === 'TimeoutError' || err?.name === 'AbortError') + ? `Timed out waiting for ${url}` + : `Agent unreachable: ${err?.message || String(err)}`; + fail(msg); + return null; + } + let data = null; + try { + data = await response.json(); + } catch { /* empty or non-JSON body */ } + if (!response.ok) { + fail(data?.error || `Agent returned HTTP ${response.status}`); + return null; + } + return data; +} + +async function executeReportSummaryCommand(options) { + const fail = (error) => { + if (options.json) { + console.log(JSON.stringify({ ok: false, error })); + } else { + console.error(`❌ Error: ${error}`); + } + process.exit(1); + }; + + const qs = buildQueryString({ from: options.from, to: options.to, network: options.network }); + const data = await fetchReportJson(`${options.url}/api/reporting/summary${qs}`, fail); + if (!data) return; // fail() already exited + + if (options.json) { + console.log(JSON.stringify(data, null, 2)); + return; + } + + console.log('πŸ“Š Nirium Reporting Summary'); + console.log(` Network: ${data.network}`); + console.log(` Period: ${data.period?.from || '(default window)'} -> ${data.period?.to || 'now'}`); + console.log(` Payroll: ${data.payroll?.settledRuns ?? 0} settled run(s), ${data.payroll?.recipientsPaid ?? 0} recipient(s) paid`); + for (const [asset, amount] of Object.entries(data.payroll?.totalsByAsset || {})) { + console.log(` ${asset}: ${amount}`); + } + console.log(` Payments: ${data.payments?.count ?? 0} receipt(s), ${data.payments?.totalUsdc ?? '0'} USDC total`); + console.log(` Anchors: ${data.anchors?.count ?? 0} (latest: ${data.anchors?.latestCid || 'none'})`); + if (Array.isArray(data.warnings) && data.warnings.length > 0) { + console.log(' ⚠ Warnings:'); + for (const w of data.warnings) console.log(` - ${w}`); + } + console.log(`\n ${data.disclaimer || ''}`); +} + +const REPORT_EXPORT_TYPES = new Set(['payroll', 'payments', 'anchors']); + +async function executeReportExportCommand(type, options) { + const fail = (error) => { + if (options.json) { + console.log(JSON.stringify({ ok: false, error })); + } else { + console.error(`❌ Error: ${error}`); + } + process.exit(1); + }; + + if (!REPORT_EXPORT_TYPES.has(type)) { + fail('`type` must be one of: payroll | payments | anchors'); + return; + } + if (options.csv && options.json) { + fail('--csv and --json are mutually exclusive - --csv writes a file, --json prints the response.'); + return; + } + if (options.limit !== undefined) { + const n = Number(options.limit); + if (!Number.isInteger(n) || n <= 0) { + fail(`Invalid --limit '${options.limit}': must be a positive integer.`); + return; + } + } + + const format = options.csv ? 'csv' : 'json'; + const qs = buildQueryString({ + type, format, from: options.from, to: options.to, limit: options.limit, network: options.network, + }); + const url = `${options.url}/api/reporting/export${qs}`; + + if (format === 'csv') { + let response; + try { + response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + } catch (err) { + fail(`Agent unreachable: ${err?.message || String(err)}`); + return; + } + if (!response.ok) { + let data = null; + try { data = await response.json(); } catch { /* non-JSON error body */ } + fail(data?.error || `Agent returned HTTP ${response.status}`); + return; + } + const text = await response.text(); + try { + fs.writeFileSync(options.csv, text); + } catch (err) { + fail(`Could not write --csv '${options.csv}': ${err?.message || String(err)}`); + return; + } + console.log(`βœ… Wrote ${options.csv}`); + return; + } + + const data = await fetchReportJson(url, fail); + if (!data) return; // fail() already exited + + if (options.json) { + console.log(JSON.stringify(data, null, 2)); + return; + } + + // `export anchors` prints exactly one CID per line on stdout - nothing + // else - so it pipes straight into `nirium verify` (xargs -n1 or a + // while-read loop). Row count and the disclaimer go to stderr instead + // of stdout for the same reason: stdout must stay pure for piping. + for (const row of data.rows || []) { + console.log(type === 'anchors' ? (row.cid || '') : JSON.stringify(row)); + } + console.error(`# ${data.rowCount ?? (data.rows || []).length} row(s). ${data.disclaimer || ''}`); +} + program.parse(); diff --git a/packages/cli/package.json b/packages/cli/package.json index 361fc58..d2cc9fa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -9,7 +9,7 @@ }, "scripts": { "prepublishOnly": "node --check bin/nirium.js", - "test": "node --test test/doctor-seller.test.js test/config-secret.test.js test/serve-api-key.test.js test/verify-hash-only.test.js test/pay-agent-construction.test.js test/pay-status-and-cap.test.js test/status.test.js" + "test": "node --test test/doctor-seller.test.js test/config-secret.test.js test/serve-api-key.test.js test/verify-hash-only.test.js test/pay-agent-construction.test.js test/pay-status-and-cap.test.js test/status.test.js test/report.test.js" }, "keywords": [ "stellar", diff --git a/packages/cli/test/report.test.js b/packages/cli/test/report.test.js new file mode 100644 index 0000000..cd685ad --- /dev/null +++ b/packages/cli/test/report.test.js @@ -0,0 +1,238 @@ +/** + * `nirium report summary` and `nirium report export` (#136): against a + * local HTTP server standing in for the Reporting node's + * GET /api/reporting/summary and GET /api/reporting/export. + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import http from 'node:http'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const cliRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const cli = path.join(cliRoot, 'bin/nirium.js'); + +const DISCLAIMER = 'Institutional-format export generated from anchored receipts and on-chain records. ' + + 'This is not certified regulatory reporting; filings presented to any authority remain the sole responsibility of the client.'; + +const SUMMARY = { + node: 'reporting', + period: { from: null, to: null }, + network: 'all', + payroll: { settledRuns: 2, recipientsPaid: 5, totalsByAsset: { USDC: '120.0000000' }, lastSettledAt: '2026-11-01T00:00:00.000Z' }, + payments: { count: 10, totalUsdc: '1.23', byGateway: { x402: { count: 8, totalUsdc: 1.0 }, mpp: { count: 2, totalUsdc: 0.23 } } }, + anchors: { count: 3, latestCid: 'QmLatestFake' }, + generatedAt: '2026-11-08T00:00:00.000Z', + disclaimer: DISCLAIMER, +}; + +const ANCHOR_ROWS = [ + { anchored_at: '2026-11-01T00:00:00.000Z', source: 'AUDIT_NODE', cid: 'QmAnchorOne', network: 'testnet' }, + { anchored_at: '2026-10-31T00:00:00.000Z', source: 'AUDIT_NODE', cid: 'QmAnchorTwo', network: 'testnet' }, +]; + +let lastRequest = null; +let scenario = 'ok'; +const server = http.createServer((req, res) => { + const u = new URL(req.url, 'http://localhost'); + lastRequest = { path: u.pathname, query: Object.fromEntries(u.searchParams) }; + + if (scenario === 'unavailable') { + res.writeHead(503, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'export source unavailable: timeout' })); + return; + } + if (scenario === 'bad-type') { + res.writeHead(400, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: '`type` must be one of: payroll | payments | anchors' })); + return; + } + + if (u.pathname === '/api/reporting/summary') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(SUMMARY)); + return; + } + if (u.pathname === '/api/reporting/export') { + const type = u.searchParams.get('type'); + const format = u.searchParams.get('format') || 'csv'; + if (format === 'csv') { + res.writeHead(200, { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': `attachment; filename="nirium-${type}.csv"` }); + res.end('anchored_at,source,cid,network\r\n2026-11-01T00:00:00.000Z,AUDIT_NODE,QmAnchorOne,testnet\r\n'); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + type, + period: { from: null, to: null }, + network: 'all', + rowCount: ANCHOR_ROWS.length, + rows: ANCHOR_ROWS, + generatedAt: '2026-11-08T00:00:00.000Z', + disclaimer: DISCLAIMER, + })); + return; + } + res.writeHead(404).end(); +}); + +server.listen(0, '127.0.0.1'); +await once(server, 'listening'); +const base = `http://127.0.0.1:${server.address().port}`; + +test.after(() => new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); +})); + +test.beforeEach(() => { scenario = 'ok'; lastRequest = null; }); + +function run(args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cli, 'report', ...args], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('timed out')); }, 10_000); + child.stdout.on('data', (d) => { stdout += d; }); + child.stderr.on('data', (d) => { stderr += d; }); + child.on('close', (code) => { clearTimeout(timer); resolve({ code, stdout, stderr }); }); + child.on('error', reject); + }); +} + +// ─── summary ─────────────────────────────────────────────────── + +test('summary --json prints the raw response', async () => { + const r = await run(['summary', '--url', base, '--json']); + assert.equal(r.code, 0, r.stdout + r.stderr); + assert.deepEqual(JSON.parse(r.stdout), SUMMARY); +}); + +test('summary human output reports payroll, payments, anchors, and the disclaimer', async () => { + const r = await run(['summary', '--url', base]); + assert.equal(r.code, 0, r.stdout + r.stderr); + assert.match(r.stdout, /2 settled run\(s\), 5 recipient\(s\) paid/); + assert.match(r.stdout, /USDC: 120\.0000000/); + assert.match(r.stdout, /10 receipt\(s\), 1\.23 USDC total/); + assert.match(r.stdout, /3 \(latest: QmLatestFake\)/); + assert.match(r.stdout, /not certified regulatory reporting/); +}); + +test('summary forwards --from/--to/--network as query params', async () => { + const r = await run(['summary', '--url', base, '--from', '2026-01-01', '--to', '2026-02-01', '--network', 'testnet', '--json']); + assert.equal(r.code, 0, r.stdout + r.stderr); + assert.equal(lastRequest.query.from, '2026-01-01'); + assert.equal(lastRequest.query.to, '2026-02-01'); + assert.equal(lastRequest.query.network, 'testnet'); +}); + +test('summary surfaces a server error as a failure', async () => { + scenario = 'unavailable'; + const r = await run(['summary', '--url', base, '--json']); + assert.equal(r.code, 1); + const json = JSON.parse(r.stdout); + assert.equal(json.ok, false); + assert.match(json.error, /unavailable/); +}); + +// ─── export ──────────────────────────────────────────────────── + +test('export anchors prints exactly one CID per line on stdout, nothing else', async () => { + const r = await run(['export', 'anchors', '--url', base]); + assert.equal(r.code, 0, r.stdout + r.stderr); + const lines = r.stdout.trim().split('\n'); + assert.deepEqual(lines, ['QmAnchorOne', 'QmAnchorTwo']); +}); + +test('export anchors footer (row count + disclaimer) goes to stderr, not stdout', async () => { + const r = await run(['export', 'anchors', '--url', base]); + assert.equal(r.code, 0, r.stdout + r.stderr); + assert.doesNotMatch(r.stdout, /disclaimer|not certified/i); + assert.match(r.stderr, /2 row\(s\)/); + assert.match(r.stderr, /not certified regulatory reporting/); +}); + +test('export anchors pipes straight into `nirium verify` without extra parsing', async () => { + const r = await run(['export', 'anchors', '--url', base]); + const cids = r.stdout.trim().split('\n').filter(Boolean); + assert.equal(cids.length, 2); + for (const cid of cids) assert.match(cid, /^Qm[A-Za-z0-9]+$/); +}); + +test('export payments/payroll print one JSON row per line (not just anchors)', async () => { + const r = await run(['export', 'anchors', '--url', base, '--json']); + assert.equal(r.code, 0, r.stdout + r.stderr); + const json = JSON.parse(r.stdout); + assert.equal(json.type, 'anchors'); + assert.equal(json.rows.length, 2); +}); + +test('export --json prints the full response, not just rows', async () => { + const r = await run(['export', 'payments', '--url', base, '--json']); + assert.equal(r.code, 0, r.stdout + r.stderr); + const json = JSON.parse(r.stdout); + assert.equal(json.rowCount, 2); + assert.equal(json.disclaimer, DISCLAIMER); +}); + +test('export --csv writes the file, not stdout', async () => { + const out = path.join(os.tmpdir(), `nirium-report-export-${process.pid}.csv`); + try { + const r = await run(['export', 'anchors', '--url', base, '--csv', out]); + assert.equal(r.code, 0, r.stdout + r.stderr); + assert.match(r.stdout, /Wrote/); + const content = fs.readFileSync(out, 'utf8'); + assert.match(content, /anchored_at,source,cid,network/); + assert.match(content, /QmAnchorOne/); + } finally { + fs.rmSync(out, { force: true }); + } +}); + +test('export --csv and --json together is rejected', async () => { + const r = await run(['export', 'anchors', '--url', base, '--csv', 'out.csv', '--json']); + assert.equal(r.code, 1); + const json = JSON.parse(r.stdout); + assert.equal(json.ok, false); + assert.match(json.error, /mutually exclusive/); + assert.equal(lastRequest, null); +}); + +test('export rejects an invalid type before any network call', async () => { + const r = await run(['export', 'invalid-type', '--url', base]); + assert.equal(r.code, 1); + assert.match(r.stderr, /must be one of: payroll \| payments \| anchors/); + assert.equal(lastRequest, null); +}); + +test('export rejects a non-positive --limit before any network call', async () => { + const r = await run(['export', 'anchors', '--url', base, '--limit', '0']); + assert.equal(r.code, 1); + assert.match(r.stderr, /Invalid --limit/); + assert.equal(lastRequest, null); +}); + +test('export forwards --from/--to/--limit/--network as query params', async () => { + const r = await run(['export', 'payroll', '--url', base, '--from', '2026-01-01', '--limit', '50', '--network', 'mainnet', '--json']); + assert.equal(r.code, 0, r.stdout + r.stderr); + assert.equal(lastRequest.query.type, 'payroll'); + assert.equal(lastRequest.query.from, '2026-01-01'); + assert.equal(lastRequest.query.limit, '50'); + assert.equal(lastRequest.query.network, 'mainnet'); +}); + +test('export surfaces a server error (e.g. 503) as a failure, writes nothing', async () => { + scenario = 'unavailable'; + const out = path.join(os.tmpdir(), `nirium-report-export-fail-${process.pid}.csv`); + try { + const r = await run(['export', 'anchors', '--url', base, '--csv', out]); + assert.equal(r.code, 1); + assert.match(r.stderr, /unavailable/); + assert.equal(fs.existsSync(out), false); + } finally { + fs.rmSync(out, { force: true }); + } +});