world-postgres: LISTEN clients lose non-enumerable pg password credentials

オープン
#5,452 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@vishalrajofficial がすでに取り組んでいます。

2026年11月15日 から。

  • #5453 @vishalrajofficial による — オープン

評価

難易度
3/5
見積もり時間
半日
初心者へのやさしさ
25/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
nodejs, postgresql, typescript

調査の方向性

Start with listenChannel in packages/world-postgres/src/streamer.ts, where the reconnect path builds its config with { ...pool.options, connectionTimeoutMillis }, and with the same spread in packages/world-postgres/src/invocation-notifications.ts. Confirm that pool.options.password is non-enumerable in pg, then check that replacement clients keep the password callback after a reconnect. Pull request #5453 is already open against this issue, so review or test that fix rather than starting a parallel one.

索引モデルが issue の本文から書いたものです。

説明

Summary

The dedicated PostgreSQL notification clients lose authentication credentials when they copy pool.options with object spread. pg.Pool deliberately makes options.password non-enumerable, so the copy omits both static passwords and async password callbacks.

Two paths are affected:

  1. listenChannel in streamer.ts: the initial subscription receives pool.options directly and succeeds, but reconnect adds connectionTimeoutMillis through { ...pool.options, connectionTimeoutMillis }. After a connection drop, replacement clients cannot authenticate. The loop retries without restoring the listener.
  2. createInvocationNotifications in invocation-notifications.ts: the same spread happens on every subscription, including the first. Authenticated configurations using password can remain on fallback reads from startup.

Ordinary pooled queries can continue working while notifications fail. Existing live stream readers may stop seeing newly persisted chunks; a fresh read can see the data. This particularly affects token-based database authentication, where the password must remain a callback invoked for each new connection.

Versions checked

  • Reproduced against @workflow/world-postgres 5.1.1, Node 24.22.2, a supplied pool from pg 8.23.1, and the package's own pg 8.20.0 client.
  • Real-database validation used PostgreSQL 17.
  • Inspected 5.2.0: the stream reconnect path still uses the credential-dropping spread.
  • Both paths remain in the source at b1f92691bcc3bc18b9df094d45379c6464cf4d65.

Minimal reproduction of the credential loss

This requires only pg and does not open a connection:

import assert from 'node:assert/strict';
import { Pool } from 'pg';

const password = async () => 'example-token';
const pool = new Pool({ password, connectionTimeoutMillis: 15000 });
const reconnectConfig = {
  ...pool.options,
  connectionTimeoutMillis: 15000,
};

assert.equal(pool.options.password, password);
assert.equal(
  Object.getOwnPropertyDescriptor(pool.options, 'password').enumerable,
  false,
);
assert.equal(reconnectConfig.password, undefined);

console.log({
  originalPasswordType: typeof pool.options.password, // 'function'
  copiedPasswordType: typeof reconnectConfig.password, // 'undefined'
});
await pool.end();

A real listener reproduction follows the same failure path:

  1. Use a password-authenticated PostgreSQL instance and pass an injected Pool with separate host/user/database fields and password: async () => currentPassword. Do not embed the password in connectionString, or supply PGPASSWORD, since those can mask the omission.
  2. Open listenChannel(pool, 'workflow_event_chunk', onPayload, { onReconnect }) from the built streamer.js; verify an initial notification is delivered.
  3. From a separate administrative connection, terminate only that listener backend using pg_terminate_backend.
  4. Its reconnect creates a client without the password callback. onReconnect never fires and new notifications are not delivered, although pooled reads still work.
  5. For the invocation notifier, subscribing a watch is sufficient to expose the failure: its initial dedicated client is already missing the callback.

Expected behavior and proposed correction

Every dedicated client should preserve the pool's authentication configuration, including the live password callback, while applying its existing timeout and application-name overrides. Do not resolve the callback once and cache the resulting token.

A local correction that preserves non-enumerability is:

const config = Object.defineProperties(
  {},
  Object.getOwnPropertyDescriptors(pool.options),
);
if (connectionTimeoutMillis) {
  config.connectionTimeoutMillis = connectionTimeoutMillis;
}
const client = new Client(config);

Apply equivalent copying before the invocation notifier's overrides.

Validation

Regression tests against the installed listener implementations and real pg.Pool/pg.Client constructors fail before correction and pass after it:

  • Stream reconnect retains a static password.
  • Stream reconnect invokes the dynamic password callback again after token rotation and resumes notifications.
  • Invocation notification subscription authenticates initially and after a disconnect.

The network methods are stubbed in those unit regressions. A separate real PostgreSQL probe also terminated the listener backends, changed the database user's password, verified that replacement clients obtained the new password through the callback, and confirmed notification delivery resumed for both paths after the correction.

Related: #4630 concerns surviving a listener disconnect and re-subscribing. This report concerns credentials being lost by the options copy in those subscription paths.

主要言語
TypeScript
スター
2.5k
フォーク
401
平均マージ
2日 36分
マージ済み PR(30日)
150

環境構築

  • Dockerfile・Docker Compose ファイルなし
  • プルリクエストのテンプレートあり
  • コントリビューションガイドなし

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

vercel/workflow のほかの issue

vercel/workflow の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。