@rebasepro/server-postgres 0.9.1-canary.d906fb7 → 0.9.1-canary.e2fc7b6

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.
@@ -19,6 +19,22 @@ export declare class UserService implements UserRepository {
19
19
  private userIdentitiesTable;
20
20
  constructor(db: NodePgDatabase, tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>);
21
21
  private getQualifiedUsersTableName;
22
+ /**
23
+ * Run a privileged auth write with an explicitly cleared RLS context.
24
+ *
25
+ * The auth services run on the base/owner connection, which by design
26
+ * carries a NULL `app.user_id` so the `auth.uid() IS NULL` server-escape
27
+ * in the default policies applies. That NULL is normally guaranteed by
28
+ * `set_config(..., is_local = true)` resetting at transaction end — but a
29
+ * GUC that survives on a pooled connection (or a connection role that
30
+ * doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
31
+ * turns the trusted write into an RLS-scoped one and denies it with
32
+ * SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
33
+ * single chokepoint, makes the server context deterministic instead of
34
+ * trusting whatever state the pool hands us. `auth.uid()` reads '' as
35
+ * NULL via NULLIF, so '' is the server context.
36
+ */
37
+ private withServerContext;
22
38
  private mapRowToUser;
23
39
  private mapPayload;
24
40
  createUser(data: CreateUserData): Promise<UserData>;
@@ -19,6 +19,27 @@ export interface PostgresPoolConfig {
19
19
  /** Enable TCP keep-alive (default: true) */
20
20
  keepAlive?: boolean;
21
21
  }
22
+ /**
23
+ * Destroy pool clients that are released while still inside a transaction.
24
+ *
25
+ * pg-pool returns a client to the idle list whenever `release()` is called
26
+ * without an error — even if the connection is still mid-transaction (status
27
+ * `T`/`E`). That happens in practice: drizzle's pool transaction releases in
28
+ * a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
29
+ * (e.g. it was queued behind a statement that hit the client-side
30
+ * query_timeout), the client goes back dirty. The next checkout then runs
31
+ * its statements inside the zombie transaction — with the previous request's
32
+ * `app.*` RLS GUCs still applied, which turns unrelated queries into
33
+ * RLS-scoped ones (observed in production as registration failing with
34
+ * SQLSTATE 42501 under a leaked anonymous context).
35
+ *
36
+ * pg-pool emits `release` before it consults its private `_expired` set, so
37
+ * marking the client expired here makes `_release()` destroy it instead of
38
+ * pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
39
+ * private APIs — feature-detect and fall back to loud logging so an upstream
40
+ * change degrades to observability, never to silent corruption.
41
+ */
42
+ export declare function guardPoolAgainstDirtyRelease(pool: Pool, label: string): void;
22
43
  /**
23
44
  * Create a Drizzle-backed Postgres connection with a production-grade
24
45
  * connection pool.
package/dist/index.es.js CHANGED
@@ -71,16 +71,51 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
71
71
  var connection_exports = /* @__PURE__ */ __exportAll({
72
72
  createDirectDatabaseConnection: () => createDirectDatabaseConnection,
73
73
  createPostgresDatabaseConnection: () => createPostgresDatabaseConnection,
74
- createReadReplicaConnection: () => createReadReplicaConnection
74
+ createReadReplicaConnection: () => createReadReplicaConnection,
75
+ guardPoolAgainstDirtyRelease: () => guardPoolAgainstDirtyRelease
75
76
  });
76
77
  var DEFAULT_POOL = {
77
78
  max: 20,
78
79
  idleTimeoutMillis: 3e4,
79
80
  connectionTimeoutMillis: 1e4,
80
- queryTimeout: 3e4,
81
+ queryTimeout: 6e4,
81
82
  statementTimeout: 3e4,
82
83
  keepAlive: true
83
84
  };
85
+ /** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
86
+ var TX_IDLE = "I";
87
+ /**
88
+ * Destroy pool clients that are released while still inside a transaction.
89
+ *
90
+ * pg-pool returns a client to the idle list whenever `release()` is called
91
+ * without an error — even if the connection is still mid-transaction (status
92
+ * `T`/`E`). That happens in practice: drizzle's pool transaction releases in
93
+ * a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
94
+ * (e.g. it was queued behind a statement that hit the client-side
95
+ * query_timeout), the client goes back dirty. The next checkout then runs
96
+ * its statements inside the zombie transaction — with the previous request's
97
+ * `app.*` RLS GUCs still applied, which turns unrelated queries into
98
+ * RLS-scoped ones (observed in production as registration failing with
99
+ * SQLSTATE 42501 under a leaked anonymous context).
100
+ *
101
+ * pg-pool emits `release` before it consults its private `_expired` set, so
102
+ * marking the client expired here makes `_release()` destroy it instead of
103
+ * pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
104
+ * private APIs — feature-detect and fall back to loud logging so an upstream
105
+ * change degrades to observability, never to silent corruption.
106
+ */
107
+ function guardPoolAgainstDirtyRelease(pool, label) {
108
+ pool.on("release", (err, client) => {
109
+ if (err) return;
110
+ const txStatus = client?._txStatus;
111
+ if (typeof txStatus !== "string" || txStatus === TX_IDLE) return;
112
+ const expired = pool._expired;
113
+ if (expired && typeof expired.add === "function" && typeof expired.has === "function" && client && typeof client === "object") {
114
+ expired.add(client);
115
+ logger.error(`[${label}] Client released back to the pool while still in a transaction (status '${txStatus}') — destroying it so the open transaction and its session state (RLS GUCs) cannot leak into the next request.`);
116
+ } else logger.error(`[${label}] Client released mid-transaction (status '${txStatus}') but the pool's internal expiry set is unavailable (pg-pool internals changed?). The connection may leak its open transaction into subsequent requests.`);
117
+ });
118
+ }
84
119
  /**
85
120
  * Create a Drizzle-backed Postgres connection with a production-grade
86
121
  * connection pool.
@@ -112,6 +147,7 @@ function createPostgresDatabaseConnection(connectionString, schema, poolConfig)
112
147
  logger.error("[pg-pool] Unexpected pool error", { detail: err.message });
113
148
  if (err.message.includes("ETIMEDOUT")) logger.warn("[pg-pool] Connection timeout detected — pool will auto-retry");
114
149
  });
150
+ guardPoolAgainstDirtyRelease(pool, "pg-pool");
115
151
  return {
116
152
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
117
153
  pool,
@@ -144,6 +180,7 @@ function createDirectDatabaseConnection(connectionString, schema, poolConfig) {
144
180
  pool.on("error", (err) => {
145
181
  logger.error("[pg-direct-pool] Unexpected pool error", { detail: err.message });
146
182
  });
183
+ guardPoolAgainstDirtyRelease(pool, "pg-direct-pool");
147
184
  return {
148
185
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
149
186
  pool,
@@ -173,6 +210,7 @@ function createReadReplicaConnection(connectionString, schema, poolConfig) {
173
210
  pool.on("error", (err) => {
174
211
  logger.error("[pg-replica-pool] Unexpected pool error", { detail: err.message });
175
212
  });
213
+ guardPoolAgainstDirtyRelease(pool, "pg-replica-pool");
176
214
  return {
177
215
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
178
216
  pool,
@@ -9196,6 +9234,7 @@ var DatabasePoolManager = class {
9196
9234
  pool.on("error", (err) => {
9197
9235
  logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
9198
9236
  });
9237
+ guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
9199
9238
  this.pools.set(databaseName, pool);
9200
9239
  return pool;
9201
9240
  }
@@ -20756,14 +20795,22 @@ async function ensureAuthTablesExist(db, collection) {
20756
20795
  $$ LANGUAGE sql STABLE
20757
20796
  `);
20758
20797
  });
20759
- await db.execute(sql`
20760
- ALTER TABLE ${sql.raw(usersTableName)}
20761
- ADD COLUMN IF NOT EXISTS is_anonymous BOOLEAN DEFAULT FALSE
20762
- `);
20763
- await db.execute(sql`
20764
- ALTER TABLE ${sql.raw(usersTableName)}
20765
- ADD COLUMN IF NOT EXISTS roles TEXT[] DEFAULT '{}' NOT NULL
20766
- `);
20798
+ for (const columnDef of [
20799
+ "display_name VARCHAR(255)",
20800
+ "photo_url VARCHAR(500)",
20801
+ "roles TEXT[] DEFAULT '{}' NOT NULL",
20802
+ "password_hash VARCHAR(255)",
20803
+ "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
20804
+ "email_verification_token VARCHAR(255)",
20805
+ "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
20806
+ "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
20807
+ "metadata JSONB DEFAULT '{}' NOT NULL",
20808
+ "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
20809
+ "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
20810
+ ]) await db.execute(sql`
20811
+ ALTER TABLE ${sql.raw(usersTableName)}
20812
+ ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
20813
+ `);
20767
20814
  try {
20768
20815
  if ((await db.execute(sql`
20769
20816
  SELECT EXISTS (
@@ -20834,6 +20881,34 @@ async function ensureAuthTablesExist(db, collection) {
20834
20881
  CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
20835
20882
  ON ${sql.raw(recoveryCodesTableName)}(user_id)
20836
20883
  `);
20884
+ try {
20885
+ const authTablePairs = [
20886
+ [usersSchema, resolvedTable],
20887
+ [authSchema, "user_identities"],
20888
+ [authSchema, "refresh_tokens"],
20889
+ [authSchema, "password_reset_tokens"],
20890
+ [authSchema, "app_config"],
20891
+ [authSchema, "mfa_factors"],
20892
+ [authSchema, "mfa_challenges"],
20893
+ [authSchema, "recovery_codes"]
20894
+ ];
20895
+ for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
20896
+ SELECT 1
20897
+ FROM pg_class c
20898
+ JOIN pg_namespace n ON n.oid = c.relnamespace
20899
+ WHERE n.nspname = ${schemaName}
20900
+ AND c.relname = ${tableName}
20901
+ AND c.relforcerowsecurity
20902
+ `)).rows.length > 0) {
20903
+ await db.execute(sql`
20904
+ ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
20905
+ NO FORCE ROW LEVEL SECURITY
20906
+ `);
20907
+ logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
20908
+ }
20909
+ } catch (rlsReconcileError) {
20910
+ logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
20911
+ }
20837
20912
  logger.info("✅ Auth tables ready");
20838
20913
  } catch (error) {
20839
20914
  logger.error("❌ Failed to create auth tables", { error });
@@ -20881,6 +20956,31 @@ var UserService = class {
20881
20956
  const name = getTableName(this.usersTable);
20882
20957
  return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
20883
20958
  }
20959
+ /**
20960
+ * Run a privileged auth write with an explicitly cleared RLS context.
20961
+ *
20962
+ * The auth services run on the base/owner connection, which by design
20963
+ * carries a NULL `app.user_id` so the `auth.uid() IS NULL` server-escape
20964
+ * in the default policies applies. That NULL is normally guaranteed by
20965
+ * `set_config(..., is_local = true)` resetting at transaction end — but a
20966
+ * GUC that survives on a pooled connection (or a connection role that
20967
+ * doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
20968
+ * turns the trusted write into an RLS-scoped one and denies it with
20969
+ * SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
20970
+ * single chokepoint, makes the server context deterministic instead of
20971
+ * trusting whatever state the pool hands us. `auth.uid()` reads '' as
20972
+ * NULL via NULLIF, so '' is the server context.
20973
+ */
20974
+ async withServerContext(fn) {
20975
+ return await this.db.transaction(async (tx) => {
20976
+ await tx.execute(sql`
20977
+ SELECT set_config('app.user_id', '', true),
20978
+ set_config('app.user_roles', '', true),
20979
+ set_config('app.jwt', '', true)
20980
+ `);
20981
+ return await fn(tx);
20982
+ });
20983
+ }
20884
20984
  mapRowToUser(row) {
20885
20985
  if (!row) return row;
20886
20986
  const id = row.id ?? row.uid;
@@ -20978,7 +21078,7 @@ var UserService = class {
20978
21078
  }
20979
21079
  async createUser(data) {
20980
21080
  const payload = this.mapPayload(data);
20981
- const [row] = await this.db.insert(this.usersTable).values(payload).returning();
21081
+ const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
20982
21082
  return this.mapRowToUser(row);
20983
21083
  }
20984
21084
  async getUserById(id) {
@@ -21017,12 +21117,12 @@ var UserService = class {
21017
21117
  }));
21018
21118
  }
