forge-trust-chain 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 +368 -0
- package/package.json +55 -0
- package/src/cli/index.js +547 -0
- package/src/core/chain.js +186 -0
- package/src/core/merkle.js +131 -0
- package/src/core/trust-atom.js +125 -0
- package/src/core/trust-pixel.js +81 -0
- package/src/core/witness.js +377 -0
- package/src/mcp/server.js +534 -0
- package/src/scanner/index.js +437 -0
- package/src/store/store.js +133 -0
- package/src/test.js +266 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scanner — `forge scan`
|
|
3
|
+
*
|
|
4
|
+
* Enumerates all trust assumptions on the current system.
|
|
5
|
+
* "See the problem before you can record the problem."
|
|
6
|
+
*
|
|
7
|
+
* Checks:
|
|
8
|
+
* 1. Exposed ports — what's listening?
|
|
9
|
+
* 2. Auth surfaces — web panels, SSH, management UIs
|
|
10
|
+
* 3. Running services — who's running as root?
|
|
11
|
+
* 4. Firewall status — iptables / ufw active?
|
|
12
|
+
* 5. SMTP configuration — open relay risk?
|
|
13
|
+
* 6. SSL/TLS certificates — expired? self-signed?
|
|
14
|
+
* 7. Cron jobs — scheduled operations nobody reviews?
|
|
15
|
+
* 8. Docker / containers — privileged? exposed sockets?
|
|
16
|
+
* 9. Recent logins — unexpected sources?
|
|
17
|
+
* 10. File permissions — world-writable sensitive files?
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execSync } from "node:child_process";
|
|
21
|
+
|
|
22
|
+
/* ================================================================
|
|
23
|
+
HELPERS
|
|
24
|
+
================================================================ */
|
|
25
|
+
|
|
26
|
+
function run(cmd) {
|
|
27
|
+
try {
|
|
28
|
+
return execSync(cmd, { encoding: "utf8", timeout: 10000 }).trim();
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function risk(level, category, finding, recommendation) {
|
|
35
|
+
return { level, category, finding, recommendation };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* ================================================================
|
|
39
|
+
INDIVIDUAL CHECKS
|
|
40
|
+
================================================================ */
|
|
41
|
+
|
|
42
|
+
function scanPorts() {
|
|
43
|
+
const results = [];
|
|
44
|
+
const out = run("ss -tlnp 2>/dev/null") || run("netstat -tlnp 2>/dev/null");
|
|
45
|
+
if (!out) {
|
|
46
|
+
results.push(risk("unknown", "ports", "Cannot determine listening ports", "Install ss or netstat"));
|
|
47
|
+
return results;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lines = out.split("\n").slice(1); // skip header
|
|
51
|
+
const dangerousPorts = {
|
|
52
|
+
25: "SMTP (open relay risk)",
|
|
53
|
+
587: "SMTP submission",
|
|
54
|
+
3306: "MySQL",
|
|
55
|
+
5432: "PostgreSQL",
|
|
56
|
+
6379: "Redis",
|
|
57
|
+
27017: "MongoDB",
|
|
58
|
+
8080: "HTTP alt (management panel?)",
|
|
59
|
+
9090: "Management UI",
|
|
60
|
+
2375: "Docker API (CRITICAL)",
|
|
61
|
+
2376: "Docker API TLS",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
const match = line.match(/:(\d+)\s/);
|
|
66
|
+
if (!match) continue;
|
|
67
|
+
const port = parseInt(match[1]);
|
|
68
|
+
const bindMatch = line.match(/(\S+):(\d+)/);
|
|
69
|
+
const bind = bindMatch ? bindMatch[1] : "?";
|
|
70
|
+
const isPublic = bind === "0.0.0.0" || bind === "*" || bind === "::";
|
|
71
|
+
|
|
72
|
+
if (isPublic && dangerousPorts[port]) {
|
|
73
|
+
results.push(
|
|
74
|
+
risk(
|
|
75
|
+
port === 2375 ? "critical" : "high",
|
|
76
|
+
"ports",
|
|
77
|
+
`Port ${port} (${dangerousPorts[port]}) open to all interfaces`,
|
|
78
|
+
`Bind to 127.0.0.1 or restrict via firewall`
|
|
79
|
+
)
|
|
80
|
+
);
|
|
81
|
+
} else if (isPublic && port < 10000) {
|
|
82
|
+
results.push(
|
|
83
|
+
risk(
|
|
84
|
+
"medium",
|
|
85
|
+
"ports",
|
|
86
|
+
`Port ${port} open to all interfaces`,
|
|
87
|
+
`Verify this port needs public access`
|
|
88
|
+
)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (results.length === 0) {
|
|
94
|
+
results.push(risk("info", "ports", "No obviously dangerous ports exposed", ""));
|
|
95
|
+
}
|
|
96
|
+
return results;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function scanFirewall() {
|
|
100
|
+
const results = [];
|
|
101
|
+
|
|
102
|
+
const ufw = run("ufw status 2>/dev/null");
|
|
103
|
+
if (ufw && ufw.includes("active")) {
|
|
104
|
+
results.push(risk("info", "firewall", "UFW is active", ""));
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ipt = run("iptables -L -n 2>/dev/null");
|
|
109
|
+
if (ipt && !ipt.includes("ACCEPT") && ipt.split("\n").length <= 8) {
|
|
110
|
+
results.push(
|
|
111
|
+
risk("high", "firewall", "No firewall rules detected", "Enable ufw or configure iptables")
|
|
112
|
+
);
|
|
113
|
+
} else if (ipt) {
|
|
114
|
+
results.push(risk("info", "firewall", "iptables has rules configured", ""));
|
|
115
|
+
} else {
|
|
116
|
+
results.push(
|
|
117
|
+
risk("medium", "firewall", "Cannot determine firewall status", "Verify firewall configuration")
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return results;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function scanSSH() {
|
|
125
|
+
const results = [];
|
|
126
|
+
const cfg = run("cat /etc/ssh/sshd_config 2>/dev/null");
|
|
127
|
+
if (!cfg) {
|
|
128
|
+
results.push(risk("info", "ssh", "SSH config not readable", ""));
|
|
129
|
+
return results;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (/PermitRootLogin\s+yes/i.test(cfg)) {
|
|
133
|
+
results.push(
|
|
134
|
+
risk("high", "ssh", "Root login via SSH is enabled", "Set PermitRootLogin no")
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (/PasswordAuthentication\s+yes/i.test(cfg)) {
|
|
139
|
+
results.push(
|
|
140
|
+
risk("medium", "ssh", "Password authentication is enabled", "Use key-based auth only")
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const port = cfg.match(/^Port\s+(\d+)/m);
|
|
145
|
+
if (!port || port[1] === "22") {
|
|
146
|
+
results.push(
|
|
147
|
+
risk("low", "ssh", "SSH on default port 22", "Consider non-standard port")
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (results.length === 0) {
|
|
152
|
+
results.push(risk("info", "ssh", "SSH configuration looks reasonable", ""));
|
|
153
|
+
}
|
|
154
|
+
return results;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function scanRootProcesses() {
|
|
158
|
+
const results = [];
|
|
159
|
+
const ps = run("ps aux 2>/dev/null");
|
|
160
|
+
if (!ps) return [risk("unknown", "processes", "Cannot list processes", "")];
|
|
161
|
+
|
|
162
|
+
const lines = ps.split("\n").slice(1);
|
|
163
|
+
const rootProcesses = lines.filter((l) => l.startsWith("root"));
|
|
164
|
+
const suspicious = rootProcesses.filter((l) => {
|
|
165
|
+
// Skip known system processes
|
|
166
|
+
const known = [
|
|
167
|
+
"init", "systemd", "kthread", "sshd", "cron", "agetty",
|
|
168
|
+
"login", "bash", "sh", "dbus", "udevd", "journald",
|
|
169
|
+
"rsyslogd", "containerd", "dockerd",
|
|
170
|
+
"ps aux", // the check itself
|
|
171
|
+
];
|
|
172
|
+
return !known.some((k) => l.includes(k));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (suspicious.length > 10) {
|
|
176
|
+
results.push(
|
|
177
|
+
risk(
|
|
178
|
+
"medium",
|
|
179
|
+
"processes",
|
|
180
|
+
`${suspicious.length} processes running as root`,
|
|
181
|
+
"Audit root-owned processes, run services as unprivileged users"
|
|
182
|
+
)
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
results.push(risk("info", "processes", `${rootProcesses.length} root processes (normal)`, ""));
|
|
186
|
+
}
|
|
187
|
+
return results;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function scanDocker() {
|
|
191
|
+
const results = [];
|
|
192
|
+
const sock = run("ls -la /var/run/docker.sock 2>/dev/null");
|
|
193
|
+
if (!sock) {
|
|
194
|
+
results.push(risk("info", "docker", "Docker socket not found", ""));
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (sock.includes("rw-rw-rw") || sock.includes("666")) {
|
|
199
|
+
results.push(
|
|
200
|
+
risk(
|
|
201
|
+
"critical",
|
|
202
|
+
"docker",
|
|
203
|
+
"Docker socket is world-readable/writable",
|
|
204
|
+
"Restrict docker.sock permissions to docker group only"
|
|
205
|
+
)
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const containers = run("docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null");
|
|
210
|
+
if (containers) {
|
|
211
|
+
const privCheck = run(
|
|
212
|
+
"docker ps --format '{{.Names}}' 2>/dev/null | xargs -I{} docker inspect --format '{{.Name}} privileged={{.HostConfig.Privileged}}' {} 2>/dev/null"
|
|
213
|
+
);
|
|
214
|
+
if (privCheck && privCheck.includes("privileged=true")) {
|
|
215
|
+
results.push(
|
|
216
|
+
risk("critical", "docker", "Privileged container detected", "Never run privileged containers in production")
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (results.length === 0) {
|
|
222
|
+
results.push(risk("info", "docker", "Docker present, no obvious misconfigurations", ""));
|
|
223
|
+
}
|
|
224
|
+
return results;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function scanCron() {
|
|
228
|
+
const results = [];
|
|
229
|
+
const crontab = run("crontab -l 2>/dev/null");
|
|
230
|
+
const rootCron = run("cat /etc/crontab 2>/dev/null");
|
|
231
|
+
const cronD = run("ls /etc/cron.d/ 2>/dev/null");
|
|
232
|
+
|
|
233
|
+
let totalJobs = 0;
|
|
234
|
+
if (crontab) totalJobs += crontab.split("\n").filter((l) => l.trim() && !l.startsWith("#")).length;
|
|
235
|
+
if (rootCron) totalJobs += rootCron.split("\n").filter((l) => l.trim() && !l.startsWith("#")).length;
|
|
236
|
+
|
|
237
|
+
if (totalJobs > 0) {
|
|
238
|
+
results.push(
|
|
239
|
+
risk(
|
|
240
|
+
"low",
|
|
241
|
+
"cron",
|
|
242
|
+
`${totalJobs} scheduled jobs found`,
|
|
243
|
+
"Audit cron jobs — each is an unmonitored trust assumption"
|
|
244
|
+
)
|
|
245
|
+
);
|
|
246
|
+
} else {
|
|
247
|
+
results.push(risk("info", "cron", "No user cron jobs found", ""));
|
|
248
|
+
}
|
|
249
|
+
return results;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function scanRecentLogins() {
|
|
253
|
+
const results = [];
|
|
254
|
+
const last = run("last -n 20 2>/dev/null");
|
|
255
|
+
if (!last) return [risk("info", "logins", "Cannot check login history", "")];
|
|
256
|
+
|
|
257
|
+
const lines = last.split("\n").filter((l) => l.trim() && !l.startsWith("wtmp"));
|
|
258
|
+
const uniqueIPs = new Set();
|
|
259
|
+
for (const line of lines) {
|
|
260
|
+
const match = line.match(/(\d+\.\d+\.\d+\.\d+)/);
|
|
261
|
+
if (match) uniqueIPs.add(match[1]);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (uniqueIPs.size > 5) {
|
|
265
|
+
results.push(
|
|
266
|
+
risk(
|
|
267
|
+
"medium",
|
|
268
|
+
"logins",
|
|
269
|
+
`${uniqueIPs.size} unique IP addresses in recent logins`,
|
|
270
|
+
"Verify all login sources are legitimate"
|
|
271
|
+
)
|
|
272
|
+
);
|
|
273
|
+
} else {
|
|
274
|
+
results.push(risk("info", "logins", `${uniqueIPs.size} unique login sources`, ""));
|
|
275
|
+
}
|
|
276
|
+
return results;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function scanSMTP() {
|
|
280
|
+
const results = [];
|
|
281
|
+
const smtp = run("ss -tlnp 2>/dev/null | grep ':25 '") || run("netstat -tlnp 2>/dev/null | grep ':25 '");
|
|
282
|
+
|
|
283
|
+
if (smtp && smtp.includes("0.0.0.0")) {
|
|
284
|
+
results.push(
|
|
285
|
+
risk(
|
|
286
|
+
"high",
|
|
287
|
+
"smtp",
|
|
288
|
+
"SMTP port 25 is open to all interfaces",
|
|
289
|
+
"Use API-based email (AWS SES, Resend) instead of exposing SMTP"
|
|
290
|
+
)
|
|
291
|
+
);
|
|
292
|
+
} else if (smtp) {
|
|
293
|
+
results.push(risk("low", "smtp", "SMTP listening on localhost only", ""));
|
|
294
|
+
} else {
|
|
295
|
+
results.push(risk("info", "smtp", "No SMTP service detected", ""));
|
|
296
|
+
}
|
|
297
|
+
return results;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function scanWebPanels() {
|
|
301
|
+
const results = [];
|
|
302
|
+
// Check for common management panel ports/processes
|
|
303
|
+
const panels = [
|
|
304
|
+
{ name: "dpanel", port: 8080, proc: "dpanel" },
|
|
305
|
+
{ name: "Portainer", port: 9000, proc: "portainer" },
|
|
306
|
+
{ name: "Webmin", port: 10000, proc: "webmin" },
|
|
307
|
+
{ name: "cPanel", port: 2083, proc: "cpanel" },
|
|
308
|
+
{ name: "Plesk", port: 8443, proc: "plesk" },
|
|
309
|
+
{ name: "CasaOS", port: 80, proc: "casaos" },
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
const ps = run("ps aux 2>/dev/null") || "";
|
|
313
|
+
const ports = run("ss -tlnp 2>/dev/null") || "";
|
|
314
|
+
|
|
315
|
+
for (const panel of panels) {
|
|
316
|
+
const hasProc = ps.toLowerCase().includes(panel.proc);
|
|
317
|
+
const hasPort = ports.includes(`:${panel.port} `);
|
|
318
|
+
|
|
319
|
+
if (hasProc || hasPort) {
|
|
320
|
+
results.push(
|
|
321
|
+
risk(
|
|
322
|
+
"high",
|
|
323
|
+
"web-panel",
|
|
324
|
+
`${panel.name} detected (port ${panel.port})`,
|
|
325
|
+
"Management panels are high-value attack targets. Restrict access by IP, use strong auth, or remove if not needed."
|
|
326
|
+
)
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (results.length === 0) {
|
|
332
|
+
results.push(risk("info", "web-panel", "No common management panels detected", ""));
|
|
333
|
+
}
|
|
334
|
+
return results;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/* ================================================================
|
|
338
|
+
MAIN SCANNER
|
|
339
|
+
================================================================ */
|
|
340
|
+
|
|
341
|
+
export function scan() {
|
|
342
|
+
const checks = [
|
|
343
|
+
...scanPorts(),
|
|
344
|
+
...scanFirewall(),
|
|
345
|
+
...scanSSH(),
|
|
346
|
+
...scanRootProcesses(),
|
|
347
|
+
...scanDocker(),
|
|
348
|
+
...scanCron(),
|
|
349
|
+
...scanRecentLogins(),
|
|
350
|
+
...scanSMTP(),
|
|
351
|
+
...scanWebPanels(),
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
const summary = {
|
|
355
|
+
total: checks.length,
|
|
356
|
+
critical: checks.filter((c) => c.level === "critical").length,
|
|
357
|
+
high: checks.filter((c) => c.level === "high").length,
|
|
358
|
+
medium: checks.filter((c) => c.level === "medium").length,
|
|
359
|
+
low: checks.filter((c) => c.level === "low").length,
|
|
360
|
+
info: checks.filter((c) => c.level === "info").length,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
return { checks, summary, scanned_at: Date.now() };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* ================================================================
|
|
367
|
+
FORMAT
|
|
368
|
+
================================================================ */
|
|
369
|
+
|
|
370
|
+
const COLORS = {
|
|
371
|
+
critical: "\x1b[91m", // bright red
|
|
372
|
+
high: "\x1b[31m", // red
|
|
373
|
+
medium: "\x1b[33m", // yellow
|
|
374
|
+
low: "\x1b[36m", // cyan
|
|
375
|
+
info: "\x1b[32m", // green
|
|
376
|
+
unknown: "\x1b[90m", // gray
|
|
377
|
+
reset: "\x1b[0m",
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const ICONS = {
|
|
381
|
+
critical: "🔴",
|
|
382
|
+
high: "🟠",
|
|
383
|
+
medium: "🟡",
|
|
384
|
+
low: "🔵",
|
|
385
|
+
info: "🟢",
|
|
386
|
+
unknown: "⚪",
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
export function formatScanResults(results) {
|
|
390
|
+
const lines = [];
|
|
391
|
+
lines.push("");
|
|
392
|
+
lines.push("╔══════════════════════════════════════════════════════╗");
|
|
393
|
+
lines.push("║ FORGE — Trust Assumption Scan ║");
|
|
394
|
+
lines.push("╚══════════════════════════════════════════════════════╝");
|
|
395
|
+
lines.push("");
|
|
396
|
+
|
|
397
|
+
// Group by category
|
|
398
|
+
const grouped = {};
|
|
399
|
+
for (const check of results.checks) {
|
|
400
|
+
if (!grouped[check.category]) grouped[check.category] = [];
|
|
401
|
+
grouped[check.category].push(check);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
for (const [category, checks] of Object.entries(grouped)) {
|
|
405
|
+
lines.push(` ── ${category.toUpperCase()} ──`);
|
|
406
|
+
for (const c of checks) {
|
|
407
|
+
const icon = ICONS[c.level] || "⚪";
|
|
408
|
+
const color = COLORS[c.level] || "";
|
|
409
|
+
const reset = COLORS.reset;
|
|
410
|
+
lines.push(` ${icon} ${color}[${c.level.toUpperCase()}]${reset} ${c.finding}`);
|
|
411
|
+
if (c.recommendation) {
|
|
412
|
+
lines.push(` → ${c.recommendation}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
lines.push("");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Summary
|
|
419
|
+
const s = results.summary;
|
|
420
|
+
lines.push(" ── SUMMARY ──");
|
|
421
|
+
lines.push(` Total checks: ${s.total}`);
|
|
422
|
+
if (s.critical > 0) lines.push(` ${COLORS.critical}CRITICAL: ${s.critical}${COLORS.reset}`);
|
|
423
|
+
if (s.high > 0) lines.push(` ${COLORS.high}HIGH: ${s.high}${COLORS.reset}`);
|
|
424
|
+
if (s.medium > 0) lines.push(` ${COLORS.medium}MEDIUM: ${s.medium}${COLORS.reset}`);
|
|
425
|
+
if (s.low > 0) lines.push(` ${COLORS.low}LOW: ${s.low}${COLORS.reset}`);
|
|
426
|
+
lines.push(` ${COLORS.info}INFO: ${s.info}${COLORS.reset}`);
|
|
427
|
+
lines.push("");
|
|
428
|
+
|
|
429
|
+
if (s.critical > 0 || s.high > 0) {
|
|
430
|
+
lines.push(" ⚠️ Action required. Run 'forge log' to begin recording trust chain.");
|
|
431
|
+
} else {
|
|
432
|
+
lines.push(" ✓ No critical issues. Run 'forge log' to begin recording trust chain.");
|
|
433
|
+
}
|
|
434
|
+
lines.push("");
|
|
435
|
+
|
|
436
|
+
return lines.join("\n");
|
|
437
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store — SQLite persistence for TrustAtom chains.
|
|
3
|
+
*
|
|
4
|
+
* Atoms and blocks must survive process restarts.
|
|
5
|
+
* The store is the "self-witness" layer — the minimum persistence
|
|
6
|
+
* before bilateral or public witnesses are added.
|
|
7
|
+
*
|
|
8
|
+
* Uses Node 22 built-in node:sqlite (experimental) if available,
|
|
9
|
+
* otherwise falls back to JSON file storage.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { join, dirname } from "node:path";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_PATH = join(
|
|
16
|
+
process.env.HOME || "/tmp",
|
|
17
|
+
".forge",
|
|
18
|
+
"chain.json"
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export class Store {
|
|
22
|
+
constructor(path = DEFAULT_PATH) {
|
|
23
|
+
this.path = path;
|
|
24
|
+
this._ensure();
|
|
25
|
+
this._data = this._load();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* ---- Atoms ---- */
|
|
29
|
+
|
|
30
|
+
appendAtom(atom) {
|
|
31
|
+
const clean = { ...atom };
|
|
32
|
+
delete clean._raw; // Don't persist raw data
|
|
33
|
+
this._data.atoms.push(clean);
|
|
34
|
+
this._save();
|
|
35
|
+
return this._data.atoms.length - 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getAtoms(from = 0, to = Infinity) {
|
|
39
|
+
return this._data.atoms.slice(from, to);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getAtom(index) {
|
|
43
|
+
return this._data.atoms[index] || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get atomCount() {
|
|
47
|
+
return this._data.atoms.length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* ---- Blocks ---- */
|
|
51
|
+
|
|
52
|
+
appendBlock(block) {
|
|
53
|
+
const clean = { ...block };
|
|
54
|
+
delete clean.layers; // Don't persist full tree (can be rebuilt)
|
|
55
|
+
this._data.blocks.push(clean);
|
|
56
|
+
this._save();
|
|
57
|
+
return this._data.blocks.length - 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getBlocks() {
|
|
61
|
+
return this._data.blocks;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ---- Meta ---- */
|
|
65
|
+
|
|
66
|
+
setMeta(key, value) {
|
|
67
|
+
this._data.meta[key] = value;
|
|
68
|
+
this._save();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getMeta(key) {
|
|
72
|
+
return this._data.meta[key];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ---- Export / Import ---- */
|
|
76
|
+
|
|
77
|
+
exportAll() {
|
|
78
|
+
return { ...this._data, exported_at: Date.now() };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
importChain(data) {
|
|
82
|
+
if (data.atoms) this._data.atoms = data.atoms;
|
|
83
|
+
if (data.blocks) this._data.blocks = data.blocks;
|
|
84
|
+
if (data.meta) this._data.meta = { ...this._data.meta, ...data.meta };
|
|
85
|
+
this._save();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ---- Last atom proof (for chain continuation) ---- */
|
|
89
|
+
|
|
90
|
+
lastProof() {
|
|
91
|
+
if (this._data.atoms.length === 0) return "genesis";
|
|
92
|
+
return this._data.atoms[this._data.atoms.length - 1].proof;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ---- Internal ---- */
|
|
96
|
+
|
|
97
|
+
_ensure() {
|
|
98
|
+
const dir = dirname(this.path);
|
|
99
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_load() {
|
|
103
|
+
if (existsSync(this.path)) {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(readFileSync(this.path, "utf8"));
|
|
106
|
+
} catch {
|
|
107
|
+
return this._empty();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return this._empty();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_save() {
|
|
114
|
+
writeFileSync(this.path, JSON.stringify(this._data, null, 2));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_empty() {
|
|
118
|
+
return {
|
|
119
|
+
version: "0.1.0",
|
|
120
|
+
created_at: Date.now(),
|
|
121
|
+
atoms: [],
|
|
122
|
+
blocks: [],
|
|
123
|
+
meta: {},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ---- Reset (for testing) ---- */
|
|
128
|
+
|
|
129
|
+
reset() {
|
|
130
|
+
this._data = this._empty();
|
|
131
|
+
this._save();
|
|
132
|
+
}
|
|
133
|
+
}
|