syndes 0.1.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 +77 -0
- package/adapters/claude-code.mjs +59 -0
- package/adapters/codex.mjs +256 -0
- package/adapters/index.mjs +92 -0
- package/analytics/index.mjs +189 -0
- package/analytics/metrics/context.mjs +95 -0
- package/analytics/metrics/cost.mjs +83 -0
- package/analytics/metrics/friction.mjs +86 -0
- package/analytics/metrics/prompts.mjs +93 -0
- package/analytics/metrics/rework.mjs +113 -0
- package/analytics/metrics/time.mjs +104 -0
- package/analytics/metrics/tokens.mjs +88 -0
- package/analytics/metrics/tools.mjs +118 -0
- package/analytics/metrics/volume.mjs +98 -0
- package/analytics/ranges.mjs +98 -0
- package/analytics/rollup.mjs +151 -0
- package/analytics/score.mjs +194 -0
- package/bin/cli.mjs +596 -0
- package/bin/postinstall.mjs +44 -0
- package/collect/classify.mjs +226 -0
- package/collect/git.mjs +78 -0
- package/collect/projects.mjs +82 -0
- package/collect/redact.mjs +85 -0
- package/collect/sessions.mjs +119 -0
- package/collect/tail.mjs +126 -0
- package/collect/tools.mjs +121 -0
- package/collect/transcript.mjs +128 -0
- package/dashboard/api/index.mjs +296 -0
- package/dashboard/auth.mjs +235 -0
- package/dashboard/router.mjs +55 -0
- package/dashboard/security.mjs +95 -0
- package/dashboard/server.mjs +156 -0
- package/dashboard/static.mjs +47 -0
- package/dashboard/web/SynDes.icns +0 -0
- package/dashboard/web/api.js +80 -0
- package/dashboard/web/app.css +532 -0
- package/dashboard/web/app.js +261 -0
- package/dashboard/web/charts.js +273 -0
- package/dashboard/web/index.html +23 -0
- package/dashboard/web/logo.png +0 -0
- package/dashboard/web/ui.js +434 -0
- package/dashboard/web/views/habits.js +166 -0
- package/dashboard/web/views/ledger.js +164 -0
- package/dashboard/web/views/overview.js +214 -0
- package/dashboard/web/views/sessions.js +133 -0
- package/dashboard/web/views/settings.js +180 -0
- package/ledger/append.mjs +126 -0
- package/ledger/chain.mjs +53 -0
- package/ledger/keys.mjs +72 -0
- package/ledger/read.mjs +77 -0
- package/ledger/retention.mjs +104 -0
- package/ledger/schema.mjs +96 -0
- package/ledger/segments.mjs +109 -0
- package/ledger/verify.mjs +174 -0
- package/notify/index.mjs +67 -0
- package/notify/linux.mjs +41 -0
- package/notify/mac.mjs +44 -0
- package/notify/terminal.mjs +15 -0
- package/notify/windows.mjs +61 -0
- package/package.json +66 -0
- package/practices/budget.mjs +97 -0
- package/practices/catalog.mjs +64 -0
- package/practices/deliver.mjs +101 -0
- package/practices/engine.mjs +107 -0
- package/practices/rules/batch-tool-calls.mjs +15 -0
- package/practices/rules/context-hygiene.mjs +17 -0
- package/practices/rules/delegate-wide-search.mjs +15 -0
- package/practices/rules/index.mjs +28 -0
- package/practices/rules/permission-friction.mjs +16 -0
- package/practices/rules/project-memory.mjs +27 -0
- package/practices/rules/prompt-specificity.mjs +15 -0
- package/practices/rules/read-before-edit.mjs +16 -0
- package/practices/rules/retry-storm.mjs +22 -0
- package/practices/rules/session-sprawl.mjs +15 -0
- package/practices/rules/verify-after-change.mjs +16 -0
- package/runtime/config.mjs +116 -0
- package/runtime/hook.mjs +154 -0
- package/runtime/jsonl.mjs +104 -0
- package/runtime/lock.mjs +98 -0
- package/runtime/log.mjs +37 -0
- package/runtime/paths.mjs +116 -0
- package/runtime/platform.mjs +74 -0
- package/runtime/spool.mjs +92 -0
- package/runtime/worker.mjs +275 -0
- package/src/briefing.mjs +94 -0
- package/src/doctor.mjs +153 -0
- package/src/export.mjs +68 -0
- package/src/install.mjs +95 -0
- package/src/open.mjs +23 -0
- package/src/report.mjs +120 -0
- package/src/settings.mjs +173 -0
- package/src/status.mjs +61 -0
- package/src/systemauth.mjs +179 -0
- package/src/term.mjs +272 -0
- package/src/uninstall.mjs +43 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings — a page, laid out in two columns of grouped cards.
|
|
3
|
+
*
|
|
4
|
+
* Only keys the server's allowlist accepts are rendered. Showing a control the
|
|
5
|
+
* API would reject is worse than not showing it: the user changes it, the write
|
|
6
|
+
* fails, and they have no idea which half lied to them.
|
|
7
|
+
*
|
|
8
|
+
* Destructive operations are deliberately absent, with the reason stated on the
|
|
9
|
+
* page rather than left as a mystery.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { api } from '../api.js';
|
|
13
|
+
import { h, card, replace, kv, modal } from '../ui.js';
|
|
14
|
+
|
|
15
|
+
export async function render() {
|
|
16
|
+
const page = h('div.settings');
|
|
17
|
+
await paint(page);
|
|
18
|
+
return h('div.content--fit', { style: 'display:grid;min-height:0;overflow-y:auto;padding-right:4px' }, [page]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function paint(page) {
|
|
22
|
+
const data = await api.config();
|
|
23
|
+
const config = data.config;
|
|
24
|
+
|
|
25
|
+
const save = async (key, value) => {
|
|
26
|
+
await api.saveConfig({ [key]: value });
|
|
27
|
+
await paint(page);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const setLock = async (mode) => {
|
|
31
|
+
let pin;
|
|
32
|
+
if (mode === 'pin') {
|
|
33
|
+
pin = await modal({
|
|
34
|
+
title: 'Choose a PIN',
|
|
35
|
+
note: 'Four to twelve digits. You will be asked for it each time the dashboard opens.',
|
|
36
|
+
confirmLabel: 'Set PIN',
|
|
37
|
+
input: {
|
|
38
|
+
type: 'password',
|
|
39
|
+
inputmode: 'numeric',
|
|
40
|
+
maxlength: 12,
|
|
41
|
+
placeholder: '••••',
|
|
42
|
+
validate: (value) => (/^\d{4,12}$/.test(value) ? null : 'Four to twelve digits, numbers only.'),
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
if (pin === null) return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await api.setAuth(mode, pin);
|
|
50
|
+
window.location.reload();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
await modal({
|
|
53
|
+
title: 'Could not change the lock',
|
|
54
|
+
note: error.body?.error ?? 'Something went wrong.',
|
|
55
|
+
confirmLabel: 'Close',
|
|
56
|
+
cancelLabel: 'Dismiss',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
replace(page,
|
|
62
|
+
card('How the dashboard unlocks', [
|
|
63
|
+
options([
|
|
64
|
+
{ id: 'open', name: 'Open', desc: 'No prompt. The link from the command line carries a one-time key.' },
|
|
65
|
+
{
|
|
66
|
+
id: 'system',
|
|
67
|
+
name: 'System',
|
|
68
|
+
desc: data.auth.system.available
|
|
69
|
+
? `Ask ${data.auth.system.method}, falling back to your login password.`
|
|
70
|
+
: data.auth.system.reason,
|
|
71
|
+
disabled: !data.auth.system.available,
|
|
72
|
+
},
|
|
73
|
+
{ id: 'pin', name: 'PIN', desc: 'Ask for a short PIN you choose.' },
|
|
74
|
+
], data.auth.mode, setLock),
|
|
75
|
+
note('Changing this signs out every open dashboard session.'),
|
|
76
|
+
]),
|
|
77
|
+
|
|
78
|
+
card('Privacy', [
|
|
79
|
+
options([
|
|
80
|
+
{ id: 'redacted', name: 'Redacted', desc: 'Scrub secrets, keep the text. The default.' },
|
|
81
|
+
{ id: 'full', name: 'Full', desc: 'Store prompts and paths exactly as written.' },
|
|
82
|
+
{ id: 'metadata', name: 'Metadata', desc: 'Lengths and counts only. No text at all.' },
|
|
83
|
+
], config.privacy, (value) => save('privacy', value)),
|
|
84
|
+
note('Redaction runs before anything is written. An append-only ledger has no take-backs, so this cannot be applied retroactively.'),
|
|
85
|
+
]),
|
|
86
|
+
|
|
87
|
+
card('What is recorded', [
|
|
88
|
+
toggle('Tool calls', config.track.tools, (v) => save('track.tools', v), 'Most of the score is built from these.'),
|
|
89
|
+
toggle('Prompts', config.track.prompts, (v) => save('track.prompts', v)),
|
|
90
|
+
toggle('Permission requests', config.track.permissions, (v) => save('track.permissions', v)),
|
|
91
|
+
toggle('Tokens and cost', config.track.transcript, (v) => save('track.transcript', v), 'Read incrementally from the session transcript.'),
|
|
92
|
+
toggle('Git commits', config.track.git, (v) => save('track.git', v)),
|
|
93
|
+
]),
|
|
94
|
+
|
|
95
|
+
card('Coaching', [
|
|
96
|
+
toggle('Enabled', config.coach.enabled, (v) => save('coach.enabled', v)),
|
|
97
|
+
toggle('Desktop notification', config.coach.channels.notification, (v) => save('coach.channels.notification', v)),
|
|
98
|
+
toggle('Card at session start', config.coach.channels.terminal, (v) => save('coach.channels.terminal', v)),
|
|
99
|
+
number('Most nudges per day', config.coach.maxPerDay, 1, 10, (v) => save('coach.maxPerDay', v)),
|
|
100
|
+
number('Cooldown per rule (hours)', config.coach.cooldownHours, 1, 168, (v) => save('coach.cooldownHours', v)),
|
|
101
|
+
note('Three dismissals mute a rule for good.'),
|
|
102
|
+
]),
|
|
103
|
+
|
|
104
|
+
card('Storage', [
|
|
105
|
+
number('Keep records for (days)', config.retention.days, 0, 3650, (v) => save('retention.days', v), '0 means forever.'),
|
|
106
|
+
number('Compress segments after (days)', config.retention.gzipAfterDays, 0, 3650, (v) => save('retention.gzipAfterDays', v)),
|
|
107
|
+
note('Pruning appends a record naming what it removed before deleting anything, so the chain stays honest about its own gap.'),
|
|
108
|
+
h('div.kv', { style: 'margin-top:8px' }, [
|
|
109
|
+
kv('Location', data.paths.data),
|
|
110
|
+
kv('Hooks wired', `${data.hooks.count} events`),
|
|
111
|
+
kv('Notifications', data.notifications.available ? data.notifications.channel : 'unavailable here'),
|
|
112
|
+
kv('Unprocessed', String(data.spoolPending)),
|
|
113
|
+
]),
|
|
114
|
+
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;margin-top:4px' }, [
|
|
115
|
+
h('a.btn.btn--sm', { href: api.exportUrl('csv', 'all'), text: 'Export CSV' }),
|
|
116
|
+
h('a.btn.btn--sm', { href: api.exportUrl('json', 'all'), text: 'Export JSON' }),
|
|
117
|
+
h('a.btn.btn--sm', { href: api.exportUrl('ndjson', 'all'), text: 'Export NDJSON' }),
|
|
118
|
+
]),
|
|
119
|
+
]),
|
|
120
|
+
|
|
121
|
+
card('Only in the terminal', [
|
|
122
|
+
h('div.kv', {}, [
|
|
123
|
+
kv('Uninstall', 'syndes uninstall'),
|
|
124
|
+
kv('Delete all history', 'syndes uninstall --purge'),
|
|
125
|
+
kv('Rebuild metrics', 'syndes rebuild'),
|
|
126
|
+
kv('Check the chain', 'syndes verify --full'),
|
|
127
|
+
]),
|
|
128
|
+
note('A web page that can delete your history is a web page that can be tricked into deleting it. These stay where they need a person at a keyboard.'),
|
|
129
|
+
]),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Controls ───────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
function options(list, current, onPick) {
|
|
136
|
+
return h('div', { style: 'display:grid;gap:8px' }, list.map((option) =>
|
|
137
|
+
h('button.optioncard', {
|
|
138
|
+
'aria-pressed': String(option.id === current),
|
|
139
|
+
disabled: option.disabled === true,
|
|
140
|
+
onclick: () => option.id !== current && !option.disabled && onPick(option.id),
|
|
141
|
+
}, [
|
|
142
|
+
h('div', {}, [
|
|
143
|
+
h('div.optioncard__name', { text: option.name }),
|
|
144
|
+
h('div.optioncard__desc', { text: option.desc }),
|
|
145
|
+
]),
|
|
146
|
+
option.id === current ? h('span', { style: 'margin-left:auto;font-weight:700', text: '✓' }) : null,
|
|
147
|
+
])));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function row(label, help, control) {
|
|
151
|
+
return h('div.setrow', {}, [
|
|
152
|
+
h('div', {}, [
|
|
153
|
+
h('div.setrow__label', { text: label }),
|
|
154
|
+
help ? h('div.setrow__help', { text: help }) : null,
|
|
155
|
+
]),
|
|
156
|
+
h('div.setrow__control', {}, [control]),
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function toggle(label, value, onChange, help = null) {
|
|
161
|
+
return row(label, help, h('button.btn.btn--sm', {
|
|
162
|
+
class: value ? 'btn--lime' : '',
|
|
163
|
+
style: 'min-width:58px;justify-content:center',
|
|
164
|
+
'aria-pressed': String(Boolean(value)),
|
|
165
|
+
text: value ? 'On' : 'Off',
|
|
166
|
+
onclick: () => onChange(!value),
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function number(label, value, min, max, onChange, help = null) {
|
|
171
|
+
return row(label, help, h('input.btn.btn--sm', {
|
|
172
|
+
type: 'number', value: String(value), min: String(min), max: String(max),
|
|
173
|
+
style: 'width:80px;text-align:right',
|
|
174
|
+
onchange: (event) => onChange(Number(event.target.value)),
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function note(text) {
|
|
179
|
+
return h('p', { style: 'font-size:11px;color:var(--ink-3);line-height:1.6;margin:0', text });
|
|
180
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only writer in the system. Called by the worker, under the lock.
|
|
3
|
+
*
|
|
4
|
+
* There is no update path and no delete path, because a ledger with an update
|
|
5
|
+
* path is a database. The single repair this module performs is recovering its
|
|
6
|
+
* own tip after a crash, and it does that by reading the chain rather than by
|
|
7
|
+
* trusting the cache.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { appendFileSync, writeFileSync, renameSync, existsSync } from 'node:fs';
|
|
11
|
+
import { ensureDataDirs, tipFile, segmentFile } from '../runtime/paths.mjs';
|
|
12
|
+
import { lastRecordOf } from '../runtime/jsonl.mjs';
|
|
13
|
+
import { readJson } from '../runtime/config.mjs';
|
|
14
|
+
import { ensureKey } from './keys.mjs';
|
|
15
|
+
import { link } from './chain.mjs';
|
|
16
|
+
import { draft, KIND } from './schema.mjs';
|
|
17
|
+
import { dayOf, listDays, isSealed, computeSeal, writeSeal, fileForDay } from './segments.mjs';
|
|
18
|
+
import { debug } from '../runtime/log.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The chain tip, recovered from the chain itself rather than from the cache.
|
|
22
|
+
*
|
|
23
|
+
* state/tip.json is only a hint. If a worker was killed between writing records
|
|
24
|
+
* and writing the tip, the cache is behind — and reusing those seq numbers would
|
|
25
|
+
* fork the chain, which is the one corruption this design must not permit. So
|
|
26
|
+
* the newest segment's last record always wins.
|
|
27
|
+
*
|
|
28
|
+
* @returns {{seq: number, hash: string, day: string, torn: boolean}|null}
|
|
29
|
+
*/
|
|
30
|
+
export function loadTip() {
|
|
31
|
+
const cached = readJson(tipFile).data;
|
|
32
|
+
const days = listDays();
|
|
33
|
+
const newest = days[days.length - 1];
|
|
34
|
+
|
|
35
|
+
if (!newest) return cached ?? null;
|
|
36
|
+
|
|
37
|
+
const file = fileForDay(newest);
|
|
38
|
+
const { record, torn } = file ? lastRecordOf(file) : { record: null, torn: false };
|
|
39
|
+
if (!record || typeof record.seq !== 'number') return cached ?? null;
|
|
40
|
+
|
|
41
|
+
if (cached && cached.seq > record.seq) {
|
|
42
|
+
// The cache claims records the chain does not contain. Trust the chain.
|
|
43
|
+
debug('tip cache ahead of chain', cached.seq, '>', record.seq);
|
|
44
|
+
}
|
|
45
|
+
return { seq: record.seq, hash: record.hash, day: newest, torn };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeTip(tip) {
|
|
49
|
+
const staging = `${tipFile}.tmp`;
|
|
50
|
+
writeFileSync(staging, `${JSON.stringify(tip)}\n`);
|
|
51
|
+
renameSync(staging, tipFile); // atomic: never a half-written tip
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Append drafts to the chain, in the order given.
|
|
56
|
+
*
|
|
57
|
+
* @param {object[]} drafts from schema.draft()
|
|
58
|
+
* @returns {Promise<{written: number, from: number|null, to: number|null, tip: object|null}>}
|
|
59
|
+
*/
|
|
60
|
+
export async function append(drafts) {
|
|
61
|
+
if (!drafts.length) return { written: 0, from: null, to: null, tip: loadTip() };
|
|
62
|
+
|
|
63
|
+
ensureDataDirs();
|
|
64
|
+
const { key, keyId } = ensureKey();
|
|
65
|
+
let tip = loadTip();
|
|
66
|
+
|
|
67
|
+
const queue = [...drafts];
|
|
68
|
+
if (!tip && !existsSync(segmentFile(dayOf(queue[0].ts)))) {
|
|
69
|
+
queue.unshift(draft(KIND.GENESIS, {
|
|
70
|
+
ts: queue[0].ts,
|
|
71
|
+
data: { started: new Date(queue[0].ts).toISOString() },
|
|
72
|
+
key: keyId,
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let written = 0;
|
|
77
|
+
let from = null;
|
|
78
|
+
|
|
79
|
+
for (const run of groupByDay(queue)) {
|
|
80
|
+
// A day change closes the previous segment. The seal record is chained into
|
|
81
|
+
// the NEW day, so the closed day stays byte-identical to what was sealed.
|
|
82
|
+
if (tip?.day && tip.day !== run.day && !isSealed(tip.day)) {
|
|
83
|
+
const seal = await computeSeal(tip.day);
|
|
84
|
+
if (seal) {
|
|
85
|
+
const sealed = link(key, tip, draft(KIND.SEAL, { ts: run.records[0].ts, data: seal }));
|
|
86
|
+
appendFileSync(segmentFile(run.day), `${JSON.stringify(sealed)}\n`);
|
|
87
|
+
writeSeal(seal); // only after the record is durable
|
|
88
|
+
tip = { seq: sealed.seq, hash: sealed.hash, day: run.day };
|
|
89
|
+
written += 1;
|
|
90
|
+
if (from === null) from = sealed.seq;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const lines = [];
|
|
95
|
+
for (const record of run.records) {
|
|
96
|
+
const linked = link(key, tip, record);
|
|
97
|
+
lines.push(JSON.stringify(linked));
|
|
98
|
+
tip = { seq: linked.seq, hash: linked.hash, day: run.day };
|
|
99
|
+
if (from === null) from = linked.seq;
|
|
100
|
+
written += 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// One write per day-run: fewer syscalls, and O_APPEND keeps it a single
|
|
104
|
+
// atomic extend on POSIX. Only this process writes here, under the lock.
|
|
105
|
+
appendFileSync(segmentFile(run.day), `${lines.join('\n')}\n`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
writeTip(tip);
|
|
109
|
+
return { written, from, to: tip.seq, tip };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Convenience for callers with a single record. */
|
|
113
|
+
export async function appendOne(record) {
|
|
114
|
+
return append([record]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function groupByDay(records) {
|
|
118
|
+
const runs = [];
|
|
119
|
+
for (const record of records) {
|
|
120
|
+
const day = dayOf(record.ts);
|
|
121
|
+
const current = runs[runs.length - 1];
|
|
122
|
+
if (current && current.day === day) current.records.push(record);
|
|
123
|
+
else runs.push({ day, records: [record] });
|
|
124
|
+
}
|
|
125
|
+
return runs;
|
|
126
|
+
}
|
package/ledger/chain.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hash chain. hash = HMAC-SHA256(key, prev || "\n" || canonical(hashable)).
|
|
3
|
+
*
|
|
4
|
+
* The genesis record's `prev` is the zero digest. Every later record's `prev` is
|
|
5
|
+
* its predecessor's `hash`, and the chain continues ACROSS daily segments — the
|
|
6
|
+
* first record of a day carries the last hash of the previous day, so a deleted
|
|
7
|
+
* day is a visible break rather than a clean seam.
|
|
8
|
+
*
|
|
9
|
+
* This is what "immutable" reduces to in practice: any edit, deletion or
|
|
10
|
+
* back-date makes every subsequent hash wrong, and verify() names the exact seq
|
|
11
|
+
* where it stopped adding up.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHmac, createHash, timingSafeEqual } from 'node:crypto';
|
|
15
|
+
import { canonical } from '../runtime/jsonl.mjs';
|
|
16
|
+
import { hashable } from './schema.mjs';
|
|
17
|
+
|
|
18
|
+
export const ZERO = '0'.repeat(64);
|
|
19
|
+
|
|
20
|
+
export function computeHash(key, prev, record) {
|
|
21
|
+
return createHmac('sha256', key)
|
|
22
|
+
.update(prev)
|
|
23
|
+
.update('\n')
|
|
24
|
+
.update(canonical(hashable(record)))
|
|
25
|
+
.digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Seal a draft into the chain. Returns a new object; the draft is not mutated. */
|
|
29
|
+
export function link(key, tip, drafted) {
|
|
30
|
+
const prev = tip?.hash ?? ZERO;
|
|
31
|
+
const seq = (tip?.seq ?? -1) + 1;
|
|
32
|
+
const record = { ...drafted, seq, prev };
|
|
33
|
+
return { ...record, hash: computeHash(key, prev, record) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Constant-time compare, because a hash check that leaks timing is theatre. */
|
|
37
|
+
export function hashMatches(a, b) {
|
|
38
|
+
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
|
|
39
|
+
return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A segment root: the digest of every record hash in it, in order.
|
|
44
|
+
*
|
|
45
|
+
* Cheaper than a Merkle tree and sufficient for the claim being made — the seal
|
|
46
|
+
* proves the whole segment at once, and per-record proofs come from the chain
|
|
47
|
+
* itself, which every record already carries.
|
|
48
|
+
*/
|
|
49
|
+
export function segmentRoot(hashes) {
|
|
50
|
+
const digest = createHash('sha256');
|
|
51
|
+
for (const hash of hashes) digest.update(hash);
|
|
52
|
+
return digest.digest('hex');
|
|
53
|
+
}
|
package/ledger/keys.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chain key: 32 random bytes at keys/chain.key, mode 0600, made at install.
|
|
3
|
+
*
|
|
4
|
+
* HMAC rather than a bare SHA-256 digest, so forging a plausible chain requires
|
|
5
|
+
* reading the key and not merely owning a hash function everybody has.
|
|
6
|
+
*
|
|
7
|
+
* Keys are identified by a truncated digest of themselves and recorded at
|
|
8
|
+
* genesis and at every rotation, so verification checks each stretch of the
|
|
9
|
+
* chain against the key it was actually written with.
|
|
10
|
+
*
|
|
11
|
+
* On Windows the 0600 is a no-op. `doctor` says so plainly rather than printing
|
|
12
|
+
* a green check it did not earn.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { keysDir, chainKeyFile } from '../runtime/paths.mjs';
|
|
19
|
+
|
|
20
|
+
export function keyIdOf(key) {
|
|
21
|
+
return createHash('sha256').update(key).digest('hex').slice(0, 16);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Create the key if it does not exist. @returns {{key: Buffer, keyId: string, created: boolean}} */
|
|
25
|
+
export function ensureKey() {
|
|
26
|
+
mkdirSync(keysDir, { recursive: true, mode: 0o700 });
|
|
27
|
+
|
|
28
|
+
if (existsSync(chainKeyFile)) {
|
|
29
|
+
const key = readFileSync(chainKeyFile);
|
|
30
|
+
return { key, keyId: keyIdOf(key), created: false };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const key = randomBytes(32);
|
|
34
|
+
writeFileSync(chainKeyFile, key, { mode: 0o600 });
|
|
35
|
+
const keyId = keyIdOf(key);
|
|
36
|
+
writeFileSync(join(keysDir, `${keyId}.key`), key, { mode: 0o600 });
|
|
37
|
+
return { key, keyId, created: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadKey() {
|
|
41
|
+
if (!existsSync(chainKeyFile)) return null;
|
|
42
|
+
const key = readFileSync(chainKeyFile);
|
|
43
|
+
return { key, keyId: keyIdOf(key) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The key a given stretch of chain was written with.
|
|
48
|
+
*
|
|
49
|
+
* Archived under its own id at mint time, so a rotation never invalidates
|
|
50
|
+
* history: old records stay verifiable against the key that signed them.
|
|
51
|
+
*/
|
|
52
|
+
export function loadKeyById(keyId) {
|
|
53
|
+
const archived = join(keysDir, `${keyId}.key`);
|
|
54
|
+
if (existsSync(archived)) return readFileSync(archived);
|
|
55
|
+
|
|
56
|
+
const current = loadKey();
|
|
57
|
+
return current && current.keyId === keyId ? current.key : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Mint a new current key, keeping the old one readable. @returns {{keyId, previousKeyId}} */
|
|
61
|
+
export function rotateKey() {
|
|
62
|
+
const previous = loadKey();
|
|
63
|
+
const key = randomBytes(32);
|
|
64
|
+
const keyId = keyIdOf(key);
|
|
65
|
+
|
|
66
|
+
writeFileSync(join(keysDir, `${keyId}.key`), key, { mode: 0o600 });
|
|
67
|
+
const staging = `${chainKeyFile}.new`;
|
|
68
|
+
writeFileSync(staging, key, { mode: 0o600 });
|
|
69
|
+
renameSync(staging, chainKeyFile); // atomic swap; never a window with no key
|
|
70
|
+
|
|
71
|
+
return { keyId, previousKeyId: previous?.keyId ?? null };
|
|
72
|
+
}
|
package/ledger/read.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming reads across segments, plain or gzipped.
|
|
3
|
+
*
|
|
4
|
+
* Everything is an async iterator. A heavy year of use is hundreds of megabytes
|
|
5
|
+
* and the dashboard must not need it resident to answer "how was last Tuesday".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { listDays, fileForDay, dayOf } from './segments.mjs';
|
|
9
|
+
import { streamRecords, lastRecordOf } from '../runtime/jsonl.mjs';
|
|
10
|
+
|
|
11
|
+
/** Every record, oldest first. */
|
|
12
|
+
export async function* iterate() {
|
|
13
|
+
for (const day of listDays()) {
|
|
14
|
+
const file = fileForDay(day);
|
|
15
|
+
if (!file) continue;
|
|
16
|
+
yield* streamRecords(file);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Records with `from <= ts < to`.
|
|
22
|
+
*
|
|
23
|
+
* Segments are UTC and callers ask in local time, so the day window is widened
|
|
24
|
+
* by one on each side and the precise bound is applied per record.
|
|
25
|
+
*/
|
|
26
|
+
export async function* readRange(from, to) {
|
|
27
|
+
const firstDay = dayOf(from - 86_400_000);
|
|
28
|
+
const lastDay = dayOf(to + 86_400_000);
|
|
29
|
+
|
|
30
|
+
for (const day of listDays()) {
|
|
31
|
+
if (day < firstDay || day > lastDay) continue;
|
|
32
|
+
const file = fileForDay(day);
|
|
33
|
+
if (!file) continue;
|
|
34
|
+
for await (const record of streamRecords(file)) {
|
|
35
|
+
if (record.ts >= from && record.ts < to) yield record;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function* readDay(day) {
|
|
41
|
+
const file = fileForDay(day);
|
|
42
|
+
if (file) yield* streamRecords(file);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function readSession(sessionId) {
|
|
46
|
+
const out = [];
|
|
47
|
+
for await (const record of iterate()) {
|
|
48
|
+
if (record.session === sessionId) out.push(record);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The last n records. Walks segments backwards so a huge chain costs one file. */
|
|
54
|
+
export async function tail(n = 50) {
|
|
55
|
+
const days = listDays().reverse();
|
|
56
|
+
const out = [];
|
|
57
|
+
|
|
58
|
+
for (const day of days) {
|
|
59
|
+
const file = fileForDay(day);
|
|
60
|
+
if (!file) continue;
|
|
61
|
+
const dayRecords = [];
|
|
62
|
+
for await (const record of streamRecords(file)) dayRecords.push(record);
|
|
63
|
+
out.unshift(...dayRecords.slice(Math.max(0, dayRecords.length - (n - out.length))));
|
|
64
|
+
if (out.length >= n) break;
|
|
65
|
+
}
|
|
66
|
+
return out.slice(-n);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Height and tip without reading the whole chain. */
|
|
70
|
+
export function height() {
|
|
71
|
+
const days = listDays();
|
|
72
|
+
const newest = days[days.length - 1];
|
|
73
|
+
if (!newest) return { seq: -1, hash: null, days: 0 };
|
|
74
|
+
const file = fileForDay(newest);
|
|
75
|
+
const { record } = file ? lastRecordOf(file) : { record: null };
|
|
76
|
+
return { seq: record?.seq ?? -1, hash: record?.hash ?? null, days: days.length };
|
|
77
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retention, without lying about it.
|
|
3
|
+
*
|
|
4
|
+
* Old segments may be gzipped, and may eventually be pruned — but pruning first
|
|
5
|
+
* appends a ledger.prune record naming the segments, their seq ranges and their
|
|
6
|
+
* seal roots, and only then removes the files. The chain therefore stays honest
|
|
7
|
+
* about its own gap, and verify() can tell "the user chose to drop 2024" from
|
|
8
|
+
* "someone deleted 2024".
|
|
9
|
+
*
|
|
10
|
+
* Default retention is forever. Deleting a user's history is opt-in.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createReadStream, createWriteStream, unlinkSync, existsSync, statSync } from 'node:fs';
|
|
14
|
+
import { createGzip } from 'node:zlib';
|
|
15
|
+
import { pipeline } from 'node:stream/promises';
|
|
16
|
+
import { listDays, fileForDay, isSealed, readSeal, segmentFile } from './segments.mjs';
|
|
17
|
+
import { append } from './append.mjs';
|
|
18
|
+
import { draft, KIND } from './schema.mjs';
|
|
19
|
+
import { loadConfig } from '../runtime/config.mjs';
|
|
20
|
+
import { debug } from '../runtime/log.mjs';
|
|
21
|
+
|
|
22
|
+
const DAY_MS = 86_400_000;
|
|
23
|
+
|
|
24
|
+
/** Compress sealed segments older than the threshold. Reads stay transparent. */
|
|
25
|
+
export async function archive({ olderThanDays } = {}) {
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
const cutoffDays = olderThanDays ?? config.retention.gzipAfterDays;
|
|
28
|
+
if (!cutoffDays) return { archived: [] };
|
|
29
|
+
|
|
30
|
+
const cutoff = dayString(Date.now() - cutoffDays * DAY_MS);
|
|
31
|
+
const archived = [];
|
|
32
|
+
|
|
33
|
+
for (const day of listDays()) {
|
|
34
|
+
if (day >= cutoff) continue;
|
|
35
|
+
const plain = segmentFile(day);
|
|
36
|
+
if (!existsSync(plain) || !isSealed(day)) continue; // never compress an open day
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await pipeline(createReadStream(plain), createGzip({ level: 9 }), createWriteStream(`${plain}.gz`));
|
|
40
|
+
unlinkSync(plain);
|
|
41
|
+
archived.push(day);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
debug('archive failed', day, error.message);
|
|
44
|
+
if (existsSync(`${plain}.gz`)) { try { unlinkSync(`${plain}.gz`); } catch { /* leave it */ } }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { archived };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Delete segments older than the retention window — declaring it first.
|
|
52
|
+
*
|
|
53
|
+
* The prune record is appended and durable before a single byte is removed. If
|
|
54
|
+
* this process dies between the two, the chain over-declares a prune that did
|
|
55
|
+
* not happen, which verify reads as "nothing missing". Over-declaring is the
|
|
56
|
+
* safe direction; the other order would produce an unexplained hole.
|
|
57
|
+
*/
|
|
58
|
+
export async function prune({ olderThanDays, dryRun = false } = {}) {
|
|
59
|
+
const config = loadConfig();
|
|
60
|
+
const days = olderThanDays ?? config.retention.days;
|
|
61
|
+
if (!days) return { pruned: [], declared: false, reason: 'retention is set to forever' };
|
|
62
|
+
|
|
63
|
+
const cutoff = dayString(Date.now() - days * DAY_MS);
|
|
64
|
+
const doomed = listDays().filter((day) => day < cutoff && isSealed(day));
|
|
65
|
+
if (!doomed.length) return { pruned: [], declared: false, reason: 'nothing old enough' };
|
|
66
|
+
|
|
67
|
+
const ranges = [];
|
|
68
|
+
const roots = [];
|
|
69
|
+
for (const day of doomed) {
|
|
70
|
+
const seal = readSeal(day);
|
|
71
|
+
if (!seal) continue;
|
|
72
|
+
ranges.push([seal.first_seq, seal.last_seq]);
|
|
73
|
+
roots.push({ day, root: seal.root, count: seal.count });
|
|
74
|
+
}
|
|
75
|
+
if (dryRun) return { pruned: doomed, declared: false, ranges, roots, dryRun: true };
|
|
76
|
+
|
|
77
|
+
await append([draft(KIND.PRUNE, {
|
|
78
|
+
data: { days: doomed, ranges, roots, retentionDays: days, reason: 'retention' },
|
|
79
|
+
})]);
|
|
80
|
+
|
|
81
|
+
const removed = [];
|
|
82
|
+
for (const day of doomed) {
|
|
83
|
+
const file = fileForDay(day);
|
|
84
|
+
if (!file) continue;
|
|
85
|
+
try { unlinkSync(file); removed.push(day); } catch (error) { debug('prune failed', day, error.message); }
|
|
86
|
+
}
|
|
87
|
+
return { pruned: removed, declared: true, ranges, roots };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Bytes on disk, for `status` and the dashboard. */
|
|
91
|
+
export function usage() {
|
|
92
|
+
let bytes = 0;
|
|
93
|
+
let segments = 0;
|
|
94
|
+
for (const day of listDays()) {
|
|
95
|
+
const file = fileForDay(day);
|
|
96
|
+
if (!file) continue;
|
|
97
|
+
try { bytes += statSync(file).size; segments += 1; } catch { /* raced with archive */ }
|
|
98
|
+
}
|
|
99
|
+
return { bytes, segments };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function dayString(ms) {
|
|
103
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
104
|
+
}
|