21019
21119
  async linkUserIdentity(userId, provider, providerId, profileData) {
21020
- await this.db.insert(this.userIdentitiesTable).values({
21120
+ await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
21021
21121
  userId,
21022
21122
  provider,
21023
21123
  providerId,
21024
21124
  profileData: profileData || null
21025
- }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
21125
+ }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
21026
21126
  }
21027
21127
  async updateUser(id, data) {
21028
21128
  const idCol = getColumn(this.usersTable, "id");
@@ -21030,13 +21130,13 @@ var UserService = class {
21030
21130
  const payload = this.mapPayload(data);
21031
21131
  const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21032
21132
  payload[updatedAtKey] = /* @__PURE__ */ new Date();
21033
- const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
21133
+ const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
21034
21134
  return row ? this.mapRowToUser(row) : null;
21035
21135
  }
21036
21136
  async deleteUser(id) {
21037
21137
  const idCol = getColumn(this.usersTable, "id");
21038
21138
  if (!idCol) return;
21039
- await this.db.delete(this.usersTable).where(eq(idCol, id));
21139
+ await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
21040
21140
  }
21041
21141
  async listUsers() {
21042
21142
  return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
@@ -21090,10 +21190,10 @@ var UserService = class {
21090
21190
  if (!idCol) return;
21091
21191
  const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
21092
21192
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21093
- await this.db.update(this.usersTable).set({
21193
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
21094
21194
  [passwordHashColKey]: passwordHash,
21095
21195
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21096
- }).where(eq(idCol, id));
21196
+ }).where(eq(idCol, id)));
21097
21197
  }
