From ee0da93c80d3dd48fdf9bb5078faf55e07c5a216 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 17:50:52 +0000 Subject: [PATCH 1/5] chore: repo-wide hardening and performance improvements - CI: scope mutation workflow permissions, SHA-pin third-party actions, pin release Node version - deps: pin @types/node, declare engines field on published packages - ingest: cap request body size at 32KB, document the endpoint threat model - redact: precompile path matchers once per config, O(1) case-insensitive leaf lookup, drop no-op lastIndex resets - pipeline: unref flush and retry timers so pending batches never hold the process open - audit: guard stableStringify against circular references - adapters: share deprecated-alias handling via applyDeprecatedAlias toolkit helper - vite: warn when sourceLocation is enabled for production builds https://claude.ai/code/session_01HyRiR2xjNsgoDshB64itH3 --- .github/workflows/mutation.yml | 3 ++ .github/workflows/release.yml | 4 +- .github/workflows/semantic-pull-request.yml | 6 +-- package.json | 2 +- packages/evlog/package.json | 3 ++ packages/evlog/src/adapters/axiom.ts | 18 ++++----- packages/evlog/src/adapters/better-stack.ts | 18 ++++----- packages/evlog/src/audit.ts | 15 ++++--- packages/evlog/src/pipeline.ts | 12 +++++- packages/evlog/src/redact.ts | 20 +++++----- .../server/routes/_evlog/ingest.post.ts | 39 ++++++++++++++++++- packages/evlog/src/shared/config.ts | 22 +++++++++++ packages/evlog/src/types.ts | 7 ++++ packages/evlog/src/utils.ts | 4 +- packages/evlog/src/vite/source-location.ts | 3 ++ packages/evlog/test/core/audit.test.ts | 23 +++++++++++ packages/evlog/test/core/redact.test.ts | 17 ++++++++ .../__snapshots__/api-surface.test.ts.snap | 1 + packages/nuxthub/package.json | 3 ++ pnpm-lock.yaml | 2 +- 20 files changed, 174 insertions(+), 48 deletions(-) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 73f7c4d5..4d50929d 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -7,6 +7,9 @@ on: - cron: '0 4 * * 1' workflow_dispatch: +permissions: + contents: read + jobs: mutate: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a55ca43f..95ca52a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: latest + node-version: 22 registry-url: https://registry.npmjs.org - name: Install dependencies @@ -41,7 +41,7 @@ jobs: - name: Create Release PR or Publish id: changesets - uses: changesets/action@v1 + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: title: "chore(repo): version packages" version: pnpm run version diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index 24663106..1dee9858 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v6 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 id: lint_pr_title with: scopes: | @@ -77,7 +77,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - uses: marocchino/sticky-pull-request-comment@v3 + - uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff # v3.0.3 # When the previous steps fail, the workflow would stop. By adding this # condition you can continue the execution with the populated error message. if: always() && (steps.lint_pr_title.outputs.error_message != null) @@ -96,7 +96,7 @@ jobs: # Delete a previous comment when the issue has been resolved - if: ${{ steps.lint_pr_title.outputs.error_message == null }} - uses: marocchino/sticky-pull-request-comment@v3 + uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff # v3.0.3 with: header: pr-title-lint-error message: | diff --git a/package.json b/package.json index 48befa0c..e55e20f7 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@changesets/changelog-github": "^0.6.0", "@changesets/cli": "^2.31.0", "@hrcd/eslint-config": "^3.0.3", - "@types/node": "latest", + "@types/node": "^25.9.1", "ai": "^6.0.168", "automd": "^0.4.3", "dotenv-cli": "^11.0.0", diff --git a/packages/evlog/package.json b/packages/evlog/package.json index a5e49115..a2229af0 100644 --- a/packages/evlog/package.json +++ b/packages/evlog/package.json @@ -35,6 +35,9 @@ "license": "MIT", "type": "module", "sideEffects": false, + "engines": { + "node": ">=18.0.0" + }, "exports": { ".": { "types": "./dist/index.d.mts", diff --git a/packages/evlog/src/adapters/axiom.ts b/packages/evlog/src/adapters/axiom.ts index b12ef705..a60f6335 100644 --- a/packages/evlog/src/adapters/axiom.ts +++ b/packages/evlog/src/adapters/axiom.ts @@ -1,6 +1,6 @@ import type { WideEvent } from '../types' import type { ConfigField } from '../shared/config' -import { resolveAdapterConfig } from '../shared/config' +import { applyDeprecatedAlias, resolveAdapterConfig } from '../shared/config' import { defineHttpDrain } from '../shared/drain' import { httpPost } from '../shared/http' @@ -63,17 +63,13 @@ const AXIOM_FIELDS: ConfigField[] = [ { key: 'retries' }, ] -let warnedAboutToken = false - function applyApiKeyAlias(config: Partial): Partial { - if (!config.apiKey && config.token) { - if (!warnedAboutToken) { - warnedAboutToken = true - console.warn('[evlog/axiom] `token` is deprecated, use `apiKey` instead. (Env: NUXT_AXIOM_TOKEN/AXIOM_TOKEN → NUXT_AXIOM_API_KEY/AXIOM_API_KEY.)') - } - config.apiKey = config.token - } - return config + return applyDeprecatedAlias(config, { + adapter: 'axiom', + from: 'token', + to: 'apiKey', + envHint: 'Env: NUXT_AXIOM_TOKEN/AXIOM_TOKEN → NUXT_AXIOM_API_KEY/AXIOM_API_KEY.', + }) } /** diff --git a/packages/evlog/src/adapters/better-stack.ts b/packages/evlog/src/adapters/better-stack.ts index f90d97c8..92e9e28d 100644 --- a/packages/evlog/src/adapters/better-stack.ts +++ b/packages/evlog/src/adapters/better-stack.ts @@ -1,6 +1,6 @@ import type { WideEvent } from '../types' import type { ConfigField } from '../shared/config' -import { resolveAdapterConfig } from '../shared/config' +import { applyDeprecatedAlias, resolveAdapterConfig } from '../shared/config' import { defineHttpDrain } from '../shared/drain' import { httpPost } from '../shared/http' @@ -29,17 +29,13 @@ const BETTER_STACK_FIELDS: ConfigField[] = [ { key: 'retries' }, ] -let warnedAboutSourceToken = false - function applyApiKeyAlias(config: BetterStackConfig): BetterStackConfig { - if (!config.apiKey && config.sourceToken) { - if (!warnedAboutSourceToken) { - warnedAboutSourceToken = true - console.warn('[evlog/better-stack] `sourceToken` is deprecated, use `apiKey` instead. (Env: NUXT_BETTER_STACK_SOURCE_TOKEN → NUXT_BETTER_STACK_API_KEY.)') - } - config.apiKey = config.sourceToken - } - return config + return applyDeprecatedAlias(config, { + adapter: 'better-stack', + from: 'sourceToken', + to: 'apiKey', + envHint: 'Env: NUXT_BETTER_STACK_SOURCE_TOKEN → NUXT_BETTER_STACK_API_KEY.', + }) } /** diff --git a/packages/evlog/src/audit.ts b/packages/evlog/src/audit.ts index b765e0ee..fd111a36 100644 --- a/packages/evlog/src/audit.ts +++ b/packages/evlog/src/audit.ts @@ -40,12 +40,17 @@ function isPlainObject(value: unknown): value is Record { return proto === Object.prototype || value.constructor === Object } -function stableStringify(value: unknown): string { +function stableStringify(value: unknown, ancestors = new WeakSet()): string { if (value === null || typeof value !== 'object') return JSON.stringify(value) - if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` - if (!isPlainObject(value)) return JSON.stringify(value) - const keys = Object.keys(value).sort() - return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}` + // Track only the current recursion path: shared (diamond) references are fine, true cycles are not. + if (ancestors.has(value)) return '"[Circular]"' + if (!Array.isArray(value) && !isPlainObject(value)) return JSON.stringify(value) + ancestors.add(value) + const out = Array.isArray(value) + ? `[${value.map(v => stableStringify(v, ancestors)).join(',')}]` + : `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stableStringify(value[k], ancestors)}`).join(',')}}` + ancestors.delete(value) + return out } /** diff --git a/packages/evlog/src/pipeline.ts b/packages/evlog/src/pipeline.ts index b0c839cf..4103de95 100644 --- a/packages/evlog/src/pipeline.ts +++ b/packages/evlog/src/pipeline.ts @@ -47,6 +47,15 @@ export interface PipelineDrainFn { * nitroApp.hooks.hook('close', () => drain.flush()) * ``` */ +/** + * Unref a timer on runtimes that support it (Node, Bun) so a pending flush or + * retry backoff never holds the process open. Buffered events are delivered on + * shutdown via the documented `flush()` contract, not by keeping timers alive. + */ +function unrefTimer(timer: ReturnType): void { + (timer as { unref?: () => void }).unref?.() +} + export function createDrainPipeline(options?: DrainPipelineOptions): (drain: (batch: T[]) => void | Promise) => PipelineDrainFn { const batchSize = options?.batch?.size ?? 50 const intervalMs = options?.batch?.intervalMs ?? 5000 @@ -94,6 +103,7 @@ export function createDrainPipeline(options?: DrainPipelineOptions< timer = null if (!activeFlush) startFlush() }, intervalMs) + unrefTimer(timer) } function getRetryDelay(attempt: number): number { @@ -122,7 +132,7 @@ export function createDrainPipeline(options?: DrainPipelineOptions< } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)) if (attempt < maxAttempts) { - await new Promise(r => setTimeout(r, getRetryDelay(attempt))) + await new Promise(r => unrefTimer(setTimeout(r, getRetryDelay(attempt)))) } } } diff --git a/packages/evlog/src/redact.ts b/packages/evlog/src/redact.ts index d424911e..ff87465d 100644 --- a/packages/evlog/src/redact.ts +++ b/packages/evlog/src/redact.ts @@ -10,7 +10,7 @@ export interface RedactPathMatchers { exactPaths: Set pathGlobs: RegExp[] keyGlobs: RegExp[] - /** Single-segment shorthands (`password` → `**.password`) matched case-insensitively on leaf keys. */ + /** Single-segment shorthands (`password` → `**.password`), stored lowercased, matched case-insensitively on leaf keys. */ caseInsensitiveLeaves: Set } @@ -72,7 +72,7 @@ function addPathGlobPattern( const leaf = pattern.match(/^\*\*\.([^.?*]+)$/) if (leaf) { exactPaths.add(leaf[1]!) - caseInsensitiveLeaves.add(leaf[1]!) + caseInsensitiveLeaves.add(leaf[1]!.toLowerCase()) } } @@ -83,10 +83,7 @@ function addPathGlobPattern( export function matchesRedactPath(fullPath: string, leafKey: string, matchers: RedactPathMatchers): boolean { if (matchers.exactPaths.has(fullPath)) return true - const leafLower = leafKey.toLowerCase() - for (const name of matchers.caseInsensitiveLeaves) { - if (leafLower === name.toLowerCase()) return true - } + if (matchers.caseInsensitiveLeaves.has(leafKey.toLowerCase())) return true for (const glob of matchers.pathGlobs) { glob.lastIndex = 0 @@ -259,7 +256,10 @@ export function resolveRedactConfig(input: boolean | RedactConfig | undefined): } if (input.builtins === false) { - return input + return { + ...input, + _pathMatchers: compileRedactPathMatchers(input.paths), + } } const maskers = Array.isArray(input.builtins) @@ -272,6 +272,7 @@ export function resolveRedactConfig(input: boolean | RedactConfig | undefined): return { ...input, _maskers: maskers, + _pathMatchers: compileRedactPathMatchers(input.paths), } } @@ -329,7 +330,8 @@ export function redactEvent(event: Record, config: RedactConfig const clone = cloneForRedaction(event) const replacement = config.replacement ?? DEFAULT_REPLACEMENT - const pathMatchers = compileRedactPathMatchers(config.paths) + // Configs resolved via resolveRedactConfig carry precompiled matchers; compile lazily for ad-hoc configs. + const pathMatchers = config._pathMatchers ?? compileRedactPathMatchers(config.paths) if (pathMatchers) { redactPathsInTree(clone, pathMatchers, replacement) } @@ -375,7 +377,6 @@ function redactPatterns(obj: unknown, patterns: RegExp[], replacement: string): function applyPatterns(value: string, patterns: RegExp[], replacement: string): string { let result = value for (const pattern of patterns) { - pattern.lastIndex = 0 result = result.replace(pattern, replacement) } return result @@ -411,7 +412,6 @@ function applyMaskersToTree(obj: unknown, maskers: Masker[]): void { function applyMaskers(value: string, maskers: Masker[]): string { let result = value for (const [pattern, mask] of maskers) { - pattern.lastIndex = 0 result = result.replace(pattern, mask) } return result diff --git a/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts b/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts index e7608b02..c31797f1 100644 --- a/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts +++ b/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts @@ -1,4 +1,4 @@ -import { createError, defineEventHandler, getHeader, getHeaders, getRequestHost, readBody, setResponseStatus } from 'h3' +import { createError, defineEventHandler, getHeader, getHeaders, getRequestHost, readRawBody, setResponseStatus } from 'h3' import { useNitroApp } from 'nitropack/runtime' import type { IngestPayload, WideEvent } from '../../../../types' import { getEnvironment, getGlobalPluginRunner } from '../../../../logger' @@ -32,6 +32,34 @@ function validateOrigin(event: Parameters[0] extends } } +/** + * Maximum accepted ingest body size in bytes. Client wide events are small; + * anything larger is rejected before it reaches the enrich/drain pipeline. + */ +const MAX_BODY_BYTES = 32 * 1024 + +async function readJsonBody(event: Parameters[0] extends (e: infer E) => unknown ? E : never): Promise { + const contentLength = Number(getHeader(event, 'content-length')) + if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) { + throw createError({ statusCode: 413, message: 'Payload too large' }) + } + + const raw = await readRawBody(event, 'utf8') + if (!raw) { + throw createError({ statusCode: 400, message: 'Invalid request body' }) + } + // String length undercounts multi-byte characters, but is a close enough proxy for a size cap. + if (raw.length > MAX_BODY_BYTES) { + throw createError({ statusCode: 413, message: 'Payload too large' }) + } + + try { + return JSON.parse(raw) + } catch { + throw createError({ statusCode: 400, message: 'Invalid request body' }) + } +} + // ISO 8601 datetime pattern (e.g., 2024-01-31T14:00:00.000Z) const ISO_8601_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/ @@ -108,10 +136,17 @@ function resolveWaitUntilContext(event: unknown): WaitUntilHost | undefined { return context } +/** + * Client log ingestion endpoint. + * + * The origin check is CSRF-level protection only: it blocks cross-site browser + * requests but is trivially satisfied by non-browser clients. Treat ingested + * events as untrusted input — this endpoint is intentionally unauthenticated. + */ export default defineEventHandler(async (event) => { validateOrigin(event) - const body = await readBody(event) + const body = await readJsonBody(event) const payload = validatePayload(body) const nitroApp = useNitroApp() const env = getEnvironment() diff --git a/packages/evlog/src/shared/config.ts b/packages/evlog/src/shared/config.ts index 3812ac44..13dea2cf 100644 --- a/packages/evlog/src/shared/config.ts +++ b/packages/evlog/src/shared/config.ts @@ -46,6 +46,28 @@ export async function resolveAdapterConfig( return config as Partial } +const warnedDeprecatedAliases = new Set() + +/** + * Copy a deprecated config field onto its replacement when the replacement is + * unset, warning once per adapter/field pair. + */ +export function applyDeprecatedAlias( + config: T, + opts: { adapter: string, from: keyof T & string, to: keyof T & string, envHint?: string }, +): T { + const record = config as Record + if (!record[opts.to] && record[opts.from]) { + const warnKey = `${opts.adapter}:${opts.from}` + if (!warnedDeprecatedAliases.has(warnKey)) { + warnedDeprecatedAliases.add(warnKey) + console.warn(`[evlog/${opts.adapter}] \`${opts.from}\` is deprecated, use \`${opts.to}\` instead.${opts.envHint ? ` (${opts.envHint})` : ''}`) + } + record[opts.to] = record[opts.from] + } + return config +} + // Avoid the Nitro virtual-module import when env/overrides already resolve // every env-backed field — optional tuning fields (timeout, retries) should // not trigger a runtime probe in non-Nitro runtimes. diff --git a/packages/evlog/src/types.ts b/packages/evlog/src/types.ts index f72724a0..bb77356e 100644 --- a/packages/evlog/src/types.ts +++ b/packages/evlog/src/types.ts @@ -126,6 +126,13 @@ export interface RedactConfig { replacement?: string /** @internal Resolved masker functions from built-in patterns. Not user-facing. */ _maskers?: Array<[RegExp, (match: string) => string]> + /** @internal Precompiled matchers for `paths`, built once by `resolveRedactConfig`. Not user-facing. */ + _pathMatchers?: { + exactPaths: Set + pathGlobs: RegExp[] + keyGlobs: RegExp[] + caseInsensitiveLeaves: Set + } } /** diff --git a/packages/evlog/src/utils.ts b/packages/evlog/src/utils.ts index 2ce976bf..2623af48 100644 --- a/packages/evlog/src/utils.ts +++ b/packages/evlog/src/utils.ts @@ -124,6 +124,8 @@ export const SENSITIVE_HEADERS = [ 'proxy-authorization', ] +const SENSITIVE_HEADER_SET = new Set(SENSITIVE_HEADERS) + /** * Filter out undefined values and sensitive headers from a raw header map. * @@ -137,7 +139,7 @@ export function filterSafeHeaders(headers: Partial = {} for (const [key, value] of Object.entries(headers)) { - if (value !== undefined && !SENSITIVE_HEADERS.includes(key.toLowerCase())) { + if (value !== undefined && !SENSITIVE_HEADER_SET.has(key.toLowerCase())) { safeHeaders[key] = value } } diff --git a/packages/evlog/src/vite/source-location.ts b/packages/evlog/src/vite/source-location.ts index 0ffddbac..fed9d9c5 100644 --- a/packages/evlog/src/vite/source-location.ts +++ b/packages/evlog/src/vite/source-location.ts @@ -13,6 +13,9 @@ export function createSourceLocationPlugin(enabled?: boolean): Plugin { configResolved({ command, root: configRoot }) { active = enabled ?? command === 'serve' root = configRoot + if (enabled === true && command === 'build') { + console.warn('[evlog] sourceLocation is enabled for a production build: source file paths will be embedded in the client bundle.') + } }, transform: { diff --git a/packages/evlog/test/core/audit.test.ts b/packages/evlog/test/core/audit.test.ts index 03c21c09..14b8fce8 100644 --- a/packages/evlog/test/core/audit.test.ts +++ b/packages/evlog/test/core/audit.test.ts @@ -500,6 +500,29 @@ describe('stableStringify plain-object guard', () => { expect(sig1).toBeDefined() expect(sig1).toBe(sig2) }) + + it('does not hang on circular references in audit changes', async () => { + const calls: WideEvent[] = [] + const drain = signed((ctx: DrainContext) => { + calls.push(ctx.event) + }, { strategy: 'hmac', secret: 'test-secret' }) + + const circular: Record = { name: 'loop' } + circular.self = circular + const shared = { id: 'x' } + + await drain(createDrainCtx({ + audit: { + action: 'update', + actor: { type: 'user', id: 'u1' }, + outcome: 'success', + changes: { circular, a: shared, b: shared }, + }, + })) + + const audit = defined(defined(calls[0], 'event').audit as AuditFields) + expect(audit.signature).toBeDefined() + }) }) describe('end-to-end: audit + auditOnly + global drain', () => { diff --git a/packages/evlog/test/core/redact.test.ts b/packages/evlog/test/core/redact.test.ts index f63a6c7f..ebe982f6 100644 --- a/packages/evlog/test/core/redact.test.ts +++ b/packages/evlog/test/core/redact.test.ts @@ -422,6 +422,23 @@ describe('resolveRedactConfig', () => { expect(config._maskers).toHaveLength(1) expect(config.patterns).toHaveLength(1) }) + + it('precompiles path matchers', () => { + const config = defined(resolveRedactConfig({ paths: ['user.password', '*_token'] }), 'redact config') + expect(config._pathMatchers).toBeDefined() + + const redacted = redactEvent({ user: { password: 'hunter2' }, api_token: 'abc' }, config) + expect((redacted.user as Record).password).toBe('[REDACTED]') + expect(redacted.api_token).toBe('[REDACTED]') + }) + + it('redacts identically with and without precompiled matchers', () => { + const event = { user: { Password: 'hunter2', name: 'jo' }, nested: { secret_token: 'x' } } + const paths = ['password', '*_token'] + const resolved = defined(resolveRedactConfig({ builtins: false, paths }), 'redact config') + const adHoc: RedactConfig = { builtins: false, paths } + expect(redactEvent(event, resolved)).toEqual(redactEvent(event, adHoc)) + }) }) describe('normalizeRedactConfig', () => { diff --git a/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap b/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap index 4ea18414..5bcfde30 100644 --- a/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap +++ b/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap @@ -219,6 +219,7 @@ exports[`public API surface > matches snapshot for all subpath exports 1`] = ` "EVLOG_VERSION", "OTEL_SEVERITY_NUMBER", "OTEL_SEVERITY_TEXT", + "applyDeprecatedAlias", "attachForkToLogger", "bindStreamingResponseLifecycle", "composeDrains", diff --git a/packages/nuxthub/package.json b/packages/nuxthub/package.json index 2c86d302..85be88bd 100644 --- a/packages/nuxthub/package.json +++ b/packages/nuxthub/package.json @@ -15,6 +15,9 @@ "license": "MIT", "type": "module", "sideEffects": false, + "engines": { + "node": ">=18.0.0" + }, "exports": { ".": { "types": "./dist/types.d.mts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5cb7147..d738f10f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: specifier: ^3.0.3 version: 3.0.3(@types/estree@1.0.8)(@typescript-eslint/utils@8.59.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) '@types/node': - specifier: latest + specifier: ^25.9.1 version: 25.9.1 ai: specifier: ^6.0.168 From 037990fa44c70d8f004ad84a723dc9f4c9b65a10 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 18:16:18 +0000 Subject: [PATCH 2/5] chore: add changeset https://claude.ai/code/session_01HyRiR2xjNsgoDshB64itH3 --- .changeset/repo-hardening-perf.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/repo-hardening-perf.md diff --git a/.changeset/repo-hardening-perf.md b/.changeset/repo-hardening-perf.md new file mode 100644 index 00000000..ed3ba55f --- /dev/null +++ b/.changeset/repo-hardening-perf.md @@ -0,0 +1,14 @@ +--- +"evlog": minor +"@evlog/nuxthub": patch +--- + +Hardening and performance improvements across the package: + +- **Redaction**: path matchers are now precompiled once per resolved config instead of on every event, and case-insensitive leaf lookups are O(1). +- **Pipeline**: flush and retry timers are `unref()`'d so a pending batch never holds a Node process open on shutdown — call `flush()` to deliver buffered events before exit (unchanged, documented contract). +- **Ingest endpoint**: request bodies are capped at 32KB (413 beyond) and parsed as strict JSON. +- **Audit**: `stableStringify` guards against circular references in audit `changes` instead of recursing forever; shared (non-circular) references keep stable signatures. +- **Toolkit**: new `applyDeprecatedAlias` helper to map deprecated config fields onto their replacement with a one-time warning, used by the Axiom and Better Stack adapters. +- **Vite**: warns when `sourceLocation` is enabled for a production build (source paths embedded in the client bundle). +- Published packages now declare `engines.node >= 18`. From 98b1af2e28de0a5745c2a879324988d1ccf4e145 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 11 Jun 2026 20:17:12 +0200 Subject: [PATCH 3/5] Update repo-hardening-perf.md --- .changeset/repo-hardening-perf.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/repo-hardening-perf.md b/.changeset/repo-hardening-perf.md index ed3ba55f..14f176e0 100644 --- a/.changeset/repo-hardening-perf.md +++ b/.changeset/repo-hardening-perf.md @@ -1,5 +1,5 @@ --- -"evlog": minor +"evlog": path "@evlog/nuxthub": patch --- From 47725da441306669ce176da5757cdc1dfa82a8cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 19:20:52 +0000 Subject: [PATCH 4/5] fix: address review feedback - ingest: measure body size in UTF-8 bytes via TextEncoder - toolkit: applyDeprecatedAlias only treats null/undefined as unset - better-stack: include non-Nuxt env vars in deprecation hint - pipeline: keep retry backoff timers ref'd so flush() can't be interrupted by process exit mid-retry; only the idle flush scheduling timer is unref'd https://claude.ai/code/session_01HyRiR2xjNsgoDshB64itH3 --- .changeset/repo-hardening-perf.md | 2 +- packages/evlog/src/adapters/better-stack.ts | 2 +- packages/evlog/src/pipeline.ts | 11 +++++++---- .../runtime/server/routes/_evlog/ingest.post.ts | 4 ++-- packages/evlog/src/shared/config.ts | 15 +++++++++------ 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.changeset/repo-hardening-perf.md b/.changeset/repo-hardening-perf.md index 14f176e0..1e5127a1 100644 --- a/.changeset/repo-hardening-perf.md +++ b/.changeset/repo-hardening-perf.md @@ -6,7 +6,7 @@ Hardening and performance improvements across the package: - **Redaction**: path matchers are now precompiled once per resolved config instead of on every event, and case-insensitive leaf lookups are O(1). -- **Pipeline**: flush and retry timers are `unref()`'d so a pending batch never holds a Node process open on shutdown — call `flush()` to deliver buffered events before exit (unchanged, documented contract). +- **Pipeline**: the idle flush scheduling timer is `unref()`'d so it never holds a Node process open on shutdown — call `flush()` to deliver buffered events before exit (unchanged, documented contract). Retry backoff timers stay ref'd so in-flight batches are not dropped mid-retry. - **Ingest endpoint**: request bodies are capped at 32KB (413 beyond) and parsed as strict JSON. - **Audit**: `stableStringify` guards against circular references in audit `changes` instead of recursing forever; shared (non-circular) references keep stable signatures. - **Toolkit**: new `applyDeprecatedAlias` helper to map deprecated config fields onto their replacement with a one-time warning, used by the Axiom and Better Stack adapters. diff --git a/packages/evlog/src/adapters/better-stack.ts b/packages/evlog/src/adapters/better-stack.ts index 92e9e28d..d94d7a5a 100644 --- a/packages/evlog/src/adapters/better-stack.ts +++ b/packages/evlog/src/adapters/better-stack.ts @@ -34,7 +34,7 @@ function applyApiKeyAlias(config: BetterStackConfig): BetterStackConfig { adapter: 'better-stack', from: 'sourceToken', to: 'apiKey', - envHint: 'Env: NUXT_BETTER_STACK_SOURCE_TOKEN → NUXT_BETTER_STACK_API_KEY.', + envHint: 'Env: NUXT_BETTER_STACK_SOURCE_TOKEN/BETTER_STACK_SOURCE_TOKEN → NUXT_BETTER_STACK_API_KEY/BETTER_STACK_API_KEY.', }) } diff --git a/packages/evlog/src/pipeline.ts b/packages/evlog/src/pipeline.ts index 4103de95..5b2f9d90 100644 --- a/packages/evlog/src/pipeline.ts +++ b/packages/evlog/src/pipeline.ts @@ -48,9 +48,12 @@ export interface PipelineDrainFn { * ``` */ /** - * Unref a timer on runtimes that support it (Node, Bun) so a pending flush or - * retry backoff never holds the process open. Buffered events are delivered on - * shutdown via the documented `flush()` contract, not by keeping timers alive. + * Unref a timer on runtimes that support it (Node, Bun) so an idle flush + * scheduling timer never holds the process open. Buffered events are delivered + * on shutdown via the documented `flush()` contract, not by keeping timers + * alive. Retry backoff timers are intentionally left ref'd: an in-flight batch + * is active work, and an unref'd timer awaited by `flush()` would let the + * process exit mid-retry. */ function unrefTimer(timer: ReturnType): void { (timer as { unref?: () => void }).unref?.() @@ -132,7 +135,7 @@ export function createDrainPipeline(options?: DrainPipelineOptions< } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)) if (attempt < maxAttempts) { - await new Promise(r => unrefTimer(setTimeout(r, getRetryDelay(attempt)))) + await new Promise(r => setTimeout(r, getRetryDelay(attempt))) } } } diff --git a/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts b/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts index c31797f1..eda4099d 100644 --- a/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts +++ b/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts @@ -48,8 +48,8 @@ async function readJsonBody(event: Parameters[0] exte if (!raw) { throw createError({ statusCode: 400, message: 'Invalid request body' }) } - // String length undercounts multi-byte characters, but is a close enough proxy for a size cap. - if (raw.length > MAX_BODY_BYTES) { + // Measure actual UTF-8 bytes so multi-byte payloads can't slip past the cap. + if (new TextEncoder().encode(raw).byteLength > MAX_BODY_BYTES) { throw createError({ statusCode: 413, message: 'Payload too large' }) } diff --git a/packages/evlog/src/shared/config.ts b/packages/evlog/src/shared/config.ts index 13dea2cf..de9f8014 100644 --- a/packages/evlog/src/shared/config.ts +++ b/packages/evlog/src/shared/config.ts @@ -57,13 +57,16 @@ export function applyDeprecatedAlias( opts: { adapter: string, from: keyof T & string, to: keyof T & string, envHint?: string }, ): T { const record = config as Record - if (!record[opts.to] && record[opts.from]) { - const warnKey = `${opts.adapter}:${opts.from}` - if (!warnedDeprecatedAliases.has(warnKey)) { - warnedDeprecatedAliases.add(warnKey) - console.warn(`[evlog/${opts.adapter}] \`${opts.from}\` is deprecated, use \`${opts.to}\` instead.${opts.envHint ? ` (${opts.envHint})` : ''}`) + if (record[opts.to] === undefined || record[opts.to] === null) { + const fromValue = record[opts.from] + if (fromValue !== undefined && fromValue !== null) { + const warnKey = `${opts.adapter}:${opts.from}` + if (!warnedDeprecatedAliases.has(warnKey)) { + warnedDeprecatedAliases.add(warnKey) + console.warn(`[evlog/${opts.adapter}] \`${opts.from}\` is deprecated, use \`${opts.to}\` instead.${opts.envHint ? ` (${opts.envHint})` : ''}`) + } + record[opts.to] = fromValue } - record[opts.to] = record[opts.from] } return config } From 41bd279dcd05c5b2f510dcc18ee5f731fa4a13b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 19:21:23 +0000 Subject: [PATCH 5/5] chore: fix changeset bump type typo (path -> patch) https://claude.ai/code/session_01HyRiR2xjNsgoDshB64itH3 --- .changeset/repo-hardening-perf.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/repo-hardening-perf.md b/.changeset/repo-hardening-perf.md index 1e5127a1..97da7d65 100644 --- a/.changeset/repo-hardening-perf.md +++ b/.changeset/repo-hardening-perf.md @@ -1,5 +1,5 @@ --- -"evlog": path +"evlog": patch "@evlog/nuxthub": patch ---