From e835238d05f1100b4dd4203adbf264448d0e285e Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Thu, 22 Oct 2026 10:08:15 -0400 Subject: [PATCH] Hardened systemd deployment contract for read-only Monero observer --- deploy/systemd/README.md | 50 +++++++++++++++++++ deploy/systemd/mtls.example.json | 6 +++ deploy/systemd/wallet-rpc.example.json | 4 ++ .../systemd/wikideck-monero-observer.service | 50 +++++++++++++++++++ docs/production-plan.md | 2 + scripts/custody-db.ts | 13 +++-- server/market/custody-db-args.ts | 12 ++++- server/market/postgres-config.ts | 1 + tests/custody-db-args.test.ts | 9 +++- tests/postgres-deployment.test.ts | 21 ++++++++ tests/postgres-transport.test.ts | 7 ++- tests/postgres/custody.test.ts | 2 +- 12 files changed, 170 insertions(+), 7 deletions(-) create mode 100755 deploy/systemd/README.md create mode 100755 deploy/systemd/mtls.example.json create mode 100755 deploy/systemd/wallet-rpc.example.json create mode 100755 deploy/systemd/wikideck-monero-observer.service diff --git a/deploy/systemd/README.md b/deploy/systemd/README.md new file mode 100755 index 0000000..a96dfb6 --- /dev/null +++ b/deploy/systemd/README.md @@ -0,0 +1,50 @@ +# Monero observer service (single Linux host) + +This is a supervised **observation-only** process for a dedicated Linux host. It is not a public API, deposit-address service, credit worker, withdrawal signer, or production-launch approval. It requires an independently deployed PostgreSQL instance and a private TLS 1.3 mTLS Wallet-RPC gateway. Do not point it at public Wallet RPC or expose either dependency to the Internet. + +## Deployment prerequisites + +- Linux x86-64 with systemd, Node.js 24, a dedicated `wikideck-observer` user/group, and a reviewed immutable application release installed read-only at `/opt/wikideck/current`. +- A production PostgreSQL database reachable only on a private network, with verified TLS, encryption at rest, restricted administrative access, encrypted backups and a tested restore. Do not reuse the local sandbox database or runtime credential. +- A mainnet Monero full node and watch-only Wallet RPC behind the already-tested mTLS method-allowlisting gateway. Network policy/firewall must permit the observer host to reach only the intended PostgreSQL and gateway addresses/ports. The gateway must not expose Wallet RPC directly. +- Protected wallet observer/mTLS JSON and credential files owned by `wikideck-observer`, mode `0600`. Keep the mTLS private key and PostgreSQL URL out of shell history, process arguments, environment values, source control and logs. Store host disks and backups encrypted; define and test certificate rotation before use. +- The chosen wallet is independently verified as the intended mainnet watch-only wallet; its spending key is not present on this host. A config endpoint match is not proof that the remote service is the intended wallet. + +The example JSON files in this directory are templates only. Copy them to `/etc/wikideck/monero-observer/`, replace the gateway hostname, and install the matching client certificate/key and gateway CA there. The endpoint in both configs must be identical and the certificate chain must be checked for validity dates, key strength and SHA-2 signatures. The application's certificate loader requires owner-only POSIX file permissions. + +## One-time database preparation + +Use the dedicated schema-owner PostgreSQL credential file and CA file in a restricted operator session. The operation below is explicit, transactional, digest-checked and idempotent; it does **not** create the observer login, add a runtime grant, or enable deposits. Do not run it against a database with unrelated or valuable data before reviewing its schema and backup. + +```sh +WIKIDECK_NETWORK=mainnet \ +WIKIDECK_CUSTODY_PG_URL_FILE=/secure/operator/schema-owner-url \ +WIKIDECK_CUSTODY_PG_CA_FILE=/secure/operator/postgres-ca.crt \ +WIKIDECK_MAINNET_SCHEMA_MIGRATION=explicit-schema-migration-only \ +node --experimental-strip-types scripts/custody-db.ts migrate-mainnet-schema +``` + +Have the database administrator create a separate `LOGIN` role with no administrative attributes and no role memberships, and place its TLS PostgreSQL URL in `postgres-url`. Then issue only the observer grants using the schema-owner credential: + +```sh +WIKIDECK_NETWORK=mainnet \ +WIKIDECK_CUSTODY_PG_URL_FILE=/secure/operator/schema-owner-url \ +WIKIDECK_CUSTODY_PG_CA_FILE=/secure/operator/postgres-ca.crt \ +node --experimental-strip-types scripts/custody-db.ts grant-monero-observer-runtime wikideck_observer +``` + +The role name in the command must exactly match the separately provisioned login. The worker role cannot read journals or balances, allocate addresses, post credits/releases, or submit transfers. Restrict the schema-owner credential to this one-time operation and remove it from the host afterward. `custody-db` continues to refuse all other mainnet financial actions. + +## Install and operate + +Install the service unit from this directory at `/etc/systemd/system/wikideck-monero-observer.service`. Create `/etc/wikideck/monero-observer/` with owner `wikideck-observer` and mode `0700`. Install the two runtime JSON files and all referenced certificates/keys plus `postgres-url` and `postgres-ca.crt`, each owned by that user and mode `0600`. The PostgreSQL URL must use the observer role and must not contain TLS downgrade parameters. Ensure the `current` application release is root-owned and not writable by the service account. + +After reviewing the unit against the host's systemd version, reload systemd and start the unit. Confirm the process is running and that logs contain only bounded scan aggregates; logs intentionally omit wallet addresses, transaction IDs, amounts and credentials. Configure an alert outside this host for service exits, sustained scan lag, database/gateway unavailability, WAL/disk pressure and unexpected worker restarts. The worker itself does not deliver alerts. + +The service uses a PostgreSQL session advisory lock so a second instance cannot scan concurrently. SIGTERM/SIGINT stops its polling loop and releases the lock. A failed RPC/database batch is retried; a failed scan does not advance the durable cursor. Logs are available through the system journal under identifier `wikideck-monero-observer`. + +## Release boundary + +This deployment only persists read-only chain observations and scan progress. It does **not** issue customer deposit addresses, expose an address to a user, calculate customer credit, release pending liabilities, enable marketplace checkout, or move XMR. There is no public endpoint or UI connected to these rows. A scan is not proof of solvency or authorization to accept deposits. + +The repository still lacks a production Monero daemon/Wallet-RPC/gateway deployment, certificate and secret lifecycle automation, nonempty owned-output validation against the deployed topology, external monitoring/restore evidence, a reviewed customer deposit/withdrawal policy, and live settlement integration. The mainnet candidate is independently marked NO-GO and its current candidate genesis time is 2027-01-01; deploying this worker does not clear any launch gate. The service file is a starting deployment contract, not a certification or instruction to accept funds. diff --git a/deploy/systemd/mtls.example.json b/deploy/systemd/mtls.example.json new file mode 100755 index 0000000..e2d1d1a --- /dev/null +++ b/deploy/systemd/mtls.example.json @@ -0,0 +1,6 @@ +{ + "url": "https://monero-rpc.internal.example/json_rpc", + "clientCertificateFile": "/etc/wikideck/monero-observer/client.crt", + "clientPrivateKeyFile": "/etc/wikideck/monero-observer/client.key", + "serverCaFile": "/etc/wikideck/monero-observer/gateway-ca.crt" +} diff --git a/deploy/systemd/wallet-rpc.example.json b/deploy/systemd/wallet-rpc.example.json new file mode 100755 index 0000000..1df32d5 --- /dev/null +++ b/deploy/systemd/wallet-rpc.example.json @@ -0,0 +1,4 @@ +{ + "network": "mainnet", + "url": "https://monero-rpc.internal.example/json_rpc" +} diff --git a/deploy/systemd/wikideck-monero-observer.service b/deploy/systemd/wikideck-monero-observer.service new file mode 100755 index 0000000..949584e --- /dev/null +++ b/deploy/systemd/wikideck-monero-observer.service @@ -0,0 +1,50 @@ +[Unit] +Description=Wikideck read-only Monero mainnet observation worker +Wants=network-online.target +After=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=5 + +[Service] +Type=simple +User=wikideck-observer +Group=wikideck-observer +WorkingDirectory=/opt/wikideck/current +Environment=WIKIDECK_MONERO_OBSERVER_WORKER=1 +Environment=WIKIDECK_MONERO_OBSERVER=1 +Environment=WIKIDECK_MONERO_WALLET_RPC_CONFIG_FILE=/etc/wikideck/monero-observer/wallet-rpc.json +Environment=WIKIDECK_MONERO_MTLS_CONFIG_FILE=/etc/wikideck/monero-observer/mtls.json +Environment=WIKIDECK_CUSTODY_PG_URL_FILE=/etc/wikideck/monero-observer/postgres-url +Environment=WIKIDECK_CUSTODY_PG_CA_FILE=/etc/wikideck/monero-observer/postgres-ca.crt +Environment=WIKIDECK_MONERO_OBSERVER_POLL_MS=30000 +ExecStart=/usr/bin/node --experimental-strip-types /opt/wikideck/current/scripts/monero-observer-worker.ts +Restart=on-failure +RestartSec=10s +TimeoutStopSec=40s +KillSignal=SIGTERM +UMask=0077 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=wikideck-monero-observer + +NoNewPrivileges=true +CapabilityBoundingSet= +AmbientCapabilities= +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectClock=true +ProtectHostname=true +RestrictSUIDSGID=true +RestrictRealtime=true +RestrictNamespaces=true +LockPersonality=true +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX + +[Install] +WantedBy=multi-user.target diff --git a/docs/production-plan.md b/docs/production-plan.md index b29a835..c3560e9 100755 --- a/docs/production-plan.md +++ b/docs/production-plan.md @@ -34,6 +34,8 @@ Status: active engineering plan; **not launch or payment authorization**. Update **Separate Monero observation worker (2026-10-21):** added a standalone opt-in process, durable bounded catch-up (100,000-block pages, four pages per batch, 1,000-block overlap), PostgreSQL session-lock leadership, restart resumption, and redacted aggregate progress logs. It persists observations only: it has no journal, balance, credit, release, address-allocation, or transfer capability. It is not started by the API and is not included in the sandbox stack as a running service. A separate role grant is available; on mainnet the `custody-db` CLI now permits only this exact observer-role grant and continues to reject migrations, audits, coordinator grants, and all financial actions. The operator must separately create a non-admin, no-membership database login, provide its protected PostgreSQL URL and the protected mTLS client/observer configuration, and run the worker under a supervised POSIX service. This is not yet a complete deployable Monero topology: no mainnet Wallet RPC/gateway/network-policy service, secret provisioning, monitoring destination, key lifecycle, nonempty owned-output test, or deposit-credit automation is provided. Validation: production test-image build succeeded; offline suite **195/195** and disposable PostgreSQL suite **37/37** passed. Worker evidence is synthetic catch-up/restart/leader fencing and journal exclusion only; it does not authorize mainnet deposits or payments. +**Single-host worker deployment increment (2026-10-22):** added a hardened systemd unit and protected configuration templates for the stated apartment-host model. Added a distinct mainnet-only, exact-acknowledgement PostgreSQL schema migration action; it runs the existing transactional, digest-checked migrations and grants no runtime permissions. PostgreSQL URL/CA files now require POSIX owner-only permissions. The worker remains non-root, private-network-only by host firewall contract, and separate from the API. This advances deployment preparation, not live service: no external host/DB/gateway was contacted, no credentials were provisioned, and no service was started. The private Monero daemon/Wallet-RPC/gateway, external monitoring and recovery, production database provisioning and independent review remain unprovided. Validation: image build passed, offline suite **196/196**, PostgreSQL suite **37/37**. See [the systemd deployment contract](../deploy/systemd/README.md). + ## 1A. MVP release boundary — proposed safe first production-hosted release The apartment/single-operator constraint changes the rollout and availability claims, not the safety evidence required to hold customer funds or valuable assets. Recommended MVP: **a public, clearly labeled non-monetary beta on the existing testnet**, with real XMR completely disabled, no custodial balances/deposits/withdrawals, and marketplace checkout/delivery disabled for valuable assets. Keep mining, browsing and wallet use only if they are explicitly described as experimental and users accept that data/ownership may be lost. This would be a production-hosted beta, **not mainnet production**, a custody MVP, or an independently secured chain. diff --git a/scripts/custody-db.ts b/scripts/custody-db.ts index 799372e..43ec632 100755 --- a/scripts/custody-db.ts +++ b/scripts/custody-db.ts @@ -9,7 +9,8 @@ import { PostgresCustody } from '../server/market/postgres-custody.ts'; import { IS_MAINNET } from '../shared/network.ts'; import { resolveCustodyIncident } from '../server/market/custody-incident.ts'; import { loadMoneroDepositReviewApproval, loadMoneroDepositReviewPolicy } from '../server/market/monero-deposit-review-config.ts'; -import { assertCustodyDbActionAllowedOnNetwork, parseCustodyDbArguments } from '../server/market/custody-db-args.ts'; +import { assertCustodyDbActionAllowedOnNetwork, assertMainnetSchemaMigrationAcknowledged, + parseCustodyDbArguments } from '../server/market/custody-db-args.ts'; import { prepareDepositCreditReview, postReviewedDepositPendingCredit, prepareDepositCreditReleaseReview, releaseReviewedDepositCredit, reconcileDepositCreditReorgHolds, prepareDepositReorgResolution, resolveReleasedDepositReorgHold, type DepositCreditApproval, type DepositCreditReleaseApproval, @@ -19,10 +20,11 @@ import { prepareDepositCreditReview, postReviewedDepositPendingCredit, prepareDe const commandArgs = process.argv.slice(2); const depositActions = ['deposit-prepare-credit', 'deposit-stage-credit', 'deposit-prepare-release', 'deposit-release', 'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold']; -const custodyActions = ['migrate', 'grant-runtime', 'grant-monero-observer-runtime', 'grant-monero-credit-runtime', +const custodyActions = ['migrate', 'migrate-mainnet-schema', 'grant-runtime', 'grant-monero-observer-runtime', 'grant-monero-credit-runtime', 'import-sqlite', 'audit', 'resolve-integrity']; const { action, argument, extraArgument } = parseCustodyDbArguments(commandArgs); assertCustodyDbActionAllowedOnNetwork(action, IS_MAINNET); +assertMainnetSchemaMigrationAcknowledged(action, process.env.WIKIDECK_MAINNET_SCHEMA_MIGRATION); if (!custodyActions.includes(action) && !depositActions.includes(action)) throw new Error('Unsupported custody operation'); try { const config = await postgresConfig(); @@ -32,7 +34,12 @@ try { } else { const pool = new Pool(config); try { - if (action === 'migrate') { await migrateCustody(pool); console.log('Custody PostgreSQL schema verified/migrated. No funds activated.'); } + if (action === 'migrate' || action === 'migrate-mainnet-schema') { + await migrateCustody(pool); + console.log(action === 'migrate-mainnet-schema' + ? 'Mainnet custody schema verified/migrated; no runtime grants, deposits, or payments enabled.' + : 'Custody PostgreSQL schema verified/migrated. No funds activated.'); + } if (action === 'grant-runtime') { await grantCustodyRuntime(pool, argument ?? ''); console.log('Runtime grants applied; no credentials printed.'); } if (action === 'grant-monero-observer-runtime') { await grantMoneroObserverRuntime(pool, argument ?? ''); diff --git a/server/market/custody-db-args.ts b/server/market/custody-db-args.ts index ae6cd9f..1e4a289 100755 --- a/server/market/custody-db-args.ts +++ b/server/market/custody-db-args.ts @@ -1,5 +1,6 @@ export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = { migrate: 0, + 'migrate-mainnet-schema': 0, 'grant-runtime': 1, 'grant-monero-observer-runtime': 1, 'grant-monero-credit-runtime': 1, @@ -19,11 +20,20 @@ export type CustodyDbAction = keyof typeof CUSTODY_DB_ACTION_ARGUMENT_COUNTS; export type CustodyDbArguments = { action: CustodyDbAction; argument?: string; extraArgument?: string }; export function assertCustodyDbActionAllowedOnNetwork(action: CustodyDbAction, isMainnet: boolean): void { - if (isMainnet && action !== 'grant-monero-observer-runtime') { + if (action === 'migrate-mainnet-schema' && !isMainnet) { + throw new Error('Mainnet schema migration action requires the mainnet profile'); + } + if (isMainnet && action !== 'grant-monero-observer-runtime' && action !== 'migrate-mainnet-schema') { throw new Error('Only the separately scoped Monero observation-role grant is allowed on mainnet'); } } +export function assertMainnetSchemaMigrationAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void { + if (action === 'migrate-mainnet-schema' && acknowledgement !== 'explicit-schema-migration-only') { + throw new Error('Mainnet schema migration requires explicit schema-migration acknowledgement'); + } +} + export function parseCustodyDbArguments(args: readonly string[]): CustodyDbArguments { const [action, ...values] = args; if (!action || !Object.hasOwn(CUSTODY_DB_ACTION_ARGUMENT_COUNTS, action)) { diff --git a/server/market/postgres-config.ts b/server/market/postgres-config.ts index 8758dd8..eb3abe5 100755 --- a/server/market/postgres-config.ts +++ b/server/market/postgres-config.ts @@ -10,6 +10,7 @@ async function secretFile(path: string | undefined): Promise { if (!path) throw new Error('Custody PostgreSQL secret file is required'); const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink() || info.size > 16384 || info.size === 0) throw new Error('Invalid PostgreSQL secret file'); + if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error('PostgreSQL secret file permissions are too broad'); return (await readFile(path, 'utf8')).trim(); } /** No URL logging, no TLS options in a connection string overriding verified transport. */ diff --git a/tests/custody-db-args.test.ts b/tests/custody-db-args.test.ts index fbe142b..1e4affb 100755 --- a/tests/custody-db-args.test.ts +++ b/tests/custody-db-args.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { assertCustodyDbActionAllowedOnNetwork, parseCustodyDbArguments } from '../server/market/custody-db-args.ts'; +import { assertCustodyDbActionAllowedOnNetwork, assertMainnetSchemaMigrationAcknowledged, + parseCustodyDbArguments } from '../server/market/custody-db-args.ts'; test('custody database CLI parses two-argument deposit operations without shifting arguments', () => { assert.deepEqual(parseCustodyDbArguments(['deposit-prepare-credit', 'a'.repeat(64), `${'b'.repeat(64)}:${'c'.repeat(64)}`]), { @@ -16,6 +17,12 @@ test('custody database CLI parses two-argument deposit operations without shifti test('mainnet custody CLI permits only the observation-role grant', () => { assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('grant-monero-observer-runtime', true)); + assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('migrate-mainnet-schema', true)); + assert.throws(() => assertCustodyDbActionAllowedOnNetwork('migrate-mainnet-schema', false), /requires the mainnet profile/); + assert.throws(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', undefined), /acknowledgement/); + assert.throws(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', 'yes'), /acknowledgement/); + assert.doesNotThrow(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', 'explicit-schema-migration-only')); + assert.doesNotThrow(() => assertMainnetSchemaMigrationAcknowledged('grant-monero-observer-runtime', undefined)); for (const action of ['migrate', 'grant-runtime', 'grant-monero-credit-runtime', 'audit', 'deposit-release'] as const) { assert.throws(() => assertCustodyDbActionAllowedOnNetwork(action, true), /only the separately scoped/i); assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork(action, false)); diff --git a/tests/postgres-deployment.test.ts b/tests/postgres-deployment.test.ts index a5f5ba6..d257d01 100755 --- a/tests/postgres-deployment.test.ts +++ b/tests/postgres-deployment.test.ts @@ -28,3 +28,24 @@ test('database HBA requires TLS+SCRAM over TCP and provisioning refuses partial assert.match(setup, /subjectAltName=DNS:database/); assert.match(setup, /-checkend/); }); + +test('standalone Monero observer service is non-root, filesystem-hardened and has no public listener', async () => { + const unit = await read('deploy/systemd/wikideck-monero-observer.service'); + const guide = await read('deploy/systemd/README.md'); + const observer = JSON.parse(await read('deploy/systemd/wallet-rpc.example.json')) as { network: string; url: string }; + const mtls = JSON.parse(await read('deploy/systemd/mtls.example.json')) as { url: string; clientPrivateKeyFile: string }; + assert.match(unit, /User=wikideck-observer/); + assert.match(unit, /ProtectSystem=strict/); + assert.match(unit, /ProtectHome=true/); + assert.match(unit, /NoNewPrivileges=true/); + assert.match(unit, /CapabilityBoundingSet=/); + assert.match(unit, /RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX/); + assert.match(unit, /WIKIDECK_MONERO_OBSERVER_WORKER=1/); + assert.doesNotMatch(unit, /^\s*(?:ListenStream|ListenDatagram|SocketUser)=/m); + assert.equal(observer.network, 'mainnet'); + assert.equal(observer.url, mtls.url); + assert.equal(mtls.clientPrivateKeyFile, '/etc/wikideck/monero-observer/client.key'); + assert.match(guide, /migrate-mainnet-schema/); + assert.match(guide, /does \*\*not\*\* issue customer deposit addresses/); + assert.doesNotMatch(`${unit}\n${guide}`, /(?:password|api[_-]?key)\s*[:=]\s*[^\s<]+/i); +}); diff --git a/tests/postgres-transport.test.ts b/tests/postgres-transport.test.ts index 38c2c9b..c4aa386 100755 --- a/tests/postgres-transport.test.ts +++ b/tests/postgres-transport.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { mkdtemp, writeFile, chmod, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { postgresConfig } from '../server/market/postgres-config.ts'; @@ -18,6 +18,11 @@ test('PostgreSQL verified TLS remains the default and cannot be downgraded throu for (const mode of ['', 'off', 'disable', 'false', 'mesh', 'TLS']) { await assert.rejects(postgresConfig({ ...env, WIKIDECK_CUSTODY_PG_TRANSPORT: mode }), /configuration/); } + if (process.platform !== 'win32') { + await chmod(file, 0o644); + await assert.rejects(postgresConfig(env), /configuration/); + await chmod(file, 0o600); + } await writeFile(file, 'postgresql://fixture:fixture-only@database.cluster.example/custody?sslmode=disable'); await assert.rejects(postgresConfig(env), /configuration/); }); diff --git a/tests/postgres/custody.test.ts b/tests/postgres/custody.test.ts index 8f065be..b951d6d 100755 --- a/tests/postgres/custody.test.ts +++ b/tests/postgres/custody.test.ts @@ -772,7 +772,7 @@ test('configuration requires secret files, verified TLS, literal backend, and lo await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'postgres', WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: 'yes', WIKIDECK_PG_DISPOSABLE_TEST: '1' }), /exactly 0 or 1/); await assert.rejects(postgresConfig({}), /configuration/); - await writeFile(file, 'postgresql://user:secret@database.example/custody'); + await writeFile(file, 'postgresql://user:secret@database.example/custody', { mode: 0o600 }); const secure = await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: file }); assert.equal((secure.ssl as { rejectUnauthorized: boolean }).rejectUnauthorized, true); await assert.rejects(postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: file, WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1' }), /configuration/); await writeFile(file, 'postgresql://user:secret@127.0.0.1/custody?sslmode=disable');