agserver 1.0.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/README.md ADDED
@@ -0,0 +1,448 @@
1
+ # AGServer
2
+
3
+ AI-assisted git project browser server. Run it against any folder of git repos and connect your client app to browse code, view git history, and chat with an AI agent via `kiro-cli`.
4
+
5
+ ---
6
+
7
+ ## Requirements
8
+
9
+ - Node.js >= 18
10
+ - `git` on PATH
11
+ - `kiro-cli` installed and authenticated (for chat endpoints)
12
+
13
+ ---
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install -g agserver
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Start the server
24
+
25
+ ```bash
26
+ # Serve a specific folder
27
+ agserver ~/my-projects
28
+
29
+ # Custom port
30
+ agserver ~/my-projects --port 9000
31
+
32
+ # Bind to all interfaces (LAN access)
33
+ agserver ~/my-projects --public
34
+
35
+ # Provide your own API key
36
+ agserver ~/my-projects --key my-secret-key-at-least-24-chars
37
+ ```
38
+
39
+ On first run the server prints the auto-generated API key — copy it, you'll need it in your client:
40
+
41
+ ```
42
+ agserver
43
+ projects root : /Users/you/my-projects
44
+ port : 8765
45
+ kiro-cli : 1.2.3 ✓
46
+ [agserver] running
47
+ [agserver] host=127.0.0.1 port=8765 apiVersion=1.0.0
48
+ [agserver] api key: <generated-key-shown-here> ← copy this
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Connecting from your client
54
+
55
+ Every request to `/api/*` must include the header:
56
+
57
+ ```
58
+ x-agserver-key: <your-api-key>
59
+ ```
60
+
61
+ The server also returns `x-agserver-version` on every response. Check it matches your client's expected major version.
62
+
63
+ ### Version compatibility check (recommended)
64
+
65
+ ```js
66
+ const res = await fetch('http://localhost:8765/health');
67
+ const { apiVersion } = await res.json();
68
+ const [serverMajor] = apiVersion.split('.');
69
+ const [clientMajor] = CLIENT_API_VERSION.split('.');
70
+ if (serverMajor !== clientMajor) throw new Error('AGServer version mismatch');
71
+ ```
72
+
73
+ ### Base client helper (JavaScript)
74
+
75
+ ```js
76
+ const BASE = 'http://localhost:8765';
77
+ const KEY = 'your-api-key-here';
78
+
79
+ async function api(path, opts = {}) {
80
+ const res = await fetch(`${BASE}${path}`, {
81
+ ...opts,
82
+ headers: { 'x-agserver-key': KEY, 'Content-Type': 'application/json', ...opts.headers },
83
+ });
84
+ if (!res.ok) throw new Error(`AGServer ${res.status}: ${await res.text()}`);
85
+ return res.json();
86
+ }
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Endpoints
92
+
93
+ ### GET /health
94
+ No auth required. Use this to verify the server is reachable and check its version.
95
+
96
+ ```
97
+ GET http://localhost:8765/health
98
+ ```
99
+
100
+ ```json
101
+ {
102
+ "ok": true,
103
+ "apiVersion": "1.0.0",
104
+ "host": "127.0.0.1",
105
+ "port": 8765,
106
+ "privateIpOnly": false,
107
+ "time": "2026-01-01T00:00:00.000Z"
108
+ }
109
+ ```
110
+
111
+ ---
112
+
113
+ ### GET /api/project-list
114
+ Returns all git repos discovered one level deep under the projects root.
115
+
116
+ ```
117
+ GET /api/project-list
118
+ x-agserver-key: <key>
119
+ ```
120
+
121
+ ```json
122
+ {
123
+ "version": 1,
124
+ "title": "AGClient",
125
+ "groups": ["All Project", "AGProject"],
126
+ "projects": [
127
+ {
128
+ "id": "project-myrepo",
129
+ "name": "myrepo",
130
+ "path": "myrepo",
131
+ "summary": "fix: login bug (main)",
132
+ "status": "online",
133
+ "lastChange": "2026-01-01",
134
+ "group": "AGProject",
135
+ "unreadCount": 3,
136
+ "initials": "MY",
137
+ "avatarColor": "#2563EB"
138
+ }
139
+ ]
140
+ }
141
+ ```
142
+
143
+ `status` is one of `"online"` | `"busy"` (uncommitted changes) | `"warning"` (merge conflicts).
144
+
145
+ ---
146
+
147
+ ### GET /api/project-branches?projectId=
148
+ Returns branches and tags for a project.
149
+
150
+ ```
151
+ GET /api/project-branches?projectId=project-myrepo
152
+ x-agserver-key: <key>
153
+ ```
154
+
155
+ ```json
156
+ {
157
+ "version": 1,
158
+ "projects": [{
159
+ "projectId": "project-myrepo",
160
+ "projectName": "myrepo",
161
+ "branches": [
162
+ {
163
+ "id": "branch-feat-login",
164
+ "name": "feat/login",
165
+ "brief": "Add login flow",
166
+ "tag": "FEAT",
167
+ "color": "#2563EB",
168
+ "icon": "alt-route",
169
+ "lastChange": "2 days ago",
170
+ "group": "active",
171
+ "commitCount": 4
172
+ }
173
+ ],
174
+ "tags": [
175
+ { "id": "tag-v1-0-0", "name": "v1.0.0", "lastChange": "3 weeks ago", "subject": "Release v1.0.0" }
176
+ ]
177
+ }]
178
+ }
179
+ ```
180
+
181
+ `group` is `"active"` or `"stale"` based on `AGSERVER_ACTIVE_BRANCH_DAYS` (default 30).
182
+
183
+ ---
184
+
185
+ ### GET /api/branch-workspace?projectId=&branchId=
186
+ Returns the full workspace data for a branch — git graph, change summary, file browser config.
187
+
188
+ ```
189
+ GET /api/branch-workspace?projectId=project-myrepo&branchId=branch-feat-login
190
+ x-agserver-key: <key>
191
+ ```
192
+
193
+ ```json
194
+ {
195
+ "version": 1,
196
+ "workspaces": [{
197
+ "projectId": "project-myrepo",
198
+ "branchId": "branch-feat-login",
199
+ "tabs": {
200
+ "git": { "graph": { "selectedBranch": "feat/login", "baseBranch": "main", "script": "..." } },
201
+ "changes": { "chips": ["All 3", "Modified 2", "New 1", "Delete 0"], "tree": [...] },
202
+ "chat": { "mode": "text-placeholder" },
203
+ "files": { "rootPath": "", "initialDepth": 2 }
204
+ }
205
+ }]
206
+ }
207
+ ```
208
+
209
+ The `git.graph.script` is a `@gitgraph/js` imperative script — pass it to a `GitgraphJS.createGitgraph` container to render the graph.
210
+
211
+ ---
212
+
213
+ ### GET /api/git/commit?projectId=&branchId=&commitId=
214
+ Returns commit metadata and its diff tree. Use `commitId=working-tree` for uncommitted changes.
215
+
216
+ ```
217
+ GET /api/git/commit?projectId=project-myrepo&branchId=branch-feat-login&commitId=abc1234
218
+ x-agserver-key: <key>
219
+ ```
220
+
221
+ ```json
222
+ {
223
+ "version": 1,
224
+ "projectId": "project-myrepo",
225
+ "branchId": "branch-feat-login",
226
+ "commit": {
227
+ "id": "abc1234",
228
+ "hash": "abc1234...",
229
+ "shortHash": "abc1234",
230
+ "subject": "fix: handle null user",
231
+ "author": "Jane",
232
+ "authoredAt": "2026-01-01",
233
+ "isWorkingTree": false
234
+ },
235
+ "changes": {
236
+ "chips": ["All 2", "Modified 2", "New 0", "Delete 0"],
237
+ "tree": [{ "id": "c1", "level": 0, "type": "file", "name": "auth.ts", "path": "src/auth.ts", "status": "M" }],
238
+ "patch": "diff --git ...",
239
+ "patchTruncated": false
240
+ }
241
+ }
242
+ ```
243
+
244
+ ---
245
+
246
+ ### GET /api/git/commit-file?projectId=&branchId=&commitId=&path=
247
+ Returns the content of a specific file at a commit.
248
+
249
+ ```
250
+ GET /api/git/commit-file?projectId=project-myrepo&branchId=branch-feat-login&commitId=abc1234&path=src/auth.ts
251
+ x-agserver-key: <key>
252
+ ```
253
+
254
+ ```json
255
+ {
256
+ "path": "src/auth.ts",
257
+ "content": "export function login() { ... }",
258
+ "size": 1024,
259
+ "binary": false,
260
+ "truncated": false
261
+ }
262
+ ```
263
+
264
+ For images: `"image": true`, content is base64. For PDFs: `"pdf": true`, content is base64. For LFS pointers that can't be smudged: `"lfs": true`, content is empty.
265
+
266
+ ---
267
+
268
+ ### GET /api/git/commit-file-diff?projectId=&branchId=&commitId=&path=
269
+ Returns the unified diff for a single file at a commit.
270
+
271
+ ```json
272
+ { "diff": "--- a/src/auth.ts\n+++ b/src/auth.ts\n...", "truncated": false }
273
+ ```
274
+
275
+ ---
276
+
277
+ ### GET /api/files/tree?projectId=&branchId=&path=&levels=
278
+ Browse the file tree. `path` defaults to repo root, `levels` is 1–4 (default 2).
279
+
280
+ ```
281
+ GET /api/files/tree?projectId=project-myrepo&branchId=branch-feat-login&path=src&levels=2
282
+ x-agserver-key: <key>
283
+ ```
284
+
285
+ ```json
286
+ {
287
+ "projectId": "project-myrepo",
288
+ "branchId": "branch-feat-login",
289
+ "path": "src",
290
+ "levels": 2,
291
+ "usingLiveFs": true,
292
+ "nodes": [
293
+ { "name": "components", "path": "src/components", "type": "dir", "hasChildren": true },
294
+ { "name": "auth.ts", "path": "src/auth.ts", "type": "file", "size": 1024, "ext": ".ts", "hasChildren": false }
295
+ ]
296
+ }
297
+ ```
298
+
299
+ `usingLiveFs: true` means the current checked-out branch is being served from disk. `false` means reading from git history.
300
+
301
+ ---
302
+
303
+ ### GET /api/files/content?projectId=&branchId=&path=
304
+ Returns the content of any file in the repo.
305
+
306
+ ```
307
+ GET /api/files/content?projectId=project-myrepo&branchId=branch-feat-login&path=src/auth.ts
308
+ x-agserver-key: <key>
309
+ ```
310
+
311
+ Same response shape as `commit-file`.
312
+
313
+ ---
314
+
315
+ ### GET /api/files/pdf?projectId=&branchId=&path=&commitId=
316
+ Returns raw `application/pdf` bytes. Use directly as a PDF source URL (include the key as a query param is not supported — use a proxy or fetch + blob URL on the client).
317
+
318
+ ---
319
+
320
+ ### POST /api/chat/stream
321
+ Streams an AI chat response via `kiro-cli`. Response is NDJSON — one JSON object per line.
322
+
323
+ ```
324
+ POST /api/chat/stream
325
+ x-agserver-key: <key>
326
+ Content-Type: application/json
327
+
328
+ {
329
+ "context": {
330
+ "projectId": "project-myrepo",
331
+ "branchId": "branch-feat-login",
332
+ "filePath": "src/auth.ts",
333
+ "fileContent": "...",
334
+ "fileDiff": "..."
335
+ },
336
+ "messages": [
337
+ { "role": "user", "content": "Why does login fail for null users?" }
338
+ ],
339
+ "attachments": [
340
+ { "path": "src/types.ts", "content": "..." }
341
+ ]
342
+ }
343
+ ```
344
+
345
+ Response stream (each line is a JSON object):
346
+ ```
347
+ {"delta":"The issue is"}
348
+ {"delta":" on line 42..."}
349
+ {"done":true,"credits":0.003,"elapsed":"1.2s"}
350
+ ```
351
+
352
+ On error:
353
+ ```
354
+ {"error":"kiro-cli exited with code 1: ...","done":true}
355
+ ```
356
+
357
+ Client reading example:
358
+ ```js
359
+ const res = await fetch(`${BASE}/api/chat/stream`, {
360
+ method: 'POST',
361
+ headers: { 'x-agserver-key': KEY, 'Content-Type': 'application/json' },
362
+ body: JSON.stringify({ context, messages, attachments }),
363
+ });
364
+ const reader = res.body.getReader();
365
+ const decoder = new TextDecoder();
366
+ let buf = '';
367
+ while (true) {
368
+ const { done, value } = await reader.read();
369
+ if (done) break;
370
+ buf += decoder.decode(value, { stream: true });
371
+ const lines = buf.split('\n');
372
+ buf = lines.pop();
373
+ for (const line of lines) {
374
+ if (!line.trim()) continue;
375
+ const msg = JSON.parse(line);
376
+ if (msg.delta) process.stdout.write(msg.delta);
377
+ if (msg.done) console.log('\n[done]', msg);
378
+ if (msg.error) console.error('[error]', msg.error);
379
+ }
380
+ }
381
+ ```
382
+
383
+ ---
384
+
385
+ ### POST /api/chat/message
386
+ Same as `/api/chat/stream` but waits for the full response and returns it in one JSON object.
387
+
388
+ ```json
389
+ { "content": "The issue is on line 42...", "credits": 0.003, "elapsed": "1.2s" }
390
+ ```
391
+
392
+ ---
393
+
394
+ ### POST /api/worktrees/dispose
395
+ Cleans up git worktrees created by chat sessions. Optionally scoped to one project.
396
+
397
+ ```json
398
+ // request body (optional)
399
+ { "projectId": "project-myrepo" }
400
+
401
+ // response
402
+ { "disposed": { "project-myrepo": [{ "dir": "...", "branch": "feat/login", "pushed": false }] } }
403
+ ```
404
+
405
+ ---
406
+
407
+ ## Error responses
408
+
409
+ All errors follow the same shape:
410
+
411
+ ```json
412
+ { "error": "error_code", "message": "optional detail" }
413
+ ```
414
+
415
+ | Code | HTTP | Meaning |
416
+ |---|---|---|
417
+ | `invalid_api_key` | 401 | Missing or wrong `x-agserver-key` |
418
+ | `forbidden_remote_ip` | 403 | IP blocked by `PRIVATE_IP_ONLY` |
419
+ | `not_found` | 404 | Unknown route |
420
+ | `project_not_found` | 404 | `projectId` doesn't match any repo |
421
+ | `branch_not_found` | 404 | `branchId` doesn't resolve |
422
+ | `commit_not_found` | 404 | `commitId` not in repo |
423
+ | `file_not_found` | 404 | Path not found at that ref |
424
+ | `missing_project_id` | 400 | Required query param missing |
425
+ | `path_outside_repo` | 400 | Path traversal attempt blocked |
426
+ | `path_blocked` | 400 | Path points to an ignored directory |
427
+ | `server_error` | 500 | Unexpected internal error |
428
+
429
+ ---
430
+
431
+ ## Environment variables
432
+
433
+ | Variable | Default | Description |
434
+ |---|---|---|
435
+ | `AGSERVER_PORT` | `8765` | Listen port |
436
+ | `AGSERVER_HOST` | `127.0.0.1` | Listen host |
437
+ | `AGSERVER_API_KEY` | auto-generated | Auth key (min 24 chars) |
438
+ | `AGSERVER_PROJECTS_ROOT` | cwd | Root folder scanned for git repos |
439
+ | `AGSERVER_ALLOW_WEAK_KEY` | `0` | Set `1` to allow short keys in dev |
440
+ | `AGSERVER_PRIVATE_IP_ONLY` | `0` | Set `1` to restrict to LAN subnet |
441
+ | `AGSERVER_ACTIVE_BRANCH_DAYS` | `30` | Days before a branch is considered stale |
442
+ | `AGSERVER_KIRO_CLI` | `kiro-cli` | Path to kiro-cli binary |
443
+
444
+ ---
445
+
446
+ ## Security note
447
+
448
+ The chat endpoints (`/api/chat/*`) give an AI agent full tool access to your machine via `kiro-cli --trust-all-tools`. Treat your API key like a root password — never expose it in client-side code or commit it to source control.
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "agserver",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "AI-assisted git project browser server — run from any folder like http-server",
6
+ "main": "server/index.mjs",
7
+ "bin": {
8
+ "agserver": "server/cli.mjs"
9
+ },
10
+ "engines": {
11
+ "node": ">=18.0.0"
12
+ },
13
+ "scripts": {
14
+ "start": "node server/cli.mjs",
15
+ "dev": "node --watch server/index.mjs"
16
+ },
17
+ "keywords": ["git", "kiro", "ai", "project-browser", "agserver"],
18
+ "license": "MIT",
19
+ "dependencies": {}
20
+ }
@@ -0,0 +1,109 @@
1
+ # AGServer — API Contract
2
+
3
+ ## Versioning
4
+
5
+ API version: `1.0.0` (semver)
6
+
7
+ The server advertises its version in every response via the `x-agserver-version` header.
8
+ The `/health` endpoint also returns `apiVersion` in the JSON body.
9
+
10
+ Compatibility rule: client and server are compatible if they share the same **major** version.
11
+ The client should warn (but not block) on minor/patch mismatches.
12
+
13
+ ## Auth
14
+
15
+ All `/api/*` endpoints require the header:
16
+ ```
17
+ x-agserver-key: <AGSERVER_API_KEY>
18
+ ```
19
+
20
+ ## Endpoints
21
+
22
+ ### GET /health
23
+ No auth required.
24
+ ```json
25
+ { "ok": true, "apiVersion": "1.0.0", "host": "...", "port": 8765, "time": "..." }
26
+ ```
27
+
28
+ ### GET /api/project-list
29
+ Returns all git repos auto-discovered under `AGSERVER_PROJECTS_ROOT`.
30
+ ```json
31
+ { "version": 1, "title": "AGClient", "groups": ["All Project", "AGProject"], "projects": [...] }
32
+ ```
33
+
34
+ ### GET /api/project-branches?projectId=
35
+ ```json
36
+ { "version": 1, "projects": [{ "projectId", "projectName", "branches": [...], "tags": [...] }] }
37
+ ```
38
+
39
+ ### GET /api/branch-workspace?projectId=&branchId=
40
+ ```json
41
+ { "version": 1, "workspaces": [{ "projectId", "branchId", "tabs": { "git", "changes", "chat", "files" } }] }
42
+ ```
43
+
44
+ ### GET /api/git/commit?projectId=&branchId=&commitId=
45
+ ```json
46
+ { "version": 1, "projectId", "branchId", "commit": { "id", "hash", "shortHash", "subject", "author", "authoredAt", "isWorkingTree" }, "changes": { "chips", "tree", "patch", "patchTruncated" } }
47
+ ```
48
+
49
+ ### GET /api/git/commit-file?projectId=&branchId=&commitId=&path=
50
+ ```json
51
+ { "path", "content", "size", "binary", "truncated", "image?", "pdf?", "lfs?" }
52
+ ```
53
+
54
+ ### GET /api/git/commit-file-diff?projectId=&branchId=&commitId=&path=
55
+ ```json
56
+ { "diff": "...", "truncated": false }
57
+ ```
58
+
59
+ ### GET /api/files/tree?projectId=&branchId=&path=&levels=
60
+ ```json
61
+ { "projectId", "branchId", "path", "levels", "nodes": [{ "name", "path", "type", "hasChildren", "size?", "ext?", "children?" }] }
62
+ ```
63
+
64
+ ### GET /api/files/content?projectId=&branchId=&path=
65
+ ```json
66
+ { "path", "content", "size", "binary", "truncated", "image?", "pdf?", "lfs?" }
67
+ ```
68
+
69
+ ### GET /api/files/pdf?projectId=&branchId=&path=&commitId=
70
+ Returns raw `application/pdf` bytes.
71
+
72
+ ### POST /api/chat/stream
73
+ NDJSON stream. Each line: `{ "delta": "..." }` or `{ "done": true, "credits?", "elapsed?" }` or `{ "error": "..." }`.
74
+
75
+ ### POST /api/chat/message
76
+ ```json
77
+ { "content": "...", "credits?", "elapsed?" }
78
+ ```
79
+
80
+ ### POST /api/worktrees/dispose
81
+ ```json
82
+ { "disposed": { "<projectId>": [...] } }
83
+ ```
84
+
85
+ ### GET /api/project-settings?projectId=
86
+ ```json
87
+ { "projectId": "...", "settings": { "worktreeEnabled": false } }
88
+ ```
89
+
90
+ ### POST /api/project-settings?projectId=
91
+ Body: `{ "worktreeEnabled": true }`
92
+ ```json
93
+ { "projectId": "...", "settings": { "worktreeEnabled": true } }
94
+ ```
95
+ Settings are persisted in `<AGSERVER_PROJECTS_ROOT>/.agserver-settings.json`.
96
+ By default `worktreeEnabled` is `false` — kiro-cli runs directly in the repo directory.
97
+ Set to `true` to enable the git worktree isolation model for that project.
98
+
99
+ ## Environment Variables
100
+
101
+ | Variable | Default | Description |
102
+ |---|---|---|
103
+ | `AGSERVER_PORT` | `8765` | Listen port |
104
+ | `AGSERVER_HOST` | `127.0.0.1` | Listen host |
105
+ | `AGSERVER_API_KEY` | auto-generated | Auth key |
106
+ | `AGSERVER_PROJECTS_ROOT` | `/Volumes/Agentic/AGProject` | Root dir scanned for git repos |
107
+ | `AGSERVER_ALLOW_WEAK_KEY` | `0` | Set `1` for dev |
108
+ | `AGSERVER_PRIVATE_IP_ONLY` | `0` | Restrict to LAN IPs |
109
+ | `AGSERVER_ACTIVE_BRANCH_DAYS` | `30` | Days threshold for active/stale branches |
@@ -0,0 +1,72 @@
1
+ import os from 'os';
2
+
3
+ export function normalizeIp(ip) {
4
+ if (!ip) return '';
5
+ if (ip.startsWith('::ffff:')) return ip.slice(7);
6
+ if (ip === '::1') return '127.0.0.1';
7
+ return ip;
8
+ }
9
+
10
+ function isPrivateIp(ip) {
11
+ const n = normalizeIp(ip);
12
+ if (!n) return false;
13
+ if (n === '127.0.0.1') return true;
14
+ if (n.startsWith('10.')) return true;
15
+ if (n.startsWith('192.168.')) return true;
16
+ if (n.startsWith('169.254.')) return true;
17
+ if (n.startsWith('172.')) {
18
+ const second = Number(n.split('.')[1] || '0');
19
+ return second >= 16 && second <= 31;
20
+ }
21
+ if (n === '::1') return true;
22
+ if (n.startsWith('fe80:') || n.startsWith('fd') || n.startsWith('fc')) return true;
23
+ return false;
24
+ }
25
+
26
+ export function listLanIps() {
27
+ const interfaces = os.networkInterfaces();
28
+ const ips = [];
29
+ Object.values(interfaces).forEach((entries) => {
30
+ (entries || []).forEach((entry) => {
31
+ if (entry.family !== 'IPv4' || entry.internal) return;
32
+ ips.push(entry.address);
33
+ });
34
+ });
35
+ return ips;
36
+ }
37
+
38
+ function sameSubnet24(a, b) {
39
+ const aa = a.split('.');
40
+ const bb = b.split('.');
41
+ if (aa.length !== 4 || bb.length !== 4) return false;
42
+ return aa[0] === bb[0] && aa[1] === bb[1] && aa[2] === bb[2];
43
+ }
44
+
45
+ function isAllowedRemoteIp(ip) {
46
+ const n = normalizeIp(ip);
47
+ if (n === '127.0.0.1') return true;
48
+ if (!isPrivateIp(n)) return false;
49
+ const localIps = listLanIps();
50
+ if (localIps.length === 0) return true;
51
+ if (n.includes(':')) return true;
52
+ return localIps.some((localIp) => sameSubnet24(localIp, n));
53
+ }
54
+
55
+ export function isWeakApiKey(value) {
56
+ return !value || value.length < 24 || value === 'chatagent-local-dev-key';
57
+ }
58
+
59
+ export function assertAuthorized(req, res, { API_KEY, PRIVATE_IP_ONLY, jsonFn }) {
60
+ const remote = normalizeIp(req.socket.remoteAddress || '');
61
+ if (PRIVATE_IP_ONLY && !isAllowedRemoteIp(remote)) {
62
+ jsonFn(res, 403, { error: 'forbidden_remote_ip', message: 'Request blocked: only local/private subnet clients are allowed.' });
63
+ return false;
64
+ }
65
+ const key = req.headers['x-agserver-key'];
66
+ if (key !== API_KEY) {
67
+ console.log(`[auth-fail] key=${key ? 'present' : 'missing'} from=${req.socket.remoteAddress} url=${req.url}`);
68
+ jsonFn(res, 401, { error: 'invalid_api_key', message: 'Missing or invalid API key.' });
69
+ return false;
70
+ }
71
+ return true;
72
+ }