From 7cd52556c6243dd2d046fe6b7190c63d900d42a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 07:09:12 +0000 Subject: [PATCH] fix(service-datasource): inject a bound secret on the mongodb DSN branch (#8696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildMongoUrl's DSN branch returned the authored config.url verbatim and never applied spec.secret, so a mongo datasource that bound external.credentialsRef connected with whatever the URL carried — i.e. no credential at all, since #8082 refuses a user:password@ userinfo at the publish door. The credential now rides beside an unmodified url as MongoClient's `auth` option: no URL rewrite, so the multi-host and +srv forms are unharmed and no second dialect of mongodb:// enters this repo. The userinfo username `auth` also requires is read through @objectstack/spec/data's urlUserinfoUsername (#8876) and percent-decoded at the call site. A url naming no user is left alone rather than given a fabricated empty username. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn --- .changeset/mongo-dsn-bound-secret-injected.md | 82 +++++++ .../bound-secret-dsn-branches.test.ts | 227 +++++++++++++++++- .../src/default-datasource-driver-factory.ts | 162 +++++++++++-- 3 files changed, 447 insertions(+), 24 deletions(-) create mode 100644 .changeset/mongo-dsn-bound-secret-injected.md diff --git a/.changeset/mongo-dsn-bound-secret-injected.md b/.changeset/mongo-dsn-bound-secret-injected.md new file mode 100644 index 0000000000..8344f7539d --- /dev/null +++ b/.changeset/mongo-dsn-bound-secret-injected.md @@ -0,0 +1,82 @@ +--- +"@objectstack/service-datasource": patch +--- + +fix(security): a mongo datasource that binds `external.credentialsRef` and authors a connection URL now connects with the bound credential instead of none (#8696) + + + +`buildMongoUrl`'s DSN branch returned the authored `config.url` verbatim and +applied `spec.secret` nowhere. A mongo datasource that bound its secret through +`external.credentialsRef` (or the connection form's secret field) therefore +connected with **whatever the URL itself carried** — which, since #8082 refuses +a `user:password@` userinfo at the publish door, is **no credential at all**. +Measured on `origin/main` @ `792524c22`, mongodb 7.5.0: + +```text +config.url 'mongodb://app@db.internal:27017/app' + a bound secret + -> MongoClient credentials {username:'app', password:''} +``` + +The connect path is fail-closed on a ref it cannot resolve, so an operator +reasonably reads "the datasource connected" as "the bound credential was used". +It was not: the credential was declared, resolved, injected into the factory — +and then dropped at the last call site with no diagnostic. That is +declared-≠-enforced (Prime Directive #10) one layer below the spec, and +`MongoConfigSchema.url` is the contract it broke, verbatim: *"bind the secret +(`external.credentialsRef` / the connection form's secret field) and **it is +injected at connect time**. A bare username (`user@host1`) stays writable."* +The arm's behaviour was decided by whether the operator happened to author a +URL — the composed branch five lines below had honoured the secret since #4410. +This closes the last arm of the family #7314 / #7385 / #8152 / #8875 have each +closed one driver at a time. + +**The fix injects `options.auth` beside an unmodified url — it does not rewrite +the URL.** Measured on mongodb 7.5.0 (the `MongoClient` constructor resolves +credentials eagerly, so all of it is assertable with no server): + +```text +'mongodb://app@db.internal:27017/app' + auth{app,BOUND} -> password BOUND +'mongodb://app:embedded-legacy@h/app' + auth{app,BOUND} -> password BOUND +'mongodb://app@h1:27017,h2:27017/app' + auth{app,BOUND} -> password BOUND +'mongodb+srv://app@c0.example.net/app' + auth{app,BOUND} -> password BOUND +'mongodb://app@h/app?authSource=admin' + auth{app,BOUND} -> source admin +``` + +So the authored URL is handed over byte for byte, no second dialect of +`mongodb://…` enters this repo, the multi-host and `+srv` forms ride through +unharmed, and a bound secret **wins** over a legacy password embedded in a +stored pre-#8082 row — the same precedence the mysql arm states, reached by a +different mechanism because the clients merge in opposite directions. The +userinfo **username** `auth` also requires is read through the platform's own +DSN grammar (`urlUserinfoUsername`, #8876) and percent-decoded at the call +site: `new URL()` cannot even parse the multi-host form this schema documents, +and a second hand-rolled copy of those boundaries is the shape #8082's ruling +rejects by name. + +**A URL that names no user gets nothing, deliberately.** `auth` is not +constructible from a password alone, and inventing an empty username is +measurably worse than silence: `mongodb://db.internal:27017/app` carries no +credentials at all today, and would carry `{username:''}` — a guaranteed +handshake failure — if the arm injected regardless. Injection happens only +where the URL already declares authenticated intent, which is also exactly what +the composed branch has always done with the same input. Making that +contradictory pair (a bound `credentialsRef` beside a user-less URL) loud +belongs at the authoring door, where both halves are visible at once; it is +filed rather than guessed at here. + +**Blast radius is exactly the broken class.** A datasource that binds no secret +reaches the client byte-for-byte as before, and the `options` passthrough keeps +arriving verbatim — the injected `auth` is merged into it, not assigned over +it. + +The pin extends `__tests__/bound-secret-dsn-branches.test.ts` (the mysql half's +file) and asserts at the **client-construction seam**: every mongo assertion +reads `MongoClient`'s own resolved `credentials`, never the URL string the +factory built. That distinction is load-bearing — a test asserting +`buildMongoUrl`'s return value would have passed throughout this defect's life, +and the postgres arm passes the equivalent config-layer assertion while still +being broken one layer lower. diff --git a/packages/services/service-datasource/src/__tests__/bound-secret-dsn-branches.test.ts b/packages/services/service-datasource/src/__tests__/bound-secret-dsn-branches.test.ts index d0b69ffb13..88977957f4 100644 --- a/packages/services/service-datasource/src/__tests__/bound-secret-dsn-branches.test.ts +++ b/packages/services/service-datasource/src/__tests__/bound-secret-dsn-branches.test.ts @@ -69,13 +69,41 @@ * The first failure is the whole defect in one line: the arm answered with the * DSN *string*, which has no key for a credential to live in. * - * ## The mongodb half is NOT closed here + * ## The mongodb half, added second (#8696's remaining arm) * - * `buildMongoUrl`'s `if (explicit) return explicit;` still drops the bound - * secret, so a mongo DSN datasource still reaches MongoClient with an empty - * password. It is not pinned as expected behaviour below — a test asserting the - * defect would read as a contract. The remedy and what blocks it are recorded - * on `buildMongoUrl` itself; #8696 stays open for it. + * `buildMongoUrl`'s `if (explicit) return explicit;` dropped the bound secret + * the same way, and is closed by `buildMongoAuth` — `options.auth` beside an + * unmodified url, which is a DIFFERENT shape from the mysql half above, on + * purpose. Its assertions also sit one layer deeper, and that difference is the + * lesson the mysql half paid for: the postgres arm passes the equivalent + * config-layer assertion and is still broken, because the client throws the + * injected password away afterwards. So nothing below asserts on the url string + * this factory built or on the options object it emitted — every mongo + * assertion reads `MongoClient`'s own resolved `credentials`, which is what a + * handshake would actually use. A test that checked `buildMongoUrl`'s return + * value would have passed throughout this defect's life. + * + * Measured on `origin/main` @ 792524c22, mongodb 7.5.0, before the fix: + * + * ```text + * config.url 'mongodb://app@db.internal:27017/app' + bound secret + * -> client credentials {username:'app', password:''} + * ``` + * + * No connection is opened anywhere in this file: the `MongoClient` constructor + * resolves `credentials` eagerly, which is exactly why this seam is assertable + * without a server. + * + * ## Reverse verification of the mongo half (predicted before running) + * + * Predicted in writing before restoring the pre-fix arm with these tests at + * their fixed state: the SIX injecting cases go RED on the password, and the + * FIVE remaining cases stay GREEN — the passthrough-preservation control + * (`options` already rode through verbatim), the two no-injection cases + * (nothing bound / a url naming no user, where both versions agree), and the + * two composed-branch controls, because the defect is branch-local and an + * arm-wide regression would mean this file measures something else. + * Measured exactly that set: 6 failed / 5 passed. */ import { describe, it, expect } from 'vitest'; @@ -174,3 +202,190 @@ describe('#8696 — mysql: a bound secret reaches the client on the DSN branch', expect(conn.password).toBe(BOUND_SECRET); }); }); + +// ── the mongodb arm ────────────────────────────────────────────────────────── + +/** The one authorable mongo URL shape post-#8082: a username, never a password. */ +const MONGO_BARE_USERNAME_DSN = 'mongodb://app@db.internal:27017/app'; + +/** + * What `MongoClient` resolved for this datasource — username, password and + * auth source as a handshake would use them. + * + * Read off the constructed client rather than off the factory's output, and + * that is the whole point of this helper: the emitted `options.auth` is what + * this module produces, `client.options.credentials` is what the client made of + * it *together with the url*. Only the second can answer "did the bound + * credential arrive", which is the question the postgres arm answers wrongly at + * the first layer. + */ +async function mongoCredentials(spec: Record): Promise { + const handle: any = await factory().create({ driver: 'mongodb', ...spec } as any); + try { + const driver = handle.driver ?? handle; + return driver?.client?.options?.credentials; + } finally { + // Nothing ever connected — the constructor only parses. + try { await handle.disconnect?.(); } catch { /* noop */ } + } +} + +/** The url the factory handed the client, to prove it was not rewritten. */ +async function mongoUrl(spec: Record): Promise { + const handle: any = await factory().create({ driver: 'mongodb', ...spec } as any); + try { + const driver = handle.driver ?? handle; + return driver?.config?.url; + } finally { + try { await handle.disconnect?.(); } catch { /* noop */ } + } +} + +describe('#8696 — mongodb: a bound secret reaches the client on the DSN branch', () => { + it('injects the bound secret beside the DSN instead of dropping it', async () => { + const spec = { + name: 'events', + config: { url: MONGO_BARE_USERNAME_DSN }, + secret: BOUND_SECRET, + }; + + // The credential the handshake would use — `''` before this change. + expect(await mongoCredentials(spec)).toMatchObject({ + username: 'app', + password: BOUND_SECRET, + }); + // And the authored url is handed over byte for byte: the credential rides + // beside it, so no `mongodb://…` is rewritten or re-encoded in this repo. + expect(await mongoUrl(spec)).toBe(MONGO_BARE_USERNAME_DSN); + }); + + it('lets the bound secret win over a legacy password embedded in a stored DSN', async () => { + // #8082 refuses this url at the publish door, so it can only arrive as a + // stored pre-#8082 row. `auth` wins over the url's own userinfo password + // (measured), which is the same precedence the mysql arm states — reached + // by a different mechanism, because the clients disagree about merge order. + const spec = { + name: 'legacy', + config: { url: 'mongodb://app:embedded-legacy@db.internal:27017/app' }, + secret: BOUND_SECRET, + }; + + expect(await mongoCredentials(spec)).toMatchObject({ + username: 'app', + password: BOUND_SECRET, + }); + expect(await mongoUrl(spec)).toBe('mongodb://app:embedded-legacy@db.internal:27017/app'); + }); + + it('carries the credential on the multi-host DSN `new URL()` cannot even parse', async () => { + // The form `MongoConfigSchema.url` documents (`host1[:port1][,…]`). It is + // the reason the username is read through the platform's own DSN grammar: + // `new URL('mongodb://app@h1:27017,h2:27017/app')` throws ERR_INVALID_URL, + // so a WHATWG-based fix would have failed exactly here, and a URL-rewriting + // fix would have had to re-emit a host list it could not parse. + expect(await mongoCredentials({ + name: 'replicated', + config: { url: 'mongodb://app@h1:27017,h2:27017/app' }, + secret: BOUND_SECRET, + })).toMatchObject({ username: 'app', password: BOUND_SECRET }); + }); + + it('carries the credential on a `mongodb+srv://` DSN too', async () => { + // Same no-rewrite argument, second form: the srv scheme resolves hosts by + // DNS at connect, so it has no host list to rewrite at all. + expect(await mongoCredentials({ + name: 'atlas', + config: { url: 'mongodb+srv://app@cluster0.example.mongodb.net/app' }, + secret: BOUND_SECRET, + })).toMatchObject({ username: 'app', password: BOUND_SECRET }); + }); + + it('decodes a percent-encoded userinfo username instead of authenticating as the raw one', async () => { + // The spec accessor answers with the RAW component by contract, and the + // client decodes the same component when it reads it from the url itself. + // Handing the raw value through would authenticate as `app%40corp` — a + // different user from the one the url names, and a silent one. + expect(await mongoCredentials({ + name: 'encoded', + config: { url: 'mongodb://app%40corp@db.internal:27017/app' }, + secret: BOUND_SECRET, + })).toMatchObject({ username: 'app@corp', password: BOUND_SECRET }); + }); + + it('wins over an `auth` block written into the `options` passthrough', async () => { + // `config.options` reaches MongoClient verbatim, so it is one more spelling + // of the same credential. A deliberately bound `credentialsRef` outranks it + // — the rule the mysql arm applies to a legacy embedded password. + const creds = await mongoCredentials({ + name: 'passthrough', + config: { + url: MONGO_BARE_USERNAME_DSN, + options: { auth: { username: 'app', password: 'from-passthrough' }, replicaSet: 'rs0' }, + }, + secret: BOUND_SECRET, + }); + + expect(creds).toMatchObject({ username: 'app', password: BOUND_SECRET }); + }); + + it('keeps the author\'s other `options` keys arriving untouched (control)', async () => { + // The injection MERGES into the passthrough; it must not replace it. + const handle: any = await factory().create({ + name: 'opts', + driver: 'mongodb', + config: { url: MONGO_BARE_USERNAME_DSN, options: { replicaSet: 'rs0' } }, + secret: BOUND_SECRET, + } as any); + const driver = handle.driver ?? handle; + + expect(driver.config.options).toMatchObject({ replicaSet: 'rs0' }); + expect(driver.client.options.replicaSet).toBe('rs0'); + try { await handle.disconnect?.(); } catch { /* noop */ } + }); + + it('leaves a DSN with nothing bound exactly as it was (no behaviour change)', async () => { + // Blast radius is "a secret was bound". A datasource that binds none must + // reach the client exactly as it did before — including the empty password + // its own url implies, which is not this change's business to alter. + const spec = { name: 'anon', config: { url: MONGO_BARE_USERNAME_DSN } }; + + expect(await mongoCredentials(spec)).toMatchObject({ username: 'app', password: '' }); + expect(await mongoUrl(spec)).toBe(MONGO_BARE_USERNAME_DSN); + }); + + it('does NOT fabricate credentials on a DSN that names no user', async () => { + // The one direction that could break a working install. `auth` needs a + // username as well as a password, and inventing an empty one is measurably + // worse than silence: this url carries NO credentials today, and would + // carry `{username:''}` — a guaranteed handshake failure — if the arm + // injected regardless. So it stays a no-op, matching the composed branch, + // and the loud half of this pair belongs at the authoring door. + expect(await mongoCredentials({ + name: 'anonymous-url', + config: { url: 'mongodb://db.internal:27017/app' }, + secret: BOUND_SECRET, + })).toBeUndefined(); + }); + + it('still reads the bound secret on the composed branch (control)', async () => { + // Green before this change and after it: the branch that already worked is + // what made the DSN branch's silence a per-branch asymmetry rather than an + // arm that never read the secret at all. + expect(await mongoCredentials({ + name: 'composed', + config: { host: 'db.internal', port: 27017, database: 'events', username: 'svc' }, + secret: BOUND_SECRET, + })).toMatchObject({ username: 'svc', password: BOUND_SECRET }); + }); + + it('keeps preferring the bound secret over an inline `config.password` (control)', async () => { + // `config.password` is `z.never()` at every authoring door since #7990, so + // this is a stored-row-only shape; the composed branch's precedence is + // unchanged by this card. + expect(await mongoCredentials({ + name: 'composed-legacy', + config: { host: 'db.internal', database: 'events', username: 'svc', password: 'inline-legacy' }, + secret: BOUND_SECRET, + })).toMatchObject({ username: 'svc', password: BOUND_SECRET }); + }); +}); diff --git a/packages/services/service-datasource/src/default-datasource-driver-factory.ts b/packages/services/service-datasource/src/default-datasource-driver-factory.ts index 7073d1769f..11bdd58fff 100644 --- a/packages/services/service-datasource/src/default-datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/default-datasource-driver-factory.ts @@ -46,7 +46,7 @@ */ import { join } from 'node:path'; -import { resolveDriverId, type BuiltinDriverId } from '@objectstack/spec/data'; +import { resolveDriverId, urlUserinfoUsername, type BuiltinDriverId } from '@objectstack/spec/data'; import type { IDatasourceDriverFactory, DatasourceConnectionSpec, @@ -586,22 +586,12 @@ function buildMemoryConfig(spec: DatasourceConnectionSpec): Record; @@ -622,6 +612,128 @@ function buildMongoUrl(spec: DatasourceConnectionSpec): string { return `mongodb://${auth}${host}:${port}/${db}${query}`; } +/** + * The `MongoClient` `auth` block that carries a bound `spec.secret` onto the + * DSN branch of the mongo arm (#8696) — the half {@link buildMongoUrl} does + * not, and cannot, do. + * + * ## The defect this closes + * + * `DatasourceConnectionService` resolves `external.credentialsRef` to a + * cleartext secret and hands it here as `spec.secret`. With a `config.url` + * present the arm returned that url verbatim and applied the secret nowhere: + * measured on `origin/main` @ 792524c22, `mongodb://app@db.internal:27017/app` + * plus a bound secret reached `MongoClient` as + * `credentials{username:'app', password:''}`. Since #8082 refuses a + * `user:password@` userinfo at the publish door, that bare-username DSN plus a + * bound secret is the ONLY authorable URL shape for an authenticated mongo + * datasource — so the arm dropped the credential of exactly the configuration + * `MongoConfigSchema.url` tells operators to write (*"bind the secret … and it + * is injected at connect time. A bare username (`user@host1`) stays + * writable."*), and the datasource then connected with no credential at all. + * Declared, resolved, injected, dropped at the last call site — Prime + * Directive #10 one layer down. + * + * ## Why `options.auth` and not a URL rewrite + * + * Measured on mongodb 7.5.0, no connection opened (the `MongoClient` + * constructor resolves credentials eagerly): + * + * ```text + * url 'mongodb://app@db.internal:27017/app' + auth{app,BOUND} -> password BOUND + * url 'mongodb://app:embedded-legacy@h/app' + auth{app,BOUND} -> password BOUND + * url 'mongodb://app@h1:27017,h2:27017/app' + auth{app,BOUND} -> password BOUND + * url 'mongodb+srv://app@c0.example.net/app' + auth{app,BOUND} -> password BOUND + * url 'mongodb://app@h/app?authSource=admin' + auth{app,BOUND} -> source admin + * ``` + * + * So the authored url is handed over untouched — no rewrite, no re-encoding of + * the secret, no second dialect of `mongodb://…` in this repo — and the bound + * secret wins over a legacy embedded userinfo password, the same precedence the + * mysql arm states. Multi-host and `+srv` forms ride through unharmed, which a + * rewrite could not have promised: `new URL()` cannot even PARSE the multi-host + * form this schema documents (measured `ERR_INVALID_URL`), which is why the + * username is read through the platform's own DSN grammar + * ({@link urlUserinfoUsername}, #8876) rather than WHATWG parsing, and why + * hand-rolling that parse here would have been the second copy of the userinfo + * boundaries #8082's ruling rejects by name. + * + * ⛔ Do NOT reach this shape by symmetry from the mysql arm. The clients merge + * a DSN against explicit keys in OPPOSITE directions — `pg` merges + * `parse(connectionString)` OVER the explicit config, which is why the postgres + * arm looks correct and is broken one layer lower (filed separately). Each + * arm's precedence is measured against its own client. + * + * ## Why a userinfo-free url gets NOTHING, deliberately + * + * `auth` is not constructible from a password alone — `{password}` without a + * username throws `MongoParseError: credentials must be an object with + * 'username' and 'password' properties` — and inventing one is worse than + * silence in the one direction that matters: measured, + * `mongodb://db.internal:27017/app` carries NO credentials at all, while the + * same url with `auth{'',BOUND}` carries `credentials{username:''}`. Injecting + * on a url that declares no userinfo would therefore turn a datasource that + * connects anonymously today into a guaranteed handshake failure. So the rule + * is: inject only where the url already declares authenticated intent. + * + * That leaves "secret bound, url names no user" a silent no-op, and that is + * chosen rather than merely inherited. It is what the COMPOSED branch above + * has always done with the same input (`const auth = user ? … : ''` — no + * username, no credential, secret unused), so making this branch loud would + * plant a second per-branch asymmetry inside one function, which is the exact + * defect class this change closes. And the loud half cannot live here anyway: + * `MongoConfigSchema` declares injection on `url`, so refusing at connect + * would contradict a published contract and reject the shape #8155's landed + * migration instructs operators to write. Making the contradictory pair + * (`external.credentialsRef` bound + a url with no userinfo) loud belongs at + * the authoring door, where both halves are visible at once — filed, not + * guessed at here. + * + * @returns the `auth` block, or `undefined` when nothing should be injected — + * no secret bound, no DSN (the composed branch injects through the url it + * builds), or a url naming no user. + */ +function buildMongoAuth(spec: DatasourceConnectionSpec): { username: string; password: string } | undefined { + if (!spec.secret) return undefined; + const cfg = (spec.config ?? {}) as Record; + const url = cfg.url as string | undefined; + if (!url) return undefined; + const username = urlUserinfoUsername(url); + // `''` (present-but-empty userinfo) is deliberately NOT excluded here: the + // accessor keeps it distinct from `undefined` precisely so this call site can + // decide, and the decision is that userinfo present = authenticated intent. + // It is inert either way — `MongoClient` refuses `mongodb://:p@h/db` and + // `mongodb://@h/db` outright ('URI contained empty userinfo section'), with + // or without `auth` — so the client renders that verdict, not this function. + if (username === undefined) return undefined; + return { username: decodeUserinfoUsername(username), password: spec.secret }; +} + +/** + * Percent-decode a userinfo username for `MongoClient`'s `auth.username`. + * + * The spec accessor answers with the RAW component by contract (byte-level + * alignment with the redaction half), and the client decodes the same + * component when it reads it from the url itself — `mongodb://a%2Fb@h/db` + * authenticates as `a/b`, measured. Handing the raw value through would + * authenticate a DSN-branch datasource as a DIFFERENT user (`a%2Fb`) than the + * url names, so decoding is required, not cosmetic. + * + * A malformed escape falls back to the raw component instead of throwing. + * `decodeURIComponent('100%')` raises `URIError: URI malformed`, and + * `MongoClient` raises `MongoParseError: URI malformed` on that same url one + * line later — measured, both. The url is fatal either way; the only thing + * decided here is WHICH error the operator reads, and the client's names the + * client and the URI. Nothing is tolerated: an unusable url stays unusable. + */ +function decodeUserinfoUsername(raw: string): string { + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + /** * Create the default datasource driver factory. Driver packages are imported * lazily so a host that never builds (e.g.) a mongo connection doesn't pay for @@ -790,10 +902,24 @@ export function createDefaultDatasourceDriverFactory( // same change — the pin in `datasource-pool-support.test.ts` reads this // arm's source and fails if the two disagree. const pool = (spec.pool ?? {}) as Record; + // #8696 — the bound secret rides into the SAME passthrough on the DSN + // branch (see `buildMongoAuth`), so it is merged rather than assigned: + // the author's `options` keep arriving verbatim, and the injected + // `auth` is spread LAST so a resolved `external.credentialsRef` wins + // over an `auth` block someone wrote into the passthrough by hand — + // the same precedence the mysql arm gives it over a legacy embedded + // password. A fresh object every time: `cfg.options` is the stored + // config's own object and the cleartext secret must never be written + // back onto it. + const mongoAuth = buildMongoAuth(spec); + const mongoOptions = { + ...(cfg.options && typeof cfg.options === 'object' ? (cfg.options as Record) : {}), + ...(mongoAuth ? { auth: mongoAuth } : {}), + }; const driver = new MongoDBDriver({ url: buildMongoUrl(spec), ...(cfg.database ? { database: cfg.database } : {}), - ...(cfg.options && typeof cfg.options === 'object' ? { options: cfg.options } : {}), + ...(Object.keys(mongoOptions).length > 0 ? { options: mongoOptions } : {}), ...(typeof pool.min === 'number' ? { minPoolSize: pool.min } : {}), ...(typeof pool.max === 'number' ? { maxPoolSize: pool.max } : {}), });