@objectstack/core 17.0.0-rc.3 → 17.0.0-rc.4

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/dist/logger.cjs CHANGED
@@ -41,6 +41,73 @@ var LEVEL_COLORS = {
41
41
  silent: ""
42
42
  };
43
43
  var RESET = "\x1B[0m";
44
+ function tokenizeFieldName(name) {
45
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/([a-zA-Z])([0-9])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((word) => word.toLowerCase());
46
+ }
47
+ function singularizeWord(word) {
48
+ if (/(?:ss|us|is)$/.test(word)) return word;
49
+ if (/(?:ch|sh|s|x|z)es$/.test(word)) return word.slice(0, -2);
50
+ if (/[a-z0-9]s$/.test(word)) return word.slice(0, -1);
51
+ return word;
52
+ }
53
+ var CONCATENATED_SECRET_QUALIFIERS = /* @__PURE__ */ new Set([
54
+ "access",
55
+ "account",
56
+ "admin",
57
+ "api",
58
+ "app",
59
+ "auth",
60
+ "bearer",
61
+ "client",
62
+ "csrf",
63
+ "db",
64
+ "database",
65
+ "encryption",
66
+ "id",
67
+ "jwt",
68
+ "master",
69
+ "oauth",
70
+ "private",
71
+ "public",
72
+ "refresh",
73
+ "root",
74
+ "secret",
75
+ "service",
76
+ "session",
77
+ "shared",
78
+ "sign",
79
+ "signing",
80
+ "ssh",
81
+ "token",
82
+ "user",
83
+ "webhook",
84
+ "xsrf"
85
+ ]);
86
+ function isQualifiedConcatenation(word, redactWord) {
87
+ for (const base of [word, singularizeWord(word)]) {
88
+ if (base.length <= redactWord.length || !base.endsWith(redactWord)) continue;
89
+ if (CONCATENATED_SECRET_QUALIFIERS.has(base.slice(0, base.length - redactWord.length))) return true;
90
+ }
91
+ return false;
92
+ }
93
+ function containsWordRun(words, run) {
94
+ for (let i = 0; i + run.length <= words.length; i++) {
95
+ if (run.every((word, offset) => words[i + offset] === word)) return true;
96
+ }
97
+ return false;
98
+ }
99
+ function fieldWordsMatchPattern(nameWords, patternWords) {
100
+ if (patternWords.length === 0 || nameWords.length === 0) return false;
101
+ if (patternWords.length > 1) {
102
+ const glued = patternWords.join("");
103
+ return containsWordRun(nameWords, patternWords) || nameWords.some((word) => word === glued || singularizeWord(word) === glued);
104
+ }
105
+ const redactWord = patternWords[0];
106
+ const isCompound = nameWords.filter((word) => /[a-z]/.test(word)).length > 1;
107
+ return nameWords.some(
108
+ (word) => word === redactWord || isCompound && singularizeWord(word) === redactWord || isQualifiedConcatenation(word, redactWord)
109
+ );
110
+ }
44
111
  function colorEnabled(stream) {
45
112
  if (typeof process !== "undefined") {
46
113
  const noColor = process.env?.NO_COLOR;
@@ -79,6 +146,7 @@ var ObjectLogger = class _ObjectLogger {
79
146
  rotation: config.rotation ?? { maxSize: "10m", maxFiles: 5 }
80
147
  };
81
148
  this.bindings = bindings;
149
+ this.redactPatterns = this.config.redact.map(tokenizeFieldName).filter((words) => words.length > 0);
82
150
  if (this.config.file && typeof process !== "undefined") {
83
151
  this.openFileStream(this.config.file);
84
152
  }
@@ -124,12 +192,25 @@ var ObjectLogger = class _ObjectLogger {
124
192
  isEnabled(level) {
125
193
  return LEVEL_ORDER[level] >= LEVEL_ORDER[this.config.level];
126
194
  }
195
+ /**
196
+ * Whether a meta field name names one of the configured secrets.
197
+ *
198
+ * Until #5573 this was `lower.includes(pattern)`, which redacted every
199
+ * field whose name merely *contained* a redact word — `keys`, `keyword`,
200
+ * `tokens`, `monkey`, `secretary` — and replaced its value with
201
+ * `***REDACTED***`, so the reader lost the fact AND was told a secret had
202
+ * been withheld. Matching is now on word boundaries: `key` matches
203
+ * `apiKey` / `api_key`, not `keys` / `monkey` / `keyword`.
204
+ */
205
+ isRedactedFieldName(key) {
206
+ const nameWords = tokenizeFieldName(key);
207
+ return this.redactPatterns.some((pattern) => fieldWordsMatchPattern(nameWords, pattern));
208
+ }
127
209
  redactSensitive(obj) {
128
210
  if (!obj || typeof obj !== "object") return obj;
129
211
  const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
130
212
  for (const key in redacted) {
131
- const lower = key.toLowerCase();
132
- if (this.config.redact.some((p) => lower.includes(p.toLowerCase()))) {
213
+ if (this.isRedactedFieldName(key)) {
133
214
  redacted[key] = "***REDACTED***";
134
215
  } else if (typeof redacted[key] === "object" && redacted[key] !== null) {
135
216
  redacted[key] = this.redactSensitive(redacted[key]);
@@ -192,18 +273,44 @@ var ObjectLogger = class _ObjectLogger {
192
273
  this.write("warn", message, meta);
193
274
  }
194
275
  error(message, errorOrMeta, meta) {
195
- if (errorOrMeta instanceof Error) {
196
- this.write("error", message, meta, errorOrMeta);
197
- } else {
198
- this.write("error", message, errorOrMeta);
199
- }
276
+ this.writeErrorLike("error", message, errorOrMeta, meta);
200
277
  }
201
278
  fatal(message, errorOrMeta, meta) {
279
+ this.writeErrorLike("fatal", message, errorOrMeta, meta);
280
+ }
281
+ /**
282
+ * `error`/`fatal` dispatch — the two levels whose contract has an `Error`
283
+ * slot in front of `meta`.
284
+ *
285
+ * The `Logger` contract declares `error(message, error?: Error, meta?)`, and
286
+ * `ObjectLogger` additionally tolerates a **meta object** in the `error`
287
+ * slot because many in-repo call sites write `logger.error(msg, { … })`.
288
+ * That tolerance is fine; dropping a parameter the contract *declares* is
289
+ * not, and that is what the previous dispatch did:
290
+ *
291
+ * if (errorOrMeta instanceof Error) this.write(level, message, meta, errorOrMeta);
292
+ * else this.write(level, message, errorOrMeta);
293
+ *
294
+ * With `error === undefined` the `else` branch passed `undefined` as the
295
+ * meta and **never read the third argument**, so every contract-shaped
296
+ * `logger.error(msg, undefined, { … })` call rendered a bare message with
297
+ * its diagnostics silently gone — ~15 such call sites across `metadata`,
298
+ * `metadata-protocol`, `client` and `core/security`, plus the connector
299
+ * reconcile seam that found this (#5575). The contract's two sibling
300
+ * implementations (`ConsoleLogger`/`JsonLogger` in `@objectstack/observability`)
301
+ * both honour the slot, so the contract was right and this class was the
302
+ * outlier — declared ≠ enforced, Prime Directive #10.
303
+ *
304
+ * All three shapes are now honoured. When both slots carry meta, `meta`
305
+ * (the later, more specific argument) wins on a key collision.
306
+ */
307
+ writeErrorLike(level, message, errorOrMeta, meta) {
202
308
  if (errorOrMeta instanceof Error) {
203
- this.write("fatal", message, meta, errorOrMeta);
204
- } else {
205
- this.write("fatal", message, errorOrMeta);
309
+ this.write(level, message, meta, errorOrMeta);
310
+ return;
206
311
  }
312
+ const merged = errorOrMeta && meta ? { ...errorOrMeta, ...meta } : errorOrMeta ?? meta;
313
+ this.write(level, message, merged);
207
314
  }
208
315
  log(message, ...args) {
209
316
  this.info(message, args.length > 0 ? { args } : void 0);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/logger.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { LoggerConfig, LogLevel } from '@objectstack/spec/system';\nimport type { Logger } from '@objectstack/spec/contracts';\n\n// Re-export the contract type so consumers can do\n// `import type { Logger } from '@objectstack/core/logger'` without also\n// pulling `@objectstack/spec` into their bundle graph manually.\nexport type { Logger };\n\nconst LEVEL_ORDER: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3,\n fatal: 4,\n silent: 5,\n};\n\nconst LEVEL_COLORS: Record<LogLevel, string> = {\n debug: '\\x1b[36m',\n info: '\\x1b[32m',\n warn: '\\x1b[33m',\n error: '\\x1b[31m',\n fatal: '\\x1b[35m',\n silent: '',\n};\n\nconst RESET = '\\x1b[0m';\n\n/**\n * Whether ANSI color may be written to the given stream.\n *\n * Follows the https://no-color.org convention: a non-empty `NO_COLOR` env var\n * disables color regardless of TTY, and non-TTY destinations (pipes, CI logs,\n * redirected output) always get plain text so plain-text log scanners see\n * uncolored level tags. Browser bundles have no `process`/TTY → plain text.\n */\nfunction colorEnabled(stream: { isTTY?: boolean } | undefined): boolean {\n if (typeof process !== 'undefined') {\n const noColor = (process as any).env?.NO_COLOR;\n if (noColor !== undefined && noColor !== '') return false;\n }\n return Boolean(stream?.isTTY);\n}\n\n/**\n * Resolve a Node builtin without putting it in this module's import graph.\n *\n * This entry is deliberately browser-safe — `@objectstack/client` bundles it —\n * so `fs`/`path` must never be imported statically. A lazy `require()` used to\n * meet that bar, but esbuild rewrites it to the `__require` shim in the ESM\n * output, which throws `Dynamic require of \"fs\" is not supported`. Every Node\n * ESM consumer (`os serve`, `os dev`) therefore lost file logging (#3110).\n * `process.getBuiltinModule` is a plain method call — opaque to bundlers — and\n * works in both module systems.\n */\nfunction loadNodeBuiltin<T>(id: string): T | undefined {\n if (typeof process === 'undefined') return undefined;\n\n const getBuiltinModule = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;\n if (typeof getBuiltinModule === 'function') {\n try {\n return getBuiltinModule.call(process, `node:${id}`) as T;\n } catch {\n return undefined;\n }\n }\n\n // Node < 20.16 / < 22.3 predates `getBuiltinModule`. Real `require` still\n // resolves in the CJS build; in the ESM build this is the shim that throws,\n // which the caller now reports rather than swallows.\n try {\n return require(id) as T;\n } catch {\n return undefined;\n }\n}\n\nexport class ObjectLogger implements Logger {\n private config: Required<Omit<LoggerConfig, 'file' | 'rotation' | 'name'>> & {\n file?: string;\n rotation?: { maxSize: string; maxFiles: number };\n name?: string;\n };\n private bindings: Record<string, any>;\n private fileStream?: any;\n /** Only the logger that opened the stream may close it — children share it. */\n private ownsFileStream = false;\n private fileLoggingDisabled = false;\n\n constructor(config: Partial<LoggerConfig> = {}, bindings: Record<string, any> = {}) {\n this.config = {\n name: config.name,\n level: config.level ?? 'info',\n format: config.format ?? 'pretty',\n redact: config.redact ?? ['password', 'token', 'secret', 'key'],\n sourceLocation: config.sourceLocation ?? false,\n file: config.file,\n rotation: config.rotation ?? { maxSize: '10m', maxFiles: 5 },\n };\n this.bindings = bindings;\n\n if (this.config.file && typeof process !== 'undefined') {\n this.openFileStream(this.config.file);\n }\n }\n\n private openFileStream(path: string) {\n const fs = loadNodeBuiltin<typeof import('node:fs')>('fs');\n const nodePath = loadNodeBuiltin<typeof import('node:path')>('path');\n if (!fs || !nodePath) {\n this.disableFileLogging(path, 'no filesystem access in this runtime');\n return;\n }\n\n try {\n fs.mkdirSync(nodePath.dirname(path), { recursive: true });\n const stream = fs.createWriteStream(path, { flags: 'a' });\n // `createWriteStream` reports open failures (EACCES, EISDIR, …)\n // asynchronously. An 'error' event with no listener is fatal to the\n // process, so file logging must degrade here rather than take the\n // host down.\n stream.on('error', (err: Error) => this.disableFileLogging(path, err.message));\n this.fileStream = stream;\n this.ownsFileStream = true;\n } catch (err) {\n this.disableFileLogging(path, (err as Error).message);\n }\n }\n\n /**\n * Report — once — that an explicitly configured `file` destination is not\n * being written, and stop trying.\n *\n * Deliberately not routed through `write()`: this says the logger cannot\n * honour its own config, so `level` must not filter it. The bare `catch {}`\n * this replaces is exactly how #3110 stayed hidden.\n */\n private disableFileLogging(path: string, reason: string) {\n this.fileStream = undefined;\n this.ownsFileStream = false;\n if (this.fileLoggingDisabled) return;\n this.fileLoggingDisabled = true;\n\n const label = this.config.name ? `[${this.config.name}] ` : '';\n const notice = `${label}logger: file logging disabled — cannot write to ${path}: ${reason}`;\n if (typeof process !== 'undefined' && (process as any).stderr) {\n (process as any).stderr.write(notice + '\\n');\n } else if (typeof console !== 'undefined') {\n console.warn(notice);\n }\n }\n\n private isEnabled(level: LogLevel): boolean {\n return LEVEL_ORDER[level] >= LEVEL_ORDER[this.config.level];\n }\n\n private redactSensitive(obj: any): any {\n if (!obj || typeof obj !== 'object') return obj;\n const redacted = Array.isArray(obj) ? [...obj] : { ...obj };\n for (const key in redacted) {\n const lower = key.toLowerCase();\n if (this.config.redact.some((p: string) => lower.includes(p.toLowerCase()))) {\n redacted[key] = '***REDACTED***';\n } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {\n redacted[key] = this.redactSensitive(redacted[key]);\n }\n }\n return redacted;\n }\n\n private write(level: LogLevel, message: string, meta?: Record<string, any>, error?: Error) {\n if (!this.isEnabled(level)) return;\n\n const context = this.redactSensitive({\n ...this.bindings,\n ...meta,\n ...(error ? { error: { message: error.message, stack: error.stack } } : {}),\n });\n\n const hasContext = Object.keys(context).length > 0;\n const ts = new Date().toISOString();\n\n const isErrorLevel = level === 'error' || level === 'fatal';\n const proc = typeof process !== 'undefined' ? (process as any) : undefined;\n const stream = proc ? (isErrorLevel ? proc.stderr : proc.stdout) : undefined;\n\n let line: string; // console output — may carry ANSI color\n let plainLine: string; // file output — never colored\n\n if (this.config.format === 'json') {\n line = plainLine = JSON.stringify({\n time: ts,\n level,\n ...(this.config.name ? { name: this.config.name } : {}),\n msg: message,\n ...context,\n });\n } else if (this.config.format === 'text') {\n const parts = [ts, level.toUpperCase(), message];\n if (hasContext) parts.push(JSON.stringify(context));\n line = plainLine = parts.join(' | ');\n } else {\n // pretty\n const label = this.config.name ? `[${this.config.name}] ` : '';\n const head = `${ts} ${level.toUpperCase()}`;\n let tail = ` ${label}${message}`;\n if (hasContext) tail += ` ${JSON.stringify(context)}`;\n plainLine = head + tail;\n const color = LEVEL_COLORS[level] || '';\n line = color && colorEnabled(stream) ? `${color}${head}${RESET}${tail}` : plainLine;\n }\n\n // Browser-safe output: prefer process streams when available, otherwise\n // fall back to console. `process` may be missing entirely (browsers) or\n // present without stdio streams (bundler shims) — both fall through to\n // console. The previous unguarded `process.stderr?.write` threw\n // `ReferenceError: process is not defined` in browsers because\n // `process` itself is the missing global, not just its `stderr` field.\n if (stream) {\n stream.write(line + '\\n');\n } else if (typeof console !== 'undefined') {\n const fn =\n level === 'error' || level === 'fatal' ? console.error\n : level === 'warn' ? console.warn\n : level === 'debug' ? console.debug\n : console.log;\n fn(line);\n }\n\n if (this.fileStream) {\n this.fileStream.write(plainLine + '\\n');\n }\n }\n\n debug(message: string, meta?: Record<string, any>): void {\n this.write('debug', message, meta);\n }\n\n info(message: string, meta?: Record<string, any>): void {\n this.write('info', message, meta);\n }\n\n warn(message: string, meta?: Record<string, any>): void {\n this.write('warn', message, meta);\n }\n\n error(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void {\n if (errorOrMeta instanceof Error) {\n this.write('error', message, meta, errorOrMeta);\n } else {\n this.write('error', message, errorOrMeta);\n }\n }\n\n fatal(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void {\n if (errorOrMeta instanceof Error) {\n this.write('fatal', message, meta, errorOrMeta);\n } else {\n this.write('fatal', message, errorOrMeta);\n }\n }\n\n log(message: string, ...args: any[]): void {\n this.info(message, args.length > 0 ? { args } : undefined);\n }\n\n child(context: Record<string, any>): ObjectLogger {\n // Construct without `file`, then share the parent's stream: the\n // constructor opens eagerly, so passing `file` through would open a\n // second stream per child and immediately orphan it. That leak was\n // unreachable while #3110 kept the ESM open path dead.\n const child = new ObjectLogger({ ...this.config, file: undefined }, { ...this.bindings, ...context });\n child.config.file = this.config.file;\n child.fileStream = this.fileStream;\n return child;\n }\n\n withTrace(traceId: string, spanId?: string): ObjectLogger {\n return this.child({ traceId, spanId });\n }\n\n async destroy(): Promise<void> {\n const stream = this.fileStream;\n this.fileStream = undefined;\n // Children share the opener's stream; if they closed it too, one child's\n // teardown would end file logging for the parent and every sibling,\n // whose writes then land on a closed stream and only trip the 'error'\n // handler above.\n if (!stream || !this.ownsFileStream) return;\n this.ownsFileStream = false;\n await new Promise<void>((resolve) => stream.end(resolve));\n }\n}\n\nexport function createLogger(config?: Partial<LoggerConfig>): ObjectLogger {\n return new ObjectLogger(config);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/logger.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { LoggerConfig, LogLevel } from '@objectstack/spec/system';\nimport type { Logger } from '@objectstack/spec/contracts';\n\n// Re-export the contract type so consumers can do\n// `import type { Logger } from '@objectstack/core/logger'` without also\n// pulling `@objectstack/spec` into their bundle graph manually.\nexport type { Logger };\n\nconst LEVEL_ORDER: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3,\n fatal: 4,\n silent: 5,\n};\n\nconst LEVEL_COLORS: Record<LogLevel, string> = {\n debug: '\\x1b[36m',\n info: '\\x1b[32m',\n warn: '\\x1b[33m',\n error: '\\x1b[31m',\n fatal: '\\x1b[35m',\n silent: '',\n};\n\nconst RESET = '\\x1b[0m';\n\n/**\n * Split a field name into lowercase words on camelCase, `snake_case`,\n * `kebab-case`, dot and letter/digit boundaries.\n *\n * `apiKey` / `api_key` / `API_KEY` / `x-api-key` all tokenize to\n * `['api','key']`, while `monkey`, `keyword` and `tokenizer` stay a single\n * word. That difference is the whole point: it is what makes the redactor a\n * **word-boundary** matcher instead of the substring matcher it used to be\n * (#5573) — a plain `keys` field no longer reads as a secret.\n */\nfunction tokenizeFieldName(name: string): string[] {\n return name\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // apiKey -> api Key\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // APIKey -> API Key\n .replace(/([a-zA-Z])([0-9])/g, '$1 $2') // key2 -> key 2\n .split(/[^A-Za-z0-9]+/) // _ - . / space\n .filter(Boolean)\n .map((word) => word.toLowerCase());\n}\n\n/**\n * Singular form of the plural spellings the redact vocabulary actually meets\n * (`keys`, `tokens`, `secrets`, `passwords`, `passes`). Deliberately not a\n * general inflector — it only has to be right for words that end up next to a\n * redact word, and it must never turn `address`/`status` into a new word.\n */\nfunction singularizeWord(word: string): string {\n if (/(?:ss|us|is)$/.test(word)) return word; // address / status / axis\n if (/(?:ch|sh|s|x|z)es$/.test(word)) return word.slice(0, -2); // passes / boxes\n if (/[a-z0-9]s$/.test(word)) return word.slice(0, -1); // keys / tokens\n return word;\n}\n\n/**\n * Words that mark the *secret* sense of a redact word when they are glued to\n * it with no boundary to split on: `apikey`, `accesstoken`, `clientsecret`.\n *\n * Word-boundary matching covers every field name spelled the way this repo\n * spells names (camelCase config keys / snake_case machine names — Prime\n * Directive #3), but an all-lowercase concatenation has no boundary at all, so\n * `apikey` would tokenize to one word and stop being redacted. A bare\n * \"ends with `key`\" rule cannot be used to rescue it, because `monkey`,\n * `turkey` and `whiskey` end with `key` too — the exact false positives #5573\n * exists to remove. So the rescue is scoped to this explicit qualifier list:\n * `<qualifier><redact word>` is a secret, anything else glued to a redact word\n * is not.\n *\n * Consequences, on purpose:\n * - Only a **suffix** concatenation counts. `secretary` and `keyword` start\n * with a redact word and stay clear.\n * - An unlisted qualifier (`foobarkey`) is not redacted. The fix is to spell\n * the field `fooBarKey` / `foo_bar_key`, which matches generically — or to\n * add the word here.\n */\nconst CONCATENATED_SECRET_QUALIFIERS = new Set([\n 'access',\n 'account',\n 'admin',\n 'api',\n 'app',\n 'auth',\n 'bearer',\n 'client',\n 'csrf',\n 'db',\n 'database',\n 'encryption',\n 'id',\n 'jwt',\n 'master',\n 'oauth',\n 'private',\n 'public',\n 'refresh',\n 'root',\n 'secret',\n 'service',\n 'session',\n 'shared',\n 'sign',\n 'signing',\n 'ssh',\n 'token',\n 'user',\n 'webhook',\n 'xsrf',\n]);\n\n/** `apikey`/`apikeys` vs `key` — see {@link CONCATENATED_SECRET_QUALIFIERS}. */\nfunction isQualifiedConcatenation(word: string, redactWord: string): boolean {\n for (const base of [word, singularizeWord(word)]) {\n if (base.length <= redactWord.length || !base.endsWith(redactWord)) continue;\n if (CONCATENATED_SECRET_QUALIFIERS.has(base.slice(0, base.length - redactWord.length))) return true;\n }\n return false;\n}\n\n/** Does `words` contain `run` as a consecutive sub-sequence? */\nfunction containsWordRun(words: string[], run: string[]): boolean {\n for (let i = 0; i + run.length <= words.length; i++) {\n if (run.every((word, offset) => words[i + offset] === word)) return true;\n }\n return false;\n}\n\n/**\n * Word-boundary match of one configured redact pattern against one field name,\n * both already tokenized by {@link tokenizeFieldName}.\n *\n * The plural rule is the one subtlety, and it is the maintainer's ruling on\n * #5573 made consistent with itself: a **bare** plural names a collection or a\n * count, not a secret (`keys` on a Zod `unrecognized_keys` issue, `tokens` on\n * an LLM usage record), so it is left alone; a plural **inside a compound**\n * still names the secret (`apiKeys: ['sk-…']`, `refresh_tokens`) and is\n * redacted. Singular words match everywhere, compound or not.\n */\nfunction fieldWordsMatchPattern(nameWords: string[], patternWords: string[]): boolean {\n if (patternWords.length === 0 || nameWords.length === 0) return false;\n\n // A multi-word pattern (`apiKey`, `api_key`) matches a consecutive run of\n // the same words, or those words written as one concatenated token.\n if (patternWords.length > 1) {\n const glued = patternWords.join('');\n return (\n containsWordRun(nameWords, patternWords) ||\n nameWords.some((word) => word === glued || singularizeWord(word) === glued)\n );\n }\n\n const redactWord = patternWords[0];\n const isCompound = nameWords.filter((word) => /[a-z]/.test(word)).length > 1;\n return nameWords.some(\n (word) =>\n word === redactWord ||\n (isCompound && singularizeWord(word) === redactWord) ||\n isQualifiedConcatenation(word, redactWord),\n );\n}\n\n/**\n * Whether ANSI color may be written to the given stream.\n *\n * Follows the https://no-color.org convention: a non-empty `NO_COLOR` env var\n * disables color regardless of TTY, and non-TTY destinations (pipes, CI logs,\n * redirected output) always get plain text so plain-text log scanners see\n * uncolored level tags. Browser bundles have no `process`/TTY → plain text.\n */\nfunction colorEnabled(stream: { isTTY?: boolean } | undefined): boolean {\n if (typeof process !== 'undefined') {\n const noColor = (process as any).env?.NO_COLOR;\n if (noColor !== undefined && noColor !== '') return false;\n }\n return Boolean(stream?.isTTY);\n}\n\n/**\n * Resolve a Node builtin without putting it in this module's import graph.\n *\n * This entry is deliberately browser-safe — `@objectstack/client` bundles it —\n * so `fs`/`path` must never be imported statically. A lazy `require()` used to\n * meet that bar, but esbuild rewrites it to the `__require` shim in the ESM\n * output, which throws `Dynamic require of \"fs\" is not supported`. Every Node\n * ESM consumer (`os serve`, `os dev`) therefore lost file logging (#3110).\n * `process.getBuiltinModule` is a plain method call — opaque to bundlers — and\n * works in both module systems.\n */\nfunction loadNodeBuiltin<T>(id: string): T | undefined {\n if (typeof process === 'undefined') return undefined;\n\n const getBuiltinModule = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;\n if (typeof getBuiltinModule === 'function') {\n try {\n return getBuiltinModule.call(process, `node:${id}`) as T;\n } catch {\n return undefined;\n }\n }\n\n // Node < 20.16 / < 22.3 predates `getBuiltinModule`. Real `require` still\n // resolves in the CJS build; in the ESM build this is the shim that throws,\n // which the caller now reports rather than swallows.\n try {\n return require(id) as T;\n } catch {\n return undefined;\n }\n}\n\nexport class ObjectLogger implements Logger {\n private config: Required<Omit<LoggerConfig, 'file' | 'rotation' | 'name'>> & {\n file?: string;\n rotation?: { maxSize: string; maxFiles: number };\n name?: string;\n };\n private bindings: Record<string, any>;\n /** `config.redact`, tokenized once — see {@link fieldWordsMatchPattern}. */\n private redactPatterns: string[][];\n private fileStream?: any;\n /** Only the logger that opened the stream may close it — children share it. */\n private ownsFileStream = false;\n private fileLoggingDisabled = false;\n\n constructor(config: Partial<LoggerConfig> = {}, bindings: Record<string, any> = {}) {\n this.config = {\n name: config.name,\n level: config.level ?? 'info',\n format: config.format ?? 'pretty',\n redact: config.redact ?? ['password', 'token', 'secret', 'key'],\n sourceLocation: config.sourceLocation ?? false,\n file: config.file,\n rotation: config.rotation ?? { maxSize: '10m', maxFiles: 5 },\n };\n this.bindings = bindings;\n this.redactPatterns = this.config.redact.map(tokenizeFieldName).filter((words) => words.length > 0);\n\n if (this.config.file && typeof process !== 'undefined') {\n this.openFileStream(this.config.file);\n }\n }\n\n private openFileStream(path: string) {\n const fs = loadNodeBuiltin<typeof import('node:fs')>('fs');\n const nodePath = loadNodeBuiltin<typeof import('node:path')>('path');\n if (!fs || !nodePath) {\n this.disableFileLogging(path, 'no filesystem access in this runtime');\n return;\n }\n\n try {\n fs.mkdirSync(nodePath.dirname(path), { recursive: true });\n const stream = fs.createWriteStream(path, { flags: 'a' });\n // `createWriteStream` reports open failures (EACCES, EISDIR, …)\n // asynchronously. An 'error' event with no listener is fatal to the\n // process, so file logging must degrade here rather than take the\n // host down.\n stream.on('error', (err: Error) => this.disableFileLogging(path, err.message));\n this.fileStream = stream;\n this.ownsFileStream = true;\n } catch (err) {\n this.disableFileLogging(path, (err as Error).message);\n }\n }\n\n /**\n * Report — once — that an explicitly configured `file` destination is not\n * being written, and stop trying.\n *\n * Deliberately not routed through `write()`: this says the logger cannot\n * honour its own config, so `level` must not filter it. The bare `catch {}`\n * this replaces is exactly how #3110 stayed hidden.\n */\n private disableFileLogging(path: string, reason: string) {\n this.fileStream = undefined;\n this.ownsFileStream = false;\n if (this.fileLoggingDisabled) return;\n this.fileLoggingDisabled = true;\n\n const label = this.config.name ? `[${this.config.name}] ` : '';\n const notice = `${label}logger: file logging disabled — cannot write to ${path}: ${reason}`;\n if (typeof process !== 'undefined' && (process as any).stderr) {\n (process as any).stderr.write(notice + '\\n');\n } else if (typeof console !== 'undefined') {\n console.warn(notice);\n }\n }\n\n private isEnabled(level: LogLevel): boolean {\n return LEVEL_ORDER[level] >= LEVEL_ORDER[this.config.level];\n }\n\n /**\n * Whether a meta field name names one of the configured secrets.\n *\n * Until #5573 this was `lower.includes(pattern)`, which redacted every\n * field whose name merely *contained* a redact word — `keys`, `keyword`,\n * `tokens`, `monkey`, `secretary` — and replaced its value with\n * `***REDACTED***`, so the reader lost the fact AND was told a secret had\n * been withheld. Matching is now on word boundaries: `key` matches\n * `apiKey` / `api_key`, not `keys` / `monkey` / `keyword`.\n */\n private isRedactedFieldName(key: string): boolean {\n const nameWords = tokenizeFieldName(key);\n return this.redactPatterns.some((pattern) => fieldWordsMatchPattern(nameWords, pattern));\n }\n\n private redactSensitive(obj: any): any {\n if (!obj || typeof obj !== 'object') return obj;\n const redacted = Array.isArray(obj) ? [...obj] : { ...obj };\n for (const key in redacted) {\n if (this.isRedactedFieldName(key)) {\n redacted[key] = '***REDACTED***';\n } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {\n redacted[key] = this.redactSensitive(redacted[key]);\n }\n }\n return redacted;\n }\n\n private write(level: LogLevel, message: string, meta?: Record<string, any>, error?: Error) {\n if (!this.isEnabled(level)) return;\n\n const context = this.redactSensitive({\n ...this.bindings,\n ...meta,\n ...(error ? { error: { message: error.message, stack: error.stack } } : {}),\n });\n\n const hasContext = Object.keys(context).length > 0;\n const ts = new Date().toISOString();\n\n const isErrorLevel = level === 'error' || level === 'fatal';\n const proc = typeof process !== 'undefined' ? (process as any) : undefined;\n const stream = proc ? (isErrorLevel ? proc.stderr : proc.stdout) : undefined;\n\n let line: string; // console output — may carry ANSI color\n let plainLine: string; // file output — never colored\n\n if (this.config.format === 'json') {\n line = plainLine = JSON.stringify({\n time: ts,\n level,\n ...(this.config.name ? { name: this.config.name } : {}),\n msg: message,\n ...context,\n });\n } else if (this.config.format === 'text') {\n const parts = [ts, level.toUpperCase(), message];\n if (hasContext) parts.push(JSON.stringify(context));\n line = plainLine = parts.join(' | ');\n } else {\n // pretty\n const label = this.config.name ? `[${this.config.name}] ` : '';\n const head = `${ts} ${level.toUpperCase()}`;\n let tail = ` ${label}${message}`;\n if (hasContext) tail += ` ${JSON.stringify(context)}`;\n plainLine = head + tail;\n const color = LEVEL_COLORS[level] || '';\n line = color && colorEnabled(stream) ? `${color}${head}${RESET}${tail}` : plainLine;\n }\n\n // Browser-safe output: prefer process streams when available, otherwise\n // fall back to console. `process` may be missing entirely (browsers) or\n // present without stdio streams (bundler shims) — both fall through to\n // console. The previous unguarded `process.stderr?.write` threw\n // `ReferenceError: process is not defined` in browsers because\n // `process` itself is the missing global, not just its `stderr` field.\n if (stream) {\n stream.write(line + '\\n');\n } else if (typeof console !== 'undefined') {\n const fn =\n level === 'error' || level === 'fatal' ? console.error\n : level === 'warn' ? console.warn\n : level === 'debug' ? console.debug\n : console.log;\n fn(line);\n }\n\n if (this.fileStream) {\n this.fileStream.write(plainLine + '\\n');\n }\n }\n\n debug(message: string, meta?: Record<string, any>): void {\n this.write('debug', message, meta);\n }\n\n info(message: string, meta?: Record<string, any>): void {\n this.write('info', message, meta);\n }\n\n warn(message: string, meta?: Record<string, any>): void {\n this.write('warn', message, meta);\n }\n\n error(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void {\n this.writeErrorLike('error', message, errorOrMeta, meta);\n }\n\n fatal(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void {\n this.writeErrorLike('fatal', message, errorOrMeta, meta);\n }\n\n /**\n * `error`/`fatal` dispatch — the two levels whose contract has an `Error`\n * slot in front of `meta`.\n *\n * The `Logger` contract declares `error(message, error?: Error, meta?)`, and\n * `ObjectLogger` additionally tolerates a **meta object** in the `error`\n * slot because many in-repo call sites write `logger.error(msg, { … })`.\n * That tolerance is fine; dropping a parameter the contract *declares* is\n * not, and that is what the previous dispatch did:\n *\n * if (errorOrMeta instanceof Error) this.write(level, message, meta, errorOrMeta);\n * else this.write(level, message, errorOrMeta);\n *\n * With `error === undefined` the `else` branch passed `undefined` as the\n * meta and **never read the third argument**, so every contract-shaped\n * `logger.error(msg, undefined, { … })` call rendered a bare message with\n * its diagnostics silently gone — ~15 such call sites across `metadata`,\n * `metadata-protocol`, `client` and `core/security`, plus the connector\n * reconcile seam that found this (#5575). The contract's two sibling\n * implementations (`ConsoleLogger`/`JsonLogger` in `@objectstack/observability`)\n * both honour the slot, so the contract was right and this class was the\n * outlier — declared ≠ enforced, Prime Directive #10.\n *\n * All three shapes are now honoured. When both slots carry meta, `meta`\n * (the later, more specific argument) wins on a key collision.\n */\n private writeErrorLike(\n level: 'error' | 'fatal',\n message: string,\n errorOrMeta?: Error | Record<string, any>,\n meta?: Record<string, any>,\n ): void {\n if (errorOrMeta instanceof Error) {\n this.write(level, message, meta, errorOrMeta);\n return;\n }\n const merged = errorOrMeta && meta ? { ...errorOrMeta, ...meta } : (errorOrMeta ?? meta);\n this.write(level, message, merged);\n }\n\n log(message: string, ...args: any[]): void {\n this.info(message, args.length > 0 ? { args } : undefined);\n }\n\n child(context: Record<string, any>): ObjectLogger {\n // Construct without `file`, then share the parent's stream: the\n // constructor opens eagerly, so passing `file` through would open a\n // second stream per child and immediately orphan it. That leak was\n // unreachable while #3110 kept the ESM open path dead.\n const child = new ObjectLogger({ ...this.config, file: undefined }, { ...this.bindings, ...context });\n child.config.file = this.config.file;\n child.fileStream = this.fileStream;\n return child;\n }\n\n withTrace(traceId: string, spanId?: string): ObjectLogger {\n return this.child({ traceId, spanId });\n }\n\n async destroy(): Promise<void> {\n const stream = this.fileStream;\n this.fileStream = undefined;\n // Children share the opener's stream; if they closed it too, one child's\n // teardown would end file logging for the parent and every sibling,\n // whose writes then land on a closed stream and only trip the 'error'\n // handler above.\n if (!stream || !this.ownsFileStream) return;\n this.ownsFileStream = false;\n await new Promise<void>((resolve) => stream.end(resolve));\n }\n}\n\nexport function createLogger(config?: Partial<LoggerConfig>): ObjectLogger {\n return new ObjectLogger(config);\n}\n"],"mappings":"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","names":[]}
package/dist/logger.d.cts CHANGED
@@ -5,6 +5,8 @@ export { Logger } from '@objectstack/spec/contracts';
5
5
  declare class ObjectLogger implements Logger {
6
6
  private config;
7
7
  private bindings;
8
+ /** `config.redact`, tokenized once — see {@link fieldWordsMatchPattern}. */
9
+ private redactPatterns;
8
10
  private fileStream?;
9
11
  /** Only the logger that opened the stream may close it — children share it. */
10
12
  private ownsFileStream;
@@ -21,6 +23,17 @@ declare class ObjectLogger implements Logger {
21
23
  */
22
24
  private disableFileLogging;
23
25
  private isEnabled;
26
+ /**
27
+ * Whether a meta field name names one of the configured secrets.
28
+ *
29
+ * Until #5573 this was `lower.includes(pattern)`, which redacted every
30
+ * field whose name merely *contained* a redact word — `keys`, `keyword`,
31
+ * `tokens`, `monkey`, `secretary` — and replaced its value with
32
+ * `***REDACTED***`, so the reader lost the fact AND was told a secret had
33
+ * been withheld. Matching is now on word boundaries: `key` matches
34
+ * `apiKey` / `api_key`, not `keys` / `monkey` / `keyword`.
35
+ */
36
+ private isRedactedFieldName;
24
37
  private redactSensitive;
25
38
  private write;
26
39
  debug(message: string, meta?: Record<string, any>): void;
@@ -28,6 +41,33 @@ declare class ObjectLogger implements Logger {
28
41
  warn(message: string, meta?: Record<string, any>): void;
29
42
  error(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void;
30
43
  fatal(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void;
44
+ /**
45
+ * `error`/`fatal` dispatch — the two levels whose contract has an `Error`
46
+ * slot in front of `meta`.
47
+ *
48
+ * The `Logger` contract declares `error(message, error?: Error, meta?)`, and
49
+ * `ObjectLogger` additionally tolerates a **meta object** in the `error`
50
+ * slot because many in-repo call sites write `logger.error(msg, { … })`.
51
+ * That tolerance is fine; dropping a parameter the contract *declares* is
52
+ * not, and that is what the previous dispatch did:
53
+ *
54
+ * if (errorOrMeta instanceof Error) this.write(level, message, meta, errorOrMeta);
55
+ * else this.write(level, message, errorOrMeta);
56
+ *
57
+ * With `error === undefined` the `else` branch passed `undefined` as the
58
+ * meta and **never read the third argument**, so every contract-shaped
59
+ * `logger.error(msg, undefined, { … })` call rendered a bare message with
60
+ * its diagnostics silently gone — ~15 such call sites across `metadata`,
61
+ * `metadata-protocol`, `client` and `core/security`, plus the connector
62
+ * reconcile seam that found this (#5575). The contract's two sibling
63
+ * implementations (`ConsoleLogger`/`JsonLogger` in `@objectstack/observability`)
64
+ * both honour the slot, so the contract was right and this class was the
65
+ * outlier — declared ≠ enforced, Prime Directive #10.
66
+ *
67
+ * All three shapes are now honoured. When both slots carry meta, `meta`
68
+ * (the later, more specific argument) wins on a key collision.
69
+ */
70
+ private writeErrorLike;
31
71
  log(message: string, ...args: any[]): void;
32
72
  child(context: Record<string, any>): ObjectLogger;
33
73
  withTrace(traceId: string, spanId?: string): ObjectLogger;
package/dist/logger.d.ts CHANGED
@@ -5,6 +5,8 @@ export { Logger } from '@objectstack/spec/contracts';
5
5
  declare class ObjectLogger implements Logger {
6
6
  private config;
7
7
  private bindings;
8
+ /** `config.redact`, tokenized once — see {@link fieldWordsMatchPattern}. */
9
+ private redactPatterns;
8
10
  private fileStream?;
9
11
  /** Only the logger that opened the stream may close it — children share it. */
10
12
  private ownsFileStream;
@@ -21,6 +23,17 @@ declare class ObjectLogger implements Logger {
21
23
  */
22
24
  private disableFileLogging;
23
25
  private isEnabled;
26
+ /**
27
+ * Whether a meta field name names one of the configured secrets.
28
+ *
29
+ * Until #5573 this was `lower.includes(pattern)`, which redacted every
30
+ * field whose name merely *contained* a redact word — `keys`, `keyword`,
31
+ * `tokens`, `monkey`, `secretary` — and replaced its value with
32
+ * `***REDACTED***`, so the reader lost the fact AND was told a secret had
33
+ * been withheld. Matching is now on word boundaries: `key` matches
34
+ * `apiKey` / `api_key`, not `keys` / `monkey` / `keyword`.
35
+ */
36
+ private isRedactedFieldName;
24
37
  private redactSensitive;
25
38
  private write;
26
39
  debug(message: string, meta?: Record<string, any>): void;
@@ -28,6 +41,33 @@ declare class ObjectLogger implements Logger {
28
41
  warn(message: string, meta?: Record<string, any>): void;
29
42
  error(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void;
30
43
  fatal(message: string, errorOrMeta?: Error | Record<string, any>, meta?: Record<string, any>): void;
44
+ /**
45
+ * `error`/`fatal` dispatch — the two levels whose contract has an `Error`
46
+ * slot in front of `meta`.
47
+ *
48
+ * The `Logger` contract declares `error(message, error?: Error, meta?)`, and
49
+ * `ObjectLogger` additionally tolerates a **meta object** in the `error`
50
+ * slot because many in-repo call sites write `logger.error(msg, { … })`.
51
+ * That tolerance is fine; dropping a parameter the contract *declares* is
52
+ * not, and that is what the previous dispatch did:
53
+ *
54
+ * if (errorOrMeta instanceof Error) this.write(level, message, meta, errorOrMeta);
55
+ * else this.write(level, message, errorOrMeta);
56
+ *
57
+ * With `error === undefined` the `else` branch passed `undefined` as the
58
+ * meta and **never read the third argument**, so every contract-shaped
59
+ * `logger.error(msg, undefined, { … })` call rendered a bare message with
60
+ * its diagnostics silently gone — ~15 such call sites across `metadata`,
61
+ * `metadata-protocol`, `client` and `core/security`, plus the connector
62
+ * reconcile seam that found this (#5575). The contract's two sibling
63
+ * implementations (`ConsoleLogger`/`JsonLogger` in `@objectstack/observability`)
64
+ * both honour the slot, so the contract was right and this class was the
65
+ * outlier — declared ≠ enforced, Prime Directive #10.
66
+ *
67
+ * All three shapes are now honoured. When both slots carry meta, `meta`
68
+ * (the later, more specific argument) wins on a key collision.
69
+ */
70
+ private writeErrorLike;
31
71
  log(message: string, ...args: any[]): void;
32
72
  child(context: Record<string, any>): ObjectLogger;
33
73
  withTrace(traceId: string, spanId?: string): ObjectLogger;
package/dist/logger.js CHANGED
@@ -23,6 +23,73 @@ var LEVEL_COLORS = {
23
23
  silent: ""
24
24
  };
25
25
  var RESET = "\x1B[0m";
26
+ function tokenizeFieldName(name) {
27
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/([a-zA-Z])([0-9])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((word) => word.toLowerCase());
28
+ }
29
+ function singularizeWord(word) {
30
+ if (/(?:ss|us|is)$/.test(word)) return word;
31
+ if (/(?:ch|sh|s|x|z)es$/.test(word)) return word.slice(0, -2);
32
+ if (/[a-z0-9]s$/.test(word)) return word.slice(0, -1);
33
+ return word;
34
+ }
35
+ var CONCATENATED_SECRET_QUALIFIERS = /* @__PURE__ */ new Set([
36
+ "access",
37
+ "account",
38
+ "admin",
39
+ "api",
40
+ "app",
41
+ "auth",
42
+ "bearer",
43
+ "client",
44
+ "csrf",
45
+ "db",
46
+ "database",
47
+ "encryption",
48
+ "id",
49
+ "jwt",
50
+ "master",
51
+ "oauth",
52
+ "private",
53
+ "public",
54
+ "refresh",
55
+ "root",
56
+ "secret",
57
+ "service",
58
+ "session",
59
+ "shared",
60
+ "sign",
61
+ "signing",
62
+ "ssh",
63
+ "token",
64
+ "user",
65
+ "webhook",
66
+ "xsrf"
67
+ ]);
68
+ function isQualifiedConcatenation(word, redactWord) {
69
+ for (const base of [word, singularizeWord(word)]) {
70
+ if (base.length <= redactWord.length || !base.endsWith(redactWord)) continue;
71
+ if (CONCATENATED_SECRET_QUALIFIERS.has(base.slice(0, base.length - redactWord.length))) return true;
72
+ }
73
+ return false;
74
+ }
75
+ function containsWordRun(words, run) {
76
+ for (let i = 0; i + run.length <= words.length; i++) {
77
+ if (run.every((word, offset) => words[i + offset] === word)) return true;
78
+ }
79
+ return false;
80
+ }
81
+ function fieldWordsMatchPattern(nameWords, patternWords) {
82
+ if (patternWords.length === 0 || nameWords.length === 0) return false;
83
+ if (patternWords.length > 1) {
84
+ const glued = patternWords.join("");
85
+ return containsWordRun(nameWords, patternWords) || nameWords.some((word) => word === glued || singularizeWord(word) === glued);
86
+ }
87
+ const redactWord = patternWords[0];
88
+ const isCompound = nameWords.filter((word) => /[a-z]/.test(word)).length > 1;
89
+ return nameWords.some(
90
+ (word) => word === redactWord || isCompound && singularizeWord(word) === redactWord || isQualifiedConcatenation(word, redactWord)
91
+ );
92
+ }
26
93
  function colorEnabled(stream) {
27
94
  if (typeof process !== "undefined") {
28
95
  const noColor = process.env?.NO_COLOR;
@@ -61,6 +128,7 @@ var ObjectLogger = class _ObjectLogger {
61
128
  rotation: config.rotation ?? { maxSize: "10m", maxFiles: 5 }
62
129
  };
63
130
  this.bindings = bindings;
131
+ this.redactPatterns = this.config.redact.map(tokenizeFieldName).filter((words) => words.length > 0);
64
132
  if (this.config.file && typeof process !== "undefined") {
65
133
  this.openFileStream(this.config.file);
66
134
  }
@@ -106,12 +174,25 @@ var ObjectLogger = class _ObjectLogger {
106
174
  isEnabled(level) {
107
175
  return LEVEL_ORDER[level] >= LEVEL_ORDER[this.config.level];
108
176
  }
177
+ /**
178
+ * Whether a meta field name names one of the configured secrets.
179
+ *
180
+ * Until #5573 this was `lower.includes(pattern)`, which redacted every
181
+ * field whose name merely *contained* a redact word — `keys`, `keyword`,
182
+ * `tokens`, `monkey`, `secretary` — and replaced its value with
183
+ * `***REDACTED***`, so the reader lost the fact AND was told a secret had
184
+ * been withheld. Matching is now on word boundaries: `key` matches
185
+ * `apiKey` / `api_key`, not `keys` / `monkey` / `keyword`.
186
+ */
187
+ isRedactedFieldName(key) {
188
+ const nameWords = tokenizeFieldName(key);
189
+ return this.redactPatterns.some((pattern) => fieldWordsMatchPattern(nameWords, pattern));
190
+ }
109
191
  redactSensitive(obj) {
110
192
  if (!obj || typeof obj !== "object") return obj;
111
193
  const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
112
194
  for (const key in redacted) {
113
- const lower = key.toLowerCase();
114
- if (this.config.redact.some((p) => lower.includes(p.toLowerCase()))) {
195
+ if (this.isRedactedFieldName(key)) {
115
196
  redacted[key] = "***REDACTED***";
116
197
  } else if (typeof redacted[key] === "object" && redacted[key] !== null) {
117
198
  redacted[key] = this.redactSensitive(redacted[key]);
@@ -174,18 +255,44 @@ var ObjectLogger = class _ObjectLogger {
174
255
  this.write("warn", message, meta);
175
256
  }
176
257
  error(message, errorOrMeta, meta) {
177
- if (errorOrMeta instanceof Error) {
178
- this.write("error", message, meta, errorOrMeta);
179
- } else {
180
- this.write("error", message, errorOrMeta);
181
- }
258
+ this.writeErrorLike("error", message, errorOrMeta, meta);
182
259
  }
183
260
  fatal(message, errorOrMeta, meta) {
261
+ this.writeErrorLike("fatal", message, errorOrMeta, meta);
262
+ }
263
+ /**
264
+ * `error`/`fatal` dispatch — the two levels whose contract has an `Error`
265
+ * slot in front of `meta`.
266
+ *
267
+ * The `Logger` contract declares `error(message, error?: Error, meta?)`, and
268
+ * `ObjectLogger` additionally tolerates a **meta object** in the `error`
269
+ * slot because many in-repo call sites write `logger.error(msg, { … })`.
270
+ * That tolerance is fine; dropping a parameter the contract *declares* is
271
+ * not, and that is what the previous dispatch did:
272
+ *
273
+ * if (errorOrMeta instanceof Error) this.write(level, message, meta, errorOrMeta);
274
+ * else this.write(level, message, errorOrMeta);
275
+ *
276
+ * With `error === undefined` the `else` branch passed `undefined` as the
277
+ * meta and **never read the third argument**, so every contract-shaped
278
+ * `logger.error(msg, undefined, { … })` call rendered a bare message with
279
+ * its diagnostics silently gone — ~15 such call sites across `metadata`,
280
+ * `metadata-protocol`, `client` and `core/security`, plus the connector
281
+ * reconcile seam that found this (#5575). The contract's two sibling
282
+ * implementations (`ConsoleLogger`/`JsonLogger` in `@objectstack/observability`)
283
+ * both honour the slot, so the contract was right and this class was the
284
+ * outlier — declared ≠ enforced, Prime Directive #10.
285
+ *
286
+ * All three shapes are now honoured. When both slots carry meta, `meta`
287
+ * (the later, more specific argument) wins on a key collision.
288
+ */
289
+ writeErrorLike(level, message, errorOrMeta, meta) {
184
290
  if (errorOrMeta instanceof Error) {
185
- this.write("fatal", message, meta, errorOrMeta);
186
- } else {
187
- this.write("fatal", message, errorOrMeta);
291
+ this.write(level, message, meta, errorOrMeta);
292
+ return;
188
293
  }
294
+ const merged = errorOrMeta && meta ? { ...errorOrMeta, ...meta } : errorOrMeta ?? meta;
295
+ this.write(level, message, merged);
189
296
  }
190
297
  log(message, ...args) {
191
298
  this.info(message, args.length > 0 ? { args } : void 0);