21098
21198
  /**
21099
21199
  * Set email verification status
@@ -21104,11 +21204,11 @@ var UserService = class {
21104
21204
  const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
21105
21205
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
21106
21206
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21107
- await this.db.update(this.usersTable).set({
21207
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
21108
21208
  [emailVerifiedColKey]: verified,
21109
21209
  [emailVerificationTokenColKey]: null,
21110
21210
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21111
- }).where(eq(idCol, id));
21211
+ }).where(eq(idCol, id)));
21112
21212
  }
21113
21213
  /**
21114
21214
  * Set email verification token
@@ -21119,11 +21219,11 @@ var UserService = class {
21119
21219
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
21120
21220
  const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
21121
21221
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21122
- await this.db.update(this.usersTable).set({
21222
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
21123
21223
  [emailVerificationTokenColKey]: token,
21124
21224
  [emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
21125
21225
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21126
- }).where(eq(idCol, id));
21226
+ }).where(eq(idCol, id)));
21127
21227
  }
21128
21228
  /**
21129
21229
  * Find user by email verification token
@@ -21168,22 +21268,22 @@ var UserService = class {
21168
21268
  async setUserRoles(userId, roleIds) {
21169
21269
  const usersTableName = this.getQualifiedUsersTableName();
21170
21270
  const rolesArray = `{${roleIds.join(",")}}`;
21171
- await this.db.execute(sql`
21271
+ await this.withServerContext(async (db) => db.execute(sql`
21172
21272
  UPDATE ${sql.raw(usersTableName)}
21173
21273
  SET roles = ${rolesArray}::text[], updated_at = NOW()
21174
21274
  WHERE id = ${userId}
21175
- `);
21275
+ `));
21176
21276
  }
21177
21277
  /**
21178
21278
  * Assign a specific role to new user (appends if not present)
21179
21279
  */
21180
21280
  async assignDefaultRole(userId, roleId) {
21181
21281
  const usersTableName = this.getQualifiedUsersTableName();
21182
- await this.db.execute(sql`
21282
+ await this.withServerContext(async (db) => db.execute(sql`
21183
21283
  UPDATE ${sql.raw(usersTableName)}
21184
21284
  SET roles = array_append(roles, ${roleId}), updated_at = NOW()
21185
21285
  WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
21186
- `);
21286
+ `));
21187
21287
  }
21188
21288
  /**
21189
21289
  * Get user with their roles
@@ -22833,6 +22933,6 @@ function createPostgresAdapter(pgConfig) {
22833
22933
  };
22834
22934
  }
22835
22935
  //#endregion
22836
- export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
22936
+ export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
22837
22937
 
22838
22938
  //# sourceMappingURL=index.es.js.map