@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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/database/field.crypto.ts +38 -13
  4. package/database/mongodbadapter.ts +85 -25
  5. package/database/vars.ts +22 -2
  6. package/index.ts +22 -0
  7. package/lib/agent.ts +707 -79
  8. package/lib/agentMemory.ts +199 -0
  9. package/lib/agents.ts +133 -52
  10. package/lib/attachment.ts +24 -3
  11. package/lib/define.ts +5 -1
  12. package/lib/eval.ts +123 -0
  13. package/lib/files.ts +21 -15
  14. package/lib/jsonSchema.ts +8 -0
  15. package/lib/mcp.ts +52 -30
  16. package/lib/messages.ts +202 -19
  17. package/lib/partialJson.ts +79 -0
  18. package/lib/providers.ts +26 -2
  19. package/lib/redact.ts +11 -0
  20. package/lib/tracing.ts +20 -0
  21. package/lib/zip.ts +23 -3
  22. package/package.json +4 -5
  23. package/server/agents.ts +66 -1
  24. package/server/api.ts +302 -20
  25. package/server/boot.ts +13 -4
  26. package/server/hono.ts +146 -6
  27. package/tests/access-log-body.test.ts +253 -0
  28. package/tests/agent-checkpoint.test.ts +195 -0
  29. package/tests/agent-hardening.test.ts +238 -0
  30. package/tests/agent-memory.test.ts +175 -1
  31. package/tests/agent-stream-advanced.test.ts +246 -0
  32. package/tests/agent-tracing-eval.test.ts +219 -0
  33. package/tests/agent-working-memory.test.ts +7 -4
  34. package/tests/agent.test.ts +54 -3
  35. package/tests/agents-api.test.ts +21 -0
  36. package/tests/api-external-guards.test.ts +304 -0
  37. package/tests/attachment.test.ts +63 -0
  38. package/tests/audit-redact.test.ts +26 -1
  39. package/tests/field-encryption.test.ts +47 -3
  40. package/tests/filters-hardening.test.ts +81 -0
  41. package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
  42. package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
  43. package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
  44. package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
  45. package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
  46. package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
  47. package/tests/fixtures/fake-provider.ts +33 -0
  48. package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
  49. package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
  50. package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
  51. package/tests/hono.test.ts +81 -1
  52. package/tests/jwt-tenant.test.ts +165 -0
  53. package/tests/logger.test.ts +62 -10
  54. package/tests/messages.test.ts +88 -32
  55. package/tests/tool-runtime.test.ts +291 -0
  56. package/tests/tool-types.ts +33 -0
  57. package/tests/upload.test.ts +175 -3
  58. package/tests/vars-api.test.ts +73 -1
  59. package/tests/vars.test.ts +1 -1
  60. package/types/agent.d.ts +386 -4
  61. package/types/config.d.ts +56 -2
  62. package/types/file.d.ts +1 -1
  63. package/types/mcp.d.ts +2 -2
  64. package/utils/func.ts +20 -0
  65. 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: 🔄 ${dayjs().format('YYYY-MM-DD HH:mm:ss')}`.gray;
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/anteros.log)
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 * 100, // 100MB
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
- // body limit
301
- app.use(bodyLimit({
302
- maxSize: cfg.server.body?.maxSize ?? 1024 * 1024 * 100, // 100MB
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
+ });