@rebasepro/server-postgres 0.0.1-canary.fc811d7 → 0.9.1-canary.09aaf62
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/PostgresBackendDriver.d.ts +25 -2
- package/dist/PostgresBootstrapper.d.ts +10 -0
- package/dist/auth/services.d.ts +16 -0
- package/dist/collections/buildRegistry.d.ts +27 -0
- package/dist/connection.d.ts +21 -0
- package/dist/data-transformer.d.ts +9 -2
- package/dist/index.es.js +2005 -2583
- package/dist/index.es.js.map +1 -1
- package/dist/module-dir.d.ts +1 -0
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/security/policy-drift.d.ts +46 -5
- package/dist/security/rls-enforcement.d.ts +27 -2
- package/dist/services/FetchService.d.ts +4 -24
- package/dist/services/PersistService.d.ts +27 -1
- package/dist/services/RelationService.d.ts +34 -1
- package/dist/services/collection-helpers.d.ts +79 -14
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +7 -0
- package/dist/services/row-pipeline.d.ts +63 -0
- package/package.json +15 -17
- package/src/PostgresBackendDriver.ts +127 -13
- package/src/PostgresBootstrapper.ts +68 -26
- package/src/auth/ensure-tables.ts +73 -11
- package/src/auth/services.ts +49 -19
- package/src/cli-helpers.ts +2 -20
- package/src/cli.ts +60 -0
- package/src/collections/buildRegistry.ts +59 -0
- package/src/connection.ts +61 -1
- package/src/data-transformer.ts +11 -9
- package/src/databasePoolManager.ts +2 -0
- package/src/module-dir.ts +7 -0
- package/src/schema/doctor-cli.ts +5 -1
- package/src/schema/doctor.ts +45 -20
- package/src/schema/generate-drizzle-schema-logic.ts +24 -29
- package/src/schema/generate-postgres-ddl-logic.ts +76 -28
- package/src/schema/introspect-db.ts +19 -2
- package/src/security/anonymous-grants.test.ts +71 -0
- package/src/security/policy-drift.test.ts +153 -14
- package/src/security/policy-drift.ts +128 -10
- package/src/security/rls-enforcement.ts +64 -3
- package/src/services/BranchService.ts +42 -10
- package/src/services/FetchService.ts +65 -270
- package/src/services/PersistService.ts +130 -14
- package/src/services/RelationService.ts +153 -94
- package/src/services/collection-helpers.ts +164 -47
- package/src/services/dataService.ts +3 -2
- package/src/services/index.ts +1 -0
- package/src/services/realtimeService.ts +40 -19
- package/src/services/row-pipeline.ts +239 -0
- package/src/utils/drizzle-conditions.ts +13 -0
- package/src/websocket.ts +4 -1
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/schema/auth-default-policies.d.ts +0 -10
- package/dist/src-Eh-CZosp.js +0 -595
- package/dist/src-Eh-CZosp.js.map +0 -1
- package/src/schema/auth-default-policies.ts +0 -125
package/src/cli-helpers.ts
CHANGED
|
@@ -1,29 +1,11 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import { execSync } from "child_process";
|
|
4
|
-
import {
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
5
|
import chalk from "chalk";
|
|
6
6
|
import { logger } from "@rebasepro/server";
|
|
7
7
|
import type { CollectionConfig, Relation } from "@rebasepro/types";
|
|
8
|
-
|
|
9
|
-
const getHelpersDirname = () => {
|
|
10
|
-
try {
|
|
11
|
-
return eval("__dirname");
|
|
12
|
-
} catch {
|
|
13
|
-
const err = new Error();
|
|
14
|
-
const stackLine = err.stack?.split("\n")[1] || "";
|
|
15
|
-
const match = stackLine.match(/\((.*?):\d+:\d+\)/) || stackLine.match(/at (.*?):\d+:\d+/);
|
|
16
|
-
if (match && match[1]) {
|
|
17
|
-
let cleanPath = match[1];
|
|
18
|
-
if (cleanPath.startsWith("file://")) {
|
|
19
|
-
cleanPath = fileURLToPath(cleanPath);
|
|
20
|
-
}
|
|
21
|
-
return path.dirname(cleanPath);
|
|
22
|
-
}
|
|
23
|
-
return process.cwd();
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
|
-
const __helpersDirname = getHelpersDirname();
|
|
8
|
+
import { moduleDir as __helpersDirname } from "./module-dir";
|
|
27
9
|
|
|
28
10
|
|
|
29
11
|
|
package/src/cli.ts
CHANGED
|
@@ -176,6 +176,7 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
|
176
176
|
|
|
177
177
|
if (databaseUrl) {
|
|
178
178
|
await applyPolicies(databaseUrl);
|
|
179
|
+
await reconcilePolicies(databaseUrl, collectionsPath);
|
|
179
180
|
await ensureRlsUserRole(databaseUrl);
|
|
180
181
|
} else {
|
|
181
182
|
logger.warn(chalk.yellow(" ⚠️ DATABASE_URL not found in environment, skipping RLS policies application."));
|
|
@@ -269,6 +270,65 @@ async function applyPolicies(databaseUrl: string): Promise<void> {
|
|
|
269
270
|
}
|
|
270
271
|
}
|
|
271
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Remove the policies an earlier push superseded but never dropped.
|
|
275
|
+
*
|
|
276
|
+
* `policies.sql` only DROPs the names it is about to CREATE, and a rule's
|
|
277
|
+
* generated name contains a hash of its own semantics — so editing a rule
|
|
278
|
+
* writes a *new* policy and abandons the old one. Postgres ORs PERMISSIVE
|
|
279
|
+
* policies together, which makes an abandoned grant outrank every tightening
|
|
280
|
+
* that replaced it, and push reported success the whole time.
|
|
281
|
+
*
|
|
282
|
+
* Runs after `applyPolicies` so the current policies are already in place: the
|
|
283
|
+
* drift check then sees exactly the set that should survive, and anything else
|
|
284
|
+
* on a managed table is by definition left over.
|
|
285
|
+
*/
|
|
286
|
+
async function reconcilePolicies(databaseUrl: string, collectionsPath: string): Promise<void> {
|
|
287
|
+
try {
|
|
288
|
+
const { checkPolicyDrift, dropOrphanedPolicies, formatPolicyDrift, hasDrift } =
|
|
289
|
+
await import("./security/policy-drift");
|
|
290
|
+
const { loadCollections } = await import("./schema/doctor");
|
|
291
|
+
|
|
292
|
+
const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
|
|
293
|
+
const { Client } = await import("pg");
|
|
294
|
+
const client = new Client({ connectionString: databaseUrl });
|
|
295
|
+
await client.connect();
|
|
296
|
+
try {
|
|
297
|
+
const drift = await checkPolicyDrift(client as never, collections);
|
|
298
|
+
const { dropped, kept } = await dropOrphanedPolicies(client as never, drift, collections);
|
|
299
|
+
|
|
300
|
+
for (const p of dropped) {
|
|
301
|
+
logger.info(chalk.gray(` ✓ Dropped superseded policy "${p.name}" on ${p.schema}.${p.table}`));
|
|
302
|
+
}
|
|
303
|
+
if (dropped.length > 0) {
|
|
304
|
+
logger.info(chalk.green(` ✓ Removed ${dropped.length} superseded RLS ${dropped.length === 1 ? "policy" : "policies"}.`));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Custom-named orphans are indistinguishable from policies someone
|
|
308
|
+
// wrote in SQL deliberately, so they are reported, never dropped.
|
|
309
|
+
if (kept.length > 0) {
|
|
310
|
+
logger.warn(chalk.yellow(" ⚠️ Policies in the database that no collection describes:"));
|
|
311
|
+
for (const p of kept) {
|
|
312
|
+
logger.warn(chalk.yellow(` • ${p.schema}.${p.table} → "${p.name}" (${p.command} TO ${p.roles.join(", ")})`));
|
|
313
|
+
}
|
|
314
|
+
logger.warn(chalk.yellow(" These still grant access. Drop them by hand if they are stale."));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Missing/diverged are not push's to fix, but staying silent about
|
|
318
|
+
// them is how a database ends up not matching its config.
|
|
319
|
+
const remaining = { ...drift, orphaned: kept };
|
|
320
|
+
if (hasDrift(remaining) && (remaining.missing.length > 0 || remaining.diverged.length > 0)) {
|
|
321
|
+
logger.warn(chalk.yellow(" ⚠️ RLS policies do not match your collections:"));
|
|
322
|
+
logger.warn(formatPolicyDrift({ ...remaining, orphaned: [] }));
|
|
323
|
+
}
|
|
324
|
+
} finally {
|
|
325
|
+
await client.end();
|
|
326
|
+
}
|
|
327
|
+
} catch (err) {
|
|
328
|
+
logger.warn(chalk.yellow(` ⚠️ Could not reconcile RLS policies: ${err instanceof Error ? err.message : String(err)}`));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
272
332
|
async function branchCommand(rawArgs: string[]): Promise<void> {
|
|
273
333
|
const branchAction = rawArgs[2]; // create, list, delete, info
|
|
274
334
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { isTable, getTableName, Relations } from "drizzle-orm";
|
|
2
|
+
import { PgEnum, PgTable } from "drizzle-orm/pg-core";
|
|
3
|
+
import { CollectionConfig } from "@rebasepro/types";
|
|
4
|
+
import { logger } from "@rebasepro/server";
|
|
5
|
+
import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
|
|
6
|
+
import { warnOnKeysTheAdminCannotResolve } from "../services/collection-helpers";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Everything a registry is built from: the collections, and the drizzle schema
|
|
10
|
+
* they are backed by. In BaaS mode all of it is introspected from the live
|
|
11
|
+
* database; in CMS mode it comes from the config and the generated schema.
|
|
12
|
+
*/
|
|
13
|
+
export interface RegistrySchema {
|
|
14
|
+
collections?: CollectionConfig[];
|
|
15
|
+
tables?: Record<string, unknown>;
|
|
16
|
+
enums?: Record<string, PgEnum<[string, ...string[]]>>;
|
|
17
|
+
relations?: Record<string, Relations>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build the collection registry for a driver.
|
|
22
|
+
*
|
|
23
|
+
* The order matters and is the reason this is one function rather than a run of
|
|
24
|
+
* statements in the bootstrapper. Keys are resolved from the drizzle schema, so
|
|
25
|
+
* anything that inspects them has to run *after* the tables are registered —
|
|
26
|
+
* and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
|
|
27
|
+
* collection whose table it cannot look up is one it has nothing to say about.
|
|
28
|
+
* Warned too early, it would skip every collection and report nothing, which
|
|
29
|
+
* reads exactly like having nothing to report.
|
|
30
|
+
*/
|
|
31
|
+
export function buildCollectionRegistry(schema: RegistrySchema): PostgresCollectionRegistry {
|
|
32
|
+
const registry = new PostgresCollectionRegistry();
|
|
33
|
+
|
|
34
|
+
if (schema.collections) {
|
|
35
|
+
registry.registerMultiple(schema.collections);
|
|
36
|
+
logger.info(
|
|
37
|
+
`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: ` +
|
|
38
|
+
`[${registry.getCollections().map(c => c.slug).join(", ")}]`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (schema.tables) {
|
|
43
|
+
Object.values(schema.tables).forEach((table) => {
|
|
44
|
+
if (isTable(table)) {
|
|
45
|
+
registry.registerTable(table as PgTable, getTableName(table));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (schema.enums) registry.registerEnums(schema.enums);
|
|
51
|
+
if (schema.relations) registry.registerRelations(schema.relations);
|
|
52
|
+
|
|
53
|
+
// Now that the keys resolve: say which of them the admin cannot see. It
|
|
54
|
+
// compiles the same collection files into its bundle but never the drizzle
|
|
55
|
+
// schema, and nothing serves it one, so only an edit to the config fixes it.
|
|
56
|
+
warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
|
|
57
|
+
|
|
58
|
+
return registry;
|
|
59
|
+
}
|
package/src/connection.ts
CHANGED
|
@@ -27,11 +27,68 @@ const DEFAULT_POOL: Required<PostgresPoolConfig> = {
|
|
|
27
27
|
max: 20,
|
|
28
28
|
idleTimeoutMillis: 30_000,
|
|
29
29
|
connectionTimeoutMillis: 10_000,
|
|
30
|
-
|
|
30
|
+
// The client-side read timeout MUST be comfortably above the server-side
|
|
31
|
+
// statement_timeout. When the client timer fires first, node-postgres
|
|
32
|
+
// abandons the in-flight statement but keeps the connection — inside a
|
|
33
|
+
// transaction that leaves the tx open (and any pending ROLLBACK is
|
|
34
|
+
// spliced out of the client queue before it ever reaches the wire), so
|
|
35
|
+
// the pooled connection is returned still in-transaction with its RLS
|
|
36
|
+
// GUCs set. The server abort (SQLSTATE 57014) is the clean path; the
|
|
37
|
+
// client timeout is only a backstop for a dead network.
|
|
38
|
+
queryTimeout: 60_000,
|
|
31
39
|
statementTimeout: 30_000,
|
|
32
40
|
keepAlive: true
|
|
33
41
|
};
|
|
34
42
|
|
|
43
|
+
/** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
|
|
44
|
+
const TX_IDLE = "I";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Destroy pool clients that are released while still inside a transaction.
|
|
48
|
+
*
|
|
49
|
+
* pg-pool returns a client to the idle list whenever `release()` is called
|
|
50
|
+
* without an error — even if the connection is still mid-transaction (status
|
|
51
|
+
* `T`/`E`). That happens in practice: drizzle's pool transaction releases in
|
|
52
|
+
* a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
|
|
53
|
+
* (e.g. it was queued behind a statement that hit the client-side
|
|
54
|
+
* query_timeout), the client goes back dirty. The next checkout then runs
|
|
55
|
+
* its statements inside the zombie transaction — with the previous request's
|
|
56
|
+
* `app.*` RLS GUCs still applied, which turns unrelated queries into
|
|
57
|
+
* RLS-scoped ones (observed in production as registration failing with
|
|
58
|
+
* SQLSTATE 42501 under a leaked anonymous context).
|
|
59
|
+
*
|
|
60
|
+
* pg-pool emits `release` before it consults its private `_expired` set, so
|
|
61
|
+
* marking the client expired here makes `_release()` destroy it instead of
|
|
62
|
+
* pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
|
|
63
|
+
* private APIs — feature-detect and fall back to loud logging so an upstream
|
|
64
|
+
* change degrades to observability, never to silent corruption.
|
|
65
|
+
*/
|
|
66
|
+
export function guardPoolAgainstDirtyRelease(pool: Pool, label: string): void {
|
|
67
|
+
pool.on("release", (err: Error | undefined, client: unknown) => {
|
|
68
|
+
if (err) return; // errored clients are already destroyed by pg-pool
|
|
69
|
+
const txStatus = (client as { _txStatus?: string | null })?._txStatus;
|
|
70
|
+
if (typeof txStatus !== "string" || txStatus === TX_IDLE) return;
|
|
71
|
+
|
|
72
|
+
// pg-pool keeps expired clients in a WeakSet (a plain object works too
|
|
73
|
+
// if upstream ever changes it — duck-type on add/has).
|
|
74
|
+
const expired = (pool as unknown as { _expired?: { add(c: object): unknown; has(c: object): boolean } })._expired;
|
|
75
|
+
if (expired && typeof expired.add === "function" && typeof expired.has === "function" && client && typeof client === "object") {
|
|
76
|
+
expired.add(client);
|
|
77
|
+
logger.error(
|
|
78
|
+
`[${label}] Client released back to the pool while still in a transaction ` +
|
|
79
|
+
`(status '${txStatus}') — destroying it so the open transaction and its ` +
|
|
80
|
+
`session state (RLS GUCs) cannot leak into the next request.`
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
logger.error(
|
|
84
|
+
`[${label}] Client released mid-transaction (status '${txStatus}') but the ` +
|
|
85
|
+
`pool's internal expiry set is unavailable (pg-pool internals changed?). ` +
|
|
86
|
+
`The connection may leak its open transaction into subsequent requests.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
35
92
|
/**
|
|
36
93
|
* Create a Drizzle-backed Postgres connection with a production-grade
|
|
37
94
|
* connection pool.
|
|
@@ -75,6 +132,7 @@ export function createPostgresDatabaseConnection(
|
|
|
75
132
|
logger.warn("[pg-pool] Connection timeout detected — pool will auto-retry");
|
|
76
133
|
}
|
|
77
134
|
});
|
|
135
|
+
guardPoolAgainstDirtyRelease(pool, "pg-pool");
|
|
78
136
|
|
|
79
137
|
// Create drizzle instance — pass schema when available to enable db.query relational API
|
|
80
138
|
const db = schema ? drizzle(pool, { schema }) : drizzle(pool);
|
|
@@ -118,6 +176,7 @@ export function createDirectDatabaseConnection(
|
|
|
118
176
|
pool.on("error", (err) => {
|
|
119
177
|
logger.error("[pg-direct-pool] Unexpected pool error", { detail: err.message });
|
|
120
178
|
});
|
|
179
|
+
guardPoolAgainstDirtyRelease(pool, "pg-direct-pool");
|
|
121
180
|
|
|
122
181
|
const db = schema ? drizzle(pool, { schema }) : drizzle(pool);
|
|
123
182
|
|
|
@@ -157,6 +216,7 @@ export function createReadReplicaConnection(
|
|
|
157
216
|
pool.on("error", (err) => {
|
|
158
217
|
logger.error("[pg-replica-pool] Unexpected pool error", { detail: err.message });
|
|
159
218
|
});
|
|
219
|
+
guardPoolAgainstDirtyRelease(pool, "pg-replica-pool");
|
|
160
220
|
|
|
161
221
|
const db = schema ? drizzle(pool, { schema }) : drizzle(pool);
|
|
162
222
|
|
package/src/data-transformer.ts
CHANGED
|
@@ -20,12 +20,19 @@ import { logger } from "@rebasepro/server";
|
|
|
20
20
|
export interface SerializedEntityData {
|
|
21
21
|
/** Scalar column values ready for INSERT/UPDATE. */
|
|
22
22
|
scalarData: Record<string, unknown>;
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Inverse relation updates that must be applied to target tables.
|
|
25
|
+
*
|
|
26
|
+
* No address here: the row being written does not know its own. These are
|
|
27
|
+
* applied by `PersistService`, which addresses them with the id it holds —
|
|
28
|
+
* the one it was given for an update, or the one the INSERT returned — and
|
|
29
|
+
* that is the authority. This item used to carry a `currentId` derived from
|
|
30
|
+
* the *input* values, which nothing ever read.
|
|
31
|
+
*/
|
|
24
32
|
inverseRelationUpdates: Array<{
|
|
25
33
|
relationKey: string;
|
|
26
34
|
relation: Relation;
|
|
27
35
|
newValue: unknown;
|
|
28
|
-
currentId?: string | number;
|
|
29
36
|
}>;
|
|
30
37
|
/** JoinPath relation updates that require multi-hop writes. */
|
|
31
38
|
joinPathRelationUpdates: Array<{
|
|
@@ -105,7 +112,6 @@ joinPathRelationUpdates: [] };
|
|
|
105
112
|
relationKey: string;
|
|
106
113
|
relation: Relation;
|
|
107
114
|
newValue: unknown;
|
|
108
|
-
currentId?: string | number;
|
|
109
115
|
}> = [];
|
|
110
116
|
const joinPathRelationUpdates: Array<{
|
|
111
117
|
relationKey: string;
|
|
@@ -146,12 +152,10 @@ joinPathRelationUpdates: [] };
|
|
|
146
152
|
} else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
147
153
|
// Inverse relation: Need to update the target table's FK
|
|
148
154
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
149
|
-
const pks = getPrimaryKeys(collection, registry!);
|
|
150
155
|
inverseRelationUpdates.push({
|
|
151
156
|
relationKey: key,
|
|
152
157
|
relation,
|
|
153
|
-
newValue: serializedValue
|
|
154
|
-
currentId: (row.id as string | number | undefined) || buildCompositeId(row, pks)
|
|
158
|
+
newValue: serializedValue
|
|
155
159
|
});
|
|
156
160
|
// Don't add the original relation property to the result
|
|
157
161
|
continue;
|
|
@@ -170,12 +174,10 @@ joinPathRelationUpdates: [] };
|
|
|
170
174
|
});
|
|
171
175
|
} else {
|
|
172
176
|
// Many inverse joinPath: capture as inverse relation update
|
|
173
|
-
const pks = getPrimaryKeys(collection, registry!);
|
|
174
177
|
inverseRelationUpdates.push({
|
|
175
178
|
relationKey: key,
|
|
176
179
|
relation,
|
|
177
|
-
newValue: serializedValue
|
|
178
|
-
currentId: (row.id as string | number | undefined) || buildCompositeId(row, pks)
|
|
180
|
+
newValue: serializedValue
|
|
179
181
|
});
|
|
180
182
|
}
|
|
181
183
|
// Don't add the original relation property to the result
|
|
@@ -2,6 +2,7 @@ import { Pool } from "pg";
|
|
|
2
2
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
4
|
import { logger } from "@rebasepro/server";
|
|
5
|
+
import { guardPoolAgainstDirtyRelease } from "./connection";
|
|
5
6
|
|
|
6
7
|
export class DatabasePoolManager {
|
|
7
8
|
private pools: Map<string, Pool> = new Map();
|
|
@@ -50,6 +51,7 @@ export class DatabasePoolManager {
|
|
|
50
51
|
pool.on("error", (err) => {
|
|
51
52
|
logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
|
|
52
53
|
});
|
|
54
|
+
guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
|
|
53
55
|
|
|
54
56
|
this.pools.set(databaseName, pool);
|
|
55
57
|
return pool;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
|
|
4
|
+
// Isolated in its own module so the jest CommonJS transform can swap it for a
|
|
5
|
+
// __dirname shim via moduleNameMapper — `import.meta` is a syntax error once
|
|
6
|
+
// ts-jest transpiles to CJS.
|
|
7
|
+
export const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
package/src/schema/doctor-cli.ts
CHANGED
|
@@ -8,7 +8,7 @@ import chalk from "chalk";
|
|
|
8
8
|
import fs from "fs";
|
|
9
9
|
import { runDoctor, loadCollections } from "./doctor";
|
|
10
10
|
import { checkPolicyDrift, formatPolicyDrift, hasDrift } from "../security/policy-drift";
|
|
11
|
-
import { validatePolicyPgRoles } from "../security/rls-enforcement";
|
|
11
|
+
import { validatePolicyPgRoles, warnOnAnonymousGrants } from "../security/rls-enforcement";
|
|
12
12
|
import { logger } from "@rebasepro/server";
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -41,6 +41,10 @@ async function runPolicyChecks(collectionsPath: string, databaseUrl?: string): P
|
|
|
41
41
|
logger.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// A rule that reads as a lockdown but is true for every caller compiles
|
|
45
|
+
// to a grant. Report it without booting a server.
|
|
46
|
+
warnOnAnonymousGrants(collections as never);
|
|
47
|
+
|
|
44
48
|
const drift = await checkPolicyDrift(pool as never, collections);
|
|
45
49
|
logger.info("");
|
|
46
50
|
if (hasDrift(drift)) {
|
package/src/schema/doctor.ts
CHANGED
|
@@ -37,7 +37,7 @@ export type IssueSeverity = "error" | "warning" | "info";
|
|
|
37
37
|
|
|
38
38
|
export interface DoctorIssue {
|
|
39
39
|
severity: IssueSeverity;
|
|
40
|
-
category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale";
|
|
40
|
+
category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale" | "sdk_not_generated";
|
|
41
41
|
table?: string;
|
|
42
42
|
column?: string;
|
|
43
43
|
expected?: string;
|
|
@@ -61,19 +61,29 @@ export function getExpectedColumnType(prop: Property): string | null {
|
|
|
61
61
|
const sp = prop as StringProperty;
|
|
62
62
|
if (sp.enum) return "USER-DEFINED"; // pgEnum → USER-DEFINED in information_schema
|
|
63
63
|
if ("isId" in sp && sp.isId === "uuid") return "uuid";
|
|
64
|
-
if (sp.columnType === "
|
|
64
|
+
if (sp.columnType === "uuid") return "uuid";
|
|
65
|
+
// A markdown/multiline string compiles to `text`, not varchar — the
|
|
66
|
+
// generator treats those UI hints as column-type signals, so the
|
|
67
|
+
// expectation here must too or every such column reads as drift.
|
|
68
|
+
if (sp.columnType === "text" || sp.ui?.markdown || sp.ui?.multiline) return "text";
|
|
65
69
|
if (sp.columnType === "char") return "character";
|
|
66
70
|
return "character varying";
|
|
67
71
|
}
|
|
68
72
|
case "number": {
|
|
69
73
|
const np = prop as NumberProperty;
|
|
70
|
-
if (np.columnType
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
if (np.columnType) {
|
|
75
|
+
// The generator passes any columnType straight through to drizzle,
|
|
76
|
+
// so mirror that rather than enumerating a subset (which reported
|
|
77
|
+
// drift for anything unlisted, e.g. smallint). Serial types are
|
|
78
|
+
// integers with a sequence default; information_schema reports the
|
|
79
|
+
// underlying width.
|
|
80
|
+
const serialWidths: Record<string, string> = {
|
|
81
|
+
serial: "integer",
|
|
82
|
+
bigserial: "bigint",
|
|
83
|
+
smallserial: "smallint"
|
|
84
|
+
};
|
|
85
|
+
return serialWidths[np.columnType] ?? np.columnType;
|
|
86
|
+
}
|
|
77
87
|
if (np.validation?.integer || ("isId" in np && np.isId)) return "integer";
|
|
78
88
|
return "numeric";
|
|
79
89
|
}
|
|
@@ -201,15 +211,18 @@ export async function checkCollectionsVsSdk(
|
|
|
201
211
|
): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
|
|
202
212
|
const issues: DoctorIssue[] = [];
|
|
203
213
|
|
|
204
|
-
//
|
|
214
|
+
// The typed SDK is opt-in — nothing in a scaffolded project imports it until
|
|
215
|
+
// you choose to. A project that never generated one isn't drifting, so report
|
|
216
|
+
// it as information rather than a warning; otherwise `doctor` can never come
|
|
217
|
+
// back clean on a fresh project and users learn to ignore its output.
|
|
205
218
|
if (!fs.existsSync(sdkFilePath)) {
|
|
206
219
|
issues.push({
|
|
207
|
-
severity: "
|
|
208
|
-
category: "
|
|
209
|
-
message:
|
|
210
|
-
fix: "Run `rebase generate-sdk`"
|
|
220
|
+
severity: "info",
|
|
221
|
+
category: "sdk_not_generated",
|
|
222
|
+
message: "Typed SDK not generated (optional).",
|
|
223
|
+
fix: "Run `rebase generate-sdk` if you want typed collection access"
|
|
211
224
|
});
|
|
212
|
-
return { passed:
|
|
225
|
+
return { passed: true,
|
|
213
226
|
issues };
|
|
214
227
|
}
|
|
215
228
|
|
|
@@ -631,11 +644,15 @@ export function renderReport(report: DoctorReport): void {
|
|
|
631
644
|
}
|
|
632
645
|
|
|
633
646
|
function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): void {
|
|
634
|
-
|
|
647
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
648
|
+
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
649
|
+
const infoIssues = issues.filter((i) => i.severity === "info");
|
|
650
|
+
|
|
651
|
+
// Informational notes don't make a phase unhealthy, so key the header off
|
|
652
|
+
// real problems rather than `passed` alone.
|
|
653
|
+
if (errorCount === 0 && warnCount === 0) {
|
|
635
654
|
logger.info(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
|
|
636
655
|
} else {
|
|
637
|
-
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
638
|
-
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
639
656
|
const parts: string[] = [];
|
|
640
657
|
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
|
641
658
|
if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
|
|
@@ -643,7 +660,14 @@ function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): voi
|
|
|
643
660
|
}
|
|
644
661
|
logger.info("");
|
|
645
662
|
|
|
646
|
-
|
|
663
|
+
// Notes render as a quiet one-liner, not a full drift box.
|
|
664
|
+
for (const issue of infoIssues) {
|
|
665
|
+
const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
|
|
666
|
+
logger.info(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
|
|
667
|
+
logger.info("");
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
for (const issue of issues.filter((i) => i.severity !== "info")) {
|
|
647
671
|
const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
|
|
648
672
|
const categoryLabel = formatCategory(issue.category);
|
|
649
673
|
logger.info(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
|
|
@@ -674,7 +698,8 @@ function formatCategory(cat: DoctorIssue["category"]): string {
|
|
|
674
698
|
missing_enum: "Missing Enum",
|
|
675
699
|
enum_value_mismatch: "Enum Value Mismatch",
|
|
676
700
|
missing_foreign_key: "Missing Foreign Key",
|
|
677
|
-
sdk_stale: "Stale SDK Types"
|
|
701
|
+
sdk_stale: "Stale SDK Types",
|
|
702
|
+
sdk_not_generated: "SDK Types Not Generated"
|
|
678
703
|
};
|
|
679
704
|
return labels[cat];
|
|
680
705
|
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
|
|
2
2
|
import { getPrimaryKeys } from "../services/collection-helpers";
|
|
3
|
-
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
|
|
4
|
-
import { toSnakeCase } from "@rebasepro/utils";
|
|
5
|
-
import { createHash } from "crypto";
|
|
3
|
+
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
|
|
4
|
+
import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
|
|
6
5
|
import { logger } from "@rebasepro/server";
|
|
7
|
-
import { getEffectiveSecurityRules } from "./auth-default-policies";
|
|
8
6
|
// --- Helper Functions ---
|
|
9
7
|
|
|
10
8
|
/**
|
|
@@ -315,22 +313,6 @@ const wrapSql = (clause: string): string => `sql\`${clause}\``;
|
|
|
315
313
|
/**
|
|
316
314
|
* Generates a deterministic hash based on the rule configuration.
|
|
317
315
|
*/
|
|
318
|
-
const getPolicyNameHash = (rule: SecurityRule): string => {
|
|
319
|
-
const data = JSON.stringify({
|
|
320
|
-
a: rule.access,
|
|
321
|
-
m: rule.mode,
|
|
322
|
-
op: rule.operation,
|
|
323
|
-
ops: rule.operations?.slice().sort(),
|
|
324
|
-
own: rule.ownerField,
|
|
325
|
-
rol: rule.roles?.slice().sort(),
|
|
326
|
-
pg: rule.pgRoles?.slice().sort(),
|
|
327
|
-
u: rule.using,
|
|
328
|
-
w: rule.withCheck,
|
|
329
|
-
c: rule.condition,
|
|
330
|
-
ch: rule.check
|
|
331
|
-
});
|
|
332
|
-
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
333
|
-
};
|
|
334
316
|
|
|
335
317
|
/**
|
|
336
318
|
* Generates Drizzle pgPolicy() calls from a declarative SecurityRule definition.
|
|
@@ -351,15 +333,11 @@ const generatePolicyCode = (collection: CollectionConfig, rule: SecurityRule, in
|
|
|
351
333
|
? rule.operations
|
|
352
334
|
: [rule.operation ?? "all"];
|
|
353
335
|
|
|
354
|
-
const
|
|
336
|
+
const policyNames = getPolicyNamesForRule(rule, tableName);
|
|
355
337
|
|
|
356
338
|
// Generate one pgPolicy per operation
|
|
357
339
|
return ops.map((op, opIdx) => {
|
|
358
|
-
|
|
359
|
-
? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
|
|
360
|
-
: `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
|
|
361
|
-
|
|
362
|
-
return generateSinglePolicyCode(collection, rule, op, policyName, resolveCollection);
|
|
340
|
+
return generateSinglePolicyCode(collection, rule, op, policyNames[opIdx], resolveCollection);
|
|
363
341
|
}).join("");
|
|
364
342
|
};
|
|
365
343
|
|
|
@@ -567,6 +545,10 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
|
|
|
567
545
|
});
|
|
568
546
|
schemaContent += "\n";
|
|
569
547
|
|
|
548
|
+
// Junction policy derivation needs every declaring side of each junction,
|
|
549
|
+
// not just the first relation that reached it in the walk below.
|
|
550
|
+
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
551
|
+
|
|
570
552
|
// 2. Identify all tables (collections and junction tables only)
|
|
571
553
|
for (const collection of collections) {
|
|
572
554
|
const tableName = getTableName(collection);
|
|
@@ -623,9 +605,22 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
|
|
|
623
605
|
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
624
606
|
schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
|
|
625
607
|
schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
|
|
626
|
-
schemaContent += "}, (table) => (
|
|
627
|
-
schemaContent += `
|
|
628
|
-
|
|
608
|
+
schemaContent += "}, (table) => ([\n";
|
|
609
|
+
schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
|
|
610
|
+
|
|
611
|
+
// Junctions are generated tables like any other: locked by default,
|
|
612
|
+
// with derived policies (reads follow the endpoints, writes follow
|
|
613
|
+
// the declaring side's update rules). RLS is enabled regardless of
|
|
614
|
+
// policy stripping — a bare junction must default-deny, not fail open.
|
|
615
|
+
const junctionSpec = junctionSpecs.get(baseTableName);
|
|
616
|
+
if (!stripPolicies && junctionSpec) {
|
|
617
|
+
const junctionCollection = getJunctionCollectionConfig(junctionSpec);
|
|
618
|
+
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
619
|
+
getJunctionSecurityRules(junctionSpec).forEach((rule: SecurityRule, idx: number) => {
|
|
620
|
+
schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
schemaContent += "])).enableRLS();\n\n";
|
|
629
624
|
} else if (!isJunction) {
|
|
630
625
|
const schema = isPostgresCollectionConfig(collection) ? collection.schema : undefined;
|
|
631
626
|
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|