tr-json-chain-tools 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/tr-json-chain-check.js +171 -0
- package/bin/tr-json-chain-cli.js +449 -0
- package/dc/docker-compose.yml +11 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tri@iki.fi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# tr-json-chain-tools
|
|
2
|
+
|
|
3
|
+
Command-line tools for [`tr-json-chain`](https://www.npmjs.com/package/tr-json-chain)
|
|
4
|
+
— an immutable, append-only, SHA-256 hash-chained JSON event log on PostgreSQL.
|
|
5
|
+
|
|
6
|
+
Two executables:
|
|
7
|
+
|
|
8
|
+
| command | what it does |
|
|
9
|
+
|---|---|
|
|
10
|
+
| `tr-json-chain-cli` | record events into a chain and inspect it (interactive or piped) |
|
|
11
|
+
| `tr-json-chain-check` | verify the integrity of a chain exported to CSV — standalone, no database |
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install -g tr-json-chain-tools
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
or run without installing:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npx -p tr-json-chain-tools tr-json-chain-cli postgres://… mychain
|
|
23
|
+
npx -p tr-json-chain-tools tr-json-chain-check export.csv
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## A PostgreSQL to play with
|
|
27
|
+
|
|
28
|
+
A throwaway server is handy for trying things out. A compose file ships under
|
|
29
|
+
`dc/`:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
docker compose -f dc/docker-compose.yml up -d
|
|
33
|
+
# → postgres on localhost:5433, user/password postgres/postgres
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## `tr-json-chain-cli`
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
tr-json-chain-cli --pg-url <uri> [--namespace <ns>] [--verify]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| option | env | description |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `--pg-url <uri>` | `OPT_PG_URL` | PostgreSQL connection URI (required) |
|
|
45
|
+
| `--namespace <ns>` | `OPT_NAMESPACE` | chain namespace, so one database can hold several independent chains (`<ns>_event_chain`, …); omit for bare names |
|
|
46
|
+
| `--verify` | `OPT_VERIFY` | on startup, verify the **entire** chain server-side (`{ verifyChain: true }`) instead of only the root-event canary |
|
|
47
|
+
| `-h`, `--help` | | show help and exit |
|
|
48
|
+
|
|
49
|
+
Every option may be supplied via its environment variable instead of the flag
|
|
50
|
+
(booleans accept `yes`/`no`), which makes the tool convenient to drive from a
|
|
51
|
+
container or service manager. `--help` lists everything.
|
|
52
|
+
|
|
53
|
+
The schema is created automatically on first use. Each input **line** is either:
|
|
54
|
+
|
|
55
|
+
- a **JSON object** → recorded as an event; its `event_id` is printed (hex); or
|
|
56
|
+
- a **slash command**:
|
|
57
|
+
|
|
58
|
+
| command | action |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `/timestamp` | record a `{ "type": "ts", "ts": … }` event |
|
|
61
|
+
| `/head` | fetch the chain head (appends an empty checkpoint if needed) |
|
|
62
|
+
| `/root` | print the chain's root event (id + stored data) |
|
|
63
|
+
| `/lc [<start> [<end>]]` | list the chain (`getEvents()` slice indices; default = all) |
|
|
64
|
+
| `/cc` | check chain integrity fully **client-side** (re-hash every event) |
|
|
65
|
+
| `/export <filename>` | export the chain (minus genesis) to a semicolon CSV |
|
|
66
|
+
| `/help` | list commands |
|
|
67
|
+
| `/exit` | quit |
|
|
68
|
+
|
|
69
|
+
When stdin is a **terminal** it runs as an interactive line editor with command
|
|
70
|
+
history (up/down) and TAB completion; with **piped** input it runs as a plain
|
|
71
|
+
batch processor. A non-object line, malformed JSON, or unknown command prints an
|
|
72
|
+
error and sets a non-zero exit code.
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
# interactive
|
|
76
|
+
tr-json-chain-cli --pg-url postgres://postgres:postgres@localhost:5433/postgres --namespace demo
|
|
77
|
+
|
|
78
|
+
# batch
|
|
79
|
+
printf '%s\n' '{"type":"user.login","user":42}' '/timestamp' '/cc' \
|
|
80
|
+
| tr-json-chain-cli --pg-url postgres://postgres:postgres@localhost:5433/postgres --namespace demo
|
|
81
|
+
|
|
82
|
+
# same, configured entirely from the environment
|
|
83
|
+
export OPT_PG_URL=postgres://postgres:postgres@localhost:5433/postgres OPT_NAMESPACE=demo
|
|
84
|
+
tr-json-chain-cli
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
By convention every event carries a top-level `"type"` discriminator (the
|
|
88
|
+
chain's own root and timestamp events do); it is not required.
|
|
89
|
+
|
|
90
|
+
### CSV export format
|
|
91
|
+
|
|
92
|
+
`/export <file>` writes a semicolon-separated CSV, genesis row excluded, in
|
|
93
|
+
sequence order starting at `#1`:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
#;event_id;parent_id;data_hash;data
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- `#` — the event's sequence number (`event_chain.id`).
|
|
100
|
+
- `event_id` / `parent_id` / `data_hash` — lowercase hex.
|
|
101
|
+
- `data` — the canonical payload text (exactly the bytes that were hashed),
|
|
102
|
+
RFC-4180 quoted; empty for events with no stored payload.
|
|
103
|
+
|
|
104
|
+
This is precisely what `tr-json-chain-check` consumes.
|
|
105
|
+
|
|
106
|
+
## `tr-json-chain-check`
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
tr-json-chain-check <file.csv>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A **standalone, self-contained** verifier (only Node builtins — no database, no
|
|
113
|
+
`tr-json-chain` dependency). It re-derives every hash from the spec, so it also
|
|
114
|
+
serves as a compact reference implementation of the chain's integrity rules:
|
|
115
|
+
|
|
116
|
+
- `data_hash == SHA256(utf8(data))` for every row whose payload is present;
|
|
117
|
+
- `event_id == SHA256(parent_id ‖ data_hash)` for every row;
|
|
118
|
+
- each `parent_id` equals the previous row's `event_id`, and `#` is contiguous.
|
|
119
|
+
|
|
120
|
+
It accepts a **partial** chain (a contiguous slice not starting at the root):
|
|
121
|
+
the first row then has `# ≠ 1` and a non-zero `parent_id`, and the summary reads
|
|
122
|
+
`Partial chain OK` instead of `Chain OK`.
|
|
123
|
+
|
|
124
|
+
The `data` column is optional: if absent, no event carries a payload; an empty
|
|
125
|
+
cell means that event's payload is unavailable.
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
$ tr-json-chain-check export.csv
|
|
129
|
+
Chain OK: 1502 events verified, payload 1501/1501 (100.0%) present, in 12.3 ms
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The `n/m` figure is payloads-present over events that have a real (non-zero)
|
|
133
|
+
`data_hash` — head/checkpoint placeholder events are excluded from the
|
|
134
|
+
denominator. Exit code is `0` on success, `1` on an integrity failure (printed
|
|
135
|
+
as `INVALID: <reason>`), `2` on a usage/IO error.
|
|
136
|
+
|
|
137
|
+
## See also
|
|
138
|
+
|
|
139
|
+
- [`tr-json-chain`](https://www.npmjs.com/package/tr-json-chain) — the library these
|
|
140
|
+
tools operate on, including the full hash specification and the
|
|
141
|
+
language-agnostic verification algorithm.
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// tr-json-chain-check — verify the integrity of a tr-json-chain CSV export.
|
|
5
|
+
//
|
|
6
|
+
// Standalone and self-contained: only Node builtins, no database and no
|
|
7
|
+
// tr-json-chain dependency. It reproduces the chain's hashing straight from
|
|
8
|
+
// the spec, so it doubles as a compact reference verifier.
|
|
9
|
+
//
|
|
10
|
+
// Usage: tr-json-chain-check <file.csv> (filename is mandatory)
|
|
11
|
+
//
|
|
12
|
+
// The CSV is semicolon-separated with header: #;event_id;parent_id;data_hash[;data]
|
|
13
|
+
// # sequence number (event_chain.id), strictly increasing by 1
|
|
14
|
+
// event_id hex, must equal SHA256(parent_id || data_hash)
|
|
15
|
+
// parent_id hex, must equal the previous row's event_id
|
|
16
|
+
// data_hash hex, equals SHA256(utf8(data)) when a payload is present
|
|
17
|
+
// data OPTIONAL column: canonical payload text; an empty cell means the
|
|
18
|
+
// payload for that event is unavailable. If the column is absent
|
|
19
|
+
// entirely, no event carries a payload.
|
|
20
|
+
//
|
|
21
|
+
// A partial chain (a contiguous slice that does not start at the root) is
|
|
22
|
+
// accepted: the first row then has # != 1 and a non-zero parent_id, and the
|
|
23
|
+
// summary says "Partial chain OK" instead of "Chain OK".
|
|
24
|
+
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const { createHash } = require('node:crypto');
|
|
27
|
+
const Optist = require('optist');
|
|
28
|
+
|
|
29
|
+
const ZERO_HEX = '0'.repeat(64);
|
|
30
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
31
|
+
|
|
32
|
+
function sha256hex(...bufs) {
|
|
33
|
+
const h = createHash('sha256');
|
|
34
|
+
for (const b of bufs) h.update(b);
|
|
35
|
+
return h.digest('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// RFC-4180 CSV parser: ';' delimiter, '"'-quoted fields with '""' escaping.
|
|
39
|
+
// Returns an array of field arrays; blank lines are dropped.
|
|
40
|
+
function parseCSV(text, delim = ';') {
|
|
41
|
+
const rows = [];
|
|
42
|
+
let row = [];
|
|
43
|
+
let field = '';
|
|
44
|
+
let inQuotes = false;
|
|
45
|
+
let sawAny = false; // any content/structure on the current line
|
|
46
|
+
const endField = () => { row.push(field); field = ''; };
|
|
47
|
+
const endRow = () => { endField(); rows.push(row); row = []; sawAny = false; };
|
|
48
|
+
for (let i = 0; i < text.length; i++) {
|
|
49
|
+
const c = text[i];
|
|
50
|
+
if (inQuotes) {
|
|
51
|
+
if (c === '"') {
|
|
52
|
+
if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false;
|
|
53
|
+
} else {
|
|
54
|
+
field += c;
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (c === '"') { inQuotes = true; sawAny = true; }
|
|
59
|
+
else if (c === delim) { endField(); sawAny = true; }
|
|
60
|
+
else if (c === '\n') { endRow(); }
|
|
61
|
+
else if (c === '\r') { /* ignore */ }
|
|
62
|
+
else { field += c; sawAny = true; }
|
|
63
|
+
}
|
|
64
|
+
if (sawAny || field !== '' || row.length) endRow();
|
|
65
|
+
// Drop blank lines (a single empty field).
|
|
66
|
+
return rows.filter((r) => !(r.length === 1 && r[0] === ''));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fail(msg) {
|
|
70
|
+
console.error(`INVALID: ${msg}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function main() {
|
|
75
|
+
const opt = new Optist()
|
|
76
|
+
.help('tr-json-chain-check')
|
|
77
|
+
.additional(1, 1)
|
|
78
|
+
.describeParam(0, 'file.csv', 'the CSV export to verify')
|
|
79
|
+
.parse();
|
|
80
|
+
const file = opt.rest()[0];
|
|
81
|
+
let text;
|
|
82
|
+
try {
|
|
83
|
+
text = fs.readFileSync(file, 'utf8');
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error(`error: cannot read ${file}: ${err.message}`);
|
|
86
|
+
return 2;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const rows = parseCSV(text);
|
|
90
|
+
if (rows.length === 0) fail('empty file (no header)');
|
|
91
|
+
|
|
92
|
+
const header = rows.shift();
|
|
93
|
+
const required = ['#', 'event_id', 'parent_id', 'data_hash'];
|
|
94
|
+
const validHeader =
|
|
95
|
+
(header.length === 4 && required.every((c, i) => header[i] === c)) ||
|
|
96
|
+
(header.length === 5 && required.every((c, i) => header[i] === c) && header[4] === 'data');
|
|
97
|
+
if (!validHeader) {
|
|
98
|
+
fail(`bad header: expected "#;event_id;parent_id;data_hash[;data]", got "${header.join(';')}"`);
|
|
99
|
+
}
|
|
100
|
+
const hasDataColumn = header.length === 5;
|
|
101
|
+
if (rows.length === 0) fail('no event rows');
|
|
102
|
+
|
|
103
|
+
const t0 = process.hrtime.bigint();
|
|
104
|
+
let prevNum = null;
|
|
105
|
+
let prevEventId = null;
|
|
106
|
+
let payloadPresent = 0; // n
|
|
107
|
+
let realEvents = 0; // m: rows with data_hash != all-zeros
|
|
108
|
+
let partial = false;
|
|
109
|
+
|
|
110
|
+
for (let r = 0; r < rows.length; r++) {
|
|
111
|
+
const f = rows[r];
|
|
112
|
+
const at = `row ${r + 2}`; // +2: 1-based, and the header was row 1
|
|
113
|
+
if (f.length < 4) fail(`${at}: too few columns (${f.length})`);
|
|
114
|
+
const numStr = f[0];
|
|
115
|
+
const eventId = f[1];
|
|
116
|
+
const parentId = f[2];
|
|
117
|
+
const dataHash = f[3];
|
|
118
|
+
const data = hasDataColumn ? (f[4] ?? '') : '';
|
|
119
|
+
|
|
120
|
+
if (!/^\d+$/.test(numStr)) fail(`${at}: # is not a non-negative integer ("${numStr}")`);
|
|
121
|
+
const num = Number(numStr);
|
|
122
|
+
if (!HEX64.test(eventId)) fail(`${at}: event_id is not 64 lowercase hex chars`);
|
|
123
|
+
if (!HEX64.test(parentId)) fail(`${at}: parent_id is not 64 lowercase hex chars`);
|
|
124
|
+
if (!HEX64.test(dataHash)) fail(`${at}: data_hash is not 64 lowercase hex chars`);
|
|
125
|
+
|
|
126
|
+
if (r === 0) {
|
|
127
|
+
if (num === 1) {
|
|
128
|
+
if (parentId !== ZERO_HEX) fail(`${at}: root event (#=1) must have an all-zero parent_id`);
|
|
129
|
+
} else {
|
|
130
|
+
if (parentId === ZERO_HEX) {
|
|
131
|
+
fail(`${at}: a non-root start (#=${num}) must not have an all-zero parent_id`);
|
|
132
|
+
}
|
|
133
|
+
partial = true;
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
if (num !== prevNum + 1) {
|
|
137
|
+
fail(`${at}: # jumped from ${prevNum} to ${num} (the chain must be contiguous)`);
|
|
138
|
+
}
|
|
139
|
+
if (parentId !== prevEventId) fail(`${at} (#${num}): parent_id does not match the previous event_id`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Payload hashing: when present, data must hash to data_hash.
|
|
143
|
+
if (data !== '') {
|
|
144
|
+
if (sha256hex(Buffer.from(data, 'utf8')) !== dataHash) {
|
|
145
|
+
fail(`${at} (#${num}): data does not hash to data_hash`);
|
|
146
|
+
}
|
|
147
|
+
payloadPresent++;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Every row: event_id == SHA256(parent_id || data_hash).
|
|
151
|
+
if (sha256hex(Buffer.from(parentId, 'hex'), Buffer.from(dataHash, 'hex')) !== eventId) {
|
|
152
|
+
fail(`${at} (#${num}): event_id != SHA256(parent_id || data_hash)`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (dataHash !== ZERO_HEX) realEvents++;
|
|
156
|
+
|
|
157
|
+
prevNum = num;
|
|
158
|
+
prevEventId = eventId;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
|
162
|
+
const pct = realEvents === 0 ? 'n/a' : `${((100 * payloadPresent) / realEvents).toFixed(1)}%`;
|
|
163
|
+
const label = partial ? 'Partial chain OK' : 'Chain OK';
|
|
164
|
+
console.log(
|
|
165
|
+
`${label}: ${rows.length} events verified, ` +
|
|
166
|
+
`payload ${payloadPresent}/${realEvents} (${pct}) present, in ${ms.toFixed(1)} ms`,
|
|
167
|
+
);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
process.exitCode = main();
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// tr-json-chain-cli — a CLI for recording into and inspecting a tr-json-chain.
|
|
5
|
+
//
|
|
6
|
+
// Reads stdin line by line. Each line is either a JSON object (recorded to
|
|
7
|
+
// the chain) or a slash command; in both cases the resulting event id is
|
|
8
|
+
// printed (hex). When stdin is a TTY it runs as an interactive line editor
|
|
9
|
+
// with in-memory history (up/down) and TAB completion; with piped input it
|
|
10
|
+
// runs as a plain batch processor.
|
|
11
|
+
//
|
|
12
|
+
// Commands:
|
|
13
|
+
// /timestamp record a timestamp event (logger.timestamp())
|
|
14
|
+
// /head fetch the chain head (may append an empty checkpoint event)
|
|
15
|
+
// /root fetch the chain root event (prints its id and stored data)
|
|
16
|
+
// /lc [<start> [<end>]] list the chain (getEvents() slice indices; default all)
|
|
17
|
+
// /cc check chain integrity (full client-side hash verification)
|
|
18
|
+
// /export <filename> export the chain (minus genesis) to a semicolon CSV
|
|
19
|
+
// /help show this help
|
|
20
|
+
// /exit quit
|
|
21
|
+
//
|
|
22
|
+
// Usage: tr-json-chain-cli [--verify] <postgres-url> [namespace]
|
|
23
|
+
// e.g. tr-json-chain-cli postgres://postgres:postgres@localhost:5433/postgres demo
|
|
24
|
+
// --verify passes { verifyChain: true } to the constructor, so init() does a
|
|
25
|
+
// full server-side verification of the entire chain instead of just the root.
|
|
26
|
+
|
|
27
|
+
const readline = require('node:readline');
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const { createHash } = require('node:crypto');
|
|
30
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
31
|
+
const Optist = require('optist');
|
|
32
|
+
const { Pool } = require('pg');
|
|
33
|
+
const { EventChainLogger, EventChainCsvExport } = require('tr-json-chain');
|
|
34
|
+
|
|
35
|
+
const ZERO_HEX = '00'.repeat(32);
|
|
36
|
+
|
|
37
|
+
// SHA-256 over the concatenation of the given buffers, returned as lowercase
|
|
38
|
+
// hex — matching PostgreSQL's encode(sha256(...), 'hex').
|
|
39
|
+
function sha256hex(...bufs) {
|
|
40
|
+
const h = createHash('sha256');
|
|
41
|
+
for (const b of bufs) h.update(b);
|
|
42
|
+
return h.digest('hex');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const COMMANDS = {
|
|
46
|
+
'/timestamp': 'record a timestamp event',
|
|
47
|
+
'/head': 'fetch the chain head (may append an empty checkpoint event)',
|
|
48
|
+
'/root': 'fetch the chain root event (id + stored data)',
|
|
49
|
+
'/lc': 'list the chain: /lc [<start> [<end>]] (getEvents slice indices)',
|
|
50
|
+
'/cc': 'check chain: full client-side hash verification',
|
|
51
|
+
'/export': 'export the chain to a semicolon-CSV file: /export <filename>',
|
|
52
|
+
'/help': 'show this help',
|
|
53
|
+
'/exit': 'quit',
|
|
54
|
+
};
|
|
55
|
+
const COMMAND_NAMES = Object.keys(COMMANDS);
|
|
56
|
+
const PROMPT = 'tr-json-chain> ';
|
|
57
|
+
|
|
58
|
+
// Pure TAB-completion logic, independent of readline so it can be unit-tested.
|
|
59
|
+
// Returns either { completions, completeOn } (real completions readline should
|
|
60
|
+
// insert/list) or { message } (an informational line to show the user).
|
|
61
|
+
function analyzeCompletion(line) {
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
|
|
64
|
+
if (trimmed === '') {
|
|
65
|
+
return {
|
|
66
|
+
message:
|
|
67
|
+
`Commands: ${COMMAND_NAMES.join(' ')}\n` +
|
|
68
|
+
' or enter a JSON object, e.g. {"key":"value"}',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (trimmed.startsWith('/')) {
|
|
73
|
+
const parts = trimmed.split(/\s+/);
|
|
74
|
+
const cmd = parts[0];
|
|
75
|
+
// Past the command word (a space typed) we're into arguments, not names.
|
|
76
|
+
if (parts.length > 1 || /\s$/.test(line)) {
|
|
77
|
+
return COMMAND_NAMES.includes(cmd)
|
|
78
|
+
? { message: `${cmd} — ${COMMANDS[cmd]}` }
|
|
79
|
+
: { message: `No such command: ${cmd}` };
|
|
80
|
+
}
|
|
81
|
+
const hits = COMMAND_NAMES.filter((c) => c.startsWith(cmd));
|
|
82
|
+
if (hits.length === 0) {
|
|
83
|
+
return { message: `No such command. Available: ${COMMAND_NAMES.join(' ')}` };
|
|
84
|
+
}
|
|
85
|
+
return { completions: hits, completeOn: cmd };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (trimmed.startsWith('{')) {
|
|
89
|
+
try {
|
|
90
|
+
JSON.parse(trimmed); // starts with { → success implies an object
|
|
91
|
+
return { message: 'Valid JSON object — press ENTER to commit it to the chain.' };
|
|
92
|
+
} catch {
|
|
93
|
+
return { message: 'JSON object is invalid or incomplete.' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
message:
|
|
99
|
+
'Invalid input — enter a JSON object {…} or a /command ' +
|
|
100
|
+
'(TAB on an empty line for help).',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Generic one-line rendering of a chain event ({ event_id, data? }):
|
|
105
|
+
// <event-id>: { <data> } when a payload is stored
|
|
106
|
+
// <event-id>: - when no payload is available
|
|
107
|
+
// event_id is always a hex string in the 0.9.0+ API.
|
|
108
|
+
function formatEvent({ event_id, data }) {
|
|
109
|
+
return data === undefined ? `${event_id}: -` : `${event_id}: ${JSON.stringify(data)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function helpText() {
|
|
113
|
+
return [
|
|
114
|
+
'Enter a JSON object to record it to the chain, or one of:',
|
|
115
|
+
...COMMAND_NAMES.map((name) => ` ${name.padEnd(11)}${COMMANDS[name]}`),
|
|
116
|
+
].join('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Lists a (possibly large) slice of the chain. Arguments mirror getEvents():
|
|
120
|
+
// /lc -> the whole chain
|
|
121
|
+
// /lc <start> -> from index <start> onwards
|
|
122
|
+
// /lc <start> <end> -> the [start, end) range
|
|
123
|
+
// getEvents() caps each call at 1000 events; this iterates internally over
|
|
124
|
+
// have_more so the caller sees the entire requested range, one event per line.
|
|
125
|
+
async function listChain(logger, args) {
|
|
126
|
+
if (args.length > 2) {
|
|
127
|
+
console.error('usage: /lc [<start> [<end>]]');
|
|
128
|
+
return { ok: false };
|
|
129
|
+
}
|
|
130
|
+
const parseIdx = (tok) => {
|
|
131
|
+
const n = Number(tok);
|
|
132
|
+
return Number.isInteger(n) ? n : null;
|
|
133
|
+
};
|
|
134
|
+
let start = 0;
|
|
135
|
+
let end; // undefined => to the end (getEvents default)
|
|
136
|
+
if (args.length >= 1) {
|
|
137
|
+
start = parseIdx(args[0]);
|
|
138
|
+
if (start === null) {
|
|
139
|
+
console.error('error: start must be an integer');
|
|
140
|
+
return { ok: false };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (args.length >= 2) {
|
|
144
|
+
end = parseIdx(args[1]);
|
|
145
|
+
if (end === null) {
|
|
146
|
+
console.error('error: end must be an integer');
|
|
147
|
+
return { ok: false };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (let from = start; ; ) {
|
|
152
|
+
const page = end === undefined
|
|
153
|
+
? await logger.getEvents(from)
|
|
154
|
+
: await logger.getEvents(from, end);
|
|
155
|
+
for (const ev of page.events) console.log(formatEvent(ev));
|
|
156
|
+
if (!page.have_more) break;
|
|
157
|
+
from = page.end + 1;
|
|
158
|
+
}
|
|
159
|
+
return { ok: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Verifies a single event from getEvents() (with includeHashedData /
|
|
163
|
+
// includeDataHash / includeParentId) against the hash-chain rules. `index` is
|
|
164
|
+
// its position (0 = genesis), `prevEventId` the hex event_id of the previous
|
|
165
|
+
// event (null before genesis). Throws Error with a precise reason on any
|
|
166
|
+
// inconsistency.
|
|
167
|
+
function verifyEvent(ev, index, prevEventId) {
|
|
168
|
+
const at = `event #${index} (${ev.event_id})`;
|
|
169
|
+
|
|
170
|
+
if (index === 0) {
|
|
171
|
+
// Genesis: all-zero data_hash and event_id, and no parent.
|
|
172
|
+
if (ev.event_id !== ZERO_HEX) throw new Error(`${at}: genesis event_id is not all-zero`);
|
|
173
|
+
if (ev.data_hash !== ZERO_HEX) throw new Error(`${at}: genesis data_hash is not all-zero`);
|
|
174
|
+
if (ev.parent_id !== undefined) throw new Error(`${at}: genesis must have no parent_id`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Link: this event's parent must be the previous event's id.
|
|
179
|
+
if (ev.parent_id === undefined) throw new Error(`${at}: missing parent_id`);
|
|
180
|
+
if (ev.parent_id !== prevEventId) {
|
|
181
|
+
throw new Error(`${at}: parent_id ${ev.parent_id} != previous event_id ${prevEventId}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// data_hash = SHA256(UTF-8 of jsonb::text). Only recomputable when the
|
|
185
|
+
// payload was stored (hashed_data present); empty / payload-less events keep
|
|
186
|
+
// a data_hash we cannot independently recompute, but still bind the event_id.
|
|
187
|
+
if (ev.hashed_data !== undefined) {
|
|
188
|
+
const dh = sha256hex(Buffer.from(ev.hashed_data, 'utf8'));
|
|
189
|
+
if (dh !== ev.data_hash) {
|
|
190
|
+
throw new Error(`${at}: data_hash mismatch (computed ${dh}, returned ${ev.data_hash})`);
|
|
191
|
+
}
|
|
192
|
+
// The hashed text must decode to the same object as the returned `data`.
|
|
193
|
+
if (!isDeepStrictEqual(JSON.parse(ev.hashed_data), ev.data)) {
|
|
194
|
+
throw new Error(`${at}: hashed_data does not decode to the returned data`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// event_id = SHA256(parent_event_id || data_hash), raw 32-byte concatenation.
|
|
199
|
+
const eid = sha256hex(Buffer.from(ev.parent_id, 'hex'), Buffer.from(ev.data_hash, 'hex'));
|
|
200
|
+
if (eid !== ev.event_id) {
|
|
201
|
+
throw new Error(`${at}: event_id mismatch (computed ${eid}, returned ${ev.event_id})`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Full client-side integrity check of the entire chain: iterates every event
|
|
206
|
+
// (paginating internally), re-derives data_hash and event_id with node:crypto,
|
|
207
|
+
// and verifies every parent link. Prints chain length and elapsed time, or the
|
|
208
|
+
// first inconsistency found. The point is to confirm — independently of the
|
|
209
|
+
// database — that the chain verifies and that this client hashes compatibly.
|
|
210
|
+
async function checkChain(logger, args) {
|
|
211
|
+
if (args.length > 0) {
|
|
212
|
+
console.error('usage: /cc');
|
|
213
|
+
return { ok: false };
|
|
214
|
+
}
|
|
215
|
+
const opts = { includeHashedData: true, includeDataHash: true, includeParentId: true };
|
|
216
|
+
const t0 = process.hrtime.bigint();
|
|
217
|
+
let count = 0;
|
|
218
|
+
let payloadVerified = 0;
|
|
219
|
+
let prevEventId = null;
|
|
220
|
+
try {
|
|
221
|
+
for (let from = 0; ; ) {
|
|
222
|
+
const page = await logger.getEvents(from, opts);
|
|
223
|
+
for (const ev of page.events) {
|
|
224
|
+
verifyEvent(ev, count, prevEventId);
|
|
225
|
+
if (ev.hashed_data !== undefined) payloadVerified++;
|
|
226
|
+
prevEventId = ev.event_id;
|
|
227
|
+
count++;
|
|
228
|
+
}
|
|
229
|
+
if (!page.have_more) break;
|
|
230
|
+
from = page.end + 1;
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
console.error(`CHAIN VERIFICATION FAILED: ${err.message}`);
|
|
234
|
+
return { ok: false };
|
|
235
|
+
}
|
|
236
|
+
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
|
237
|
+
console.log(
|
|
238
|
+
`Chain OK: ${count} events verified ` +
|
|
239
|
+
`(${payloadVerified} payload hashes recomputed) in ${ms.toFixed(1)} ms`,
|
|
240
|
+
);
|
|
241
|
+
return { ok: true };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Exports the chain (excluding the genesis row) to a semicolon-separated CSV:
|
|
245
|
+
// #;event_id;parent_id;data_hash;data
|
|
246
|
+
// The format is owned by the library: EventChainCsvExport renders the header and
|
|
247
|
+
// each row (RFC-4180 quoting, `#` = event_chain.id, empty `data` cell for events
|
|
248
|
+
// with no stored payload). We just fetch every column and stream the lines.
|
|
249
|
+
async function exportChain(logger, args) {
|
|
250
|
+
const filename = args.join(' ');
|
|
251
|
+
if (!filename) {
|
|
252
|
+
console.error('usage: /export <filename>');
|
|
253
|
+
return { ok: false };
|
|
254
|
+
}
|
|
255
|
+
const opts = { includeHashedData: true, includeDataHash: true, includeParentId: true };
|
|
256
|
+
const enc = new EventChainCsvExport();
|
|
257
|
+
let fd;
|
|
258
|
+
try {
|
|
259
|
+
fd = fs.openSync(filename, 'w');
|
|
260
|
+
} catch (err) {
|
|
261
|
+
console.error(`error: cannot open ${filename}: ${err.message}`);
|
|
262
|
+
return { ok: false };
|
|
263
|
+
}
|
|
264
|
+
let count = 0;
|
|
265
|
+
try {
|
|
266
|
+
fs.writeSync(fd, enc.header() + '\n');
|
|
267
|
+
for (let from = 1; ; ) {
|
|
268
|
+
// Start at index 1 to skip the genesis row (index 0).
|
|
269
|
+
const page = await logger.getEvents(from, opts);
|
|
270
|
+
for (const ev of page.events) {
|
|
271
|
+
fs.writeSync(fd, enc.event(ev) + '\n');
|
|
272
|
+
count++;
|
|
273
|
+
}
|
|
274
|
+
if (!page.have_more) break;
|
|
275
|
+
from = page.end + 1;
|
|
276
|
+
}
|
|
277
|
+
} finally {
|
|
278
|
+
fs.closeSync(fd);
|
|
279
|
+
}
|
|
280
|
+
console.log(`exported ${count} events to ${filename}`);
|
|
281
|
+
return { ok: true };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Handles a single input line against the chain. Returns { ok, exit }.
|
|
285
|
+
// Printing of event ids / data happens here so both modes behave identically.
|
|
286
|
+
async function handleLine(logger, line) {
|
|
287
|
+
const trimmed = line.trim();
|
|
288
|
+
if (trimmed === '') return { ok: true };
|
|
289
|
+
|
|
290
|
+
if (trimmed.startsWith('/')) {
|
|
291
|
+
const [cmd, ...rest] = trimmed.split(/\s+/);
|
|
292
|
+
switch (cmd) {
|
|
293
|
+
case '/exit':
|
|
294
|
+
return { ok: true, exit: true };
|
|
295
|
+
case '/help':
|
|
296
|
+
console.log(helpText());
|
|
297
|
+
return { ok: true };
|
|
298
|
+
case '/timestamp':
|
|
299
|
+
console.log(await logger.timestamp());
|
|
300
|
+
return { ok: true };
|
|
301
|
+
case '/head':
|
|
302
|
+
console.log(await logger.getChainHead());
|
|
303
|
+
return { ok: true };
|
|
304
|
+
case '/root':
|
|
305
|
+
console.log(formatEvent(await logger.getRootEvent()));
|
|
306
|
+
return { ok: true };
|
|
307
|
+
case '/lc':
|
|
308
|
+
return listChain(logger, rest);
|
|
309
|
+
case '/cc':
|
|
310
|
+
return checkChain(logger, rest);
|
|
311
|
+
case '/export':
|
|
312
|
+
return exportChain(logger, rest);
|
|
313
|
+
default:
|
|
314
|
+
console.error(`error: unknown command: ${cmd}`);
|
|
315
|
+
return { ok: false };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let data;
|
|
320
|
+
try {
|
|
321
|
+
data = JSON.parse(line);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
console.error(`error: invalid JSON: ${err.message}`);
|
|
324
|
+
return { ok: false };
|
|
325
|
+
}
|
|
326
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
327
|
+
console.error('error: expected a JSON object');
|
|
328
|
+
return { ok: false };
|
|
329
|
+
}
|
|
330
|
+
console.log(await logger.recordEvent(data));
|
|
331
|
+
return { ok: true };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function runInteractive(logger) {
|
|
335
|
+
let rl;
|
|
336
|
+
const notify = (message) => {
|
|
337
|
+
process.stdout.write(`\n${message}\n`);
|
|
338
|
+
rl.prompt(true); // redraw prompt + current line buffer
|
|
339
|
+
};
|
|
340
|
+
const completer = (line) => {
|
|
341
|
+
const res = analyzeCompletion(line);
|
|
342
|
+
if (res.completions) return [res.completions, res.completeOn];
|
|
343
|
+
notify(res.message);
|
|
344
|
+
return [[], line];
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
rl = readline.createInterface({
|
|
348
|
+
input: process.stdin,
|
|
349
|
+
output: process.stdout,
|
|
350
|
+
terminal: true,
|
|
351
|
+
completer,
|
|
352
|
+
prompt: PROMPT,
|
|
353
|
+
historySize: 100, // in-memory only; not persisted
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
console.log('Interactive mode — TAB for help, /exit to quit.');
|
|
357
|
+
let failures = 0;
|
|
358
|
+
let exited = false;
|
|
359
|
+
rl.prompt();
|
|
360
|
+
for await (const line of rl) {
|
|
361
|
+
let res;
|
|
362
|
+
try {
|
|
363
|
+
res = await handleLine(logger, line);
|
|
364
|
+
} catch (err) {
|
|
365
|
+
console.error(`error: ${err.message}`);
|
|
366
|
+
res = { ok: false };
|
|
367
|
+
}
|
|
368
|
+
if (!res.ok) failures++;
|
|
369
|
+
if (res.exit) {
|
|
370
|
+
exited = true;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
rl.prompt();
|
|
374
|
+
}
|
|
375
|
+
// On EOF (Ctrl-D) readline closes without echoing a newline, which would
|
|
376
|
+
// leave the shell prompt on the same line as ours; emit one. After /exit
|
|
377
|
+
// the committed ENTER already moved to a fresh line, so skip it there.
|
|
378
|
+
if (!exited) process.stdout.write('\n');
|
|
379
|
+
rl.close();
|
|
380
|
+
return failures ? 1 : 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function runBatch(logger) {
|
|
384
|
+
let failures = 0;
|
|
385
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
386
|
+
for await (const line of rl) {
|
|
387
|
+
const res = await handleLine(logger, line);
|
|
388
|
+
if (!res.ok) failures++;
|
|
389
|
+
if (res.exit) break;
|
|
390
|
+
}
|
|
391
|
+
return failures ? 1 : 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function main() {
|
|
395
|
+
const opt = new Optist()
|
|
396
|
+
.opts([
|
|
397
|
+
{
|
|
398
|
+
longName: 'pg-url',
|
|
399
|
+
description: 'PostgreSQL connection URI',
|
|
400
|
+
hasArg: true,
|
|
401
|
+
required: true,
|
|
402
|
+
environment: 'OPT_PG_URL',
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
longName: 'namespace',
|
|
406
|
+
description: 'chain namespace (default: unprefixed bare names)',
|
|
407
|
+
hasArg: true,
|
|
408
|
+
environment: 'OPT_NAMESPACE',
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
longName: 'verify',
|
|
412
|
+
description: 'verify the entire chain server-side on startup',
|
|
413
|
+
environment: 'OPT_VERIFY',
|
|
414
|
+
},
|
|
415
|
+
])
|
|
416
|
+
.help('tr-json-chain-cli')
|
|
417
|
+
.additional(0, 0)
|
|
418
|
+
.parse();
|
|
419
|
+
|
|
420
|
+
const url = opt.value('pg-url');
|
|
421
|
+
const namespace = opt.value('namespace');
|
|
422
|
+
|
|
423
|
+
const options = {};
|
|
424
|
+
if (namespace) options.namespace = namespace;
|
|
425
|
+
if (opt.value('verify')) options.verifyChain = true;
|
|
426
|
+
|
|
427
|
+
const pool = new Pool({ connectionString: url });
|
|
428
|
+
try {
|
|
429
|
+
const logger = new EventChainLogger(pool, options);
|
|
430
|
+
await logger.init(); // fail fast on bad creds / namespace / schema (full chain if --verify)
|
|
431
|
+
// Must await here: a bare `return <promise>` would let the finally below
|
|
432
|
+
// close the pool before the loop finishes.
|
|
433
|
+
return process.stdin.isTTY ? await runInteractive(logger) : await runBatch(logger);
|
|
434
|
+
} finally {
|
|
435
|
+
await pool.end();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (require.main === module) {
|
|
440
|
+
main().then(
|
|
441
|
+
(code) => { process.exitCode = code; },
|
|
442
|
+
(err) => { console.error(`fatal: ${err.message}`); process.exitCode = 1; },
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
module.exports = {
|
|
447
|
+
analyzeCompletion, helpText, handleLine, formatEvent, listChain,
|
|
448
|
+
verifyEvent, checkChain, exportChain, sha256hex, COMMAND_NAMES,
|
|
449
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Optional PostgreSQL for trying out tr-json-chain-tools:
|
|
2
|
+
# docker compose -f dc/docker-compose.yml up -d
|
|
3
|
+
# echo '{"type":"hello","msg":"world"}' | \
|
|
4
|
+
# tr-json-chain-cli --pg-url postgres://postgres:postgres@localhost:5433/postgres --namespace demo
|
|
5
|
+
services:
|
|
6
|
+
postgres:
|
|
7
|
+
image: postgres:16
|
|
8
|
+
environment:
|
|
9
|
+
POSTGRES_PASSWORD: postgres
|
|
10
|
+
ports:
|
|
11
|
+
- "5433:5432"
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tr-json-chain-tools",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Command-line tools for tr-json-chain: a CLI to record into and inspect a hash-chained JSON event log, and a standalone CSV-export integrity verifier.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"tr-json-chain",
|
|
7
|
+
"audit-log",
|
|
8
|
+
"hash-chain",
|
|
9
|
+
"cli",
|
|
10
|
+
"tamper-evident",
|
|
11
|
+
"postgresql",
|
|
12
|
+
"integrity"
|
|
13
|
+
],
|
|
14
|
+
"bin": {
|
|
15
|
+
"tr-json-chain-cli": "bin/tr-json-chain-cli.js",
|
|
16
|
+
"tr-json-chain-check": "bin/tr-json-chain-check.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"dc/docker-compose.yml",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"optist": "^2.0.1",
|
|
29
|
+
"pg": "^8.11.0",
|
|
30
|
+
"tr-json-chain": "^0.9.0"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/rinne/node-tr-json-chain-tools.git"
|
|
35
|
+
},
|
|
36
|
+
"author": {
|
|
37
|
+
"name": "Timo J. Rinne",
|
|
38
|
+
"email": "tri@iki.fi",
|
|
39
|
+
"url": "https://github.com/rinne/"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/rinne/node-tr-json-chain-tools/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/rinne/node-tr-json-chain-tools#readme"
|
|
46
|
+
}
|