dsh-loci 2.0.0 → 2.0.2

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.
@@ -1,208 +1,208 @@
1
- /**
2
- * The append-only audit trail (`audit.jsonl`).
3
- *
4
- * Every injected section, recall, write, trust change, deletion, and
5
- * maintenance pass lands here with its byte count, so "what did this request
6
- * cost" and "who removed that fact" are answerable from one file. Bodies and
7
- * queries are never written: ids, lengths, and counts are enough to audit and
8
- * cannot leak the content they describe.
9
- * @module dsh-loci/store/audit
10
- */
11
- import {
12
- appendFileSync,
13
- closeSync,
14
- existsSync,
15
- fstatSync,
16
- openSync,
17
- readSync,
18
- renameSync,
19
- rmSync,
20
- statSync,
21
- } from 'node:fs';
22
- import { scanSecrets } from '../inject/sanitize.js';
23
-
24
- /** Events that must survive a crash immediately; everything else is buffered. */
25
- const SYNC_EVENTS = new Set(['write', 'delete', 'verify', 'import', 'migrate', 'error', 'maintenance']);
26
-
27
- /** How many recent events the in-memory ring keeps for the panel. */
28
- const RING_SIZE = 500;
29
-
30
- /**
31
- * Derive the rotated file name for one generation.
32
- * @param file - live audit path.
33
- * @param generation - 1-based generation.
34
- * @returns the rotated path.
35
- */
36
- function rotatedPath(file, generation) {
37
- return `${file}.${generation}`;
38
- }
39
-
40
- /**
41
- * JSONL audit writer with size rotation and a small in-memory tail.
42
- */
43
- export class AuditLog {
44
- /** @type {string} */
45
- file;
46
- /** @type {number} */
47
- maxBytes;
48
- /** @type {number} */
49
- keep;
50
- /** @type {object[]} */
51
- ring = [];
52
- /** @type {object[]} */
53
- buffer = [];
54
- /** @type {NodeJS.Timeout | undefined} */
55
- timer;
56
- closed = false;
57
-
58
- /**
59
- * @param options - `file`, `maxBytes`, `keep`, `log`.
60
- */
61
- constructor(options) {
62
- this.file = options.file;
63
- this.maxBytes = options.maxBytes ?? 8 * 1024 * 1024;
64
- this.keep = options.keep ?? 5;
65
- this.log = options.log;
66
- }
67
-
68
- /**
69
- * Record one event.
70
- * @param event - event name (`inject`, `recall`, `write`, ...).
71
- * @param detail - small JSON-safe payload; never include body text.
72
- * @param options - `sync` forces an immediate flush.
73
- */
74
- append(event, detail = {}, options = {}) {
75
- if (this.closed) return;
76
- const line = { ts: Date.now(), event, ...sanitizeDetail(detail) };
77
- this.buffer.push(line);
78
- this.ring.push(line);
79
- if (this.ring.length > RING_SIZE) this.ring.splice(0, this.ring.length - RING_SIZE);
80
- if (options.sync === true || SYNC_EVENTS.has(event)) this.flush();
81
- else this.scheduleFlush();
82
- }
83
-
84
- /** Schedule a flush soon, keeping the timer unref'd so it never holds the process open. */
85
- scheduleFlush() {
86
- if (this.timer !== undefined) return;
87
- this.timer = setTimeout(() => {
88
- this.timer = undefined;
89
- this.flush();
90
- }, 300);
91
- this.timer.unref?.();
92
- }
93
-
94
- /** Write buffered lines and rotate when the file grew past the limit. */
95
- flush() {
96
- if (this.buffer.length === 0) return;
97
- const text = `${this.buffer.map((line) => JSON.stringify(line)).join('\n')}\n`;
98
- this.buffer = [];
99
- try {
100
- appendFileSync(this.file, text, 'utf8');
101
- this.rotateIfNeeded();
102
- } catch (error) {
103
- this.log?.warn?.(`loci: audit write failed: ${String(error?.message ?? error)}`);
104
- }
105
- }
106
-
107
- /** Rotate `audit.jsonl` to `.1`, shifting older generations. */
108
- rotateIfNeeded() {
109
- let size = 0;
110
- try {
111
- size = statSync(this.file).size;
112
- } catch {
113
- return;
114
- }
115
- if (size < this.maxBytes) return;
116
- try {
117
- const oldest = rotatedPath(this.file, this.keep);
118
- if (existsSync(oldest)) rmSync(oldest, { force: true });
119
- for (let generation = this.keep - 1; generation >= 1; generation -= 1) {
120
- const from = rotatedPath(this.file, generation);
121
- if (existsSync(from)) renameSync(from, rotatedPath(this.file, generation + 1));
122
- }
123
- renameSync(this.file, rotatedPath(this.file, 1));
124
- } catch (error) {
125
- this.log?.warn?.(`loci: audit rotation failed: ${String(error?.message ?? error)}`);
126
- }
127
- }
128
-
129
- /**
130
- * The newest events, from the ring when it is warm and from the file tail
131
- * otherwise.
132
- * @param limit - how many events to return, newest last.
133
- * @returns parsed events, oldest first.
134
- */
135
- tail(limit = 100) {
136
- if (this.ring.length >= limit) return this.ring.slice(-limit);
137
- return readAuditTail(this.file, limit);
138
- }
139
-
140
- /** Flush and stop the timer. */
141
- close() {
142
- this.flush();
143
- this.closed = true;
144
- if (this.timer !== undefined) {
145
- clearTimeout(this.timer);
146
- this.timer = undefined;
147
- }
148
- }
149
- }
150
-
151
- /**
152
- * Read the last `limit` JSON lines of a file without loading all of it.
153
- * Malformed lines (a process killed mid-append) are skipped and counted.
154
- * @param file - audit path.
155
- * @param limit - maximum lines to return.
156
- * @returns parsed events, oldest first.
157
- */
158
- export function readAuditTail(file, limit = 100) {
159
- if (!existsSync(file)) return [];
160
- let fd;
161
- try {
162
- fd = openSync(file, 'r');
163
- const size = fstatSync(fd).size;
164
- const window = Math.min(size, Math.max(64 * 1024, limit * 512));
165
- const start = size - window;
166
- const buffer = Buffer.alloc(window);
167
- readSync(fd, buffer, 0, window, start);
168
- const text = buffer.toString('utf8');
169
- const lines = text.split('\n');
170
- if (start > 0) lines.shift();
171
- const events = [];
172
- for (const line of lines) {
173
- const trimmed = line.trim();
174
- if (trimmed.length === 0) continue;
175
- try {
176
- events.push(JSON.parse(trimmed));
177
- } catch {
178
- /* a torn final line from a killed process is expected; skip it */
179
- }
180
- }
181
- return events.slice(-limit);
182
- } finally {
183
- if (fd !== undefined) closeSync(fd);
184
- }
185
- }
186
-
187
- /**
188
- * Keep free text out of the audit log unless it is short and harmless.
189
- *
190
- * The audit is a factual record of ids, modes and counts. A caller-supplied
191
- * `reason` is the one field that can carry arbitrary prose, and prose is where a
192
- * credential ends up after a copy-paste. It is therefore capped and scanned with
193
- * the same detector the memory body uses; a hit replaces the text with a marker
194
- * instead of dropping the event.
195
- *
196
- * @param detail - the fields the caller wants recorded.
197
- * @returns the fields that are safe to write.
198
- */
199
- function sanitizeDetail(detail) {
200
- if (typeof detail.reason !== 'string') return detail;
201
- const cleaned = detail.reason.replace(/\s+/g, ' ').trim();
202
- const { hits } = scanSecrets(cleaned, 'redact');
203
- return {
204
- ...detail,
205
- reason: hits.length > 0 ? '[redacted]' : cleaned.slice(0, 160),
206
- };
207
- }
208
-
1
+ /**
2
+ * The append-only audit trail (`audit.jsonl`).
3
+ *
4
+ * Every injected section, recall, write, trust change, deletion, and
5
+ * maintenance pass lands here with its byte count, so "what did this request
6
+ * cost" and "who removed that fact" are answerable from one file. Bodies and
7
+ * queries are never written: ids, lengths, and counts are enough to audit and
8
+ * cannot leak the content they describe.
9
+ * @module dsh-loci/store/audit
10
+ */
11
+ import {
12
+ appendFileSync,
13
+ closeSync,
14
+ existsSync,
15
+ fstatSync,
16
+ openSync,
17
+ readSync,
18
+ renameSync,
19
+ rmSync,
20
+ statSync,
21
+ } from 'node:fs';
22
+ import { scanSecrets } from '../inject/sanitize.js';
23
+
24
+ /** Events that must survive a crash immediately; everything else is buffered. */
25
+ const SYNC_EVENTS = new Set(['write', 'delete', 'verify', 'import', 'migrate', 'error', 'maintenance']);
26
+
27
+ /** How many recent events the in-memory ring keeps for the panel. */
28
+ const RING_SIZE = 500;
29
+
30
+ /**
31
+ * Derive the rotated file name for one generation.
32
+ * @param file - live audit path.
33
+ * @param generation - 1-based generation.
34
+ * @returns the rotated path.
35
+ */
36
+ function rotatedPath(file, generation) {
37
+ return `${file}.${generation}`;
38
+ }
39
+
40
+ /**
41
+ * JSONL audit writer with size rotation and a small in-memory tail.
42
+ */
43
+ export class AuditLog {
44
+ /** @type {string} */
45
+ file;
46
+ /** @type {number} */
47
+ maxBytes;
48
+ /** @type {number} */
49
+ keep;
50
+ /** @type {object[]} */
51
+ ring = [];
52
+ /** @type {object[]} */
53
+ buffer = [];
54
+ /** @type {NodeJS.Timeout | undefined} */
55
+ timer;
56
+ closed = false;
57
+
58
+ /**
59
+ * @param options - `file`, `maxBytes`, `keep`, `log`.
60
+ */
61
+ constructor(options) {
62
+ this.file = options.file;
63
+ this.maxBytes = options.maxBytes ?? 8 * 1024 * 1024;
64
+ this.keep = options.keep ?? 5;
65
+ this.log = options.log;
66
+ }
67
+
68
+ /**
69
+ * Record one event.
70
+ * @param event - event name (`inject`, `recall`, `write`, ...).
71
+ * @param detail - small JSON-safe payload; never include body text.
72
+ * @param options - `sync` forces an immediate flush.
73
+ */
74
+ append(event, detail = {}, options = {}) {
75
+ if (this.closed) return;
76
+ const line = { ts: Date.now(), event, ...sanitizeDetail(detail) };
77
+ this.buffer.push(line);
78
+ this.ring.push(line);
79
+ if (this.ring.length > RING_SIZE) this.ring.splice(0, this.ring.length - RING_SIZE);
80
+ if (options.sync === true || SYNC_EVENTS.has(event)) this.flush();
81
+ else this.scheduleFlush();
82
+ }
83
+
84
+ /** Schedule a flush soon, keeping the timer unref'd so it never holds the process open. */
85
+ scheduleFlush() {
86
+ if (this.timer !== undefined) return;
87
+ this.timer = setTimeout(() => {
88
+ this.timer = undefined;
89
+ this.flush();
90
+ }, 300);
91
+ this.timer.unref?.();
92
+ }
93
+
94
+ /** Write buffered lines and rotate when the file grew past the limit. */
95
+ flush() {
96
+ if (this.buffer.length === 0) return;
97
+ const text = `${this.buffer.map((line) => JSON.stringify(line)).join('\n')}\n`;
98
+ this.buffer = [];
99
+ try {
100
+ appendFileSync(this.file, text, 'utf8');
101
+ this.rotateIfNeeded();
102
+ } catch (error) {
103
+ this.log?.warn?.(`loci: audit write failed: ${String(error?.message ?? error)}`);
104
+ }
105
+ }
106
+
107
+ /** Rotate `audit.jsonl` to `.1`, shifting older generations. */
108
+ rotateIfNeeded() {
109
+ let size = 0;
110
+ try {
111
+ size = statSync(this.file).size;
112
+ } catch {
113
+ return;
114
+ }
115
+ if (size < this.maxBytes) return;
116
+ try {
117
+ const oldest = rotatedPath(this.file, this.keep);
118
+ if (existsSync(oldest)) rmSync(oldest, { force: true });
119
+ for (let generation = this.keep - 1; generation >= 1; generation -= 1) {
120
+ const from = rotatedPath(this.file, generation);
121
+ if (existsSync(from)) renameSync(from, rotatedPath(this.file, generation + 1));
122
+ }
123
+ renameSync(this.file, rotatedPath(this.file, 1));
124
+ } catch (error) {
125
+ this.log?.warn?.(`loci: audit rotation failed: ${String(error?.message ?? error)}`);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * The newest events, from the ring when it is warm and from the file tail
131
+ * otherwise.
132
+ * @param limit - how many events to return, newest last.
133
+ * @returns parsed events, oldest first.
134
+ */
135
+ tail(limit = 100) {
136
+ if (this.ring.length >= limit) return this.ring.slice(-limit);
137
+ return readAuditTail(this.file, limit);
138
+ }
139
+
140
+ /** Flush and stop the timer. */
141
+ close() {
142
+ this.flush();
143
+ this.closed = true;
144
+ if (this.timer !== undefined) {
145
+ clearTimeout(this.timer);
146
+ this.timer = undefined;
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Read the last `limit` JSON lines of a file without loading all of it.
153
+ * Malformed lines (a process killed mid-append) are skipped and counted.
154
+ * @param file - audit path.
155
+ * @param limit - maximum lines to return.
156
+ * @returns parsed events, oldest first.
157
+ */
158
+ export function readAuditTail(file, limit = 100) {
159
+ if (!existsSync(file)) return [];
160
+ let fd;
161
+ try {
162
+ fd = openSync(file, 'r');
163
+ const size = fstatSync(fd).size;
164
+ const window = Math.min(size, Math.max(64 * 1024, limit * 512));
165
+ const start = size - window;
166
+ const buffer = Buffer.alloc(window);
167
+ readSync(fd, buffer, 0, window, start);
168
+ const text = buffer.toString('utf8');
169
+ const lines = text.split('\n');
170
+ if (start > 0) lines.shift();
171
+ const events = [];
172
+ for (const line of lines) {
173
+ const trimmed = line.trim();
174
+ if (trimmed.length === 0) continue;
175
+ try {
176
+ events.push(JSON.parse(trimmed));
177
+ } catch {
178
+ /* a torn final line from a killed process is expected; skip it */
179
+ }
180
+ }
181
+ return events.slice(-limit);
182
+ } finally {
183
+ if (fd !== undefined) closeSync(fd);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Keep free text out of the audit log unless it is short and harmless.
189
+ *
190
+ * The audit is a factual record of ids, modes and counts. A caller-supplied
191
+ * `reason` is the one field that can carry arbitrary prose, and prose is where a
192
+ * credential ends up after a copy-paste. It is therefore capped and scanned with
193
+ * the same detector the memory body uses; a hit replaces the text with a marker
194
+ * instead of dropping the event.
195
+ *
196
+ * @param detail - the fields the caller wants recorded.
197
+ * @returns the fields that are safe to write.
198
+ */
199
+ function sanitizeDetail(detail) {
200
+ if (typeof detail.reason !== 'string') return detail;
201
+ const cleaned = detail.reason.replace(/\s+/g, ' ').trim();
202
+ const { hits } = scanSecrets(cleaned, 'redact');
203
+ return {
204
+ ...detail,
205
+ reason: hits.length > 0 ? '[redacted]' : cleaned.slice(0, 160),
206
+ };
207
+ }
208
+