dsh-perm-gate 0.2.0 → 0.2.1-beta.3

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/lib/events.js CHANGED
@@ -2,22 +2,236 @@
2
2
  * Gate decision events for dsh-perm-gate: a JSONL audit feed the browser half
3
3
  * polls so every automatic allow / ask / deny is visible in the conversation UI.
4
4
  *
5
+ * This module also owns the review-page data plane demonstrated by
6
+ * dsh-approval-gate: a per-event snapshot of the files a decision touched (the
7
+ * pre-change content), a line-level diff against the current content, and the
8
+ * revert / snapshot-management routes the approval-history view drives.
9
+ *
5
10
  * Pure append-only logging: every I/O error is swallowed (events are best-effort
6
11
  * and must never influence gating), and the id sequence resumes from the file on
7
12
  * first use so restarts never reuse an id the client has already seen.
8
13
  */
9
- import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
10
- import { dirname } from 'node:path';
14
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { dirname, join } from 'node:path';
11
17
  const REASON_MAX = 200;
18
+ const JUSTIFICATION_MAX = 400;
19
+ const FILES_MAX = 8;
20
+ // Snapshot bounds: one file ≤256KB, ≤5 files per event.
21
+ const SNAPSHOT_MAX_BYTES = 256 * 1024;
22
+ const SNAPSHOT_MAX_FILES = 5;
23
+ /** Text-file probe: binary payloads and oversized files are not snapshotted. */
24
+ const SNAPSHOT_BINARY_RE = /[\x00-\x08\x0e-\x1f]/;
25
+ function isSnapshotText(buf) {
26
+ if (buf.length > SNAPSHOT_MAX_BYTES)
27
+ return false;
28
+ const head = buf.subarray(0, Math.min(buf.length, 8192));
29
+ return !SNAPSHOT_BINARY_RE.test(head.toString('latin1'));
30
+ }
31
+ /** Read one file as snapshot text; null when unreadable, binary, or oversized. */
32
+ export function readSnapshotFile(absPath) {
33
+ try {
34
+ const buf = readFileSync(absPath);
35
+ return isSnapshotText(buf) ? buf.toString('utf8') : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** Device/pseudo files carry no reviewable content — never snapshotted. */
42
+ function isDevicePath(absPath) {
43
+ return /^(\/dev\/|\/proc\/|\/sys\/)/.test(absPath)
44
+ || /^[A-Za-z]:[\\/](?:nul|con|prn|aux|com\d|lpt\d)$/i.test(absPath);
45
+ }
46
+ /**
47
+ * Resolve a path from a decision into an absolute path: `~` expands to home,
48
+ * absolute paths pass through, relative paths try the session cwd, the process
49
+ * cwd, then home (first existing wins; otherwise the first candidate).
50
+ */
51
+ export function resolveAbsPath(p, baseDir) {
52
+ const raw = String(p ?? '');
53
+ if (raw === '')
54
+ return raw;
55
+ if (raw.startsWith('~'))
56
+ return join(homedir(), raw.slice(1));
57
+ if (raw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(raw) || raw.startsWith('\\\\'))
58
+ return raw;
59
+ const candidates = [baseDir, process.cwd(), homedir()].filter((b) => typeof b === 'string' && b !== '');
60
+ const seen = new Set();
61
+ for (const b of candidates) {
62
+ const abs = join(b, raw);
63
+ if (seen.has(abs))
64
+ continue;
65
+ seen.add(abs);
66
+ if (existsSync(abs))
67
+ return abs;
68
+ }
69
+ return join(candidates[0] ?? process.cwd(), raw);
70
+ }
71
+ /** Write the pre-change snapshot of every file one event touched. */
72
+ export function saveEventSnapshots(dir, eventId, files, baseDir, sessionId, now = Date.now) {
73
+ if (files.length === 0)
74
+ return;
75
+ const snapshots = [];
76
+ const seen = new Set();
77
+ for (const f of files) {
78
+ if (snapshots.length >= SNAPSHOT_MAX_FILES)
79
+ break;
80
+ const abs = resolveAbsPath(f, baseDir);
81
+ if (abs === '' || seen.has(abs) || isDevicePath(abs))
82
+ continue;
83
+ seen.add(abs);
84
+ const content = readSnapshotFile(abs);
85
+ // Missing, binary, oversized, or empty content has no diff meaning.
86
+ if (content === null || content === '')
87
+ continue;
88
+ snapshots.push({ path: abs, content, ts: new Date(now()).toISOString() });
89
+ }
90
+ if (snapshots.length === 0)
91
+ return;
92
+ try {
93
+ mkdirSync(dir, { recursive: true });
94
+ const body = {
95
+ eventId,
96
+ sessionId: String(sessionId ?? ''),
97
+ cwd: baseDir ?? process.cwd(),
98
+ snapshots,
99
+ };
100
+ writeFileSync(join(dir, `${eventId}.json`), JSON.stringify(body, null, 2), 'utf8');
101
+ }
102
+ catch {
103
+ // snapshot persistence is best-effort; the event itself is already logged
104
+ }
105
+ }
106
+ /** Load one event's snapshots ([] when absent or corrupt). */
107
+ export function loadEventSnapshots(dir, eventId) {
108
+ try {
109
+ const data = JSON.parse(readFileSync(join(dir, `${eventId}.json`), 'utf8'));
110
+ return Array.isArray(data.snapshots) ? data.snapshots : [];
111
+ }
112
+ catch {
113
+ return [];
114
+ }
115
+ }
116
+ /** Whether one snapshot file belongs to a session (no sessionId = legacy, never matches a filter). */
117
+ function snapshotMatchesSession(absPath, sessionId) {
118
+ if (sessionId === '')
119
+ return true;
120
+ try {
121
+ const data = JSON.parse(readFileSync(absPath, 'utf8'));
122
+ return String(data.sessionId ?? '') === String(sessionId);
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
128
+ /**
129
+ * Line-level diff with context: a greedy in-order LCS approximation (each `a`
130
+ * line matches the next unused equal `b` line), then change windows of ±`CTX`
131
+ * context lines clustered into hunks. Ported from dsh-approval-gate so both
132
+ * review pages render identical diffs.
133
+ */
134
+ export function diffLines(before, after, contextLines = 5) {
135
+ const CTX = Number.isInteger(contextLines) && contextLines >= 0 ? contextLines : 5;
136
+ const a = String(before ?? '').split('\n');
137
+ const b = String(after ?? '').split('\n');
138
+ const bPos = new Map();
139
+ for (let j = 0; j < b.length; j += 1) {
140
+ const list = bPos.get(b[j]);
141
+ if (list === undefined)
142
+ bPos.set(b[j], [j]);
143
+ else
144
+ list.push(j);
145
+ }
146
+ const aMatch = new Array(a.length).fill(-1);
147
+ const bUsed = new Array(b.length).fill(false);
148
+ let limit = 0;
149
+ for (let i = 0; i < a.length; i += 1) {
150
+ const q = bPos.get(a[i]);
151
+ if (q === undefined)
152
+ continue;
153
+ for (const pos of q) {
154
+ if (pos >= limit && !bUsed[pos]) {
155
+ aMatch[i] = pos;
156
+ bUsed[pos] = true;
157
+ limit = pos + 1;
158
+ break;
159
+ }
160
+ }
161
+ }
162
+ const ops = [];
163
+ let i = 0;
164
+ let j = 0;
165
+ while (i < a.length || j < b.length) {
166
+ if (i < a.length && aMatch[i] >= 0) {
167
+ const target = aMatch[i];
168
+ while (j < target) {
169
+ ops.push({ type: 'add', bNo: j + 1, text: b[j] });
170
+ j += 1;
171
+ }
172
+ ops.push({ type: 'same', aNo: i + 1, bNo: target + 1, text: a[i] });
173
+ j = target + 1;
174
+ i += 1;
175
+ }
176
+ else if (i < a.length) {
177
+ ops.push({ type: 'del', aNo: i + 1, text: a[i] });
178
+ i += 1;
179
+ }
180
+ else {
181
+ ops.push({ type: 'add', bNo: j + 1, text: b[j] });
182
+ j += 1;
183
+ }
184
+ }
185
+ const show = new Array(ops.length).fill(false);
186
+ for (let idx = 0; idx < ops.length; idx += 1) {
187
+ if (ops[idx].type === 'same')
188
+ continue;
189
+ for (let k = Math.max(0, idx - CTX); k <= Math.min(ops.length - 1, idx + CTX); k += 1)
190
+ show[k] = true;
191
+ }
192
+ const hunks = [];
193
+ let hiddenBefore = 0;
194
+ let pending = [];
195
+ let started = false;
196
+ for (let idx = 0; idx < ops.length; idx += 1) {
197
+ if (show[idx]) {
198
+ started = true;
199
+ pending.push(ops[idx]);
200
+ }
201
+ else {
202
+ if (started && pending.length > 0) {
203
+ hunks.push({ hiddenBefore, lines: pending });
204
+ pending = [];
205
+ started = false;
206
+ }
207
+ hiddenBefore += 1;
208
+ }
209
+ }
210
+ if (pending.length > 0 && started)
211
+ hunks.push({ hiddenBefore, lines: pending });
212
+ if (hunks.length > 0)
213
+ hunks[0] = { hiddenBefore: 0, lines: hunks[0].lines };
214
+ const added = ops.filter((o) => o.type === 'add').length;
215
+ const removed = ops.filter((o) => o.type === 'del').length;
216
+ return {
217
+ hunks,
218
+ stats: { added, removed, contextLines: Math.max(a.length, b.length) - (added + removed) },
219
+ changedLines: ops.filter((o) => o.type !== 'same').slice(0, 500),
220
+ };
221
+ }
12
222
  export class EventLog {
13
223
  filePath;
14
224
  now;
225
+ snapshotsDir;
15
226
  seq = -1; // lazily restored from the file on first append
16
227
  constructor(
17
228
  /** JSONL path; `undefined` disables event recording entirely. */
18
- filePath, now = Date.now) {
229
+ filePath, now = Date.now,
230
+ /** Snapshot directory; `undefined` disables diff/revert support. */
231
+ snapshotsDir = undefined) {
19
232
  this.filePath = filePath;
20
233
  this.now = now;
234
+ this.snapshotsDir = snapshotsDir;
21
235
  }
22
236
  /** Append one event; returns it, or undefined when recording is disabled/failed. */
23
237
  append(input) {
@@ -25,6 +239,9 @@ export class EventLog {
25
239
  return undefined;
26
240
  try {
27
241
  const id = this.nextId();
242
+ const files = input.files === undefined || input.files.length === 0
243
+ ? undefined
244
+ : input.files.slice(0, FILES_MAX);
28
245
  const ev = {
29
246
  id,
30
247
  ts: new Date(this.now()).toISOString(),
@@ -33,9 +250,22 @@ export class EventLog {
33
250
  kind: input.kind,
34
251
  ...(input.risk === undefined ? {} : { risk: input.risk }),
35
252
  reason: String(input.reason).slice(0, REASON_MAX),
253
+ ...(input.verdict === undefined ? {} : { verdict: input.verdict }),
254
+ ...(input.justification === undefined ? {} : { justification: String(input.justification).slice(0, JUSTIFICATION_MAX) }),
255
+ ...(input.mode === undefined ? {} : { mode: input.mode }),
256
+ ...(input.category === undefined ? {} : { category: input.category }),
257
+ ...(files === undefined ? {} : { files }),
258
+ ...(input.learningCount === undefined ? {} : { learningCount: input.learningCount }),
259
+ ...(input.threshold === undefined ? {} : { threshold: input.threshold }),
36
260
  };
37
261
  mkdirSync(dirname(this.filePath), { recursive: true });
38
262
  appendFileSync(this.filePath, JSON.stringify(ev) + '\n', 'utf8');
263
+ // `auto`/`ask` are both "before the change lands" moments (an approved ask
264
+ // executes right after): snapshot the files so the review page can diff
265
+ // and revert them later.
266
+ if ((ev.kind === 'auto' || ev.kind === 'ask') && this.snapshotsDir !== undefined && files !== undefined) {
267
+ saveEventSnapshots(this.snapshotsDir, ev.id, files, input.baseDir, ev.sessionId, this.now);
268
+ }
39
269
  return ev;
40
270
  }
41
271
  catch {
@@ -73,6 +303,10 @@ export class EventLog {
73
303
  }
74
304
  return out;
75
305
  }
306
+ /** One event by id (undefined when absent). */
307
+ byId(id) {
308
+ return this.query().find((ev) => ev.id === id);
309
+ }
76
310
  nextId() {
77
311
  if (this.seq < 0) {
78
312
  this.seq = 0;
@@ -85,19 +319,53 @@ export class EventLog {
85
319
  return this.seq;
86
320
  }
87
321
  }
322
+ function routeServer(server) {
323
+ if (typeof server !== 'object' || server === null)
324
+ return undefined;
325
+ const candidate = server;
326
+ return typeof candidate.register === 'function' ? server : undefined;
327
+ }
328
+ function json(res, code, body) {
329
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' });
330
+ res.end(JSON.stringify(body));
331
+ }
332
+ function readBody(req, limit = 1024 * 1024) {
333
+ return new Promise((resolve, reject) => {
334
+ let body = '';
335
+ req.on?.('data', (chunk) => {
336
+ body += String(chunk);
337
+ if (body.length > limit)
338
+ reject(new Error('payload too large'));
339
+ });
340
+ req.on?.('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
341
+ req.on?.('end', () => {
342
+ try {
343
+ resolve(body === '' ? {} : JSON.parse(body));
344
+ }
345
+ catch (e) {
346
+ reject(e instanceof Error ? e : new Error(String(e)));
347
+ }
348
+ });
349
+ });
350
+ }
88
351
  export const EVENTS_ROUTE = '/api/dsh-perm-gate/events';
352
+ export const LEARNING_ROUTE = '/api/dsh-perm-gate/learning';
353
+ export const HEALTH_ROUTE = '/api/dsh-perm-gate/health';
354
+ export const RECEIVER_ROUTE = '/api/dsh-perm-gate/receiver';
355
+ export const DIFF_ROUTE = '/api/dsh-perm-gate/diff';
356
+ export const REVERT_ROUTE = '/api/dsh-perm-gate/revert';
357
+ export const SNAPSHOTS_STATS_ROUTE = '/api/dsh-perm-gate/snapshots-stats';
358
+ export const SNAPSHOTS_CLEAR_ROUTE = '/api/dsh-perm-gate/snapshots-clear';
89
359
  /**
90
360
  * Register `GET /api/dsh-perm-gate/events?sessionId=&since=` on the webServer
91
361
  * service. Returns whether the route was registered (false when the service is
92
362
  * unavailable — the host keeps running, only the HTTP API is missing).
93
363
  */
94
364
  export function registerEventsRoute(server, log) {
95
- if (typeof server !== 'object' || server === null)
96
- return false;
97
- const candidate = server;
98
- if (typeof candidate.register !== 'function')
99
- return false;
100
- candidate.register({
365
+ const ws = routeServer(server);
366
+ if (ws === undefined)
367
+ return undefined;
368
+ return ws.register({
101
369
  kind: 'exact',
102
370
  path: EVENTS_ROUTE,
103
371
  handler: (rawReq, rawRes) => {
@@ -120,10 +388,307 @@ export function registerEventsRoute(server, log) {
120
388
  catch {
121
389
  // unparseable URL: answer with the unfiltered tail
122
390
  }
123
- const events = log.query({ sessionId, since });
124
- res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' });
125
- res.end(JSON.stringify({ events }));
391
+ json(res, 200, { events: log.query({ sessionId, since }) });
392
+ },
393
+ });
394
+ }
395
+ /**
396
+ * Register the review-page routes:
397
+ * `GET /diff?eventId=&path=` → before/after line diff for one file
398
+ * `POST /revert` → deliver a revert instruction into the session
399
+ * `GET /snapshots-stats?sessionId=` → snapshot count/bytes/ids/file map
400
+ * `POST /snapshots-clear` → drop snapshots (session-scoped or all)
401
+ */
402
+ export function registerReviewRoutes(server, deps) {
403
+ const ws = routeServer(server);
404
+ if (ws === undefined)
405
+ return [];
406
+ const { log, snapshotsDir, send, audit } = deps;
407
+ const offs = [];
408
+ offs.push(ws.register({
409
+ kind: 'exact',
410
+ path: DIFF_ROUTE,
411
+ handler: (rawReq, rawRes) => {
412
+ const req = rawReq;
413
+ const res = rawRes;
414
+ try {
415
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
416
+ json(res, 405, { ok: false, error: 'method not allowed' });
417
+ return;
418
+ }
419
+ if (snapshotsDir === undefined) {
420
+ json(res, 404, { ok: false, error: '快照目录未配置' });
421
+ return;
422
+ }
423
+ const url = new URL(req.url ?? '/', 'http://localhost');
424
+ const eventId = Number.parseInt(url.searchParams.get('eventId') ?? '', 10);
425
+ const path = url.searchParams.get('path') ?? '';
426
+ if (!Number.isInteger(eventId) || path === '') {
427
+ json(res, 400, { ok: false, error: 'eventId/path 必填' });
428
+ return;
429
+ }
430
+ const snaps = loadEventSnapshots(snapshotsDir, eventId);
431
+ // The client sends the raw path recorded on the event (absolute,
432
+ // relative, or a bare name): align it against the snapshot's absolute
433
+ // path, falling back to a suffix/basename match.
434
+ const base = resolveAbsPath(path);
435
+ const baseName = path.split(/[\\/]/).pop() ?? '';
436
+ const snap = snaps.find((s) => s.path === base || s.path === path)
437
+ ?? snaps.find((s) => s.path.endsWith('/' + path)
438
+ || (baseName !== '' && (s.path.endsWith('/' + baseName) || s.path.endsWith('\\' + baseName))));
439
+ if (snap === undefined) {
440
+ json(res, 404, { ok: false, error: '该事件没有此文件的快照' });
441
+ return;
442
+ }
443
+ const after = readSnapshotFile(snap.path);
444
+ const result = diffLines(snap.content, after);
445
+ json(res, 200, {
446
+ ok: true,
447
+ path,
448
+ eventId,
449
+ beforeExists: true,
450
+ afterExists: after !== null,
451
+ changedLines: result.changedLines,
452
+ hunks: result.hunks,
453
+ stats: result.stats,
454
+ });
455
+ }
456
+ catch (e) {
457
+ json(res, 400, { ok: false, error: String(e?.message ?? e) });
458
+ }
459
+ },
460
+ }));
461
+ offs.push(ws.register({
462
+ kind: 'exact',
463
+ path: REVERT_ROUTE,
464
+ handler: (rawReq, rawRes) => {
465
+ const req = rawReq;
466
+ const res = rawRes;
467
+ if (req.method !== 'POST') {
468
+ json(res, 405, { ok: false, error: 'method not allowed' });
469
+ return;
470
+ }
471
+ void (async () => {
472
+ try {
473
+ const body = await readBody(req);
474
+ const sessionId = String(body.sessionId ?? '');
475
+ const eventId = Number.parseInt(String(body.eventId ?? ''), 10);
476
+ if (sessionId === '' || !Number.isInteger(eventId)) {
477
+ json(res, 400, { ok: false, error: 'sessionId/eventId 必填' });
478
+ return;
479
+ }
480
+ const event = log.byId(eventId);
481
+ if (event === undefined) {
482
+ json(res, 404, { ok: false, error: '未找到该事件' });
483
+ return;
484
+ }
485
+ const files = (event.files ?? []).map((f) => '`' + f + '`').join('、');
486
+ const snaps = snapshotsDir === undefined ? [] : loadEventSnapshots(snapshotsDir, eventId);
487
+ const snapHint = snaps.length > 0
488
+ ? '改动前的文件内容快照保存在 ' + snapshotsDir + '(按事件 ID 命名),可参考恢复;请确认改动内容后执行撤销。'
489
+ : '注意:该事件已无可用快照(可能已被清除),请基于当前文件内容判断如何恢复原状;无法确定时请先说明再操作。';
490
+ const content = '请撤销以下自动放行操作带来的文件改动(恢复为审批前的状态):\n'
491
+ + '- 操作:' + (event.justification ?? event.reason ?? '(无说明)') + '\n'
492
+ + '- 涉及文件:' + (files !== '' ? files : '(未知)') + '\n'
493
+ + '- 判定:' + (event.verdict ?? event.kind) + '(自动放行)\n'
494
+ + '- 事件时间:' + event.ts + '\n'
495
+ + snapHint;
496
+ const result = await send(sessionId, content);
497
+ audit?.(`REVERT event=${eventId} session=${sessionId} via=${result.via ?? 'none'}`);
498
+ json(res, result.ok ? 200 : 500, result);
499
+ }
500
+ catch (e) {
501
+ json(res, 400, { ok: false, error: String(e?.message ?? e) });
502
+ }
503
+ })();
504
+ },
505
+ }));
506
+ offs.push(ws.register({
507
+ kind: 'exact',
508
+ path: SNAPSHOTS_STATS_ROUTE,
509
+ handler: (rawReq, rawRes) => {
510
+ const req = rawReq;
511
+ const res = rawRes;
512
+ try {
513
+ if (snapshotsDir === undefined) {
514
+ json(res, 200, { ok: true, count: 0, bytes: 0, ids: [], files: {}, sessionId: null });
515
+ return;
516
+ }
517
+ const url = new URL(req.url ?? '/', 'http://localhost');
518
+ const filterSession = url.searchParams.get('sessionId') ?? '';
519
+ let count = 0;
520
+ let bytes = 0;
521
+ const ids = [];
522
+ const files = {};
523
+ for (const name of readdirSafe(snapshotsDir)) {
524
+ if (!name.endsWith('.json'))
525
+ continue;
526
+ const abs = join(snapshotsDir, name);
527
+ if (filterSession !== '' && !snapshotMatchesSession(abs, filterSession))
528
+ continue;
529
+ const id = name.slice(0, -'.json'.length);
530
+ count += 1;
531
+ ids.push(id);
532
+ bytes += statSize(abs);
533
+ try {
534
+ const data = JSON.parse(readFileSync(abs, 'utf8'));
535
+ if (Array.isArray(data.snapshots)) {
536
+ files[id] = data.snapshots.map((s) => String(s?.path ?? '')).filter((p) => p !== '');
537
+ }
538
+ }
539
+ catch {
540
+ // corrupt snapshot file: still counted, no file map
541
+ }
542
+ }
543
+ json(res, 200, { ok: true, count, bytes, ids, files, sessionId: filterSession !== '' ? filterSession : null });
544
+ }
545
+ catch (e) {
546
+ json(res, 400, { ok: false, error: String(e?.message ?? e) });
547
+ }
548
+ },
549
+ }));
550
+ offs.push(ws.register({
551
+ kind: 'exact',
552
+ path: SNAPSHOTS_CLEAR_ROUTE,
553
+ handler: (rawReq, rawRes) => {
554
+ const req = rawReq;
555
+ const res = rawRes;
556
+ if (req.method !== 'POST') {
557
+ json(res, 405, { ok: false, error: 'method not allowed' });
558
+ return;
559
+ }
560
+ void (async () => {
561
+ try {
562
+ const body = await readBody(req);
563
+ const filterSession = String(body.sessionId ?? '');
564
+ let removed = 0;
565
+ if (snapshotsDir !== undefined) {
566
+ for (const name of readdirSafe(snapshotsDir)) {
567
+ if (!name.endsWith('.json'))
568
+ continue;
569
+ const abs = join(snapshotsDir, name);
570
+ if (filterSession !== '' && !snapshotMatchesSession(abs, filterSession))
571
+ continue;
572
+ try {
573
+ rmSync(abs, { force: true });
574
+ removed += 1;
575
+ }
576
+ catch {
577
+ // skip unremovable entry
578
+ }
579
+ }
580
+ }
581
+ audit?.(`CONFIG snapshots-clear session=${filterSession !== '' ? filterSession : '*'} removed=${removed}`);
582
+ json(res, 200, { ok: true, removed, sessionId: filterSession !== '' ? filterSession : null });
583
+ }
584
+ catch (e) {
585
+ json(res, 400, { ok: false, error: String(e?.message ?? e) });
586
+ }
587
+ })();
588
+ },
589
+ }));
590
+ return offs;
591
+ }
592
+ function readdirSafe(dir) {
593
+ try {
594
+ return readdirSync(dir);
595
+ }
596
+ catch {
597
+ return [];
598
+ }
599
+ }
600
+ function statSize(absPath) {
601
+ try {
602
+ return statSync(absPath).size;
603
+ }
604
+ catch {
605
+ return 0;
606
+ }
607
+ }
608
+ /**
609
+ * Register `GET /api/dsh-perm-gate/receiver` — the receiver projection for the
610
+ * settings card: the effective provider/model plus (host mode) the live
611
+ * provider/model-group catalog from the DSH `llm` service.
612
+ */
613
+ export function registerReceiverRoute(server, provider) {
614
+ const ws = routeServer(server);
615
+ if (ws === undefined)
616
+ return undefined;
617
+ return ws.register({
618
+ kind: 'exact',
619
+ path: RECEIVER_ROUTE,
620
+ handler: (rawReq, rawRes) => {
621
+ const req = rawReq;
622
+ const res = rawRes;
623
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
624
+ json(res, 405, { ok: false, error: 'method not allowed' });
625
+ return;
626
+ }
627
+ provider.info().then((info) => json(res, 200, { ok: true, ...info }), (e) => json(res, 500, { ok: false, error: String(e?.message ?? e) }));
628
+ },
629
+ });
630
+ }
631
+ /**
632
+ * Register `POST /api/dsh-perm-gate/health` — runs one minimal completion
633
+ * through the currently configured llmAssist receiver and returns
634
+ * `{ ok, ms, detail }` (the settings card's health test).
635
+ */
636
+ export function registerHealthRoute(server, provider) {
637
+ const ws = routeServer(server);
638
+ if (ws === undefined)
639
+ return undefined;
640
+ return ws.register({
641
+ kind: 'exact',
642
+ path: HEALTH_ROUTE,
643
+ handler: (rawReq, rawRes) => {
644
+ const req = rawReq;
645
+ const res = rawRes;
646
+ if (req.method !== 'POST') {
647
+ json(res, 405, { ok: false, error: 'method not allowed' });
648
+ return;
649
+ }
650
+ provider.check().then((result) => json(res, 200, { ...result, ok: true }), (e) => json(res, 500, { ok: false, error: String(e?.message ?? e) }));
651
+ },
652
+ });
653
+ }
654
+ /**
655
+ * Register the learning-store routes on the webServer service:
656
+ * `GET /api/dsh-perm-gate/learning` → the store snapshot + live threshold,
657
+ * `POST /api/dsh-perm-gate/learning` → `{ key, fp? }` terminates one key's
658
+ * learning or drops one sedimented sample. Returns whether registered.
659
+ */
660
+ export function registerLearningRoute(server, provider) {
661
+ const ws = routeServer(server);
662
+ if (ws === undefined)
663
+ return undefined;
664
+ return ws.register({
665
+ kind: 'exact',
666
+ path: LEARNING_ROUTE,
667
+ handler: (rawReq, rawRes) => {
668
+ const req = rawReq;
669
+ const res = rawRes;
670
+ if (req.method === 'GET' || req.method === 'HEAD') {
671
+ json(res, 200, { ok: true, threshold: provider.threshold(), ...provider.snapshot() });
672
+ return;
673
+ }
674
+ if (req.method !== 'POST') {
675
+ json(res, 405, { ok: false, error: 'method not allowed' });
676
+ return;
677
+ }
678
+ void (async () => {
679
+ try {
680
+ const parsed = await readBody(req);
681
+ if (typeof parsed.key !== 'string' || parsed.key === '') {
682
+ json(res, 400, { ok: false, error: 'missing key' });
683
+ return;
684
+ }
685
+ provider.reset(parsed.key, typeof parsed.fp === 'string' && parsed.fp !== '' ? parsed.fp : undefined);
686
+ json(res, 200, { ok: true });
687
+ }
688
+ catch (e) {
689
+ json(res, 400, { ok: false, error: String(e?.message ?? e) });
690
+ }
691
+ })();
126
692
  },
127
693
  });
128
- return true;
129
694
  }