@anteros/core 0.0.44 → 0.0.47
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 +1 -1
- package/database/field.crypto.ts +38 -13
- package/database/mongodbadapter.ts +85 -25
- package/database/vars.ts +22 -2
- package/index.ts +22 -0
- package/lib/agent.ts +707 -79
- package/lib/agentMemory.ts +199 -0
- package/lib/agents.ts +133 -52
- package/lib/attachment.ts +24 -3
- package/lib/define.ts +5 -1
- package/lib/eval.ts +123 -0
- package/lib/files.ts +21 -15
- package/lib/jsonSchema.ts +8 -0
- package/lib/mcp.ts +52 -30
- package/lib/messages.ts +202 -19
- package/lib/partialJson.ts +79 -0
- package/lib/providers.ts +26 -2
- package/lib/redact.ts +11 -0
- package/lib/tracing.ts +20 -0
- package/lib/zip.ts +23 -3
- package/package.json +4 -5
- package/server/agents.ts +66 -1
- package/server/api.ts +302 -20
- package/server/boot.ts +13 -4
- package/server/hono.ts +146 -6
- package/tests/access-log-body.test.ts +253 -0
- package/tests/agent-checkpoint.test.ts +195 -0
- package/tests/agent-hardening.test.ts +238 -0
- package/tests/agent-memory.test.ts +175 -1
- package/tests/agent-stream-advanced.test.ts +246 -0
- package/tests/agent-tracing-eval.test.ts +219 -0
- package/tests/agent-working-memory.test.ts +7 -4
- package/tests/agent.test.ts +54 -3
- package/tests/agents-api.test.ts +21 -0
- package/tests/api-external-guards.test.ts +304 -0
- package/tests/attachment.test.ts +63 -0
- package/tests/audit-redact.test.ts +26 -1
- package/tests/field-encryption.test.ts +47 -3
- package/tests/filters-hardening.test.ts +81 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
- package/tests/fixtures/fake-provider.ts +33 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
- package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
- package/tests/hono.test.ts +81 -1
- package/tests/jwt-tenant.test.ts +165 -0
- package/tests/logger.test.ts +62 -10
- package/tests/messages.test.ts +88 -32
- package/tests/tool-runtime.test.ts +291 -0
- package/tests/tool-types.ts +33 -0
- package/tests/upload.test.ts +175 -3
- package/tests/vars-api.test.ts +73 -1
- package/tests/vars.test.ts +1 -1
- package/types/agent.d.ts +386 -4
- package/types/config.d.ts +56 -2
- package/types/file.d.ts +1 -1
- package/types/mcp.d.ts +2 -2
- package/utils/func.ts +20 -0
- package/utils/logger.ts +125 -33
package/server/boot.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { createApp, collectHeaders } from './hono'
|
|
|
3
3
|
import { parseBootFlags, resolveCapabilities, conflictingBootFlags, offCapabilities, capabilitiesLabel, FULL_CAPABILITIES } from './flags'
|
|
4
4
|
import type { BootCapabilities } from './flags'
|
|
5
5
|
import type { ServerConfig } from '../types/config'
|
|
6
|
-
import dayjs from 'dayjs';
|
|
7
6
|
import pkg from '../package.json';
|
|
8
7
|
import boxen from 'boxen';
|
|
9
8
|
import "@colors/colors";
|
|
@@ -36,6 +35,13 @@ type BootAppOptions = ServerConfig & {
|
|
|
36
35
|
|
|
37
36
|
}
|
|
38
37
|
|
|
38
|
+
/** `YYYY-MM-DD HH:mm:ss` in **local** time — what the banner shows, from `Date` alone. */
|
|
39
|
+
function stamp(date = new Date()): string {
|
|
40
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
41
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
|
|
42
|
+
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
/**
|
|
40
46
|
* Console banner — name / PID / role, env, reusePort, cache, replication.
|
|
41
47
|
* `url` is omitted in replication-only mode (nothing is listening).
|
|
@@ -104,7 +110,7 @@ function renderBanner(opts: {
|
|
|
104
110
|
|
|
105
111
|
box += url ? `Url: ${url}`.gray.bold : `Url: none — no API`.yellow.bold;
|
|
106
112
|
box += '\n\n';
|
|
107
|
-
box += `Last boot: 🔄 ${
|
|
113
|
+
box += `Last boot: 🔄 ${stamp()}`.gray;
|
|
108
114
|
|
|
109
115
|
return boxen(box, {
|
|
110
116
|
title: ` @anteros/core ${pkg.version}`,
|
|
@@ -134,7 +140,7 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
134
140
|
console.log('⚠ --replication-only conflicts with --no-replication/--api-only — running replication-only'.yellow);
|
|
135
141
|
}
|
|
136
142
|
|
|
137
|
-
// Logging: configure from cfg.server.logging (console + file, e.g. logs/
|
|
143
|
+
// Logging: configure from cfg.server.logging (console + file, e.g. logs/app.log)
|
|
138
144
|
logger.configure(cfg.server.logging);
|
|
139
145
|
// File-only: the boxen banner already shows name / PID / URL / env on the console
|
|
140
146
|
logger.file('Starting Anteros server', {
|
|
@@ -193,6 +199,7 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
193
199
|
for (const tenant of cfg.tenants ?? []) {
|
|
194
200
|
try { tenant.database?.client?.close(); } catch {}
|
|
195
201
|
}
|
|
202
|
+
try { await logger.flush(); } catch {}
|
|
196
203
|
process.exit(0);
|
|
197
204
|
};
|
|
198
205
|
process.once('SIGINT', () => shutdownReplicationOnly('SIGINT'));
|
|
@@ -293,6 +300,7 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
293
300
|
for (const tenant of cfg.tenants ?? []) {
|
|
294
301
|
try { tenant.database?.client?.close(); } catch {}
|
|
295
302
|
}
|
|
303
|
+
try { await logger.flush(); } catch {}
|
|
296
304
|
process.exit(0);
|
|
297
305
|
};
|
|
298
306
|
process.once('SIGINT', () => shutdownCluster('SIGINT'));
|
|
@@ -378,7 +386,7 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
378
386
|
// Sockets are disabled by not serving `/socket.io/` (below): without an
|
|
379
387
|
// explicit `server.upgrade()` call no client can ever open a websocket.
|
|
380
388
|
websocket: websocket,
|
|
381
|
-
maxRequestBodySize: 1024 * 1024 *
|
|
389
|
+
maxRequestBodySize: Math.max(cfg.server.body?.maxSize ?? 16 * 1024 * 1024, 101 * 1024 * 1024), // 16MB normal, 100MB+overhead uploads
|
|
382
390
|
})
|
|
383
391
|
|
|
384
392
|
// Register server for built-in metrics (GET /health)
|
|
@@ -397,6 +405,7 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
397
405
|
for (const tenant of cfg.tenants ?? []) {
|
|
398
406
|
try { tenant.database?.client?.close(); } catch {}
|
|
399
407
|
}
|
|
408
|
+
try { await logger.flush(); } catch {}
|
|
400
409
|
process.exit(0);
|
|
401
410
|
};
|
|
402
411
|
process.once('SIGINT', () => shutdownServer('SIGINT'));
|
package/server/hono.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { initializeAgents } from "./agents";
|
|
|
18
18
|
import { initializeMcp } from "./mcp";
|
|
19
19
|
import { getGlobalMiddlewares } from "../lib/middleware";
|
|
20
20
|
import { jwt } from "../utils/func";
|
|
21
|
+
import { DEFAULT_REDACT_KEYS, isSensitiveKey, redactKeys } from "../lib/redact";
|
|
21
22
|
import { AppError } from "../lib/error";
|
|
22
23
|
import { getMetrics } from "./metrics";
|
|
23
24
|
import { logger } from "../utils/logger";
|
|
@@ -27,17 +28,85 @@ import type { HonoVariables } from "./env";
|
|
|
27
28
|
* Caddy-style header collection (array values); sensitive headers stripped
|
|
28
29
|
* (`authorization`, `cookie`, `proxy-authorization`, `set-cookie`).
|
|
29
30
|
*/
|
|
31
|
+
/** Request bodies are only read on demand (`logging.http.body`) — textual content types only. */
|
|
32
|
+
type CapturedBody = { value: string; bytes: number; truncated: boolean };
|
|
33
|
+
|
|
34
|
+
const TEXTUAL_BODY = /(^text\/)|json|xml|x-www-form-urlencoded|graphql/i;
|
|
35
|
+
|
|
36
|
+
/** Methods whose body is captured by default (`logging.http.methods` overrides). */
|
|
37
|
+
const DEFAULT_CAPTURE_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
38
|
+
|
|
39
|
+
async function captureBody(res: Response, maxBytes: number): Promise<CapturedBody | undefined> {
|
|
40
|
+
if (!res.body) return undefined;
|
|
41
|
+
const reader = res.body.getReader();
|
|
42
|
+
const chunks: Uint8Array[] = [];
|
|
43
|
+
let captured = 0;
|
|
44
|
+
let truncated = false;
|
|
45
|
+
|
|
46
|
+
for (;;) {
|
|
47
|
+
const { done, value } = await reader.read();
|
|
48
|
+
if (done) break;
|
|
49
|
+
if (!value || value.byteLength === 0) continue;
|
|
50
|
+
|
|
51
|
+
const remaining = maxBytes - captured;
|
|
52
|
+
if (value.byteLength > remaining) {
|
|
53
|
+
chunks.push(value.subarray(0, remaining));
|
|
54
|
+
captured = maxBytes;
|
|
55
|
+
truncated = true;
|
|
56
|
+
await reader.cancel().catch(() => {});
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
chunks.push(value);
|
|
61
|
+
captured += value.byteLength;
|
|
62
|
+
|
|
63
|
+
if (captured === maxBytes) {
|
|
64
|
+
const next = await reader.read();
|
|
65
|
+
if (!next.done) truncated = true;
|
|
66
|
+
await reader.cancel().catch(() => {});
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const buffer = new Uint8Array(captured);
|
|
72
|
+
let offset = 0;
|
|
73
|
+
for (const chunk of chunks) {
|
|
74
|
+
buffer.set(chunk, offset);
|
|
75
|
+
offset += chunk.byteLength;
|
|
76
|
+
}
|
|
77
|
+
return { value: new TextDecoder().decode(buffer), bytes: captured, truncated };
|
|
78
|
+
}
|
|
79
|
+
|
|
30
80
|
export function collectHeaders(h: Headers): Record<string, string[]> {
|
|
31
81
|
const EXCLUDE = ['authorization', 'cookie', 'proxy-authorization', 'set-cookie'];
|
|
82
|
+
const keys = redactKeys() ?? new Set(DEFAULT_REDACT_KEYS.map((key) => key.toLowerCase()));
|
|
32
83
|
const out: Record<string, string[]> = {};
|
|
33
84
|
for (const [name, value] of h.entries()) {
|
|
34
85
|
const key = name.toLowerCase();
|
|
35
|
-
if (EXCLUDE.includes(key)) continue;
|
|
86
|
+
if (EXCLUDE.includes(key) || isSensitiveKey(key, keys)) continue;
|
|
36
87
|
out[key] = [value];
|
|
37
88
|
}
|
|
38
89
|
return out;
|
|
39
90
|
}
|
|
40
91
|
|
|
92
|
+
/** Redact sensitive query-string parameters before they reach the access log. */
|
|
93
|
+
export function redactUri(uri: string): string {
|
|
94
|
+
const separator = uri.indexOf('?');
|
|
95
|
+
if (separator === -1) return uri;
|
|
96
|
+
const pathname = uri.slice(0, separator);
|
|
97
|
+
const keys = redactKeys() ?? new Set(DEFAULT_REDACT_KEYS.map((key) => key.toLowerCase()));
|
|
98
|
+
const params = new URLSearchParams(uri.slice(separator + 1));
|
|
99
|
+
let changed = false;
|
|
100
|
+
for (const name of [...params.keys()]) {
|
|
101
|
+
if (isSensitiveKey(name, keys)) {
|
|
102
|
+
params.set(name, '[redacted]');
|
|
103
|
+
changed = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!changed) return uri;
|
|
107
|
+
return `${pathname}?${params.toString()}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
41
110
|
// ─── Redis-backed rate limiting (C3) ─────────────────────────────────────────
|
|
42
111
|
// Lazy singleton: created once per process and reused across createApp() calls.
|
|
43
112
|
// Each increment falls back to the in-memory store if Redis is unavailable,
|
|
@@ -223,6 +292,25 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
223
292
|
// Caddy-compatible JSONL payload: `request`/`resp_headers` blocks + flat fields.
|
|
224
293
|
app.use(async (c, next) => {
|
|
225
294
|
const start = performance.now();
|
|
295
|
+
// `logging.http.body` is opt-in: clone the body BEFORE the handler consumes it.
|
|
296
|
+
const loggingCfg = cfg.server.logging;
|
|
297
|
+
const httpCfg = loggingCfg?.http;
|
|
298
|
+
if (httpCfg?.enabled === false) {
|
|
299
|
+
await next();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const maxBodyBytes = httpCfg?.maxBodyBytes ?? loggingCfg?.maxBodyBytes ?? 64 * 1024;
|
|
303
|
+
const captureEnabled = httpCfg?.body ?? httpCfg?.captureBody ?? loggingCfg?.captureBody ?? false;
|
|
304
|
+
const contentType = c.req.header('content-type') ?? '';
|
|
305
|
+
// GET is included by default too: some clients send a body on GET (non-standard but accepted).
|
|
306
|
+
const captureMethods = (httpCfg?.methods ?? httpCfg?.captureMethods ?? DEFAULT_CAPTURE_METHODS)
|
|
307
|
+
.map((method) => method.toUpperCase());
|
|
308
|
+
const hasBodyMethod = captureMethods.includes(c.req.method.toUpperCase());
|
|
309
|
+
const bodyPromise = captureEnabled
|
|
310
|
+
&& hasBodyMethod
|
|
311
|
+
&& TEXTUAL_BODY.test(contentType) && !contentType.includes('multipart/form-data')
|
|
312
|
+
? captureBody(c.req.raw.clone(), maxBodyBytes).catch(() => undefined)
|
|
313
|
+
: undefined;
|
|
226
314
|
try {
|
|
227
315
|
await next();
|
|
228
316
|
} finally {
|
|
@@ -231,7 +319,7 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
231
319
|
const conn = c.get('connInfo');
|
|
232
320
|
const token = c.get('token');
|
|
233
321
|
const url = new URL(c.req.raw.url);
|
|
234
|
-
const uri = url.pathname + url.search;
|
|
322
|
+
const uri = redactUri(url.pathname + url.search);
|
|
235
323
|
const ip = c.get('clientIp') ?? 'unknown';
|
|
236
324
|
const remoteIp = conn?.remote?.address ?? null;
|
|
237
325
|
const remotePort = conn?.remote?.port ?? null;
|
|
@@ -250,6 +338,11 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
250
338
|
ip,
|
|
251
339
|
traceId: trace?.id ?? crypto.randomUUID(),
|
|
252
340
|
tenant: requestCtxStorage.get<string>('tenant_id') ?? null,
|
|
341
|
+
...(bodyPromise ? await bodyPromise.then((body) => body && body.bytes > 0 ? {
|
|
342
|
+
request_body: body.value,
|
|
343
|
+
request_body_bytes: body.bytes,
|
|
344
|
+
request_body_truncated: body.truncated,
|
|
345
|
+
} : {}) : {}),
|
|
253
346
|
request: {
|
|
254
347
|
headers: collectHeaders(c.req.raw.headers),
|
|
255
348
|
client_ip: ip,
|
|
@@ -286,6 +379,19 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
286
379
|
allowList: cfg.server?.ipRestriction?.allowList || [],
|
|
287
380
|
}))
|
|
288
381
|
|
|
382
|
+
// Cross-Origin-Resource-Policy — the API keeps `same-origin` by default, but a
|
|
383
|
+
// served file must be embeddable by the app even when it lives on another origin
|
|
384
|
+
// (an `<img>` is a no-cors request: only CORP can block it). Registered before
|
|
385
|
+
// `secureHeaders` so this middleware writes the final value after it.
|
|
386
|
+
const securityCorp = cfg.server.security?.crossOriginResourcePolicy ?? 'same-origin';
|
|
387
|
+
const fileCorp = cfg.server.files?.crossOriginResourcePolicy ?? 'cross-origin';
|
|
388
|
+
app.use(async (c, next) => {
|
|
389
|
+
await next();
|
|
390
|
+
const segments = c.req.path.split('/');
|
|
391
|
+
const isFile = segments[1] === 'api' && segments[3] === 'files';
|
|
392
|
+
c.header('Cross-Origin-Resource-Policy', isFile ? fileCorp : securityCorp);
|
|
393
|
+
});
|
|
394
|
+
|
|
289
395
|
// security headers
|
|
290
396
|
app.use(secureHeaders({
|
|
291
397
|
strictTransportSecurity: true,
|
|
@@ -294,13 +400,24 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
294
400
|
xXssProtection: true,
|
|
295
401
|
referrerPolicy: true,
|
|
296
402
|
removePoweredBy: true,
|
|
403
|
+
// Hono defaults CORP to `same-origin`; configurable (see `server.security`)
|
|
404
|
+
crossOriginResourcePolicy: securityCorp,
|
|
297
405
|
}))
|
|
298
406
|
|
|
299
407
|
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
408
|
+
// Body limit — 16MB default for normal requests; the upload route gets the
|
|
409
|
+
// per-file allowance (100MB default) plus multipart overhead so a 100MB file
|
|
410
|
+
// is actually uploadable. `server.body.maxSize` raises both.
|
|
411
|
+
const DEFAULT_BODY_BYTES = 16 * 1024 * 1024;
|
|
412
|
+
const DEFAULT_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
413
|
+
const isUploadPath = (path: string) => /^\/api\/[^/]+\/upload\/[^/]+\/?$/.test(path);
|
|
414
|
+
app.use((c, next) => {
|
|
415
|
+
const configured = cfg.server.body?.maxSize ?? DEFAULT_BODY_BYTES;
|
|
416
|
+
const maxSize = isUploadPath(c.req.path)
|
|
417
|
+
? Math.max(configured, DEFAULT_UPLOAD_BYTES + 1024 * 1024)
|
|
418
|
+
: configured;
|
|
419
|
+
return bodyLimit({ maxSize })(c, next);
|
|
420
|
+
});
|
|
304
421
|
|
|
305
422
|
// ── Health & metrics (built-in, zero dependency) ──
|
|
306
423
|
app.get('/health', (c) => {
|
|
@@ -390,6 +507,29 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
390
507
|
})
|
|
391
508
|
})
|
|
392
509
|
|
|
510
|
+
// Tenant-bound tokens (#35) — a JWT carrying a `tenant` claim only opens that
|
|
511
|
+
// tenant's routes. `requireTenantClaim` rejects claim-less (legacy) tokens once set.
|
|
512
|
+
app.use(async (c, next) => {
|
|
513
|
+
const token = (c as any).get('token') as { decoded: Record<string, unknown> | null; provided?: boolean } | undefined;
|
|
514
|
+
const claim = token?.decoded?.['tenant'] ?? token?.decoded?.['_tenant_'] ?? token?.decoded?.['tenant_id'];
|
|
515
|
+
// `c.req.param()` is not reliable inside a global middleware — read the path.
|
|
516
|
+
const segments = c.req.path.split('/').filter(Boolean);
|
|
517
|
+
const tenantId = segments[0] === 'api'
|
|
518
|
+
? segments[1]
|
|
519
|
+
: segments[0] === '_dnax' && segments[1] === 'config'
|
|
520
|
+
? segments[2]
|
|
521
|
+
: undefined;
|
|
522
|
+
|
|
523
|
+
if (token?.provided && claim === undefined && cfg.server.jwt?.requireTenantClaim === true) {
|
|
524
|
+
return c.json({ message: 'Tenant claim required', code: 'TENANT_CLAIM_REQUIRED' }, 401);
|
|
525
|
+
}
|
|
526
|
+
if (claim !== undefined && tenantId !== undefined && String(claim) !== tenantId) {
|
|
527
|
+
return c.json({ message: 'Token does not belong to this tenant', code: 'TENANT_MISMATCH' }, 401);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
await next();
|
|
531
|
+
});
|
|
532
|
+
|
|
393
533
|
// Global user-defined middlewares — has access to requestCtxStorage, asyncContextStorage, etc.
|
|
394
534
|
for (const mw of getGlobalMiddlewares()) {
|
|
395
535
|
app.use(mw.handler);
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createApp } from "../server/hono";
|
|
5
|
+
import { cfg } from "../server/config";
|
|
6
|
+
import { logger } from "../utils/logger";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Opt-in request-body capture (`logging.captureBody`) — file-only access log.
|
|
10
|
+
* The handler must still receive the body intact (we read a clone).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DIR = path.join(import.meta.dir, ".tmp-access-body");
|
|
14
|
+
let server: any;
|
|
15
|
+
let url = "";
|
|
16
|
+
let originalLogging: any;
|
|
17
|
+
|
|
18
|
+
function readAccessEntries(): any[] {
|
|
19
|
+
const file = path.join(DIR, "app.log");
|
|
20
|
+
if (!fs.existsSync(file)) return [];
|
|
21
|
+
return fs.readFileSync(file, "utf-8")
|
|
22
|
+
.trim().split("\n").filter(Boolean)
|
|
23
|
+
.map((line) => JSON.parse(line))
|
|
24
|
+
.filter((entry) => entry.logger === "http.log.access");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function boot(http: any = {}) {
|
|
28
|
+
cfg.server = {
|
|
29
|
+
...cfg.server,
|
|
30
|
+
logging: { file: true, dir: DIR, console: false, level: "info", http },
|
|
31
|
+
};
|
|
32
|
+
const app = createApp();
|
|
33
|
+
app.post("/echo", async (c) => {
|
|
34
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
35
|
+
if (contentType.includes("multipart/form-data")) {
|
|
36
|
+
await c.req.formData();
|
|
37
|
+
return c.json({ ok: true });
|
|
38
|
+
}
|
|
39
|
+
return c.json(await c.req.json());
|
|
40
|
+
});
|
|
41
|
+
app.put("/echo", async (c) => c.json(await c.req.json()));
|
|
42
|
+
app.get("/no-body", (c) => c.json({ ok: true }));
|
|
43
|
+
server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
44
|
+
url = `${server.url.href.replace(/\/$/, "")}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeAll(() => {
|
|
48
|
+
originalLogging = cfg.server.logging;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
fs.rmSync(DIR, { recursive: true, force: true });
|
|
53
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
54
|
+
logger.configure({ dir: DIR, file: true, console: false, level: "info" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
try { server?.stop(true); } catch {}
|
|
59
|
+
server = null;
|
|
60
|
+
logger.configure({ file: false, console: false });
|
|
61
|
+
cfg.server = { ...cfg.server, logging: originalLogging };
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterAll(() => {
|
|
65
|
+
try { fs.rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("access log — request body capture", () => {
|
|
69
|
+
it("captures a JSON body without breaking the handler", async () => {
|
|
70
|
+
await boot({ body: true });
|
|
71
|
+
const res = await fetch(`${url}/echo`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: JSON.stringify({ email: "ada@example.com", password: "SuperSecret123!" }),
|
|
75
|
+
});
|
|
76
|
+
expect(res.status).toBe(200);
|
|
77
|
+
expect(await res.json()).toEqual({ email: "ada@example.com", password: "SuperSecret123!" });
|
|
78
|
+
|
|
79
|
+
await logger.flush();
|
|
80
|
+
const entry = readAccessEntries().find((line) => line.uri === "/echo");
|
|
81
|
+
expect(entry).toBeDefined();
|
|
82
|
+
expect(entry.request_body).toBe('{"email":"ada@example.com","password":"SuperSecret123!"}');
|
|
83
|
+
expect(entry.request_body_bytes).toBeGreaterThan(0);
|
|
84
|
+
expect(entry.request_body_truncated).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not capture a body by default", async () => {
|
|
88
|
+
await boot({ body: false });
|
|
89
|
+
await fetch(`${url}/echo`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
body: JSON.stringify({ secret: "hidden" }),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await logger.flush();
|
|
96
|
+
const entry = readAccessEntries().find((line) => line.uri === "/echo");
|
|
97
|
+
expect(entry).toBeDefined();
|
|
98
|
+
expect(entry.request_body).toBeUndefined();
|
|
99
|
+
expect("request_body" in entry).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("truncates at maxBodyBytes", async () => {
|
|
103
|
+
await boot({ body: true, maxBodyBytes: 10 });
|
|
104
|
+
await fetch(`${url}/echo`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "Content-Type": "application/json" },
|
|
107
|
+
body: JSON.stringify({ value: "x".repeat(100) }),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await logger.flush();
|
|
111
|
+
const entry = readAccessEntries().find((line) => line.uri === "/echo");
|
|
112
|
+
expect(entry.request_body_truncated).toBe(true);
|
|
113
|
+
expect(entry.request_body.length).toBe(10);
|
|
114
|
+
expect(entry.request_body_bytes).toBe(10);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("skips multipart bodies", async () => {
|
|
118
|
+
await boot({ body: true });
|
|
119
|
+
const form = new FormData();
|
|
120
|
+
form.append("file", new Blob(["binary"], { type: "application/octet-stream" }), "f.bin");
|
|
121
|
+
await fetch(`${url}/echo`, { method: "POST", body: form });
|
|
122
|
+
|
|
123
|
+
await logger.flush();
|
|
124
|
+
const entry = readAccessEntries().find((line) => line.uri === "/echo");
|
|
125
|
+
expect(entry).toBeDefined();
|
|
126
|
+
expect(entry.request_body).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("access log — HTTP options group", () => {
|
|
131
|
+
it("can disable the access log entirely", async () => {
|
|
132
|
+
await boot({ enabled: false });
|
|
133
|
+
await fetch(`${url}/echo`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { "Content-Type": "application/json" },
|
|
136
|
+
body: JSON.stringify({ hidden: true }),
|
|
137
|
+
});
|
|
138
|
+
await logger.flush();
|
|
139
|
+
expect(readAccessEntries().length).toBe(0);
|
|
140
|
+
|
|
141
|
+
// The request itself is unaffected
|
|
142
|
+
const res = await fetch(`${url}/echo`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: { "Content-Type": "application/json" },
|
|
145
|
+
body: JSON.stringify({ ok: true }),
|
|
146
|
+
});
|
|
147
|
+
expect(res.status).toBe(200);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("still accepts the deprecated top-level captureBody", async () => {
|
|
151
|
+
cfg.server = {
|
|
152
|
+
...cfg.server,
|
|
153
|
+
logging: { file: true, dir: DIR, console: false, level: "info", captureBody: true },
|
|
154
|
+
};
|
|
155
|
+
const app = createApp();
|
|
156
|
+
app.post("/echo", async (c) => c.json(await c.req.json()));
|
|
157
|
+
server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
158
|
+
url = `${server.url.href.replace(/\/$/, "")}`;
|
|
159
|
+
|
|
160
|
+
await fetch(`${url}/echo`, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: { "Content-Type": "application/json" },
|
|
163
|
+
body: JSON.stringify({ legacy: true }),
|
|
164
|
+
});
|
|
165
|
+
await logger.flush();
|
|
166
|
+
const entry = readAccessEntries().find((line) => line.uri === "/echo");
|
|
167
|
+
expect(entry.request_body).toBe('{"legacy":true}');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("access log — GET", () => {
|
|
172
|
+
it("does not add request_body for a bodyless GET", async () => {
|
|
173
|
+
await boot({ body: true });
|
|
174
|
+
const res = await fetch(`${url}/no-body`);
|
|
175
|
+
expect(res.status).toBe(200);
|
|
176
|
+
|
|
177
|
+
await logger.flush();
|
|
178
|
+
const entry = readAccessEntries().find((line) => line.uri === "/no-body");
|
|
179
|
+
expect(entry).toBeDefined();
|
|
180
|
+
expect("request_body" in entry).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("access log — method selection", () => {
|
|
185
|
+
it("captures only the configured methods, case-insensitively", async () => {
|
|
186
|
+
await boot({ body: true, methods: ["post"] });
|
|
187
|
+
|
|
188
|
+
await fetch(`${url}/echo`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify({ captured: true }),
|
|
192
|
+
});
|
|
193
|
+
await fetch(`${url}/echo`, {
|
|
194
|
+
method: "PUT",
|
|
195
|
+
headers: { "Content-Type": "application/json" },
|
|
196
|
+
body: JSON.stringify({ captured: false }),
|
|
197
|
+
});
|
|
198
|
+
await logger.flush();
|
|
199
|
+
|
|
200
|
+
const entries = readAccessEntries().filter((line) => line.uri === "/echo");
|
|
201
|
+
const post = entries.find((line) => line.method === "POST");
|
|
202
|
+
const put = entries.find((line) => line.method === "PUT");
|
|
203
|
+
expect(post.request_body).toBe('{"captured":true}');
|
|
204
|
+
expect("request_body" in put).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("an empty methods list disables body capture", async () => {
|
|
208
|
+
await boot({ body: true, methods: [] });
|
|
209
|
+
await fetch(`${url}/echo`, {
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: { "Content-Type": "application/json" },
|
|
212
|
+
body: JSON.stringify({ hidden: true }),
|
|
213
|
+
});
|
|
214
|
+
await logger.flush();
|
|
215
|
+
|
|
216
|
+
const entry = readAccessEntries().find((line) => line.uri === "/echo");
|
|
217
|
+
expect(entry).toBeDefined();
|
|
218
|
+
expect("request_body" in entry).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("access log — deprecated option names", () => {
|
|
223
|
+
it("still accepts nested captureBody/captureMethods", async () => {
|
|
224
|
+
cfg.server = {
|
|
225
|
+
...cfg.server,
|
|
226
|
+
logging: {
|
|
227
|
+
file: true, dir: DIR, console: false, level: "info",
|
|
228
|
+
http: { captureBody: true, captureMethods: ["POST"] },
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
const app = createApp();
|
|
232
|
+
app.post("/echo-deprecated", async (c) => c.json(await c.req.json()));
|
|
233
|
+
app.put("/echo-deprecated", async (c) => c.json(await c.req.json()));
|
|
234
|
+
server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
235
|
+
url = `${server.url.href.replace(/\/$/, "")}`;
|
|
236
|
+
|
|
237
|
+
await fetch(`${url}/echo-deprecated`, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: { "Content-Type": "application/json" },
|
|
240
|
+
body: JSON.stringify({ post: true }),
|
|
241
|
+
});
|
|
242
|
+
await fetch(`${url}/echo-deprecated`, {
|
|
243
|
+
method: "PUT",
|
|
244
|
+
headers: { "Content-Type": "application/json" },
|
|
245
|
+
body: JSON.stringify({ put: true }),
|
|
246
|
+
});
|
|
247
|
+
await logger.flush();
|
|
248
|
+
|
|
249
|
+
const entries = readAccessEntries().filter((line) => line.uri === "/echo-deprecated");
|
|
250
|
+
expect(entries.find((line) => line.method === "POST").request_body).toBe('{"post":true}');
|
|
251
|
+
expect("request_body" in entries.find((line) => line.method === "PUT")).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
});
|