@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.1-canary.g18cfeb7
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 +49 -2
- package/dist/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-BbFOPJ1S.js → auth-users-columns-Do9mw5Y5.js} +602 -200
- package/dist/auth-users-columns-Do9mw5Y5.js.map +1 -0
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/backup-service-Bww-Lg0s.js +8999 -0
- package/dist/backup-service-Bww-Lg0s.js.map +1 -0
- package/dist/cli-helpers.d.ts +40 -1
- package/dist/connection-BuZ97wsr.js +250 -0
- package/dist/connection-BuZ97wsr.js.map +1 -0
- package/dist/connection.d.ts +42 -0
- package/dist/ensure-collection-policies-DMUdRQdM.js +57 -0
- package/dist/ensure-collection-policies-DMUdRQdM.js.map +1 -0
- package/dist/ensure-collection-tables-DSIxvLLD.js +694 -0
- package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
- package/dist/index.es.js +1701 -10167
- package/dist/index.es.js.map +1 -1
- package/dist/policy-CPkCqVTz.js +105 -0
- package/dist/policy-CPkCqVTz.js.map +1 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +83 -144
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +45 -3
- package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +137 -3
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/introspect-db-constraints.d.ts +57 -0
- package/dist/schema/introspect-db-logic.d.ts +94 -5
- package/dist/schema/introspect-db-queries.d.ts +119 -0
- package/dist/schema/introspect-db-structure.d.ts +263 -0
- package/dist/schema/introspect-db-types.d.ts +11 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/security/rls-enforcement.d.ts +53 -2
- package/dist/services/FetchService.d.ts +14 -8
- package/dist/services/RelationService.d.ts +24 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +36 -2
- package/dist/services/dataService.d.ts +5 -1
- package/dist/services/realtimeService.d.ts +25 -21
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +5 -4
- package/dist/utils/pg-error-utils.d.ts +35 -0
- package/dist/websocket-D0TBU3ia.js +530 -0
- package/dist/websocket-D0TBU3ia.js.map +1 -0
- package/package.json +17 -16
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +169 -3
- package/src/PostgresBootstrapper.ts +233 -35
- package/src/auth/ensure-tables.ts +335 -81
- package/src/auth/services.ts +24 -2
- package/src/backup/backup-cli.ts +41 -2
- package/src/backup/backup-service.ts +38 -5
- package/src/backup/pg-tools.ts +96 -3
- package/src/cli-helpers.ts +92 -9
- package/src/cli.ts +219 -56
- package/src/collections/validate-relations.ts +136 -14
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +22 -6
- package/src/databasePoolManager.ts +5 -2
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +30 -19
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor.ts +7 -5
- package/src/schema/ensure-collection-policies.ts +105 -0
- package/src/schema/ensure-collection-tables.test.ts +105 -9
- package/src/schema/ensure-collection-tables.ts +301 -44
- package/src/schema/generate-drizzle-schema-logic.ts +49 -7
- package/src/schema/generate-postgres-ddl-logic.ts +474 -27
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-constraints.ts +385 -0
- package/src/schema/introspect-db-inference.ts +18 -8
- package/src/schema/introspect-db-logic.ts +385 -71
- package/src/schema/introspect-db-queries.ts +326 -0
- package/src/schema/introspect-db-structure.ts +670 -0
- package/src/schema/introspect-db-types.ts +56 -0
- package/src/schema/introspect-db.ts +37 -80
- package/src/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +11 -3
- package/src/security/rls-enforcement.ts +141 -3
- package/src/services/BranchService.ts +71 -28
- package/src/services/FetchService.ts +100 -107
- package/src/services/PersistService.ts +34 -7
- package/src/services/RelationService.ts +249 -48
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/channel-history.ts +8 -0
- package/src/services/channel-presence.ts +6 -0
- package/src/services/collection-helpers.ts +69 -3
- package/src/services/dataService.ts +5 -1
- package/src/services/realtimeService.ts +38 -35
- package/src/services/row-pipeline.ts +1 -1
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +31 -6
- package/src/utils/pg-error-utils.ts +65 -0
- package/src/websocket.ts +18 -9
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
- package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-BbFOPJ1S.js.map +0 -1
- package/dist/src-Zqwaw3P5.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
package/src/connection.ts
CHANGED
|
@@ -21,10 +21,19 @@ export interface PostgresPoolConfig {
|
|
|
21
21
|
statementTimeout?: number;
|
|
22
22
|
/** Enable TCP keep-alive (default: true) */
|
|
23
23
|
keepAlive?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* `search_path` pinned on every connection (default: `"public"`).
|
|
26
|
+
*
|
|
27
|
+
* Pass `false` to send no `search_path` at all and inherit whatever the
|
|
28
|
+
* server/role defaults to. See {@link pinSearchPath} for why the default
|
|
29
|
+
* is not "inherit".
|
|
30
|
+
*/
|
|
31
|
+
searchPath?: string | false;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
const DEFAULT_POOL: Required<PostgresPoolConfig> = {
|
|
27
35
|
max: 20,
|
|
36
|
+
searchPath: "public",
|
|
28
37
|
idleTimeoutMillis: 30_000,
|
|
29
38
|
connectionTimeoutMillis: 10_000,
|
|
30
39
|
// The client-side read timeout MUST be comfortably above the server-side
|
|
@@ -43,6 +52,67 @@ const DEFAULT_POOL: Required<PostgresPoolConfig> = {
|
|
|
43
52
|
/** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
|
|
44
53
|
const TX_IDLE = "I";
|
|
45
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Pin `search_path` into a connection string, so unqualified SQL resolves to a
|
|
57
|
+
* schema this framework chose rather than to one Postgres inferred.
|
|
58
|
+
*
|
|
59
|
+
* Postgres defaults `search_path` to `"$user", public`: the *first* candidate
|
|
60
|
+
* is a schema named after the connecting role. Rebase creates a schema called
|
|
61
|
+
* `rebase` (auth, history, api keys), and every template, compose file and
|
|
62
|
+
* deployment doc names the database role `rebase` too — so `$user` resolves to
|
|
63
|
+
* a schema that exists, and every unqualified statement lands there instead of
|
|
64
|
+
* in `public`. The generated Drizzle schema emits bare `pgTable("posts", …)`
|
|
65
|
+
* for any collection without an explicit `schema`, which makes the *runtime's*
|
|
66
|
+
* own reads and writes unqualified; a developer's raw `rebase.sql(...)`, the
|
|
67
|
+
* Studio SQL editor and any hand-written migration are unqualified too. The
|
|
68
|
+
* result is collection tables created in, and served from, `rebase`.
|
|
69
|
+
*
|
|
70
|
+
* Drizzle cannot express the fix on its side: `pgSchema("public")` throws by
|
|
71
|
+
* design ("just use pgTable() instead"), so there is no way to emit a
|
|
72
|
+
* public-qualified table from the generator. The pin has to live on the
|
|
73
|
+
* connection.
|
|
74
|
+
*
|
|
75
|
+
* Precedence is deliberate and verified against node-postgres: `options` in
|
|
76
|
+
* the connection string wins over the `options` field passed to `Pool`, so
|
|
77
|
+
* rewriting the URL — rather than setting the field — is what makes this
|
|
78
|
+
* authoritative. Two escape hatches survive it:
|
|
79
|
+
*
|
|
80
|
+
* - an `options` that already mentions `search_path` is left untouched, so a
|
|
81
|
+
* deployment that deliberately pins something else keeps it;
|
|
82
|
+
* - `searchPath: false` (or an unparseable, non-URL connection string) sends
|
|
83
|
+
* nothing and inherits the server default.
|
|
84
|
+
*
|
|
85
|
+
* Anything else in `options` (a `statement_timeout`, say) is preserved and the
|
|
86
|
+
* `search_path` flag is appended to it.
|
|
87
|
+
*/
|
|
88
|
+
export function pinSearchPath(connectionString: string, searchPath: string | false = "public"): string {
|
|
89
|
+
if (searchPath === false) return connectionString;
|
|
90
|
+
|
|
91
|
+
let url: URL;
|
|
92
|
+
try {
|
|
93
|
+
url = new URL(connectionString);
|
|
94
|
+
} catch {
|
|
95
|
+
// Key/value DSNs and anything else we cannot parse are returned as
|
|
96
|
+
// given: a connection that works unpinned beats one we corrupted.
|
|
97
|
+
return connectionString;
|
|
98
|
+
}
|
|
99
|
+
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return connectionString;
|
|
100
|
+
|
|
101
|
+
const existing = url.searchParams.get("options");
|
|
102
|
+
if (existing && /(^|\s)-c\s*search_path\s*=/.test(existing)) return connectionString;
|
|
103
|
+
|
|
104
|
+
const flag = `-c search_path=${searchPath}`;
|
|
105
|
+
url.searchParams.set("options", existing ? `${existing} ${flag}` : flag);
|
|
106
|
+
// Re-serialize by hand. `URLSearchParams` writes a space as `+`, which
|
|
107
|
+
// node-postgres happens to decode but libpq does not — and this same string
|
|
108
|
+
// is handed to `pg_dump`/`psql` for backups. Percent-encoding is the form
|
|
109
|
+
// both agree on, and is what the scaffolded `.env` already ships.
|
|
110
|
+
url.search = Array.from(url.searchParams.entries())
|
|
111
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
112
|
+
.join("&");
|
|
113
|
+
return url.toString();
|
|
114
|
+
}
|
|
115
|
+
|
|
46
116
|
/**
|
|
47
117
|
* Destroy pool clients that are released while still inside a transaction.
|
|
48
118
|
*
|
|
@@ -108,6 +178,7 @@ export function createPostgresDatabaseConnection(
|
|
|
108
178
|
) {
|
|
109
179
|
const opts = { ...DEFAULT_POOL,
|
|
110
180
|
...poolConfig };
|
|
181
|
+
connectionString = pinSearchPath(connectionString, opts.searchPath);
|
|
111
182
|
|
|
112
183
|
const pgPoolConfig: PoolConfig = {
|
|
113
184
|
connectionString,
|
|
@@ -159,6 +230,7 @@ export function createDirectDatabaseConnection(
|
|
|
159
230
|
max: 5,
|
|
160
231
|
...poolConfig
|
|
161
232
|
};
|
|
233
|
+
connectionString = pinSearchPath(connectionString, opts.searchPath);
|
|
162
234
|
|
|
163
235
|
const pgPoolConfig: PoolConfig = {
|
|
164
236
|
connectionString,
|
|
@@ -199,6 +271,7 @@ export function createReadReplicaConnection(
|
|
|
199
271
|
max: 10,
|
|
200
272
|
...poolConfig
|
|
201
273
|
};
|
|
274
|
+
connectionString = pinSearchPath(connectionString, opts.searchPath);
|
|
202
275
|
|
|
203
276
|
const pgPoolConfig: PoolConfig = {
|
|
204
277
|
connectionString,
|
package/src/data-transformer.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
|
3
3
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
4
|
import { CollectionConfig, Properties, Property, ResolvedRelation, RelationProperty, Vector, BinaryProperty, hasForeignKeyOnTarget, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, type ResolvedVia } from "@rebasepro/types";
|
|
5
5
|
import { getTableName, resolveCollectionRelations, findRelation, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
|
|
6
|
+
import { isPrototypePollutingKey } from "@rebasepro/utils";
|
|
6
7
|
import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
|
|
7
8
|
import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
|
|
8
9
|
import { getPrimaryKeys, buildCompositeId } from "./services/collection-helpers";
|
|
@@ -68,9 +69,15 @@ export function sanitizeAndConvertDates(obj: unknown): unknown {
|
|
|
68
69
|
if (typeof obj === "object") {
|
|
69
70
|
const newObj: Record<string, unknown> = {};
|
|
70
71
|
for (const key in obj) {
|
|
71
|
-
if (Object.prototype.hasOwnProperty.call(obj, key))
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
|
73
|
+
// `JSON.parse` creates `__proto__` as an *own* property, so it gets
|
|
74
|
+
// past the check above — and `newObj[key] = …` then invokes the
|
|
75
|
+
// prototype setter instead of creating a property. The row's
|
|
76
|
+
// prototype becomes whatever the request body supplied, so
|
|
77
|
+
// `row.isAdmin` answers `true` while `Object.keys(row)` shows
|
|
78
|
+
// nothing of the sort. No column can be reached by these names.
|
|
79
|
+
if (isPrototypePollutingKey(key)) continue;
|
|
80
|
+
newObj[key] = sanitizeAndConvertDates((obj as Record<string, unknown>)[key]);
|
|
74
81
|
}
|
|
75
82
|
return newObj;
|
|
76
83
|
}
|
|
@@ -126,6 +133,9 @@ joinPathRelationUpdates: [] };
|
|
|
126
133
|
});
|
|
127
134
|
|
|
128
135
|
for (const [key, value] of Object.entries(row)) {
|
|
136
|
+
// Same reasoning as `sanitizeAndConvertDates`: these keys come from the
|
|
137
|
+
// request body, and no column answers to them.
|
|
138
|
+
if (isPrototypePollutingKey(key)) continue;
|
|
129
139
|
const property = properties[key as keyof M] as Property;
|
|
130
140
|
|
|
131
141
|
// Coerce empty strings to null for any field that acts as a foreign key
|
|
@@ -328,9 +338,15 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
328
338
|
const targetCollection = relation.target();
|
|
329
339
|
const targetTable = registry.getTable(getTableName(targetCollection));
|
|
330
340
|
const pks = getPrimaryKeys(collection, registry!);
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
341
|
+
// What the target's foreign key holds. Ordinarily this
|
|
342
|
+
// row's id; for a link on a natural key, the value of
|
|
343
|
+
// the column it points at — which is on the row already,
|
|
344
|
+
// so this costs nothing extra.
|
|
345
|
+
const currentId = relation.sourceKey
|
|
346
|
+
? (data as Record<string, unknown>)[relation.sourceKey] as string | number | undefined
|
|
347
|
+
: buildCompositeId(data, pks);
|
|
348
|
+
|
|
349
|
+
if (targetTable && currentId !== undefined && currentId !== null && currentId !== "") {
|
|
334
350
|
const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
|
|
335
351
|
if (foreignKeyColumn) {
|
|
336
352
|
// Query the target table to find row that references this row
|
|
@@ -2,7 +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
|
+
import { guardPoolAgainstDirtyRelease, pinSearchPath } from "./connection";
|
|
6
6
|
|
|
7
7
|
export class DatabasePoolManager {
|
|
8
8
|
private pools: Map<string, Pool> = new Map();
|
|
@@ -41,7 +41,10 @@ export class DatabasePoolManager {
|
|
|
41
41
|
url.pathname = `/${databaseName}`;
|
|
42
42
|
|
|
43
43
|
const pool = new Pool({
|
|
44
|
-
|
|
44
|
+
// Same pin as the primary pool: these are branch/multi-database
|
|
45
|
+
// connections to the *same* server, so they inherit the same
|
|
46
|
+
// `"$user"` hazard. See `pinSearchPath`.
|
|
47
|
+
connectionString: pinSearchPath(url.toString()),
|
|
45
48
|
max: 10, // Default sensible limit, can be tuned later
|
|
46
49
|
idleTimeoutMillis: 10000, // Reduced from 30000 for aggressive cleanup
|
|
47
50
|
allowExitOnIdle: true // Prevent idle clients from hanging the Node.js process
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
2
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { logger } from "@rebasepro/server";
|
|
4
|
+
import { revokeInternalTableSql } from "@rebasepro/common";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Auto-create the row history table if it doesn't exist.
|
|
@@ -38,6 +39,12 @@ export async function ensureHistoryTableExists(db: NodePgDatabase): Promise<void
|
|
|
38
39
|
ON rebase.entity_history(table_name, entity_id, updated_at DESC)
|
|
39
40
|
`);
|
|
40
41
|
|
|
42
|
+
// Every previous value of every audited row, in one table with no RLS
|
|
43
|
+
// and no tenant scoping — so a readable copy defeats the row policies on
|
|
44
|
+
// the tables it shadows. The driver's schema-wide grant reaches it
|
|
45
|
+
// (created here, after that grant ran), so take it back.
|
|
46
|
+
await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
|
|
47
|
+
|
|
41
48
|
logger.info("✅ Entity history table ready");
|
|
42
49
|
} catch (error) {
|
|
43
50
|
logger.error("❌ Failed to create row history table", { error: error });
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { pgSchema, pgTable,
|
|
1
|
+
import { pgSchema, pgTable, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
|
|
2
2
|
import { relations } from "drizzle-orm";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Factory function to dynamically create the auth tables bound to the specified schema names.
|
|
6
|
+
*
|
|
7
|
+
* This module builds queries; it does not create tables. `ensureAuthTablesExist`
|
|
8
|
+
* owns the DDL, which makes everything here a *claim* about a database it cannot
|
|
9
|
+
* enforce — and the claims drifted. Every column below was declared
|
|
10
|
+
* `varchar(n)` while the DDL created it as `TEXT`: `user_agent` as varchar(500),
|
|
11
|
+
* `ip_address` as varchar(45), `secret_encrypted` as varchar(500), every
|
|
12
|
+
* `token_hash` as varchar(255). None of it was true of any database this
|
|
13
|
+
* framework ever provisioned. Harmless at runtime — drizzle does not enforce a
|
|
14
|
+
* length client-side, so the widths only ever misled the next reader — but a
|
|
15
|
+
* schema module that describes columns that do not exist is worse than no
|
|
16
|
+
* schema module. They are `text` here now because they are TEXT there.
|
|
6
17
|
*/
|
|
7
18
|
export function createAuthSchema(usersSchemaName = "rebase") {
|
|
8
19
|
const usersSchema = usersSchemaName === "public" ? null : pgSchema(usersSchemaName);
|
|
@@ -15,12 +26,12 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
15
26
|
*/
|
|
16
27
|
const users = usersTableCreator("users", {
|
|
17
28
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
18
|
-
email:
|
|
19
|
-
passwordHash:
|
|
20
|
-
displayName:
|
|
21
|
-
photoUrl:
|
|
29
|
+
email: text("email").notNull().unique(),
|
|
30
|
+
passwordHash: text("password_hash"), // NULL for OAuth-only users
|
|
31
|
+
displayName: text("display_name"),
|
|
32
|
+
photoUrl: text("photo_url"),
|
|
22
33
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
|
23
|
-
emailVerificationToken:
|
|
34
|
+
emailVerificationToken: text("email_verification_token"),
|
|
24
35
|
emailVerificationSentAt: timestamp("email_verification_sent_at"),
|
|
25
36
|
isAnonymous: boolean("is_anonymous").default(false).notNull(),
|
|
26
37
|
roles: text("roles").array().default([]).notNull(),
|
|
@@ -65,7 +76,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
65
76
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
66
77
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
67
78
|
sessionId: uuid("session_id").defaultRandom().notNull(),
|
|
68
|
-
tokenHash:
|
|
79
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
69
80
|
expiresAt: timestamp("expires_at").notNull(),
|
|
70
81
|
revoked: boolean("revoked").default(false).notNull(),
|
|
71
82
|
rotatedAt: timestamp("rotated_at"),
|
|
@@ -76,8 +87,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
76
87
|
* that rotates immediately after it.
|
|
77
88
|
*/
|
|
78
89
|
sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
|
|
79
|
-
userAgent:
|
|
80
|
-
ipAddress:
|
|
90
|
+
userAgent: text("user_agent"),
|
|
91
|
+
ipAddress: text("ip_address"),
|
|
81
92
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
82
93
|
}, (table) => ({
|
|
83
94
|
sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId)
|
|
@@ -89,7 +100,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
89
100
|
const passwordResetTokens = tableCreator("password_reset_tokens", {
|
|
90
101
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
91
102
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
92
|
-
tokenHash:
|
|
103
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
93
104
|
expiresAt: timestamp("expires_at").notNull(),
|
|
94
105
|
usedAt: timestamp("used_at"),
|
|
95
106
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
@@ -99,7 +110,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
99
110
|
* App config - key/value store for custom settings
|
|
100
111
|
*/
|
|
101
112
|
const appConfig = tableCreator("app_config", {
|
|
102
|
-
key:
|
|
113
|
+
key: text("key").primaryKey(),
|
|
103
114
|
value: jsonb("value").notNull(),
|
|
104
115
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
105
116
|
});
|
|
@@ -110,8 +121,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
110
121
|
const userIdentities = tableCreator("user_identities", {
|
|
111
122
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
112
123
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
113
|
-
provider:
|
|
114
|
-
providerId:
|
|
124
|
+
provider: text("provider").notNull(), // e.g. 'google', 'linkedin'
|
|
125
|
+
providerId: text("provider_id").notNull(),
|
|
115
126
|
profileData: jsonb("profile_data"),
|
|
116
127
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
117
128
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
@@ -125,9 +136,9 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
125
136
|
const mfaFactors = tableCreator("mfa_factors", {
|
|
126
137
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
127
138
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
128
|
-
factorType:
|
|
129
|
-
secretEncrypted:
|
|
130
|
-
friendlyName:
|
|
139
|
+
factorType: text("factor_type").notNull(), // 'totp'
|
|
140
|
+
secretEncrypted: text("secret_encrypted").notNull(),
|
|
141
|
+
friendlyName: text("friendly_name"),
|
|
131
142
|
verified: boolean("verified").default(false).notNull(),
|
|
132
143
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
133
144
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
@@ -141,7 +152,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
141
152
|
factorId: uuid("factor_id").notNull().references(() => mfaFactors.id, { onDelete: "cascade" }),
|
|
142
153
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
143
154
|
verifiedAt: timestamp("verified_at"),
|
|
144
|
-
ipAddress:
|
|
155
|
+
ipAddress: text("ip_address"),
|
|
145
156
|
expiresAt: timestamp("expires_at").notNull()
|
|
146
157
|
});
|
|
147
158
|
|
|
@@ -151,7 +162,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
151
162
|
const recoveryCodes = tableCreator("recovery_codes", {
|
|
152
163
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
153
164
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
154
|
-
codeHash:
|
|
165
|
+
codeHash: text("code_hash").notNull(),
|
|
155
166
|
usedAt: timestamp("used_at"),
|
|
156
167
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
157
168
|
});
|
|
@@ -162,7 +173,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
162
173
|
const magicLinkTokens = tableCreator("magic_link_tokens", {
|
|
163
174
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
164
175
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
165
|
-
tokenHash:
|
|
176
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
166
177
|
expiresAt: timestamp("expires_at").notNull(),
|
|
167
178
|
usedAt: timestamp("used_at"),
|
|
168
179
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one description of what an auth user table's columns must be.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Three different code paths create `rebase.users`, and before this module they
|
|
7
|
+
* disagreed about it:
|
|
8
|
+
*
|
|
9
|
+
* | column | `db push` (generator) | `ensureAuthTablesExist` | `ensureCollectionTables` |
|
|
10
|
+
* |------------------|-----------------------|-------------------------|--------------------------|
|
|
11
|
+
* | `email` | `TEXT UNIQUE NOT NULL`| `TEXT NOT NULL` + CHECK | `TEXT` (nullable) |
|
|
12
|
+
* | `roles` | `TEXT[]` | `NOT NULL DEFAULT '{}'` | nullable, no default |
|
|
13
|
+
* | `email_verified` | `BOOLEAN` | `NOT NULL DEFAULT FALSE`| nullable |
|
|
14
|
+
* | `created_at` | `DEFAULT now()` | `NOT NULL DEFAULT NOW()`| no default |
|
|
15
|
+
*
|
|
16
|
+
* They are all `CREATE TABLE IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`, so
|
|
17
|
+
* whichever ran first decided the table and the other two silently no-op'd.
|
|
18
|
+
* Boot order therefore chose the constraints: a managed deploy (collection
|
|
19
|
+
* tables first) got a users table whose `email` was nullable and whose
|
|
20
|
+
* `email_verified` had no default, while the same project pushed from a
|
|
21
|
+
* checkout got the strict one. In the same table, `is_anonymous` came out
|
|
22
|
+
* `NOT NULL DEFAULT false` — because *that* column existed in only one of the
|
|
23
|
+
* three lists, so its owner's definition won by default.
|
|
24
|
+
*
|
|
25
|
+
* A second consequence was drift in the other direction. The scaffold's
|
|
26
|
+
* `users.ts` describes 12 columns; auth needs 14. `db push` is declarative and
|
|
27
|
+
* builds its desired state from the collection alone, so the two columns only
|
|
28
|
+
* auth knows about (`is_anonymous`, `tokens_valid_after`) read as unmanaged
|
|
29
|
+
* drift, and a push run after the server had booted once planned to DROP them.
|
|
30
|
+
*
|
|
31
|
+
* Both problems are the same problem: no single place said what this table is.
|
|
32
|
+
* This is that place. Every creator asks here first and only then falls back to
|
|
33
|
+
* the collection's own property definitions, so a column auth owns has one
|
|
34
|
+
* definition regardless of who gets there first, and a column the developer
|
|
35
|
+
* added to their users collection still behaves like any other field.
|
|
36
|
+
*
|
|
37
|
+
* ## What belongs here
|
|
38
|
+
*
|
|
39
|
+
* Only columns the auth services read or write. A developer's own additions to
|
|
40
|
+
* their users collection (`bio`, `stripe_customer_id`) are ordinary columns and
|
|
41
|
+
* must NOT be listed — they are generated from the collection like every other
|
|
42
|
+
* field, and listing them here would freeze a user's schema.
|
|
43
|
+
*
|
|
44
|
+
* `id` is deliberately absent: its type comes from the collection's id property
|
|
45
|
+
* (uuid / increment / text) and each creator already derives it.
|
|
46
|
+
*
|
|
47
|
+
* Keep in step with {@link AUTH_SCHEMA_VERSION} when a change here makes an
|
|
48
|
+
* older runtime unable to work against a migrated table.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A column auth owns.
|
|
53
|
+
*
|
|
54
|
+
* Structured rather than one SQL string because the same facts are needed in
|
|
55
|
+
* three grammars — a `CREATE TABLE` column list, an `ADD COLUMN IF NOT EXISTS`,
|
|
56
|
+
* and a pair of `ALTER COLUMN … SET DEFAULT` / `SET NOT NULL` reconciles for a
|
|
57
|
+
* table that already exists with the wrong shape. Parsing a string back apart
|
|
58
|
+
* for the third of those is how the copies drifted in the first place.
|
|
59
|
+
*/
|
|
60
|
+
export interface AuthUsersColumn {
|
|
61
|
+
/** Physical column name. */
|
|
62
|
+
column: string;
|
|
63
|
+
/** Postgres type. */
|
|
64
|
+
type: string;
|
|
65
|
+
/** Default expression, verbatim, or absent for no default. */
|
|
66
|
+
default?: string;
|
|
67
|
+
/** Whether the column is NOT NULL. */
|
|
68
|
+
notNull?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
|
|
73
|
+
* — it synthesizes `anon_<32 hex>@anonymous.local` rather than inserting NULL.
|
|
74
|
+
* The 320-char bound (RFC 5321) is a CHECK rather than a `VARCHAR(n)`, added
|
|
75
|
+
* separately by `ensureAuthTablesExist` so it can be `NOT VALID` on an adopted
|
|
76
|
+
* table that already holds a longer row.
|
|
77
|
+
*/
|
|
78
|
+
export const AUTH_USERS_COLUMNS: readonly AuthUsersColumn[] = [
|
|
79
|
+
{ column: "email", type: "TEXT", notNull: true },
|
|
80
|
+
{ column: "display_name", type: "TEXT" },
|
|
81
|
+
{ column: "photo_url", type: "TEXT" },
|
|
82
|
+
{ column: "roles", type: "TEXT[]", default: "'{}'", notNull: true },
|
|
83
|
+
{ column: "password_hash", type: "TEXT" },
|
|
84
|
+
{ column: "email_verified", type: "BOOLEAN", default: "FALSE", notNull: true },
|
|
85
|
+
{ column: "email_verification_token", type: "TEXT" },
|
|
86
|
+
{ column: "email_verification_sent_at", type: "TIMESTAMP WITH TIME ZONE" },
|
|
87
|
+
{ column: "is_anonymous", type: "BOOLEAN", default: "FALSE", notNull: true },
|
|
88
|
+
{ column: "metadata", type: "JSONB", default: "'{}'", notNull: true },
|
|
89
|
+
{ column: "tokens_valid_after", type: "TIMESTAMP WITH TIME ZONE" },
|
|
90
|
+
{ column: "created_at", type: "TIMESTAMP WITH TIME ZONE", default: "NOW()", notNull: true },
|
|
91
|
+
{ column: "updated_at", type: "TIMESTAMP WITH TIME ZONE", default: "NOW()", notNull: true }
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const BY_COLUMN = new Map(AUTH_USERS_COLUMNS.map(c => [c.column, c]));
|
|
95
|
+
|
|
96
|
+
/** Type + inline constraints, as they appear after the column name. */
|
|
97
|
+
export function authUsersColumnSql(spec: AuthUsersColumn): string {
|
|
98
|
+
return [
|
|
99
|
+
spec.type,
|
|
100
|
+
spec.default !== undefined ? `DEFAULT ${spec.default}` : "",
|
|
101
|
+
spec.notNull ? "NOT NULL" : ""
|
|
102
|
+
].filter(Boolean).join(" ");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The auth-owned definition for a physical column name, or `undefined` when
|
|
107
|
+
* auth does not own it.
|
|
108
|
+
*
|
|
109
|
+
* Callers pass the RESOLVED column name (after `columnName` mapping), because
|
|
110
|
+
* that is the only name the three creators agree on: the scaffold's users
|
|
111
|
+
* collection spells the property `displayName` and the column `display_name`.
|
|
112
|
+
*/
|
|
113
|
+
export function authUsersColumnDefinition(column: string): string | undefined {
|
|
114
|
+
const spec = BY_COLUMN.get(column);
|
|
115
|
+
return spec ? authUsersColumnSql(spec) : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether a collection is an auth collection, i.e. whether the definitions in
|
|
120
|
+
* this module apply to its table at all.
|
|
121
|
+
*
|
|
122
|
+
* Duplicated in shape from `@rebasepro/common`'s policy defaults on purpose:
|
|
123
|
+
* that one takes a `CollectionConfig`, this one is called from DDL code paths
|
|
124
|
+
* that hold looser objects, and both spellings must accept `auth: true` as well
|
|
125
|
+
* as `auth: { enabled: true }`.
|
|
126
|
+
*/
|
|
127
|
+
export function isAuthCollection(collection: unknown): boolean {
|
|
128
|
+
const auth = (collection as { auth?: unknown } | undefined)?.auth;
|
|
129
|
+
if (auth === true) return true;
|
|
130
|
+
return typeof auth === "object" && auth !== null && (auth as { enabled?: unknown }).enabled === true;
|
|
131
|
+
}
|
package/src/schema/doctor.ts
CHANGED
|
@@ -15,7 +15,7 @@ import chalk from "chalk";
|
|
|
15
15
|
import { CollectionConfig, isPostgresCollectionConfig, Property, NumberProperty, StringProperty, DateProperty, ArrayProperty, MapProperty, RelationProperty, type ResolvedManyToMany, type ResolvedBelongsTo, isManyToMany } from "@rebasepro/types";
|
|
16
16
|
import { generateSchema } from "./generate-drizzle-schema-logic";
|
|
17
17
|
import { generateTypedefs } from "@rebasepro/codegen";
|
|
18
|
-
import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
18
|
+
import { getTableName, resolveCollectionRelations, findRelation, relationalCollections } from "@rebasepro/common";
|
|
19
19
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
20
20
|
import { logger, loadCollectionsFromDirectory } from "@rebasepro/server";
|
|
21
21
|
|
|
@@ -164,8 +164,10 @@ export async function checkCollectionsVsSchema(
|
|
|
164
164
|
issues };
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
// Re-generate schema in-memory and compare with file on disk
|
|
168
|
-
|
|
167
|
+
// Re-generate schema in-memory and compare with file on disk. Only the
|
|
168
|
+
// collections the generator itself will emit — a Firestore collection has
|
|
169
|
+
// no table in the generated file and must not be reported as missing one.
|
|
170
|
+
const postgresCollections = relationalCollections(collections);
|
|
169
171
|
if (postgresCollections.length === 0) {
|
|
170
172
|
return { passed: true,
|
|
171
173
|
issues };
|
|
@@ -292,7 +294,7 @@ export async function checkCollectionsVsDatabase(
|
|
|
292
294
|
const schemas = Array.from(new Set([
|
|
293
295
|
"public",
|
|
294
296
|
"rebase",
|
|
295
|
-
...collections
|
|
297
|
+
...relationalCollections(collections)
|
|
296
298
|
.filter(isPostgresCollectionConfig)
|
|
297
299
|
.map(c => c.schema)
|
|
298
300
|
.filter((s): s is string => !!s)
|
|
@@ -383,7 +385,7 @@ export async function checkCollectionsVsDatabase(
|
|
|
383
385
|
|
|
384
386
|
// ── Compare each collection against the database ─────────────────
|
|
385
387
|
|
|
386
|
-
const postgresCollections = collections
|
|
388
|
+
const postgresCollections = relationalCollections(collections);
|
|
387
389
|
|
|
388
390
|
for (const collection of postgresCollections) {
|
|
389
391
|
const tableName = getTableName(collection);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applying a bundle's RLS policies to a database at boot, idempotently.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* {@link ensureCollectionTables} creates the collection *tables* a managed
|
|
7
|
+
* runtime boots against, but a table with row-level security disabled and no
|
|
8
|
+
* policies is not servable: authenticated requests run as the restricted
|
|
9
|
+
* `rebase_user` role, so a read with no `SELECT` policy returns nothing (a
|
|
10
|
+
* public collection answered 401) and a write with no `INSERT`/`UPDATE` policy
|
|
11
|
+
* is denied. The policies live in the collections' `securityRules`; nothing at
|
|
12
|
+
* boot applied them. `rebase db push` does — but it drives Atlas against a
|
|
13
|
+
* local `DATABASE_URL`, and a managed tenant's database is reachable only from
|
|
14
|
+
* inside the cluster, by the runtime that is already connected to it. So the
|
|
15
|
+
* runtime is the only thing that *can* apply them, and this is where it does.
|
|
16
|
+
*
|
|
17
|
+
* ## Why this is safe to run on every boot
|
|
18
|
+
*
|
|
19
|
+
* Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once
|
|
20
|
+
* enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by
|
|
21
|
+
* a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds
|
|
22
|
+
* and replaces; it never drops data. (It does not *reconcile* — a policy a
|
|
23
|
+
* previous push left behind under an old name is not removed here; that stays a
|
|
24
|
+
* `db push` / `db migrate` concern, alongside destructive schema changes.)
|
|
25
|
+
*
|
|
26
|
+
* Unlike table creation, a failure here is not fatal: RLS stays enabled, so a
|
|
27
|
+
* table whose policies could not be applied fails **closed** (denies) rather
|
|
28
|
+
* than leaking rows. One collection's policy failing (e.g. a rule that
|
|
29
|
+
* references a table a real migration has not created yet) must not crash-loop
|
|
30
|
+
* the whole deployment and take the other collections' working routes down with
|
|
31
|
+
* it. Failures are reported loudly and per-table so the operator can see
|
|
32
|
+
* exactly which collection is not yet servable and why.
|
|
33
|
+
*/
|
|
34
|
+
import { type CollectionConfig } from "@rebasepro/types";
|
|
35
|
+
import { planCollectionPolicies } from "./generate-postgres-ddl-logic";
|
|
36
|
+
import { readExistingSchema, type Queryable } from "./ensure-collection-tables";
|
|
37
|
+
|
|
38
|
+
export interface PolicyEnsureResult {
|
|
39
|
+
/** `CREATE POLICY` statements that ran successfully. */
|
|
40
|
+
policiesApplied: number;
|
|
41
|
+
/** Tables that had RLS enabled. */
|
|
42
|
+
tablesSecured: number;
|
|
43
|
+
/** Declared tables absent from the database — left to a real migration. */
|
|
44
|
+
skipped: { table: string; reason: string }[];
|
|
45
|
+
/** Tables whose RLS could not be fully applied (fail closed). */
|
|
46
|
+
failures: { table: string; error: string }[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test(statement);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Bring the declared collections' RLS policies up to date. Returns what it did.
|
|
53
|
+
*
|
|
54
|
+
* Only tables that already exist are touched: the boot-time table creator runs
|
|
55
|
+
* first, so anything still missing is a table this additive path is not allowed
|
|
56
|
+
* to create (a junction, or a relation left to a migration). Enabling RLS on a
|
|
57
|
+
* non-existent table would error, so those are recorded as skipped, not failed.
|
|
58
|
+
*/
|
|
59
|
+
export async function ensureCollectionPolicies(
|
|
60
|
+
client: Queryable,
|
|
61
|
+
collections: CollectionConfig[],
|
|
62
|
+
log?: (message: string) => void
|
|
63
|
+
): Promise<PolicyEnsureResult> {
|
|
64
|
+
const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [] };
|
|
65
|
+
|
|
66
|
+
const plans = planCollectionPolicies(collections);
|
|
67
|
+
if (plans.length === 0) return result;
|
|
68
|
+
|
|
69
|
+
const schemas = Array.from(new Set(plans.map(p => p.schema)));
|
|
70
|
+
const existing = await readExistingSchema(client, schemas);
|
|
71
|
+
|
|
72
|
+
for (const plan of plans) {
|
|
73
|
+
if (!existing.tables.has(plan.qualified)) {
|
|
74
|
+
result.skipped.push({
|
|
75
|
+
table: plan.qualified,
|
|
76
|
+
reason: "table is not present in the database; create it with `rebase db push` / `rebase db migrate`"
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Enable first: if a later policy statement fails, the table is left
|
|
83
|
+
// locked (deny-all for the user role) rather than open.
|
|
84
|
+
await client.query(plan.enableRls);
|
|
85
|
+
result.tablesSecured++;
|
|
86
|
+
|
|
87
|
+
let created = 0;
|
|
88
|
+
for (const statement of plan.policyStatements) {
|
|
89
|
+
await client.query(statement);
|
|
90
|
+
if (isCreatePolicy(statement)) {
|
|
91
|
+
result.policiesApplied++;
|
|
92
|
+
created++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied`);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
result.failures.push({
|
|
98
|
+
table: plan.qualified,
|
|
99
|
+
error: err instanceof Error ? err.message : String(err)
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
}
|