@rebasepro/server-postgres 0.9.1-canary.a57c262 → 0.9.1-canary.a639738
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/dist/PostgresBootstrapper.d.ts +10 -0
- package/dist/auth/services.d.ts +16 -0
- package/dist/connection.d.ts +21 -0
- package/dist/index.es.js +208 -37
- package/dist/index.es.js.map +1 -1
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/security/policy-drift.d.ts +30 -0
- package/dist/services/row-pipeline.d.ts +5 -2
- package/package.json +7 -6
- package/src/PostgresBackendDriver.ts +18 -5
- package/src/PostgresBootstrapper.ts +51 -2
- package/src/auth/ensure-tables.ts +73 -11
- package/src/auth/services.ts +49 -19
- package/src/cli.ts +60 -0
- package/src/connection.ts +61 -1
- package/src/databasePoolManager.ts +2 -0
- package/src/schema/doctor.ts +45 -20
- package/src/schema/introspect-db.ts +11 -1
- package/src/security/policy-drift.test.ts +106 -1
- package/src/security/policy-drift.ts +56 -0
- package/src/services/PersistService.ts +9 -2
- package/src/services/row-pipeline.ts +70 -7
- package/src/utils/drizzle-conditions.ts +13 -0
|
@@ -9,6 +9,7 @@ import { PostgresBackendDriver } from "./PostgresBackendDriver";
|
|
|
9
9
|
import { RealtimeService } from "./services/realtimeService";
|
|
10
10
|
import { DatabasePoolManager } from "./databasePoolManager";
|
|
11
11
|
import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
|
|
12
|
+
import { type CdcTableRef } from "./services/cdc/trigger-cdc";
|
|
12
13
|
export interface PostgresDriverConfig {
|
|
13
14
|
connectionString?: string;
|
|
14
15
|
adminConnectionString?: string;
|
|
@@ -36,6 +37,15 @@ export interface PostgresDriverInternals {
|
|
|
36
37
|
realtimeService: RealtimeService;
|
|
37
38
|
driver: PostgresBackendDriver;
|
|
38
39
|
poolManager?: DatabasePoolManager;
|
|
40
|
+
/**
|
|
41
|
+
* Attach CDC triggers to tables that did not exist when the driver
|
|
42
|
+
* bootstrapped. Only set when database-level capture is actually active.
|
|
43
|
+
*
|
|
44
|
+
* Auth owns its own tables and creates them later in boot, so at driver
|
|
45
|
+
* bootstrap they are legitimately missing and get skipped; without this
|
|
46
|
+
* they would stay uninstrumented until the next restart.
|
|
47
|
+
*/
|
|
48
|
+
provisionCdcForTables?: (tables: CdcTableRef[]) => Promise<void>;
|
|
39
49
|
}
|
|
40
50
|
/**
|
|
41
51
|
* Default PostgreSQL bootstrapper.
|
package/dist/auth/services.d.ts
CHANGED
|
@@ -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>;
|
package/dist/connection.d.ts
CHANGED
|
@@ -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:
|
|
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,
|
|
@@ -5228,6 +5266,7 @@ var DrizzleConditionBuilder = class {
|
|
|
5228
5266
|
static buildVectorSearchConditions(table, vectorSearch) {
|
|
5229
5267
|
const column = table[vectorSearch.property];
|
|
5230
5268
|
if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
|
|
5269
|
+
if (!Array.isArray(vectorSearch.vector) || vectorSearch.vector.length === 0 || !vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))) throw new Error("Vector search requires a non-empty array of finite numbers");
|
|
5231
5270
|
const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
|
|
5232
5271
|
const distanceFn = vectorSearch.distance || "cosine";
|
|
5233
5272
|
let operator;
|
|
@@ -6456,9 +6495,56 @@ function unwrapJunctionRow(item) {
|
|
|
6456
6495
|
const nestedKey = Object.keys(item).find((key) => typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key]));
|
|
6457
6496
|
return nestedKey ? item[nestedKey] : item;
|
|
6458
6497
|
}
|
|
6498
|
+
/**
|
|
6499
|
+
* Give back the number a `number` property was declared to be.
|
|
6500
|
+
*
|
|
6501
|
+
* Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
|
|
6502
|
+
* numeric can hold more precision than a double. But the collection declared
|
|
6503
|
+
* this property `number` and the OpenAPI spec this server publishes says
|
|
6504
|
+
* `type: number`, so serving `"9.99"` breaks its own contract — and breaks it
|
|
6505
|
+
* asymmetrically, because a create answers with the number and the read that
|
|
6506
|
+
* follows answers with the string. Any client that multiplies a price works
|
|
6507
|
+
* until the first refresh.
|
|
6508
|
+
*
|
|
6509
|
+
* Declaring a property `number` already accepts double precision — that is what
|
|
6510
|
+
* the admin has always parsed it to. Columns REST serves that no property
|
|
6511
|
+
* declares are left exactly as the database returned them.
|
|
6512
|
+
*/
|
|
6513
|
+
function coerceDeclaredNumber(value, property) {
|
|
6514
|
+
if (property?.type !== "number" || typeof value !== "string") return value;
|
|
6515
|
+
const parsed = parseFloat(value);
|
|
6516
|
+
return isNaN(parsed) ? null : parsed;
|
|
6517
|
+
}
|
|
6518
|
+
/** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
|
|
6519
|
+
function coerceDeclaredNumbers(row, collection) {
|
|
6520
|
+
const properties = collection.properties;
|
|
6521
|
+
if (!properties) return row;
|
|
6522
|
+
const out = {};
|
|
6523
|
+
for (const [key, value] of Object.entries(row)) out[key] = coerceDeclaredNumber(value, properties[key]);
|
|
6524
|
+
return out;
|
|
6525
|
+
}
|
|
6459
6526
|
/** Render one target row in the requested style. */
|
|
6527
|
+
/**
|
|
6528
|
+
* Drop every column the collection marked `excludeFromApi`.
|
|
6529
|
+
*
|
|
6530
|
+
* Password hashes and verification tokens have to be readable server-side but
|
|
6531
|
+
* must never reach a client — and "never" has to mean every exit from this
|
|
6532
|
+
* pipeline, including relation targets, or a secret leaks through whichever
|
|
6533
|
+
* path was overlooked. Keyed by both the property name and its column name,
|
|
6534
|
+
* since a row can arrive keyed either way depending on the caller.
|
|
6535
|
+
*/
|
|
6536
|
+
function stripExcluded(row, collection) {
|
|
6537
|
+
const properties = collection.properties;
|
|
6538
|
+
if (!properties) return row;
|
|
6539
|
+
for (const [key, property] of Object.entries(properties)) {
|
|
6540
|
+
if (!property?.excludeFromApi) continue;
|
|
6541
|
+
delete row[key];
|
|
6542
|
+
if (property.columnName) delete row[property.columnName];
|
|
6543
|
+
}
|
|
6544
|
+
return row;
|
|
6545
|
+
}
|
|
6460
6546
|
function renderTarget(targetRow, targetCollection, style, registry) {
|
|
6461
|
-
if (style === "inline") return { ...targetRow };
|
|
6547
|
+
if (style === "inline") return stripExcluded(coerceDeclaredNumbers({ ...targetRow }, targetCollection), targetCollection);
|
|
6462
6548
|
const address = relationTargetAddress(targetRow, targetCollection, registry);
|
|
6463
6549
|
const path = targetCollection.slug;
|
|
6464
6550
|
return createRelationRefWithData(address, path, {
|
|
@@ -6500,14 +6586,17 @@ function toCmsRow(row, collection, registry) {
|
|
|
6500
6586
|
normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
|
|
6501
6587
|
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
|
|
6502
6588
|
}
|
|
6503
|
-
return normalized;
|
|
6589
|
+
return stripExcluded(normalized, collection);
|
|
6504
6590
|
}
|
|
6505
6591
|
/**
|
|
6506
6592
|
* The row REST serves: every column under its own name, with the value Postgres
|
|
6507
6593
|
* returned, and relations inlined as the target's columns.
|
|
6508
6594
|
*
|
|
6509
|
-
*
|
|
6510
|
-
*
|
|
6595
|
+
* Values are the ones the database returned, except where that contradicts the
|
|
6596
|
+
* declared type: a `number` property is served as a number (see
|
|
6597
|
+
* {@link coerceDeclaredNumber}). Dates stay as the database returned them —
|
|
6598
|
+
* JSON has its own opinions about dates that the admin's view-model does not
|
|
6599
|
+
* share.
|
|
6511
6600
|
*
|
|
6512
6601
|
* Keyed by the row rather than by the relation list — a REST fetch only loads
|
|
6513
6602
|
* the relations `include` asked for, so the row is the authority on which are
|
|
@@ -6520,9 +6609,9 @@ function toRestRow(row, collection, registry) {
|
|
|
6520
6609
|
const relation = findRelation(resolvedRelations, key);
|
|
6521
6610
|
if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
|
|
6522
6611
|
else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
|
|
6523
|
-
else flat[key] = value;
|
|
6612
|
+
else flat[key] = coerceDeclaredNumber(value, collection.properties?.[key]);
|
|
6524
6613
|
}
|
|
6525
|
-
return flat;
|
|
6614
|
+
return stripExcluded(flat, collection);
|
|
6526
6615
|
}
|
|
6527
6616
|
//#endregion
|
|
6528
6617
|
//#region src/services/FetchService.ts
|
|
@@ -7625,7 +7714,7 @@ var PersistService = class {
|
|
|
7625
7714
|
} catch (error) {
|
|
7626
7715
|
throw this.toUserFriendlyError(error, collection.slug);
|
|
7627
7716
|
}
|
|
7628
|
-
const finalEntity = await this.fetchService.
|
|
7717
|
+
const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
|
|
7629
7718
|
if (!finalEntity) throw new Error("Could not fetch row after save.");
|
|
7630
7719
|
return finalEntity;
|
|
7631
7720
|
}
|
|
@@ -8544,12 +8633,14 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8544
8633
|
let updatedValues = values;
|
|
8545
8634
|
const contextForCallback = this.buildCallContext();
|
|
8546
8635
|
let previousValuesForHistory;
|
|
8547
|
-
if (status === "existing" && id) {
|
|
8548
|
-
const existing = await this.dataService.
|
|
8636
|
+
if (status === "existing" && id) try {
|
|
8637
|
+
const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
|
|
8549
8638
|
if (existing) {
|
|
8550
8639
|
const { id: _existingId, ...existingValues } = existing;
|
|
8551
8640
|
previousValuesForHistory = existingValues;
|
|
8552
8641
|
}
|
|
8642
|
+
} catch (err) {
|
|
8643
|
+
logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
|
|
8553
8644
|
}
|
|
8554
8645
|
if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
|
|
8555
8646
|
if (globalCallbacks?.beforeSave) {
|
|
@@ -9162,6 +9253,7 @@ var DatabasePoolManager = class {
|
|
|
9162
9253
|
pool.on("error", (err) => {
|
|
9163
9254
|
logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
|
|
9164
9255
|
});
|
|
9256
|
+
guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
|
|
9165
9257
|
this.pools.set(databaseName, pool);
|
|
9166
9258
|
return pool;
|
|
9167
9259
|
}
|
|
@@ -20722,14 +20814,22 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20722
20814
|
$$ LANGUAGE sql STABLE
|
|
20723
20815
|
`);
|
|
20724
20816
|
});
|
|
20725
|
-
|
|
20726
|
-
|
|
20727
|
-
|
|
20728
|
-
|
|
20729
|
-
|
|
20730
|
-
|
|
20731
|
-
|
|
20732
|
-
|
|
20817
|
+
for (const columnDef of [
|
|
20818
|
+
"display_name VARCHAR(255)",
|
|
20819
|
+
"photo_url VARCHAR(500)",
|
|
20820
|
+
"roles TEXT[] DEFAULT '{}' NOT NULL",
|
|
20821
|
+
"password_hash VARCHAR(255)",
|
|
20822
|
+
"email_verified BOOLEAN DEFAULT FALSE NOT NULL",
|
|
20823
|
+
"email_verification_token VARCHAR(255)",
|
|
20824
|
+
"email_verification_sent_at TIMESTAMP WITH TIME ZONE",
|
|
20825
|
+
"is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
|
|
20826
|
+
"metadata JSONB DEFAULT '{}' NOT NULL",
|
|
20827
|
+
"created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
|
|
20828
|
+
"updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
|
|
20829
|
+
]) await db.execute(sql`
|
|
20830
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
20831
|
+
ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
|
|
20832
|
+
`);
|
|
20733
20833
|
try {
|
|
20734
20834
|
if ((await db.execute(sql`
|
|
20735
20835
|
SELECT EXISTS (
|
|
@@ -20800,6 +20900,34 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20800
20900
|
CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
|
|
20801
20901
|
ON ${sql.raw(recoveryCodesTableName)}(user_id)
|
|
20802
20902
|
`);
|
|
20903
|
+
try {
|
|
20904
|
+
const authTablePairs = [
|
|
20905
|
+
[usersSchema, resolvedTable],
|
|
20906
|
+
[authSchema, "user_identities"],
|
|
20907
|
+
[authSchema, "refresh_tokens"],
|
|
20908
|
+
[authSchema, "password_reset_tokens"],
|
|
20909
|
+
[authSchema, "app_config"],
|
|
20910
|
+
[authSchema, "mfa_factors"],
|
|
20911
|
+
[authSchema, "mfa_challenges"],
|
|
20912
|
+
[authSchema, "recovery_codes"]
|
|
20913
|
+
];
|
|
20914
|
+
for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
|
|
20915
|
+
SELECT 1
|
|
20916
|
+
FROM pg_class c
|
|
20917
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
20918
|
+
WHERE n.nspname = ${schemaName}
|
|
20919
|
+
AND c.relname = ${tableName}
|
|
20920
|
+
AND c.relforcerowsecurity
|
|
20921
|
+
`)).rows.length > 0) {
|
|
20922
|
+
await db.execute(sql`
|
|
20923
|
+
ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
|
|
20924
|
+
NO FORCE ROW LEVEL SECURITY
|
|
20925
|
+
`);
|
|
20926
|
+
logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
|
|
20927
|
+
}
|
|
20928
|
+
} catch (rlsReconcileError) {
|
|
20929
|
+
logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
|
|
20930
|
+
}
|
|
20803
20931
|
logger.info("✅ Auth tables ready");
|
|
20804
20932
|
} catch (error) {
|
|
20805
20933
|
logger.error("❌ Failed to create auth tables", { error });
|
|
@@ -20847,6 +20975,31 @@ var UserService = class {
|
|
|
20847
20975
|
const name = getTableName(this.usersTable);
|
|
20848
20976
|
return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
|
|
20849
20977
|
}
|
|
20978
|
+
/**
|
|
20979
|
+
* Run a privileged auth write with an explicitly cleared RLS context.
|
|
20980
|
+
*
|
|
20981
|
+
* The auth services run on the base/owner connection, which by design
|
|
20982
|
+
* carries a NULL `app.user_id` so the `auth.uid() IS NULL` server-escape
|
|
20983
|
+
* in the default policies applies. That NULL is normally guaranteed by
|
|
20984
|
+
* `set_config(..., is_local = true)` resetting at transaction end — but a
|
|
20985
|
+
* GUC that survives on a pooled connection (or a connection role that
|
|
20986
|
+
* doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
|
|
20987
|
+
* turns the trusted write into an RLS-scoped one and denies it with
|
|
20988
|
+
* SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
|
|
20989
|
+
* single chokepoint, makes the server context deterministic instead of
|
|
20990
|
+
* trusting whatever state the pool hands us. `auth.uid()` reads '' as
|
|
20991
|
+
* NULL via NULLIF, so '' is the server context.
|
|
20992
|
+
*/
|
|
20993
|
+
async withServerContext(fn) {
|
|
20994
|
+
return await this.db.transaction(async (tx) => {
|
|
20995
|
+
await tx.execute(sql`
|
|
20996
|
+
SELECT set_config('app.user_id', '', true),
|
|
20997
|
+
set_config('app.user_roles', '', true),
|
|
20998
|
+
set_config('app.jwt', '', true)
|
|
20999
|
+
`);
|
|
21000
|
+
return await fn(tx);
|
|
21001
|
+
});
|
|
21002
|
+
}
|
|
20850
21003
|
mapRowToUser(row) {
|
|
20851
21004
|
if (!row) return row;
|
|
20852
21005
|
const id = row.id ?? row.uid;
|
|
@@ -20944,7 +21097,7 @@ var UserService = class {
|
|
|
20944
21097
|
}
|
|
20945
21098
|
async createUser(data) {
|
|
20946
21099
|
const payload = this.mapPayload(data);
|
|
20947
|
-
const [row] = await this.db.insert(this.usersTable).values(payload).returning();
|
|
21100
|
+
const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
|
|
20948
21101
|
return this.mapRowToUser(row);
|
|
20949
21102
|
}
|
|
20950
21103
|
async getUserById(id) {
|
|
@@ -20983,12 +21136,12 @@ var UserService = class {
|
|
|
20983
21136
|
}));
|
|
20984
21137
|
}
|
|
20985
21138
|
async linkUserIdentity(userId, provider, providerId, profileData) {
|
|
20986
|
-
await this.db.insert(this.userIdentitiesTable).values({
|
|
21139
|
+
await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
|
|
20987
21140
|
userId,
|
|
20988
21141
|
provider,
|
|
20989
21142
|
providerId,
|
|
20990
21143
|
profileData: profileData || null
|
|
20991
|
-
}).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
|
|
21144
|
+
}).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
|
|
20992
21145
|
}
|
|
20993
21146
|
async updateUser(id, data) {
|
|
20994
21147
|
const idCol = getColumn(this.usersTable, "id");
|
|
@@ -20996,13 +21149,13 @@ var UserService = class {
|
|
|
20996
21149
|
const payload = this.mapPayload(data);
|
|
20997
21150
|
const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
20998
21151
|
payload[updatedAtKey] = /* @__PURE__ */ new Date();
|
|
20999
|
-
const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
|
|
21152
|
+
const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
|
|
21000
21153
|
return row ? this.mapRowToUser(row) : null;
|
|
21001
21154
|
}
|
|
21002
21155
|
async deleteUser(id) {
|
|
21003
21156
|
const idCol = getColumn(this.usersTable, "id");
|
|
21004
21157
|
if (!idCol) return;
|
|
21005
|
-
await this.db.delete(this.usersTable).where(eq(idCol, id));
|
|
21158
|
+
await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
|
|
21006
21159
|
}
|
|
21007
21160
|
async listUsers() {
|
|
21008
21161
|
return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
|
|
@@ -21056,10 +21209,10 @@ var UserService = class {
|
|
|
21056
21209
|
if (!idCol) return;
|
|
21057
21210
|
const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
|
|
21058
21211
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
21059
|
-
await this.db.update(this.usersTable).set({
|
|
21212
|
+
await this.withServerContext(async (db) => db.update(this.usersTable).set({
|
|
21060
21213
|
[passwordHashColKey]: passwordHash,
|
|
21061
21214
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
21062
|
-
}).where(eq(idCol, id));
|
|
21215
|
+
}).where(eq(idCol, id)));
|
|
21063
21216
|
}
|
|
21064
21217
|
/**
|
|
21065
21218
|
* Set email verification status
|
|
@@ -21070,11 +21223,11 @@ var UserService = class {
|
|
|
21070
21223
|
const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
|
|
21071
21224
|
const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
|
|
21072
21225
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
21073
|
-
await this.db.update(this.usersTable).set({
|
|
21226
|
+
await this.withServerContext(async (db) => db.update(this.usersTable).set({
|
|
21074
21227
|
[emailVerifiedColKey]: verified,
|
|
21075
21228
|
[emailVerificationTokenColKey]: null,
|
|
21076
21229
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
21077
|
-
}).where(eq(idCol, id));
|
|
21230
|
+
}).where(eq(idCol, id)));
|
|
21078
21231
|
}
|
|
21079
21232
|
/**
|
|
21080
21233
|
* Set email verification token
|
|
@@ -21085,11 +21238,11 @@ var UserService = class {
|
|
|
21085
21238
|
const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
|
|
21086
21239
|
const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
|
|
21087
21240
|
const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
21088
|
-
await this.db.update(this.usersTable).set({
|
|
21241
|
+
await this.withServerContext(async (db) => db.update(this.usersTable).set({
|
|
21089
21242
|
[emailVerificationTokenColKey]: token,
|
|
21090
21243
|
[emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
|
|
21091
21244
|
[updatedAtColKey]: /* @__PURE__ */ new Date()
|
|
21092
|
-
}).where(eq(idCol, id));
|
|
21245
|
+
}).where(eq(idCol, id)));
|
|
21093
21246
|
}
|
|
21094
21247
|
/**
|
|
21095
21248
|
* Find user by email verification token
|
|
@@ -21134,22 +21287,22 @@ var UserService = class {
|
|
|
21134
21287
|
async setUserRoles(userId, roleIds) {
|
|
21135
21288
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
21136
21289
|
const rolesArray = `{${roleIds.join(",")}}`;
|
|
21137
|
-
await this.db.execute(sql`
|
|
21290
|
+
await this.withServerContext(async (db) => db.execute(sql`
|
|
21138
21291
|
UPDATE ${sql.raw(usersTableName)}
|
|
21139
21292
|
SET roles = ${rolesArray}::text[], updated_at = NOW()
|
|
21140
21293
|
WHERE id = ${userId}
|
|
21141
|
-
`);
|
|
21294
|
+
`));
|
|
21142
21295
|
}
|
|
21143
21296
|
/**
|
|
21144
21297
|
* Assign a specific role to new user (appends if not present)
|
|
21145
21298
|
*/
|
|
21146
21299
|
async assignDefaultRole(userId, roleId) {
|
|
21147
21300
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
21148
|
-
await this.db.execute(sql`
|
|
21301
|
+
await this.withServerContext(async (db) => db.execute(sql`
|
|
21149
21302
|
UPDATE ${sql.raw(usersTableName)}
|
|
21150
21303
|
SET roles = array_append(roles, ${roleId}), updated_at = NOW()
|
|
21151
21304
|
WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
|
|
21152
|
-
`);
|
|
21305
|
+
`));
|
|
21153
21306
|
}
|
|
21154
21307
|
/**
|
|
21155
21308
|
* Get user with their roles
|
|
@@ -22619,6 +22772,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22619
22772
|
const wantsCdc = cdcMode !== "off";
|
|
22620
22773
|
const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
|
|
22621
22774
|
let cdcEnabled = false;
|
|
22775
|
+
let provisionCdcForTables;
|
|
22622
22776
|
if (wantsCdc && !directUrl) {
|
|
22623
22777
|
const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
|
|
22624
22778
|
if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
|
|
@@ -22635,6 +22789,9 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22635
22789
|
})).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
|
|
22636
22790
|
await realtimeService.enableCdc(directUrl);
|
|
22637
22791
|
cdcEnabled = true;
|
|
22792
|
+
provisionCdcForTables = async (tables) => {
|
|
22793
|
+
await provisionTriggerCdc(cdcRunSql, tables);
|
|
22794
|
+
};
|
|
22638
22795
|
logger.info(`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). All writes now emit realtime events regardless of origin.`);
|
|
22639
22796
|
} catch (err) {
|
|
22640
22797
|
if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
|
|
@@ -22659,13 +22816,14 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22659
22816
|
const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
|
|
22660
22817
|
const missing = [];
|
|
22661
22818
|
for (const col of registeredCollections) {
|
|
22819
|
+
if (col.auth?.enabled) continue;
|
|
22662
22820
|
const schemaName = "schema" in col && col.schema ? col.schema : "public";
|
|
22663
22821
|
const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
|
|
22664
22822
|
const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
|
|
22665
22823
|
const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
|
|
22666
22824
|
if (!dbTables.has(fullCheckName)) missing.push({
|
|
22667
22825
|
slug: col.slug,
|
|
22668
|
-
table:
|
|
22826
|
+
table: fullCheckName
|
|
22669
22827
|
});
|
|
22670
22828
|
}
|
|
22671
22829
|
if (missing.length > 0) {
|
|
@@ -22699,7 +22857,8 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22699
22857
|
registry,
|
|
22700
22858
|
realtimeService,
|
|
22701
22859
|
driver,
|
|
22702
|
-
poolManager
|
|
22860
|
+
poolManager,
|
|
22861
|
+
provisionCdcForTables
|
|
22703
22862
|
}
|
|
22704
22863
|
};
|
|
22705
22864
|
},
|
|
@@ -22711,6 +22870,18 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22711
22870
|
const registry = internals.registry;
|
|
22712
22871
|
const authCollection = authConfig.collection;
|
|
22713
22872
|
await ensureAuthTablesExist(db, authCollection);
|
|
22873
|
+
if (authCollection && internals.provisionCdcForTables) {
|
|
22874
|
+
const authSchema = "schema" in authCollection && typeof authCollection.schema === "string" ? authCollection.schema : "rebase";
|
|
22875
|
+
const authTable = "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug;
|
|
22876
|
+
if (authTable) try {
|
|
22877
|
+
await internals.provisionCdcForTables([{
|
|
22878
|
+
schema: authSchema,
|
|
22879
|
+
table: authTable
|
|
22880
|
+
}]);
|
|
22881
|
+
} catch (err) {
|
|
22882
|
+
logger.warn(`⚠️ [CDC] Could not attach change-capture to the auth table "${authSchema}.${authTable}" — writes to it won't emit database-level events.`, { detail: err instanceof Error ? err.message : String(err) });
|
|
22883
|
+
}
|
|
22884
|
+
}
|
|
22714
22885
|
let emailService;
|
|
22715
22886
|
if (authConfig.email) emailService = createEmailService(authConfig.email);
|
|
22716
22887
|
const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;
|
|
@@ -22781,6 +22952,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
22781
22952
|
};
|
|
22782
22953
|
}
|
|
22783
22954
|
//#endregion
|
|
22784
|
-
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 };
|
|
22955
|
+
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 };
|
|
22785
22956
|
|
|
22786
22957
|
//# sourceMappingURL=index.es.js.map
|