Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to the `nirium-cli` package are documented here.

## Unreleased

### Added

- `nirium anchor` ([#133](https://github.com/nirium-protocol/nirium/issues/133)): anchors a sha-256 hash (`--hash`), a file's sha-256 (`--file`, the file itself is never uploaded), or a small JSON record (`--record`, max 8KB, enforced client-side) to IPFS via an Audit Trail node's `POST /api/audit/log`. Optional `--tx`/`--tag`; no `--network` flag by design - the agent box decides its own network. Prints the CID, content hash, gateway URL, and a `nirium verify` suggestion.

## 3.0.1 - 2026-10-24

### Fixed
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,46 @@ Each line is pass or fail:

`--json` prints the same report (`ok`, `seller`, `network`, `checks`) for CI.

## Anchor a record

```bash
nirium anchor --file evidence.json
nirium anchor --hash <64-hex-sha256>
nirium anchor --record '{"order":"A-12"}'
```

Anchors a sha-256 hash (or a small JSON object, up to 8KB) to IPFS via an
Audit Trail node's `POST /api/audit/log`, and prints back the CID.

- `--file <path>` computes the sha-256 of the file locally and sends only
the hash - the file's contents are never uploaded. IPFS cannot delete
anything once pinned, so hashing locally means raw evidence never
leaves your machine.
- `--hash <hex>` sends a sha-256 you already have (64 hex chars, optional
`sha-256:` prefix).
- `--record <json>` anchors a small JSON object verbatim (max 8KB,
enforced here before the request, not just by the server). Don't put
personal data in it - an anchored record can never be deleted.

Exactly one of the three is required. `--tx <hash>` and `--tag <label>`
are optional and attached to the anchor as-is. There is no `--network`
flag: the agent box anchoring the record decides its own network, and a
client-supplied network field is rejected server-side anyway.

| Option | Purpose | Default |
|---|---|---|
| `--file <path>` | anchor this file's sha-256 | none |
| `--hash <hex>` | anchor this sha-256 directly | none |
| `--record <json>` | anchor this small JSON object verbatim | none |
| `--tx <hash>` | associate a transaction hash | none |
| `--tag <label>` | free-form label | none |
| `-u, --url <url>` | agent URL | `http://localhost:3002` |
| `--json` | machine-readable result | off |

On success, prints the CID, the content hash, the gateway URL, and a
`nirium verify <cid>` suggestion so the anchor can be independently
re-checked right away.

## Verify an audit record

```bash
Expand Down
140 changes: 140 additions & 0 deletions packages/cli/bin/nirium.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,21 @@ program
}
});

// --- COMMAND: anchor ---
program
.command('anchor')
.description('Anchor a sha-256 hash (or a small JSON record) to IPFS via an Audit Trail node')
.option('--file <path>', 'Anchor the sha-256 of this file - the file itself is never uploaded')
.option('--hash <hex>', 'sha-256 hex of your evidence (64 chars, optional sha-256: prefix)')
.option('--record <json>', 'Small JSON object (max 8KB) to anchor verbatim, e.g. \'{"order":"A-12"}\'')
.option('--tx <hash>', 'Transaction hash to associate with this anchor')
.option('--tag <tag>', 'Free-form label for this anchor')
.option('-u, --url <url>', 'Agent URL', 'http://localhost:3002')
.option('--json', 'Output result as JSON')
.action(async (options) => {
await executeAnchorCommand(options);
});

// --- COMMAND: doctor ---
program
.command('doctor')
Expand Down Expand Up @@ -1650,4 +1665,129 @@ function formatVerifyOutput(result) {
return lines.join('\n');
}

// ═══════════════════════════════════════════════════════════════
// anchor — anchor a hash or small record to IPFS via the Audit Trail node
// ═══════════════════════════════════════════════════════════════

const ANCHOR_MAX_RECORD_BYTES = 8 * 1024;

// `nirium anchor` deliberately has no `--network` flag: the agent box
// decides its own network (STELLAR_NETWORK) and the server already
// rejects a `network` field in the body. A client-side flag here would
// only ever be ignored, which is worse than not having it.
async function executeAnchorCommand(options) {
const fail = (error, hint) => {
if (options.json) {
console.log(JSON.stringify({ ok: false, error, ...(hint ? { hint } : {}) }));
} else {
console.error(`❌ Error: ${error}${hint ? `\n ${hint}` : ''}`);
}
process.exit(1);
};

const given = ['file', 'hash', 'record'].filter((k) => options[k] !== undefined);
if (given.length === 0) {
fail('Provide exactly one of --file, --hash, or --record.');
return;
}
if (given.length > 1) {
fail(`--${given[0]} and --${given[1]} were both given - use exactly one.`);
return;
}

const body = {};
if (options.file !== undefined) {
let fileBuf;
try {
fileBuf = fs.readFileSync(options.file);
} catch (err) {
fail(`Could not read --file '${options.file}': ${err?.message || String(err)}`);
return;
}
body.hash = crypto.createHash('sha256').update(fileBuf).digest('hex');
} else if (options.hash !== undefined) {
try {
body.hash = normalizeHashInput(options.hash);
} catch (err) {
fail(err.message);
return;
}
} else {
let record;
try {
record = JSON.parse(options.record);
} catch (err) {
fail(`--record is not valid JSON: ${err?.message || String(err)}`);
return;
}
if (typeof record !== 'object' || record === null || Array.isArray(record)) {
fail('--record must be a JSON object, e.g. \'{"order":"A-12"}\'');
return;
}
// Mirrors the server's own 8KB limit (MAX_RECORD_BYTES in
// auditRoutes.ts) so an oversized record fails locally, before any
// network call, instead of round-tripping for a 413.
const raw = JSON.stringify(record);
if (Buffer.byteLength(raw, 'utf8') > ANCHOR_MAX_RECORD_BYTES) {
fail(`--record exceeds ${ANCHOR_MAX_RECORD_BYTES / 1024}KB - anchor a sha-256 hash instead (--hash or --file).`);
return;
}
body.record = record;
}

if (options.tx !== undefined) body.txHash = options.tx;
if (options.tag !== undefined) body.tag = options.tag;

const agentAttestation = await maybeBuildAgentAttestation(options, body, fail);
if (agentAttestation === 'failed') return; // fail() already called and exited
if (agentAttestation) body.agent = agentAttestation;

let response;
try {
response = await fetch(`${options.url}/api/audit/log`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
} catch (err) {
const msg = (err?.name === 'TimeoutError' || err?.name === 'AbortError')
? `Timed out waiting for ${options.url}/api/audit/log`
: `Agent unreachable at ${options.url}: ${err?.message || String(err)}`;
fail(msg);
return;
}

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}`, data?.sign ? `Sign this statement instead: ${data.sign}` : undefined);
return;
}

if (options.json) {
console.log(JSON.stringify({ ok: true, ...data }, null, 2));
return;
}

console.log('✅ Anchored to IPFS');
console.log(` CID: ${data.cid}`);
console.log(` Content sha256: ${data.content_sha256}`);
if (data.gatewayUrl) console.log(` Gateway URL: ${data.gatewayUrl}`);
if (data.attestedBy) console.log(` Attested by: ${data.attestedBy}`);
console.log(` Anchored at: ${data.anchoredAt}`);
console.log(`\n Verify independently: nirium verify ${data.cid}`);
}

// Returns the `agent` object for the request body, `undefined` when
// `--sign` was not passed, or the string 'failed' when fail() already
// ran (caller must return without doing anything else — placeholder for
// #134, which adds `--sign`/`--key-env` on top of this command).
async function maybeBuildAgentAttestation(_options, _body, _fail) {
return undefined;
}

program.parse();
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/anchor.test.js"
},
"keywords": [
"stellar",
Expand Down
Loading
Loading