bazis 0.98.27 → 0.98.29

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/cli/build.ts CHANGED
@@ -10,6 +10,13 @@ export interface BuildOptions {
10
10
  readonly bin: boolean;
11
11
  /** Executable path relative to the project; default `bin/<package name>`. */
12
12
  readonly outfile?: string;
13
+ /** Let the executable read `.env` files from its working directory (off by default). */
14
+ readonly dotenv?: boolean;
15
+ }
16
+
17
+ export interface CompileOptions {
18
+ /** Let the executable read `.env`, `.env.local`, `.env.<NODE_ENV>` from its working directory. */
19
+ readonly dotenv?: boolean;
13
20
  }
14
21
 
15
22
  /**
@@ -103,7 +110,7 @@ export async function runBuild(cwd: string, options: BuildOptions, codegen: Code
103
110
  if (typecheck !== 0 || !options.bin) return typecheck;
104
111
  const outfile = path.resolve(cwd, options.outfile ?? path.join("bin", await binaryName(cwd)));
105
112
  log(`[bazis] compile ${path.relative(cwd, outfile)}...`);
106
- return compileBinary(bun, await projectEntry(cwd), outfile);
113
+ return compileBinary(bun, await projectEntry(cwd), outfile, { dotenv: options.dotenv });
107
114
  }
108
115
 
109
116
  /**
@@ -111,11 +118,18 @@ export async function runBuild(cwd: string, options: BuildOptions, codegen: Code
111
118
  * `.<hash>.bun-build` file in its working directory and cannot remove it when
112
119
  * that executable is read-only or carries `uchg` (scripts/bazis-bun). Compile
113
120
  * from a private directory and remove it afterwards.
121
+ *
122
+ * By default a Bun executable reads `bunfig.toml` and `.env` files from the
123
+ * directory it is started in: a `preload` in such a bunfig runs foreign code
124
+ * inside the application, and a stray `.env` silently changes its settings.
125
+ * The executable gets its settings from the real environment instead;
126
+ * `dotenv: true` restores reading `.env`. A bunfig is never read.
114
127
  */
