@chatpanel/gateway 0.4.1 → 0.4.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/package.json +4 -2
- package/src/ner-assets.js +8 -0
- package/src/ner.js +31 -5
- package/src/server.js +52 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"start": "node bin/chatpanel-gateway.js",
|
|
15
15
|
"test": "node --test",
|
|
16
16
|
"typecheck": "tsc -p tsconfig.json",
|
|
17
|
-
"build:bin": "bash scripts/build-binaries.sh"
|
|
17
|
+
"build:bin": "bash scripts/build-binaries.sh",
|
|
18
|
+
"gen:ner": "node scripts/gen-ner-assets.mjs",
|
|
19
|
+
"prepublishOnly": "node scripts/gen-ner-assets.mjs"
|
|
18
20
|
},
|
|
19
21
|
"files": [
|
|
20
22
|
"src",
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-ner-assets.mjs — do NOT edit. Edit ner/* and re-run.
|
|
2
|
+
// Base64 of the bundled spaCy NER server, embedded so a standalone binary can
|
|
3
|
+
// write a runnable ner/ directory to disk (the binary has no ner/ on the FS).
|
|
4
|
+
export const NER_ASSETS = {
|
|
5
|
+
"run.sh": "IyEvdXNyL2Jpbi9lbnYgYmFzaAojIE9uZS1zaG90OiBjcmVhdGUgdGhlIHZlbnYgKGlmIG5lZWRlZCksIGluc3RhbGwgZGVwcyArIHRoZSBzcGFDeSBtb2RlbCwgYW5kIHNlcnZlLgojIFVzYWdlOiAgLi9ydW4uc2ggICAgICAgIChkZWZhdWx0cyB0byBwb3J0IDkwMDkpCiMgICAgICAgICBQT1JUPTkxMDAgLi9ydW4uc2gKc2V0IC1ldW8gcGlwZWZhaWwKY2QgIiQoZGlybmFtZSAiJDAiKSIKCmlmIFsgISAtZCAudmVudiBdOyB0aGVuCiAgZWNobyAi4oaSIGNyZWF0aW5nIHZpcnR1YWwgZW52ICgudmVudinigKYiCiAgcHl0aG9uMyAtbSB2ZW52IC52ZW52CmZpCiMgc2hlbGxjaGVjayBkaXNhYmxlPVNDMTA5MQpzb3VyY2UgLnZlbnYvYmluL2FjdGl2YXRlCgplY2hvICLihpIgaW5zdGFsbGluZyBkZXBlbmRlbmNpZXPigKYiCnBpcCBpbnN0YWxsIC1xIC0tdXBncmFkZSBwaXAKcGlwIGluc3RhbGwgLXEgLXIgcmVxdWlyZW1lbnRzLnR4dApweXRob24gLWMgImltcG9ydCBlbl9jb3JlX3dlYl9zbSIgPi9kZXYvbnVsbCAyPiYxIHx8IHB5dGhvbiAtbSBzcGFjeSBkb3dubG9hZCBlbl9jb3JlX3dlYl9zbQoKZWNobyAi4oaSIHNlcnZpbmcgb24gaHR0cDovLzEyNy4wLjAuMToke1BPUlQ6LTkwMDl9L25lciAgKEN0cmwtQyB0byBzdG9wKSIKZXhlYyB1dmljb3JuIHNlcnZlcjphcHAgLS1wb3J0ICIke1BPUlQ6LTkwMDl9Igo=",
|
|
6
|
+
"server.py": "IiIiCkNoYXRQYW5lbCBsb2NhbCBORVIgaGVscGVyIOKAlCBhIHRpbnkgb24tZGV2aWNlIGVudGl0eSBkZXRlY3RvciBmb3IgUElJIHJlZGFjdGlvbi4KCkNoYXRQYW5lbCdzIFByaXZhY3kg4oaSICJBSSBkZXRlY3Rpb24iIGNhbiBwb2ludCBhdCBhbnkgTE9DQUwgc2VydmljZSB0aGF0IGFjY2VwdHMKICAgIFBPU1QgeyJ0ZXh0IjogIi4uLiJ9CmFuZCByZXR1cm5zCiAgICB7ImVudGl0aWVzIjogW3sidmFsdWUiOiAiLi4uIiwgInR5cGUiOiAiUEVSU09OfE9SR3xHUEV8RU1BSUx8UEhPTkV8Li4uIn1dfQoKVGhpcyB3cmFwcyBzcGFDeSAocGVvcGxlIC8gb3JnYW5pemF0aW9ucyAvIGxvY2F0aW9ucykgQU5EIGFkZHMgYSByZWdleCBwYXNzIGZvcgp0aGUgc3RydWN0dXJlZCBpZGVudGlmaWVycyBzcGFDeSBkb2Vzbid0IGVtaXQgKGVtYWlscywgcGhvbmUgbnVtYmVycywgU1NOcywgY2FyZHMsCklQcyksIHNvIHRoZSBkZXRlY3RvciBpcyBjb21wcmVoZW5zaXZlIG9uIGl0cyBvd24uIE9ubHkgdGhlIHJlZGFjdGVkIHBsYWNlaG9sZGVycwooZS5nLiBbW1BFUlNPTl8xXV0pIGV2ZXIgcmVhY2ggdGhlIGNoYXQgbW9kZWwg4oCUIHRoZSByYXcgdGV4dCBuZXZlciBsZWF2ZXMgeW91ciBib3guCgotLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQpTZXR1cCAgKG1vc3QgbWFjaGluZXMgYmxvY2sgZ2xvYmFsIHBpcCBpbnN0YWxscywgc28gdXNlIGEgdmlydHVhbCBlbnYpCgogICAgcHl0aG9uMyAtbSB2ZW52IC52ZW52CiAgICBzb3VyY2UgLnZlbnYvYmluL2FjdGl2YXRlICAgICAgICAgICMgV2luZG93czogLnZlbnZcXFNjcmlwdHNcXGFjdGl2YXRlCiAgICBwaXAgaW5zdGFsbCAtciByZXF1aXJlbWVudHMudHh0CiAgICBweXRob24gLW0gc3BhY3kgZG93bmxvYWQgZW5fY29yZV93ZWJfc20KClJ1biAgKHRoZSBmaWxlIGlzIHNlcnZlci5weSwgc28gdGhlIHV2aWNvcm4gaW1wb3J0IHBhdGggaXMgInNlcnZlcjphcHAiKQoKICAgIHV2aWNvcm4gc2VydmVyOmFwcCAtLXBvcnQgOTAwOQoKVGhlbiBpbiBDaGF0UGFuZWwg4oaSIFNldHRpbmdzIOKGkiBQcml2YWN5OgogICAgUmVkYWN0aW9uIDogT24g4oCUICsgQUkgZGV0ZWN0aW9uCiAgICBEZXRlY3RvciAgOiBMb2NhbCBORVIgc2VydmljZSAoc3BhQ3kgLyBQcmVzaWRpbykKICAgIFVSTCAgICAgICA6IGh0dHA6Ly8xMjcuMC4wLjE6OTAwOS9uZXIKClRpcDogZW5fY29yZV93ZWJfc20gaXMgc21hbGwgKyBmYXN0LiBGb3IgYmV0dGVyIGFjY3VyYWN5IHVzZSBlbl9jb3JlX3dlYl9tZCBvcgplbl9jb3JlX3dlYl90cmYgKGRvd25sb2FkIHRoZSBzYW1lIHdheSwgdGhlbiBjaGFuZ2UgdGhlIGxvYWQgYmVsb3cpLgotLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQoiIiIKZnJvbSBmYXN0YXBpIGltcG9ydCBGYXN0QVBJCgppbXBvcnQgcmUKaW1wb3J0IHNwYWN5CgpNT0RFTCA9ICJlbl9jb3JlX3dlYl9zbSIKbmxwID0gc3BhY3kubG9hZChNT0RFTCkKCmFwcCA9IEZhc3RBUEkodGl0bGU9IkNoYXRQYW5lbCBORVIgaGVscGVyIikKCiMgc3BhQ3kncyBORVIgZW1pdHMgbmFtZXMgLyBvcmdzIC8gbG9jYXRpb25zIGJ1dCBOT1Qgc3RydWN0dXJlZCBpZGVudGlmaWVycywgc28gYWRkCiMgYSByZWdleCBwYXNzIGZvciB0aG9zZS4gKENoYXRQYW5lbCBhbHNvIGNhdGNoZXMgdGhlc2Ugb24tZGV2aWNlLCBidXQgZW1pdHRpbmcgdGhlbQojIGhlcmUga2VlcHMgdGhlIGRldGVjdG9yIHNlbGYtY29udGFpbmVkIOKAlCB3aGF0IHlvdSB0ZXN0IGlzIHdoYXQgeW91IGdldC4pIE9yZGVyCiMgbWF0dGVyczogbW9yZSBzcGVjaWZpYyBwYXR0ZXJucyBydW4gZmlyc3Qgc28gYSBjYXJkIC8gU1NOIGlzbid0IHJlLW1hdGNoZWQgYXMgYQojIHBob25lIG51bWJlci4KX1BBVFRFUk5TID0gWwogICAgKCJFTUFJTCIsIHJlLmNvbXBpbGUociJbQS1aYS16MC05Ll8lK1wtXStAW0EtWmEtejAtOS5cLV0rXC5bQS1aYS16XXsyLH0iKSksCiAgICAoIlNTTiIsIHJlLmNvbXBpbGUociJcYlxkezN9LVxkezJ9LVxkezR9XGIiKSksCiAgICAoIkNSRURJVF9DQVJEIiwgcmUuY29tcGlsZShyIlxiKD86XGRbIC1dPyl7MTMsMTl9XGIiKSksCiAgICAoIklQIiwgcmUuY29tcGlsZShyIlxiKD86XGR7MSwzfVwuKXszfVxkezEsM31cYiIpKSwKICAgICgiUEhPTkUiLCByZS5jb21waWxlKHIiKD88IVxkKSg/OlwrP1xkezEsMn1bIC5cLV0/KT9cKD9cZHszfVwpP1sgLlwtXT9cZHszfVsgLlwtXT9cZHs0fSg/IVxkKSIpKSwKXQoKCmRlZiBfcmVnZXhfZW50aXRpZXModGV4dCk6CiAgICBvdXQsIHRha2VuID0gW10sIFtdCiAgICBmb3IgbGFiZWwsIHJ4IGluIF9QQVRURVJOUzoKICAgICAgICBmb3IgbSBpbiByeC5maW5kaXRlcih0ZXh0KToKICAgICAgICAgICAgc3RhcnQsIGVuZCA9IG0uc3RhcnQoKSwgbS5lbmQoKQogICAgICAgICAgICBpZiBhbnkoc3RhcnQgPCB0ZSBhbmQgdHMgPCBlbmQgZm9yIHRzLCB0ZSBpbiB0YWtlbik6CiAgICAgICAgICAgICAgICBjb250aW51ZSAgIyBzcGFuIGFscmVhZHkgY2xhaW1lZCBieSBhIG1vcmUgc3BlY2lmaWMgcGF0dGVybgogICAgICAgICAgICB0YWtlbi5hcHBlbmQoKHN0YXJ0LCBlbmQpKQogICAgICAgICAgICBvdXQuYXBwZW5kKHsidmFsdWUiOiBtLmdyb3VwKDApLnN0cmlwKCksICJ0eXBlIjogbGFiZWx9KQogICAgcmV0dXJuIG91dAoKCkBhcHAuZ2V0KCIvaGVhbHRoIikKZGVmIGhlYWx0aCgpOgogICAgcmV0dXJuIHsib2siOiBUcnVlLCAibW9kZWwiOiBNT0RFTH0KCgpAYXBwLnBvc3QoIi9uZXIiKQpkZWYgbmVyKHBheWxvYWQ6IGRpY3QpOgogICAgIiIiUmV0dXJuIHNwYUN5IGVudGl0aWVzICsgcmVnZXggaWRlbnRpZmllcnMgaW4gQ2hhdFBhbmVsJ3MgZXhwZWN0ZWQgc2hhcGUuCgogICAgQ2hhdFBhbmVsIG1hcHMgY29tbW9uIGxhYmVscyBpdHNlbGYgKFBFUuKGklBFUlNPTiwgR1BF4oaSTE9DQVRJT04sIOKApikgYW5kIGtlZXBzCiAgICBvbmx5IHRoZSBjYXRlZ29yaWVzIHlvdSBlbmFibGUgaW4gdGhlIFByaXZhY3kgdGFiLCBzbyBpdCdzIGZpbmUgdG8gcmV0dXJuIGFsbAogICAgb2YgdGhlbSBoZXJlLgogICAgIiIiCiAgICB0ZXh0ID0gKHBheWxvYWQgb3Ige30pLmdldCgidGV4dCIsICIiKSBvciAiIgogICAgZG9jID0gbmxwKHRleHQpCiAgICBlbnRzID0gW3sidmFsdWUiOiBlbnQudGV4dCwgInR5cGUiOiBlbnQubGFiZWxffSBmb3IgZW50IGluIGRvYy5lbnRzXQogICAgZW50cy5leHRlbmQoX3JlZ2V4X2VudGl0aWVzKHRleHQpKQogICAgIyBEZS1kdXAgaWRlbnRpY2FsIHZhbHVlK3R5cGUgKHNwYUN5IGFuZCBhIHJlZ2V4IGNhbiBib3RoIHN1cmZhY2UgdGhlIHNhbWUgc3BhbikuCiAgICBzZWVuLCBvdXQgPSBzZXQoKSwgW10KICAgIGZvciBlIGluIGVudHM6CiAgICAgICAga2V5ID0gKGVbInR5cGUiXSwgZVsidmFsdWUiXS5sb3dlcigpKQogICAgICAgIGlmIGVbInZhbHVlIl0gYW5kIGtleSBub3QgaW4gc2VlbjoKICAgICAgICAgICAgc2Vlbi5hZGQoa2V5KQogICAgICAgICAgICBvdXQuYXBwZW5kKGUpCiAgICByZXR1cm4geyJlbnRpdGllcyI6IG91dH0K",
|
|
7
|
+
"requirements.txt": "ZmFzdGFwaQp1dmljb3JuW3N0YW5kYXJkXQpzcGFjeQo=",
|
|
8
|
+
};
|
package/src/ner.js
CHANGED
|
@@ -9,10 +9,32 @@
|
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { dirname, join } from 'node:path';
|
|
12
|
-
import { existsSync } from 'node:fs';
|
|
12
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from 'node:fs';
|
|
13
13
|
import os from 'node:os';
|
|
14
|
+
import { NER_ASSETS } from './ner-assets.js';
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
// Where the NER server lives ON DISK. We always run from a real, writable user dir
|
|
17
|
+
// (~/.chatpanel/ner) rather than the package's ner/ — because a STANDALONE BINARY
|
|
18
|
+
// has no ner/ on the filesystem at all (it's inside the SEA snapshot), and a global
|
|
19
|
+
// npm dir may be read-only. The venv + installed deps persist here across restarts.
|
|
20
|
+
function materializeNerDir() {
|
|
21
|
+
const dir = join(os.homedir(), '.chatpanel', 'ner');
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
for (const [name, b64] of Object.entries(NER_ASSETS)) {
|
|
25
|
+
const target = join(dir, name);
|
|
26
|
+
const want = Buffer.from(b64, 'base64');
|
|
27
|
+
// Refresh the scripts if changed (pick up updates), but never touch .venv.
|
|
28
|
+
const have = existsSync(target) ? readFileSync(target) : null;
|
|
29
|
+
if (!have || !have.equals(want)) writeFileSync(target, want);
|
|
30
|
+
if (name.endsWith('.sh')) { try { chmodSync(target, 0o755); } catch { /* best effort */ } }
|
|
31
|
+
}
|
|
32
|
+
return dir;
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.log(`[ner] could not materialize NER dir at ${dir} (${e.message})`);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
16
38
|
|
|
17
39
|
// A login service (LaunchAgent / systemd) inherits a MINIMAL PATH — not your
|
|
18
40
|
// shell's — so `bash` and a pyenv/homebrew `python3` aren't found. Resolve bash
|
|
@@ -86,10 +108,14 @@ export function startNer(cfg) {
|
|
|
86
108
|
// 1) Adopt an existing NER already serving on the port (e.g. the user's own).
|
|
87
109
|
if (await nerReachable(port, ac.signal)) { wire('using existing NER'); return; }
|
|
88
110
|
|
|
89
|
-
// 2) Otherwise launch the bundled spaCy server.
|
|
111
|
+
// 2) Otherwise launch the bundled spaCy server — materializing run.sh +
|
|
112
|
+
// server.py + requirements.txt to a real on-disk dir first (the binary has
|
|
113
|
+
// no ner/ on the FS). run.sh then creates the venv, installs deps, and runs.
|
|
114
|
+
const nerDir = materializeNerDir();
|
|
115
|
+
if (!nerDir) { console.log('[ner] no writable NER dir — deterministic redaction only'); return; }
|
|
90
116
|
try {
|
|
91
117
|
child = spawn(resolveBash(), ['run.sh'], {
|
|
92
|
-
cwd:
|
|
118
|
+
cwd: nerDir,
|
|
93
119
|
env: {
|
|
94
120
|
...process.env,
|
|
95
121
|
PORT: String(port),
|
|
@@ -129,7 +155,7 @@ export function startNer(cfg) {
|
|
|
129
155
|
if (await nerReachable(port, ac.signal)) { wire('ready'); return; }
|
|
130
156
|
await sleep(1000);
|
|
131
157
|
}
|
|
132
|
-
if (!stopped) console.log(
|
|
158
|
+
if (!stopped) console.log(`[ner] not ready after 300s — continuing deterministic-only (run ${join(os.homedir(), '.chatpanel', 'ner', 'run.sh')} manually to debug).`);
|
|
133
159
|
})();
|
|
134
160
|
|
|
135
161
|
const stop = () => {
|
package/src/server.js
CHANGED
|
@@ -33,7 +33,7 @@ import * as openai from './openai.js';
|
|
|
33
33
|
import * as responses from './responses.js';
|
|
34
34
|
import * as anthropic from './anthropic.js';
|
|
35
35
|
|
|
36
|
-
export const VERSION = '0.4.
|
|
36
|
+
export const VERSION = '0.4.3';
|
|
37
37
|
|
|
38
38
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
39
39
|
|
|
@@ -150,6 +150,28 @@ function sendJson(res, status, obj) {
|
|
|
150
150
|
res.end(JSON.stringify(obj));
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// The detector URL (…:9009/ner) and its sibling /health. Returns null when no
|
|
154
|
+
// detector is wired (deterministic-only).
|
|
155
|
+
function nerBaseUrl(cfg) {
|
|
156
|
+
const url = cfg.redaction?.detection?.url;
|
|
157
|
+
if (!url || cfg.redaction?.detection?.backend === 'off') return null;
|
|
158
|
+
return url;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Live health probe of the bundled/configured NER (GET /health → { ok, model }).
|
|
162
|
+
async function probeNerHealth(cfg) {
|
|
163
|
+
const url = nerBaseUrl(cfg);
|
|
164
|
+
if (!url) return { configured: false, ok: false, url: null, model: null };
|
|
165
|
+
try {
|
|
166
|
+
const r = await fetch(url.replace(/\/ner\/?$/, '') + '/health', { signal: AbortSignal.timeout(2000) });
|
|
167
|
+
if (!r.ok) return { configured: true, ok: false, url, model: null };
|
|
168
|
+
const j = await r.json().catch(() => ({}));
|
|
169
|
+
return { configured: true, ok: true, url, model: j.model || null };
|
|
170
|
+
} catch {
|
|
171
|
+
return { configured: true, ok: false, url, model: null };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
153
175
|
function forwardHeaders(headers, base) {
|
|
154
176
|
const out = {};
|
|
155
177
|
for (const [k, v] of Object.entries(headers)) {
|
|
@@ -330,14 +352,41 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
330
352
|
// --- Config API (the extension's "Gateway" tab is a client of these) ---
|
|
331
353
|
if (pathname === '/status' && req.method === 'GET') {
|
|
332
354
|
const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
|
|
333
|
-
const
|
|
355
|
+
const health = await probeNerHealth(cfg); // live GET /health on the detector
|
|
334
356
|
return sendJson(res, 200, {
|
|
335
357
|
ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier,
|
|
336
|
-
ner: {
|
|
358
|
+
ner: {
|
|
359
|
+
autostart: !!cfg.ner?.autostart,
|
|
360
|
+
configured: health.configured,
|
|
361
|
+
ready: health.ok, // the detector actually answered /health
|
|
362
|
+
model: health.model, // e.g. "en_core_web_sm"
|
|
363
|
+
url: health.url,
|
|
364
|
+
},
|
|
337
365
|
pro: { unlocked: proUnlocked }, usage: usage(cfg),
|
|
338
366
|
uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
|
|
339
367
|
});
|
|
340
368
|
}
|
|
369
|
+
// Proxy the detector so it's checkable on the gateway's own port (the NER runs
|
|
370
|
+
// on its own port, e.g. 9009 — hitting :4320/ner used to 404). GET → health.
|
|
371
|
+
if (pathname === '/ner') {
|
|
372
|
+
const url = nerBaseUrl(cfg);
|
|
373
|
+
if (!url) return sendJson(res, 503, { error: { message: 'NER not configured — deterministic-only redaction', type: 'ner_off' } });
|
|
374
|
+
if (req.method === 'GET') {
|
|
375
|
+
const health = await probeNerHealth(cfg);
|
|
376
|
+
return sendJson(res, health.ok ? 200 : 503, health);
|
|
377
|
+
}
|
|
378
|
+
if (req.method === 'POST') {
|
|
379
|
+
try {
|
|
380
|
+
const body = await readBody(req, cfg.maxBodyBytes);
|
|
381
|
+
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: AbortSignal.timeout(8000) });
|
|
382
|
+
const text = await r.text();
|
|
383
|
+
res.writeHead(r.status, { 'content-type': 'application/json' });
|
|
384
|
+
return res.end(text);
|
|
385
|
+
} catch (e) {
|
|
386
|
+
return sendJson(res, 502, { error: { message: `NER unreachable: ${e.message}`, type: 'ner_unreachable' } });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
341
390
|
if (pathname === '/logs' && req.method === 'GET') {
|
|
342
391
|
return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only
|
|
343
392
|
}
|