115
- export function compileBinary(bun: string, entry: string, outfile: string): number {
128
+ export function compileBinary(bun: string, entry: string, outfile: string, options: CompileOptions = {}): number {
116
129
  const workDir = mkdtempSync(path.join(tmpdir(), "bazis-compile-"));
130
+ const autoload = ["--no-compile-autoload-bunfig", options.dotenv === true ? "--compile-autoload-dotenv" : "--no-compile-autoload-dotenv"];
117
131
  try {
118
- const result = Bun.spawnSync([bun, "build", "--compile", path.resolve(entry), "--outfile", path.resolve(outfile)],
132
+ const result = Bun.spawnSync([bun, "build", "--compile", ...autoload, path.resolve(entry), "--outfile", path.resolve(outfile)],
119
133
  { cwd: workDir, stdout: "inherit", stderr: "inherit" });
120
134
  return result.exitCode ?? 1;
121
135
  } finally {
@@ -164,7 +164,8 @@ function buildProjectFiles(name: string, dependency: string, frameworkMode: Fram
164
164
  ["package.json", `${JSON.stringify(manifest, null, 2)}\n`],
165
165
  ["tsconfig.json", `${JSON.stringify(tsconfig, null, 2)}\n`],
166
166
  ["bazis.config.json", `${JSON.stringify(codegen, null, 2)}\n`],
167
- [".gitignore", "node_modules/\nbin/\nsrc/generated/\n.env\n"],
167
+ // Bun reads .env, .env.local and .env.<NODE_ENV>: none of them belongs in git.
168
+ [".gitignore", "node_modules/\nbin/\nsrc/generated/\n.env\n.env.*\n!.env.example\n"],
168
169
  [".env.example", "# Copy to .env (Bun loads it automatically).\nBAZIS_ENV=development\nHOST=127.0.0.1\nPORT=3000\n"],
169
170
  ["src/app/test/health.test.ts", HEALTH_TEST],
170
171
  ["AGENTS.md", "# Working on this bazis project\n\nBefore changing the application, read the [module architecture](docs/architecture/MODULE_ARCHITECTURE.md). Create new modules only with `bunx bazis g module` or `bunx bazis g pack`; fill in the generated `MODULE.md` afterwards. Only codegen updates `src/generated/`.\n"],
package/cli/main.ts CHANGED
@@ -16,6 +16,7 @@ export const USAGE = `Usage:
16
16
  bazis test [<bun test args>] Codegen, then bun test
17
17
  bazis build Codegen and typecheck
18
18
  bazis build --bin [--outfile <path>] Also compile a standalone executable (default bin/<name>)
19
+ bazis build --bin --dotenv The executable also reads .env files from its working directory
19
20
  bazis --help
20
21
  g can also be written as generate.
21
22
 
@@ -85,7 +86,7 @@ export async function runCli(argv: readonly string[], runtime: CliRuntime = defa
85
86
  if (parsed.kind === "codegen") return await runtime.codegen(process.cwd(), parsed.target);
86
87
  if (parsed.kind === "dev") return await runDev(process.cwd(), runtime.codegen, runtime.log, { watch: parsed.watch });
87
88
  if (parsed.kind === "test") return await runTest(process.cwd(), parsed.args, runtime.codegen);
88
- if (parsed.kind === "build") return await runBuild(process.cwd(), { bin: parsed.bin, outfile: parsed.outfile }, runtime.codegen, runtime.log);
89
+ if (parsed.kind === "build") return await runBuild(process.cwd(), { bin: parsed.bin, outfile: parsed.outfile, dotenv: parsed.dotenv }, runtime.codegen, runtime.log);
89
90
  if (parsed.kind === "new") {
90
91
  const result = await generateProject({ name: parsed.name, outputPath: parsed.outputPath, frameworkPath: parsed.frameworkPath, linkFramework: parsed.linkFramework, vendor: parsed.vendor, dryRun: parsed.dryRun });
91
92
  runtime.log(`[bazis] ${result.dryRun ? "planned" : "created"} project: ${result.projectDir}`);
package/cli/parseCli.ts CHANGED
@@ -25,7 +25,7 @@ export type ParseCliResult =
25
25
  | { readonly kind: "codegen"; readonly target?: string }
26
26
  | { readonly kind: "dev"; readonly watch: boolean }
27
27
  | { readonly kind: "test"; readonly args: readonly string[] }
28
- | { readonly kind: "build"; readonly bin: boolean; readonly outfile?: string }
28
+ | { readonly kind: "build"; readonly bin: boolean; readonly outfile?: string; readonly dotenv: boolean }
29
29
  | { readonly kind: "error"; readonly message: string };
30
30
 
31
31
  const GENERATOR_ALIASES = new Map<string, "module" | "pack">([
@@ -33,7 +33,7 @@ const GENERATOR_ALIASES = new Map<string, "module" | "pack">([
33
33
  ["pack", "pack"], ["p", "pack"], ["module-pack", "pack"],
34
34
  ]);
35
35
  const VALUE_OPTIONS = new Set(["--modules-root", "--app-module", "--pack", "--parts", "--target", "--path", "--framework", "--outfile"]);
36
- const FLAG_OPTIONS = new Set(["--no-register", "--force", "--full", "--enterprise", "--minimal", "--empty", "--dry-run", "--no-codegen", "--link-framework", "--vendor", "--bin", "--watch"]);
36
+ const FLAG_OPTIONS = new Set(["--no-register", "--force", "--full", "--enterprise", "--minimal", "--empty", "--dry-run", "--no-codegen", "--link-framework", "--vendor", "--bin", "--watch", "--dotenv"]);
37
37
 
38
38
  /** Parsing is pure: help and invalid input can never start generation. */
39
39
  export function parseCliArgs(argv: readonly string[]): ParseCliResult {
@@ -89,14 +89,15 @@ export function parseCliArgs(argv: readonly string[]): ParseCliResult {
89
89
  }
90
90
  if (options.has("--watch")) return error("--watch is only supported by dev.");
91
91
  if (command === "build") {
92
- if (positional.length !== 1) return error("Use: bazis build [--bin [--outfile <path>]]");
92
+ if (positional.length !== 1) return error("Use: bazis build [--bin [--outfile <path>] [--dotenv]]");
93
93
  for (const option of options.keys()) {
94
- if (option !== "--bin" && option !== "--outfile") return error(`Option ${option} is not supported by build.`);
94
+ if (option !== "--bin" && option !== "--outfile" && option !== "--dotenv") return error(`Option ${option} is not supported by build.`);
95
95
  }
96
96
  if (options.has("--outfile") && !options.has("--bin")) return error("--outfile requires --bin.");
97
- return { kind: "build", bin: options.has("--bin"), outfile: options.get("--outfile") };
97
+ if (options.has("--dotenv") && !options.has("--bin")) return error("--dotenv requires --bin.");
98
+ return { kind: "build", bin: options.has("--bin"), outfile: options.get("--outfile"), dotenv: options.has("--dotenv") };
98
99
  }
99
- if (options.has("--bin") || options.has("--outfile")) return error("--bin and --outfile are only supported by build.");
100
+ if (options.has("--bin") || options.has("--outfile") || options.has("--dotenv")) return error("--bin, --outfile and --dotenv are only supported by build.");
100
101
  if (command === "codegen") {
101
102
  if (positional.length !== 1) return error("Use: bazis codegen [--target <name|all>]");
102
103
  for (const option of options.keys()) {
@@ -15,6 +15,7 @@ import { ServiceRegistry } from "./internal/ServiceRegistry";
15
15
  import { ResolutionTracker, type ResolutionActivation } from "./internal/ResolutionTracker";
16
16
  import { ScopeLifecycle } from "./internal/ScopeLifecycle";
17
17
  import { ServiceScope } from "./ServiceScope";
18
+ import { RESOLVE_DEPENDENCY_LIST } from "./internal/resolveDependencyList";
18
19
  import { getConstructorDeps, getProviderDeps } from "./internal/providerDeps";
19
20
  import type { BuildServiceProviderOptions, ServiceKey, ServiceResolver } from "./types";
20
21
  import type {
@@ -550,7 +551,29 @@ export class ServiceProvider implements ServiceResolver {
550
551
  return this.tryResolveInternal(token, key, scopeState, stack(), ownerLifetime);
551
552
  },
552
553
  has: (token: Token<unknown>, key?: ServiceKey): boolean => this.has(token, key),
553
- };
554
+ [RESOLVE_DEPENDENCY_LIST]: (key: object, deps: ProviderDependencyList): unknown[] => {
555
+ assertOriginScopeLive();
556
+ return this.resolveListIn(key, deps, scopeState, stack(), ownerLifetime);
557
+ },
558
+ } as ServiceResolver;
559
+ }
560
+
561
+ /** @internal See resolveDependencyList: the root resolver is the provider itself. */
562
+ [RESOLVE_DEPENDENCY_LIST](key: object, deps: ProviderDependencyList): unknown[] {
563
+ return this.resolveListIn(key, deps, this.lifetime.root, [], undefined);
564
+ }
565
+
566
+ // A dependency list resolved outside a registered provider (a DbContext's
567
+ // generated constructor dependencies). The synthetic provider is cached per
568
+ // key, so its plan is built once like any provider's.
569
+ private readonly listProviders = new WeakMap<object, Provider<unknown>>();
570
+ private resolveListIn(key: object, deps: ProviderDependencyList, scopeState: ResolutionScopeState, stack: ResolutionActivation[], ownerLifetime: ProviderLifetime | undefined): unknown[] {
571
+ let provider = this.listProviders.get(key);
572
+ if (provider === undefined || getProviderDeps(provider) !== deps) {
573
+ provider = { provide: key as Token<unknown>, useFactory: () => undefined, deps } as unknown as Provider<unknown>;
574
+ this.listProviders.set(key, provider);
575
+ }
576
+ return this.resolveDeps(this.getPlan(provider), scopeState, stack, ownerLifetime ?? "scoped");
554
577
  }
555
578
 
556
579
  private resolveDeps(
@@ -0,0 +1,19 @@
1
+ import type { ProviderDependencyList } from "../provider";
2
+ import type { ServiceResolver } from "../types";
3
+
4
+ /** @internal A resolver created by ServiceProvider resolves a dependency list in its own scope. */
5
+ export const RESOLVE_DEPENDENCY_LIST = Symbol("bazis.di.resolveDependencyList");
6
+
7
+ type ListResolver = ServiceResolver & { readonly [RESOLVE_DEPENDENCY_LIST]?: (key: object, deps: ProviderDependencyList) => unknown[] };
8
+
9
+ /**
10
+ * Resolves `deps` (tokens, keyed, named, lazy or optional dependencies, as
11
+ * codegen records them) in the scope of `resolver`. `key` identifies the list
12
+ * so its resolution plan is built once. For framework factories that construct
13
+ * a class with generated dependencies, such as a DbContext.
14
+ */
15
+ export function resolveDependencyList(resolver: ServiceResolver, key: object, deps: ProviderDependencyList): unknown[] {
16
+ const resolve = (resolver as ListResolver)[RESOLVE_DEPENDENCY_LIST];
17
+ if (resolve === undefined) throw new TypeError("resolveDependencyList() needs a resolver created by the container");
18
+ return resolve(key, deps);
19
+ }
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.98.27" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.29" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -26,6 +26,16 @@ Flat Configuration key names are case-insensitive. A later source also wins
26
26
  between the canonical name and an explicit env alias.
27
27
  Numbers are finite; an empty string never becomes zero; a Secret does not allow an
28
28
  empty or whitespace value. TypeScript forbids unsupported object types.
29
+ In `production` a Secret whose value would come from the `default` section (no source
30
+ supplied it and the `production` section does not declare it) is an error: a secret
31
+ default in the code is a development value. `production: { key: secret("…") }`
32
+ declares a production value explicitly.
33
+
34
+ `secretFilesSource(directory, { optional })` reads secret files as Docker and Kubernetes
35
+ mount them (`/run/secrets`): one file per value, the file name is the key (`db.password`)
36
+ or its variable (`BAZIS_DB__PASSWORD`), trailing line breaks are removed, hidden entries
37
+ (Kubernetes `..data`) and directories are skipped, a file over 64 KiB is an error.
38
+ Errors name the file, never its content. A missing directory is an error unless `optional`.
29
39
 
30
40
  A declaration stores no chosen environment, values or cache. `get/has/ensureValid`
31
41
  stay for standalone reading of the process env, but `ensureValid` no longer
@@ -156,7 +156,8 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
156
156
  const issues: string[] = [];
157
157
  // A wrong value names what came and where to change it: the variable that
158
158
  // supplied it, or the variables that can override the value from the code.
159
- const got = (raw: unknown) => raw === undefined ? "" : `, got ${JSON.stringify(String(raw)).slice(0, 80)}`;
159
+ // A key with a sensitive name never shows its value.
160
+ const got = (key: string, raw: unknown) => raw === undefined || isSensitiveKey(fullKey(key)) ? "" : `, got ${JSON.stringify(String(raw)).slice(0, 80)}`;
160
161
  const source = (key: string, selected: { readonly name: string } | undefined) => ` (${selected ? selected.name : names.get(key)!.join(", ")})`;
161
162
  for (const key of keys) {
162
163
  const base = schema.default[key]!;
@@ -179,23 +180,31 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
179
180
  let value: ConfigValueType | undefined;
180
181
  if (type === "secret") {
181
182
  if (typeof raw !== "string" || !raw.trim()) issues.push(`${fullKey(key)} — required non-empty secret is not set (${names.get(key)!.join(", ")})`);
183
+ // A secret default in the code is a development value; production must
184
+ // get the secret from a source or declare its own value explicitly.
185
+ // No "apiKey: …" in the hint: the console redaction would hide it.
186
+ else if (environment === "production" && selected === undefined && override === undefined) issues.push(`${fullKey(key)} — production would use the development default secret from the code; set ${names.get(key)!.join(" or ")}, or declare ${key} in the production section`);
182
187
  else value = new Secret(raw);
183
188
  } else if (type === "number") {
184
189
  const parsed = Number(raw);
185
- if (raw === undefined || (typeof raw === "string" && !raw.trim()) || !Number.isFinite(parsed)) issues.push(`${fullKey(key)} — expected a finite number${got(raw)}${source(key, selected)}`);
190
+ if (raw === undefined || (typeof raw === "string" && !raw.trim()) || !Number.isFinite(parsed)) issues.push(`${fullKey(key)} — expected a finite number${got(key, raw)}${source(key, selected)}`);
186
191
  else value = parsed;
187
192
  } else if (type === "boolean") {
188
193
  const normalized = String(raw).toLowerCase();
189
194
  if (normalized === "true" || normalized === "1") value = true;
190
195
  else if (normalized === "false" || normalized === "0") value = false;
191
- else issues.push(`${fullKey(key)} — expected a boolean (true, false, 1, 0)${got(raw)}${source(key, selected)}`);
196
+ else issues.push(`${fullKey(key)} — expected a boolean (true, false, 1, 0)${got(key, raw)}${source(key, selected)}`);
192
197
  } else if (typeof raw === "string") value = raw;
193
198
  else issues.push(`${fullKey(key)} — expected a string`);
194
199
  if (value === undefined) continue;
195
- if (isEnum(base) && !base.values.includes(value as string | number)) issues.push(`${fullKey(key)} — ${JSON.stringify(value)} is not allowed, use one of: ${base.values.join(", ")}${source(key, selected)}`);
200
+ if (isEnum(base) && !base.values.includes(value as string | number)) issues.push(`${fullKey(key)} — ${isSensitiveKey(fullKey(key)) ? "the value" : JSON.stringify(value)} is not allowed, use one of: ${base.values.join(", ")}${source(key, selected)}`);
196
201
  try {
197
202
  const issue = validators[key]?.(value);
198
- if (issue) issues.push(`${fullKey(key)} — ${issue}`);
203
+ // A validator may quote its own value; hide it in that message only.
204
+ // Replacing secrets across the whole text turned a one-letter secret
205
+ // into "m***il.***piKey".
206
+ const sensitive = value instanceof Secret ? value.reveal() : typeof value === "string" && isSensitiveKey(fullKey(key)) ? value : "";
207
+ if (issue) issues.push(`${fullKey(key)} — ${sensitive.trim() ? issue.replaceAll(sensitive, "***") : issue}`);
199
208
  } catch { issues.push(`${fullKey(key)} — validator failed`); }
200
209
  values.set(key, value);
201
210
  inspection.push(Object.freeze({ key: fullKey(key), type: type as ConfigInspection["type"], env: names.get(key)!, source: selected?.source ?? (override === undefined ? "default" : environment), value: safeValue(fullKey(key), value) }));
@@ -203,11 +212,7 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
203
212
  if (issues.length) {
204
213
  // "key — text", not "key: text": the console redaction would read
205
214
  // "db.password: required" as a secret value and hide the word "required".
206
- let message = issues.join("; ");
207
- for (const [key, value] of values) {
208
- const sensitive = value instanceof Secret ? value.reveal() : typeof value === "string" && isSensitiveKey(key) ? value : undefined;
209
- if (sensitive) message = message.replaceAll(sensitive, "***");
210
- }
215
+ const message = issues.join("; ");
211
216
  throw new KernelError(`Invalid configuration (environment "${environment}"): ${message}.`);
212
217
  }
213
218
  const rows = Object.freeze(inspection);
@@ -1,3 +1,5 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import { KernelError } from "../errors";
2
4
  import type { ConfigSource } from "../types";
3
5
 
@@ -96,6 +98,62 @@ export function jsonFileSource(path: string, options?: { readonly optional?: boo
96
98
  };
97
99
  }
98
100
 
101
+ /** Largest secret file read by {@link secretFilesSource}: keys and certificates are a few KiB. */
102
+ const SECRET_FILE_MAX_BYTES = 64 * 1024;
103
+
104
+ /**
105
+ * Secret files of a directory, as Docker and Kubernetes mount them
106
+ * (`/run/secrets`). Each file is one value; its name is the key, written as
107
+ * the key (`db.password`) or as its variable (`BAZIS_DB__PASSWORD`). Trailing
108
+ * line breaks are removed. Hidden entries (Kubernetes `..data`) and
109
+ * directories are skipped. Errors name the file, never its content.
110
+ */
111
+ export function secretFilesSource(directory: string, options?: { readonly optional?: boolean }): ConfigSource {
112
+ return {
113
+ description: `files(${directory})`,
114
+ load: async () => {
115
+ let names: string[];
116
+ try {
117
+ names = await readdir(directory);
118
+ } catch (error) {
119
+ if (options?.optional === true && (error as NodeJS.ErrnoException).code === "ENOENT") {
120
+ return {};
121
+ }
122
+ throw new KernelError(`Secret directory "${directory}" cannot be read (${(error as NodeJS.ErrnoException).code ?? "error"}).`);
123
+ }
124
+ const result: Record<string, string> = {};
125
+ for (const name of names.sort()) {
126
+ if (name.startsWith(".")) {
127
+ continue;
128
+ }
129
+ const file = path.join(directory, name);
130
+ let text: string;
131
+ try {
132
+ // stat follows the symlinks Kubernetes uses for each key.
133
+ const info = await stat(file);
134
+ if (!info.isFile()) {
135
+ continue;
136
+ }
137
+ if (info.size > SECRET_FILE_MAX_BYTES) {
138
+ throw new KernelError(`Secret file "${file}" is larger than ${SECRET_FILE_MAX_BYTES} bytes.`);
139
+ }
140
+ text = await Bun.file(file).text();
141
+ } catch (error) {
142
+ if (error instanceof KernelError) throw error;
143
+ throw new KernelError(`Secret file "${file}" cannot be read (${(error as NodeJS.ErrnoException).code ?? "error"}).`);
144
+ }
145
+ const key = name.toUpperCase().startsWith(ENV_PREFIX)
146
+ ? name.slice(ENV_PREFIX.length).toLowerCase().replaceAll("__", ".")
147
+ : name.toLowerCase();
148
+ if (key.length > 0) {
149
+ result[key] = text.replace(/[\r\n]+$/, "");
150
+ }
151
+ }
152
+ return result;
153
+ },
154
+ };
155
+ }
156
+
99
157
  function flatten(tree: ConfigTree, prefix = "", into: Record<string, string> = {}): Record<string, string> {
100
158
  for (const [key, value] of Object.entries(tree)) {
101
159
  const fullKey = prefix.length > 0 ? `${prefix}.${key}` : key;
@@ -29,7 +29,7 @@ export {
29
29
  type ValidatableConfig,
30
30
  } from "./config/defineConfig";
31
31
  export { addConfigOptions, configOptions, type ConfigOptionsBinding } from "./config/addConfigOptions";
32
- export { argsSource, envSource, jsonFileSource, memorySource } from "./config/sources";
32
+ export { argsSource, envSource, jsonFileSource, memorySource, secretFilesSource } from "./config/sources";
33
33
 
34
34
  export {
35
35
  EVENT_HANDLER,
@@ -1,4 +1,4 @@
1
- import { redactSensitive, type SensitiveRedactionOptions } from "../../../library/redaction";
1
+ import { redactSensitive, redactSensitiveText, type SensitiveRedactionOptions } from "../../../library/redaction";
2
2
  import type { LogFields, LogLevel, Logger } from "./Logger";
3
3
  import { getRequestId, getTraceparent } from "../correlation/requestContext";
4
4
 
@@ -16,7 +16,10 @@ export interface ConsoleLoggerOptions {
16
16
  readonly minLevel?: LogLevel;
17
17
  /** Prefix tag, e.g. the app name. */
18
18
  readonly name?: string;
19
- /** Structured fields are redacted by default; pass false only for trusted local debugging. */
19
+ /**
20
+ * The message and structured fields are redacted by default (secret keys, `password=…`,
21
+ * bearer tokens, URL credentials); pass false only for trusted local debugging.
22
+ */
20
23
  readonly redaction?: SensitiveRedactionOptions | false;
21
24
  /**
22
25
  * Inside a request, add `requestId` (and `traceparent`, when the request
@@ -83,6 +86,7 @@ export class ConsoleLogger implements Logger {
83
86
  return;
84
87
  }
85
88
  const line = this.requestContext ? withRequestContext(fields) : fields;
86
- LEVEL_SINK[level](`${this.prefix}${level}: ${message}${formatFields(line, this.redaction)}`);
89
+ const text = this.redaction === false ? message : redactSensitiveText(message, this.redaction);
90
+ LEVEL_SINK[level](`${this.prefix}${level}: ${text}${formatFields(line, this.redaction)}`);
87
91
  }
88
92
  }
@@ -1,6 +1,8 @@
1
- import { DI, HOSTED_SERVICE, type InjectionToken, type ModuleOwnedContributionSnapshot, type ProviderDefinition } from "../di";
1
+ import { DI, HOSTED_SERVICE, type InjectionToken, type ModuleOwnedContributionSnapshot, type ProviderDefinition, type ProviderDependencyList, type ServiceResolver } from "../di";
2
+ import { getGeneratedClassDeps } from "../di/module/autoDeps";
3
+ import { resolveDependencyList } from "../di/internal/resolveDependencyList";
2
4
  import { HEALTH_CHECK } from "../kernel";
3
- import { DbContextOptions, type DatabaseProvider, type DbContext } from "../../library/orm";
5
+ import { DbContextOptions, OrmError, type DatabaseProvider, type DbContext } from "../../library/orm";
4
6
  import { OrmLifecycle } from "./OrmLifecycle";
5
7
  import { ownedStoreLifecycleFor, ownedStoreLifecycleHealthFor } from "./OrmOwnedStoreLifecycle";
6
8
  import { effectiveOrmModuleConfig, ownedStoreRegistrations } from "./ownedStoreContributions";
@@ -9,7 +11,9 @@ import { DATABASE_PROVIDER } from "./DATABASE_PROVIDER";
9
11
  import type { OrmModuleConfig } from "./ormModule";
10
12
 
11
13
  type EntityClass = new () => object;
12
- type ContextClass<TContext extends DbContext> = new (options: DbContextOptions) => TContext;
14
+ /** A DbContext subclass: `DbContextOptions` first, then its own constructor dependencies (since 0.98.28). */
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ type ContextClass<TContext extends DbContext> = new (options: DbContextOptions, ...dependencies: any[]) => TContext;
13
17
 
14
18
  type OrmBuildConfig<TContext extends DbContext> = OrmModuleConfig<TContext> & {
15
19
  readonly context: ContextClass<TContext>;
@@ -27,6 +31,27 @@ function createContextOptions<TContext extends DbContext>(
27
31
  });
28
32
  }
29
33
 
34
+ const contextDependencies = new WeakMap<object, ProviderDependencyList>();
35
+
36
+ /**
37
+ * Creates a context with `options` and the rest of its constructor
38
+ * dependencies (generated by codegen) resolved in the request scope, so a
39
+ * context may read the current user or tenant: `constructor(options:
40
+ * DbContextOptions, private readonly user: CurrentUser)`.
41
+ */
42
+ function constructContext<TContext extends DbContext>(context: ContextClass<TContext>, options: DbContextOptions, resolver: ServiceResolver): TContext {
43
+ const generated = getGeneratedClassDeps(context);
44
+ if (generated === undefined || generated.length === 0) return new context(options);
45
+ const first = generated[0];
46
+ if (first !== DbContextOptions && !(typeof first === "function" && first.name === "DbContextOptions")) {
47
+ throw new OrmError(`${context.name}: the first constructor parameter of a DbContext must be DbContextOptions; put its other dependencies after it.`);
48
+ }
49
+ if (generated.length === 1) return new context(options);
50
+ let rest = contextDependencies.get(context);
51
+ if (rest === undefined) { rest = generated.slice(1); contextDependencies.set(context, rest); }
52
+ return new context(options, ...resolveDependencyList(resolver, context, rest));
53
+ }
54
+
30
55
  function createOrmLifecycle<TContext extends DbContext>(
31
56
  config: OrmBuildConfig<TContext>,
32
57
  options: DbContextOptions,
@@ -72,9 +97,9 @@ export function buildOrmModuleProviders<TContext extends DbContext>(
72
97
  const token = providerOrToken;
73
98
  const providers: ProviderDefinition[] = [
74
99
  DI.scoped(
75
- DI.factoryProvider(config.context, [token, SERVICE_PROVIDER] as const, (provider: DatabaseProvider, serviceProvider) => {
100
+ DI.factoryProviderWithResolver(config.context, [token, SERVICE_PROVIDER] as const, (resolver, provider: DatabaseProvider, serviceProvider) => {
76
101
  const current = effectiveConfigFor(serviceProvider, registrationIdentity, config);
77
- return new current.context(createContextOptions(current, provider));
102
+ return constructContext(current.context, createContextOptions(current, provider), resolver);
78
103
  }),
79
104
  ),
80
105
  DI.singleton(
@@ -105,10 +130,10 @@ export function buildOrmModuleProviders<TContext extends DbContext>(
105
130
 
106
131
  const provider = providerOrToken;
107
132
  const providers: ProviderDefinition[] = [
108
- DI.scoped(DI.factoryProvider(config.context, [SERVICE_PROVIDER], (serviceProvider) => {
133
+ DI.scoped(DI.factoryProviderWithResolver(config.context, [SERVICE_PROVIDER], (resolver, serviceProvider) => {
109
134
  const current = effectiveConfigFor(serviceProvider, registrationIdentity, config);
110
135
  const context = current === config ? config.context : current.context;
111
- return new context(current === config && optionsAtModuleCall !== undefined ? optionsAtModuleCall : createContextOptions(current, provider));
136
+ return constructContext(context, current === config && optionsAtModuleCall !== undefined ? optionsAtModuleCall : createContextOptions(current, provider), resolver);
112
137
  })),
113
138
  // Standalone mode: the context owns its provider and closes it.
114
139
  DI.singleton(DI.factoryProvider(HOSTED_SERVICE, [SERVICE_PROVIDER], (serviceProvider) => {
@@ -20,7 +20,9 @@ import { OrmConnectionLifecycle } from "./OrmLifecycle";
20
20
  import { attachOrmGraphContribution, attachOwnedStoreRegistration } from "./ownedStoreContributions";
21
21
 
22
22
  type EntityClass = new () => object;
23
- type ContextClass<TContext extends DbContext> = new (options: DbContextOptions) => TContext;
23
+ /** A DbContext subclass: `DbContextOptions` first, then its own constructor dependencies (since 0.98.28). */
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ type ContextClass<TContext extends DbContext> = new (options: DbContextOptions, ...dependencies: any[]) => TContext;
24
26
 
25
27
  /**
26
28
  * The single ORM registration shape. One function, three scenarios depending
@@ -3,7 +3,9 @@ import { Repository, type DbContext, type DbContextOptions } from "../../library
3
3
  import { repositoryFor } from "./repository";
4
4
 
5
5
  type EntityClass = new () => object;
6
- type ContextClass<TContext extends DbContext> = new (options: DbContextOptions) => TContext;
6
+ /** A DbContext subclass: `DbContextOptions` first, then its own constructor dependencies (since 0.98.28). */
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ type ContextClass<TContext extends DbContext> = new (options: DbContextOptions, ...dependencies: any[]) => TContext;
7
9
 
8
10
  /**
9
11
  * Registers a scoped `IRepository<T>` for each context entity and the
@@ -36,7 +36,7 @@ export abstract class DbContext {
36
36
  this.#options = options;
37
37
  this.changeTracker = new ChangeTracker();
38
38
  const provider = observedProvider(this, options.provider);
39
- this.#runtime = { provider, models: options.model, tracker: this.changeTracker, runImmediateOperation: (operation) => monitorImmediateOperation(this, operation) };
39
+ this.#runtime = { context: this, provider, models: options.model, tracker: this.changeTracker, runImmediateOperation: (operation) => monitorImmediateOperation(this, operation) };
40
40
  this.database = new DatabaseFacade(provider, options.model);
41
41
  }
42
42
 
@@ -143,6 +143,7 @@ export class ModelBuilder {
143
143
  relations,
144
144
  foreignKeys,
145
145
  queryFilters: raw.queryFilters ?? [],
146
+ contextQueryFilters: raw.contextQueryFilters ?? [],
146
147
  softDeleteProperty: raw.softDeleteProperty,
147
148
  propertyByName: (name: string) => byName.get(name),
148
149
  relationByName: (name: string) => relationByName.get(name),
@@ -1,6 +1,6 @@
1
1
  import type { ColumnOptionsType, RelationKind, PropertyConvention } from "./types";
2
2
  import type { ValueConverter } from "./ValueConverter";
3
- import { evaluatePredicate, type PredicateFn } from "../Query/conditions";
3
+ import { evaluatePredicate, type FieldSelector, type PredicateFn, type Predicate } from "../Query/conditions";
4
4
  import type { Condition } from "../Query/conditions";
5
5
  import { compileCheck, type CheckPredicate } from "../Schema/CheckExpression";
6
6
 
@@ -114,6 +114,8 @@ export interface RawEntity {
114
114
  properties: Map<string, RawProperty>;
115
115
  relations: RawRelation[];
116
116
  queryFilters?: Condition[];
117
+ /** `@QueryFilter((entity, context) => …)`: evaluated per query with the context. */
118
+ contextQueryFilters?: ContextQueryFilter[];
117
119
  /** Soft-delete property name (see `@SoftDelete` / `@Entity({ softDelete })`). */
118
120
  softDeleteProperty?: string;
119
121
  keyDeclaration?: { properties: readonly string[]; name?: string; anchor: string; composite: boolean };
@@ -135,7 +137,7 @@ function cloneRaw(source: RawEntity): RawEntity {
135
137
  for (const [name, prop] of source.properties) {
136
138
  properties.set(name, { ...prop, index: prop.index ? { ...prop.index } : undefined });
137
139
  }
138
- return { ...source, properties, relations: source.relations.map((relation) => ({ ...relation })), keyDeclaration: source.keyDeclaration && { ...source.keyDeclaration, properties: [...source.keyDeclaration.properties] }, indexes: source.indexes?.map((index) => ({ ...index, properties: [...index.properties] })), foreignKeys: source.foreignKeys?.map((foreignKey) => ({ ...foreignKey, properties: [...foreignKey.properties] })), checks: source.checks?.map((check) => ({ ...check })), queryFilters: source.queryFilters?.map(cloneCondition) };
140
+ return { ...source, properties, relations: source.relations.map((relation) => ({ ...relation })), keyDeclaration: source.keyDeclaration && { ...source.keyDeclaration, properties: [...source.keyDeclaration.properties] }, indexes: source.indexes?.map((index) => ({ ...index, properties: [...index.properties] })), foreignKeys: source.foreignKeys?.map((foreignKey) => ({ ...foreignKey, properties: [...foreignKey.properties] })), checks: source.checks?.map((check) => ({ ...check })), queryFilters: source.queryFilters?.map(cloneCondition), contextQueryFilters: source.contextQueryFilters && [...source.contextQueryFilters] };
139
141
  }
140
142
 
141
143
  function cloneCondition(condition: Condition): Condition {
@@ -364,14 +366,30 @@ export function HasConversion(converter: ValueConverter) {
364
366
  };
365
367
  }
366
368
 
369
+ /** A query filter that reads the `DbContext` of the query (a tenant, the current user). */
370
+ export type ContextQueryFilter = (entity: FieldSelector<never>, context: never) => Predicate;
371
+
367
372
  /**
368
373
  * Global query filter for the entity (multi-tenant, flags and so on).
369
374
  * Applied automatically; disabled with `ignoreQueryFilters()`.
375
+ *
376
+ * With one parameter the filter is fixed when the class is declared:
377
+ * `@QueryFilter<Note>((n) => n.archived.eq(false))`. With a second parameter
378
+ * it receives the `DbContext` of each query and is evaluated then:
379
+ * `@QueryFilter<Note, NotesDb>((n, db) => n.tenantId.eq(db.tenantId))`. If it
380
+ * throws, the query fails instead of running unfiltered.
370
381
  */
371
- export function QueryFilter<T extends object>(predicate: PredicateFn<T>) {
382
+ type QueryFilterDecorator = (value: abstract new (...args: never[]) => unknown, context: ClassDecoratorContext) => void;
383
+ export function QueryFilter<T extends object>(predicate: PredicateFn<T>): QueryFilterDecorator;
384
+ export function QueryFilter<T extends object, C extends object>(predicate: (entity: FieldSelector<T>, context: C) => Predicate): QueryFilterDecorator;
385
+ export function QueryFilter<T extends object>(predicate: PredicateFn<T> | ((entity: FieldSelector<T>, context: never) => Predicate)): QueryFilterDecorator {
372
386
  return (_value: abstract new (...args: never[]) => unknown, context: ClassDecoratorContext): void => {
373
387
  const raw = ownRaw(context.metadata);
374
- const node = evaluatePredicate(predicate, "@QueryFilter").node;
388
+ if (predicate.length >= 2) {
389
+ raw.contextQueryFilters = [...(raw.contextQueryFilters ?? []), predicate as unknown as ContextQueryFilter];
390
+ return;
391
+ }
392
+ const node = evaluatePredicate(predicate as PredicateFn<T>, "@QueryFilter").node;
375
393
  raw.queryFilters = [...(raw.queryFilters ?? []), node];
376
394
  };
377
395
  }
@@ -131,6 +131,8 @@ export interface EntityModel {
131
131
  readonly foreignKeys: readonly ForeignKeyModel[];
132
132
  /** Global query filters (`@QueryFilter`), applied to every SELECT. */
133
133
  readonly queryFilters: readonly Condition[];
134
+ /** Query filters that read the query's `DbContext`; evaluated per query (see `effectiveQueryFilters`). */
135
+ readonly contextQueryFilters?: readonly import("./decorators").ContextQueryFilter[];
134
136
  /** Soft-delete property name (NULL = not deleted). `@SoftDelete` / `@Entity({ softDelete })`. */
135
137
  readonly softDeleteProperty?: string;
136
138
  /** Property by name. */
@@ -6,6 +6,7 @@ import { IncludeLoader } from "./IncludeLoader";
6
6
  import { materialize, materializeProjection } from "./materialize";
7
7
  import { EMPTY_PLAN, withCondition, withOrder, type QueryPlan } from "./QueryPlan";
8
8
  import { SqlTranslator } from "./SqlTranslator";
9
+ import { effectiveQueryFilters } from "./queryFilters";
9
10
  import { executeImmediateDelete, executeImmediateUpdate, type OrmMutationResultV1, type OrmUpdateValuesV1 } from "./ImmediateMutations";
10
11
 
11
12
  export interface ForUpdateOptionsV1 { readonly skipLocked?: boolean; }
@@ -144,7 +145,7 @@ export class EntityQuery<T extends object, TResult = T> {
144
145
  }
145
146
 
146
147
  protected translator(): SqlTranslator {
147
- return new SqlTranslator(this.model, this.runtime.provider.dialect);
148
+ return new SqlTranslator(this.model, this.runtime.provider.dialect, this.plan.ignoreQueryFilters ? [] : effectiveQueryFilters(this.model, this.runtime.context));
148
149
  }
149
150
 
150
151
  async toList(): Promise<TResult[]> {
@@ -8,6 +8,7 @@ import type { SqlParam } from "../Providers/types";
8
8
  import type { QueryPlan } from "./QueryPlan";
9
9
  import { Operand, type Condition, type FieldSelector } from "./conditions";
10
10
  import { SqlTranslator } from "./SqlTranslator";
11
+ import { effectiveQueryFilters } from "./queryFilters";
11
12
 
12
13
  export interface OrmMutationResultV1 { readonly affectedRows: number; }
13
14
  export interface OrmInsertIfAbsentResultV1 { readonly inserted: boolean; }
@@ -17,7 +18,7 @@ export type OrmUniqueKeySelectorV1<T extends object> = (entity: FieldSelector<T>
17
18
  export function executeImmediateUpdate<T extends object>(model: EntityModel, runtime: DbContextRuntime, plan: QueryPlan, values: OrmUpdateValuesV1<T>): Promise<OrmMutationResultV1> {
18
19
  return runtime.runImmediateOperation(async () => {
19
20
  guard(model, runtime); const admittedPlan = admitPlan(plan, "executeUpdate");
20
- const conditions = effectiveConditions(model, admittedPlan); const assignments = encodeAssignments(runtime, admitUpdateValues(model, values));
21
+ const conditions = effectiveConditions(model, admittedPlan, runtime); const assignments = encodeAssignments(runtime, admitUpdateValues(model, values));
21
22
  const compiled = new SqlTranslator(model, runtime.provider.dialect).immediateUpdate(assignments, conditions, (property, value) => encode(runtime, property, snapshot(value), true));
22
23
  Object.freeze(compiled.params);
23
24
  guard(model, runtime);
@@ -28,7 +29,7 @@ export function executeImmediateUpdate<T extends object>(model: EntityModel, run
28
29
 
29
30
  export function executeImmediateDelete(model: EntityModel, runtime: DbContextRuntime, plan: QueryPlan): Promise<OrmMutationResultV1> {
30
31
  return runtime.runImmediateOperation(async () => {
31
- guard(model, runtime); const admittedPlan = admitPlan(plan, "executeDelete"); const conditions = effectiveConditions(model, admittedPlan);
32
+ guard(model, runtime); const admittedPlan = admitPlan(plan, "executeDelete"); const conditions = effectiveConditions(model, admittedPlan, runtime);
32
33
  const compiled = new SqlTranslator(model, runtime.provider.dialect).immediateDelete(conditions, (property, value) => encode(runtime, property, snapshot(value), true));
33
34
  Object.freeze(compiled.params);
34
35
  guard(model, runtime);
@@ -177,8 +178,8 @@ function conflictTarget<T extends object>(model: EntityModel, options: { readonl
177
178
  return Object.freeze(columns as string[]);
178
179
  }
179
180
 
180
- function effectiveConditions(model: EntityModel, plan: AdmittedImmediatePlan): readonly Condition[] {
181
- const source: Condition[] = plan.ignoreQueryFilters ? [] : readConditionList(model.queryFilters);
181
+ function effectiveConditions(model: EntityModel, plan: AdmittedImmediatePlan, runtime: DbContextRuntime): readonly Condition[] {
182
+ const source: Condition[] = plan.ignoreQueryFilters ? [] : readConditionList([...effectiveQueryFilters(model, runtime.context)]);
182
183
  if (!plan.ignoreQueryFilters && model.softDeleteProperty) source.push({ kind: "null", property: model.softDeleteProperty, negated: false });
183
184
  source.push(...plan.explicitConditions); const active = new Set<object>(); const memo = new WeakMap<object, Condition>();
184
185
  return Object.freeze(source.map((condition) => cloneCondition(model, condition, active, memo)));
@@ -6,6 +6,7 @@ import type { DbContextRuntime } from "../runtime";
6
6
  import { materialize } from "./materialize";
7
7
  import { EMPTY_PLAN } from "./QueryPlan";
8
8
  import { SqlTranslator } from "./SqlTranslator";
9
+ import { effectiveQueryFilters } from "./queryFilters";
9
10
 
10
11
  interface Level {
11
12
  readonly model: EntityModel;
@@ -156,7 +157,7 @@ export class IncludeLoader {
156
157
  }
157
158
 
158
159
  private async queryIn(model: EntityModel, property: string, values: readonly unknown[], noTracking: boolean): Promise<object[]> {
159
- const translator = new SqlTranslator(model, this.runtime.provider.dialect);
160
+ const translator = new SqlTranslator(model, this.runtime.provider.dialect, effectiveQueryFilters(model, this.runtime.context));
160
161
  const result: object[] = [];
161
162
  const chunkSize = maxParametersPerInList(this.runtime.provider);
162
163
  for (let start = 0; start < values.length; start += chunkSize) {
@@ -180,7 +181,7 @@ export class IncludeLoader {
180
181
 
181
182
  private async queryTuples(model: EntityModel, properties: readonly string[], values: readonly (readonly unknown[])[], noTracking: boolean): Promise<object[]> {
182
183
  const result: object[] = []; const perTuple = properties.length; const chunkSize = Math.max(1, Math.floor(maxParametersPerInList(this.runtime.provider) / perTuple));
183
- for (let start = 0; start < values.length; start += chunkSize) { const translator = new SqlTranslator(model, this.runtime.provider.dialect); const { sql, params } = translator.selectAll({ ...EMPTY_PLAN, conditions: [{ kind: "tuples", properties, values: values.slice(start, start + chunkSize) }], noTracking }); for (let entity of (await this.runtime.provider.query(sql, params)).map((row) => materialize<object>(model, row, this.runtime.provider.dialect))) { if (!noTracking) entity = this.runtime.tracker.trackLoaded(entity, model); result.push(entity); } }
184
+ for (let start = 0; start < values.length; start += chunkSize) { const translator = new SqlTranslator(model, this.runtime.provider.dialect, effectiveQueryFilters(model, this.runtime.context)); const { sql, params } = translator.selectAll({ ...EMPTY_PLAN, conditions: [{ kind: "tuples", properties, values: values.slice(start, start + chunkSize) }], noTracking }); for (let entity of (await this.runtime.provider.query(sql, params)).map((row) => materialize<object>(model, row, this.runtime.provider.dialect))) { if (!noTracking) entity = this.runtime.tracker.trackLoaded(entity, model); result.push(entity); } }
184
185
  return result;
185
186
  }
186
187
  private tuple(model: EntityModel, values: readonly unknown[]): string | undefined {
@@ -35,9 +35,11 @@ export function quotedColumnList(model: EntityModel, dialect: SqlDialect): strin
35
35
  * parameter array (no concatenation of user data).
36
36
  */
37
37
  export class SqlTranslator {
38
+ /** `filters`: the query filters to apply (see `effectiveQueryFilters`); the model's fixed ones by default. */
38
39
  constructor(
39
40
  private readonly model: EntityModel,
40
41
  private readonly dialect: SqlDialect,
42
+ private readonly filters: readonly Condition[] = model.queryFilters,
41
43
  ) {}
42
44
 
43
45
  /** SELECT of all columns (or a projection) with WHERE/ORDER/LIMIT/OFFSET. */
@@ -98,7 +100,7 @@ export class SqlTranslator {
98
100
  private whereClause(plan: QueryPlan, params: SqlParam[], encode: ConditionValueEncoder = (property, value) => encodeProperty(property, value, this.dialect)): string {
99
101
  const parts: string[] = [];
100
102
  if (!plan.ignoreQueryFilters) {
101
- for (const filter of this.model.queryFilters) {
103
+ for (const filter of this.filters) {
102
104
  parts.push(this.condition(filter, params, encode));
103
105
  }
104
106
  if (this.model.softDeleteProperty) {
@@ -0,0 +1,16 @@
1
+ import type { EntityModel } from "../Metadata/types";
2
+ import { evaluatePredicate, type Condition, type FieldSelector } from "./conditions";
3
+
4
+ /**
5
+ * The query filters of `model` for a query of `context`: the fixed ones plus
6
+ * the context filters evaluated now. A context filter that throws fails the
7
+ * query; it never runs without the filter.
8
+ */
9
+ export function effectiveQueryFilters(model: EntityModel, context: object | undefined): readonly Condition[] {
10
+ const dynamic = model.contextQueryFilters ?? [];
11
+ if (dynamic.length === 0) return model.queryFilters;
12
+ return [
13
+ ...model.queryFilters,
14
+ ...dynamic.map((filter) => evaluatePredicate((entity) => filter(entity as FieldSelector<never>, context as never), "@QueryFilter").node),
15
+ ];
16
+ }
@@ -7,6 +7,8 @@ import type { ChangeTracker } from "./Tracking/ChangeTracker";
7
7
  * module to break import cycles between the context, the set and the tracker.
8
8
  */
9
9
  export interface DbContextRuntime {
10
+ /** The DbContext that owns this runtime; context query filters read it. */
11
+ readonly context?: object;
10
12
  readonly provider: DatabaseProvider;
11
13
  readonly models: OrmModel;
12
14
  readonly tracker: ChangeTracker;
@@ -19,6 +19,8 @@ const DEFAULT_SENSITIVE_FRAGMENTS = [
19
19
  const SECRET_TEXT_PATTERN =
20
20
  /\b(password|passwd|passphrase|secret|token|api[-_]?key|cookie|set-cookie|private[-_]?key|client[-_]?secret)\b(\s*[:=]\s*)("[^"]*"|'[^']*'|[^,\s}]+)/gi;
21
21
  const BEARER_TEXT_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
22
+ // Credentials in a URL: postgres://user:password@host, https://token@host.
23
+ const URL_USERINFO_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/)([^\s/?#@]+)@/gi;
22
24
 
23
25
  export interface SensitiveRedactionOptions {
24
26
  readonly replacement?: string;
@@ -162,5 +164,7 @@ function normalizeKey(key: string): string {
162
164
  function redactText(value: string, replacement: string): string {
163
165
  return value
164
166
  .replace(SECRET_TEXT_PATTERN, (_match, name: string, separator: string) => `${name}${separator}${replacement}`)
165
- .replace(BEARER_TEXT_PATTERN, (_match, scheme: string) => `${scheme} ${replacement}`);
167
+ .replace(BEARER_TEXT_PATTERN, (_match, scheme: string) => `${scheme} ${replacement}`)
168
+ .replace(URL_USERINFO_PATTERN, (_match, scheme: string, userinfo: string) =>
169
+ `${scheme}${userinfo.includes(":") ? `${replacement}:${replacement}` : replacement}@`);
166
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.27",
3
+ "version": "0.98.29",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",