@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
|
@@ -1,7 +1,10 @@
|
|
|
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 { revokeInternalTableAccess } from "@rebasepro/common";
|
|
4
5
|
import type { CollectionConfig } from "@rebasepro/types";
|
|
6
|
+
import { AUTH_USERS_COLUMNS, authUsersColumnSql } from "../schema/auth-users-columns";
|
|
7
|
+
import { RLS_BOOTSTRAP_STATEMENTS } from "../schema/rls-bootstrap-sql";
|
|
5
8
|
import {
|
|
6
9
|
AuthSchemaVersionError,
|
|
7
10
|
assertAuthSchemaCompatible,
|
|
@@ -44,13 +47,23 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
44
47
|
? `"${resolvedTable}"`
|
|
45
48
|
: `"${usersSchema}"."${resolvedTable}"`;
|
|
46
49
|
|
|
47
|
-
// Derive ID column type from collection properties
|
|
50
|
+
// Derive ID column type from collection properties.
|
|
51
|
+
//
|
|
52
|
+
// `"increment"`, not `"autoincrement"`. The latter was tested for
|
|
53
|
+
// here and exists nowhere in the type system — the union is
|
|
54
|
+
// `boolean | "manual" | "increment" | string` — so the INTEGER branch
|
|
55
|
+
// was unreachable and an integer-keyed auth collection fell through
|
|
56
|
+
// to TEXT. Introspection below hid it whenever the table already
|
|
57
|
+
// existed; on a database where it did not, this created
|
|
58
|
+
// `id TEXT DEFAULT gen_random_uuid()::text` for a collection that
|
|
59
|
+
// declares a number, and every `uid` foreign key was typed to match
|
|
60
|
+
// the wrong thing.
|
|
48
61
|
const idProp = collection.properties?.id;
|
|
49
62
|
if (idProp) {
|
|
50
63
|
const isId = ("isId" in idProp) ? (idProp as unknown as Record<string, unknown>).isId : undefined;
|
|
51
64
|
if (isId === "uuid") {
|
|
52
65
|
userIdType = "UUID";
|
|
53
|
-
} else if (isId === "
|
|
66
|
+
} else if (isId === "increment") {
|
|
54
67
|
userIdType = "INTEGER";
|
|
55
68
|
}
|
|
56
69
|
// Otherwise keep TEXT as default
|
|
@@ -107,22 +120,46 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
107
120
|
? "GENERATED ALWAYS AS IDENTITY"
|
|
108
121
|
: "DEFAULT gen_random_uuid()::text";
|
|
109
122
|
|
|
123
|
+
// Identifiers for the constraint and indexes reconciled further down.
|
|
124
|
+
// Derived from the resolved table name so two auth tables in different
|
|
125
|
+
// schemas cannot collide, and truncated to Postgres's 63-byte identifier
|
|
126
|
+
// limit here rather than letting the server truncate silently — the
|
|
127
|
+
// `IF NOT EXISTS` guards below have to compare against the same name
|
|
128
|
+
// Postgres actually stored, or they re-run forever.
|
|
129
|
+
const authIdentifier = (suffix: string) => `${resolvedTable}_${suffix}`.slice(0, 63);
|
|
130
|
+
const emailLengthConstraint = `"${authIdentifier("email_length_check")}"`;
|
|
131
|
+
const emailLowerUniqueIndex = authIdentifier("email_lower_key");
|
|
132
|
+
const verificationTokenIndex = authIdentifier("email_verification_token_idx");
|
|
133
|
+
|
|
134
|
+
// Every string column here is TEXT, deliberately. In Postgres VARCHAR(n)
|
|
135
|
+
// and TEXT are the same type with the same storage and the same
|
|
136
|
+
// performance; the only difference is a length check, and none of these
|
|
137
|
+
// columns wants one. The widths this table used to carry were inherited
|
|
138
|
+
// MySQL habit (255) and they were all wrong in the same direction —
|
|
139
|
+
// `password_hash VARCHAR(255)` against a 193-char scrypt string left 62
|
|
140
|
+
// characters of headroom in front of a KEY_LENGTH constant living in
|
|
141
|
+
// another package, and `photo_url VARCHAR(500)` rejected the `data:` URIs
|
|
142
|
+
// and long signed URLs that OAuth providers hand back. A limit worth
|
|
143
|
+
// having is a CHECK — alterable without a table rewrite, unlike a type
|
|
144
|
+
// modifier — which is why `email` has one and nothing else does.
|
|
145
|
+
//
|
|
146
|
+
// The column list comes from AUTH_USERS_COLUMNS rather than being spelled
|
|
147
|
+
// out here, because this is not the only place that creates this table:
|
|
148
|
+
// `db push` and the boot-time collection ensure do too, and when the
|
|
149
|
+
// three lists were maintained separately they disagreed and boot order
|
|
150
|
+
// silently decided which shape the database got. The `email` CHECK is
|
|
151
|
+
// appended rather than listed there — it is a named constraint the
|
|
152
|
+
// migration below has to be able to add separately, `NOT VALID`, to a
|
|
153
|
+
// table that already holds rows.
|
|
154
|
+
const usersColumnDdl = AUTH_USERS_COLUMNS
|
|
155
|
+
.map((spec) => spec.column === "email"
|
|
156
|
+
? `${spec.column} ${authUsersColumnSql(spec)} CONSTRAINT ${emailLengthConstraint} CHECK (length(email) <= 320)`
|
|
157
|
+
: `${spec.column} ${authUsersColumnSql(spec)}`)
|
|
158
|
+
.join(",\n ");
|
|
110
159
|
await db.execute(sql`
|
|
111
160
|
CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
|
|
112
161
|
id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
|
|
113
|
-
|
|
114
|
-
display_name VARCHAR(255),
|
|
115
|
-
photo_url VARCHAR(500),
|
|
116
|
-
roles TEXT[] DEFAULT '{}' NOT NULL,
|
|
117
|
-
password_hash VARCHAR(255),
|
|
118
|
-
email_verified BOOLEAN DEFAULT FALSE NOT NULL,
|
|
119
|
-
email_verification_token VARCHAR(255),
|
|
120
|
-
email_verification_sent_at TIMESTAMP WITH TIME ZONE,
|
|
121
|
-
is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
|
|
122
|
-
metadata JSONB DEFAULT '{}' NOT NULL,
|
|
123
|
-
tokens_valid_after TIMESTAMP WITH TIME ZONE,
|
|
124
|
-
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
125
|
-
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
162
|
+
${sql.raw(usersColumnDdl)}
|
|
126
163
|
)
|
|
127
164
|
`);
|
|
128
165
|
|
|
@@ -142,26 +179,51 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
142
179
|
// column once no old backend remains (phase 2, contract).
|
|
143
180
|
//
|
|
144
181
|
// Idempotent throughout: every step is guarded on catalogue state.
|
|
145
|
-
|
|
146
|
-
CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
|
|
147
|
-
BEGIN
|
|
148
|
-
IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
|
|
149
|
-
NEW.uid := NEW.user_id;
|
|
150
|
-
ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
|
|
151
|
-
NEW.user_id := NEW.uid;
|
|
152
|
-
END IF;
|
|
153
|
-
RETURN NEW;
|
|
154
|
-
END $$ LANGUAGE plpgsql
|
|
155
|
-
`);
|
|
156
|
-
|
|
157
|
-
for (const authTable of [
|
|
182
|
+
const legacyFkTables = [
|
|
158
183
|
"user_identities",
|
|
159
184
|
"refresh_tokens",
|
|
160
185
|
"password_reset_tokens",
|
|
161
186
|
"magic_link_tokens",
|
|
162
187
|
"mfa_factors",
|
|
163
188
|
"recovery_codes"
|
|
164
|
-
]
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
// Only on a database that actually carries the legacy column. This whole
|
|
192
|
+
// block is a 0.x compatibility shim, and it used to run unconditionally —
|
|
193
|
+
// so every brand-new database was provisioned with a trigger function
|
|
194
|
+
// written to reconcile a column it can never have, permanently, as part
|
|
195
|
+
// of its first boot. A fresh install should not ship someone else's
|
|
196
|
+
// migration history.
|
|
197
|
+
// The table list is inlined rather than bound: drizzle expands a JS
|
|
198
|
+
// array into a parameter TUPLE — `ANY(($2, $3, …))` — which Postgres
|
|
199
|
+
// rejects, and the thrown error is swallowed by the catch around this
|
|
200
|
+
// whole function, so auth would silently stop provisioning. These are
|
|
201
|
+
// module-level constants, not input.
|
|
202
|
+
const legacyFkTableList = legacyFkTables.map(t => `'${t}'`).join(", ");
|
|
203
|
+
const legacyUserIdPresent = await db.execute(sql`
|
|
204
|
+
SELECT 1
|
|
205
|
+
FROM information_schema.columns
|
|
206
|
+
WHERE table_schema = ${authSchema}
|
|
207
|
+
AND table_name IN (${sql.raw(legacyFkTableList)})
|
|
208
|
+
AND column_name = 'user_id'
|
|
209
|
+
LIMIT 1
|
|
210
|
+
`);
|
|
211
|
+
|
|
212
|
+
if (legacyUserIdPresent.rows.length > 0) {
|
|
213
|
+
await db.execute(sql`
|
|
214
|
+
CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
|
|
215
|
+
BEGIN
|
|
216
|
+
IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
|
|
217
|
+
NEW.uid := NEW.user_id;
|
|
218
|
+
ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
|
|
219
|
+
NEW.user_id := NEW.uid;
|
|
220
|
+
END IF;
|
|
221
|
+
RETURN NEW;
|
|
222
|
+
END $$ LANGUAGE plpgsql
|
|
223
|
+
`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const authTable of legacyUserIdPresent.rows.length > 0 ? legacyFkTables : []) {
|
|
165
227
|
const qualified = `"${authSchema}"."${authTable}"`;
|
|
166
228
|
await db.execute(sql`
|
|
167
229
|
DO $$
|
|
@@ -313,39 +375,21 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
313
375
|
)
|
|
314
376
|
`);
|
|
315
377
|
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
//
|
|
378
|
+
// The RLS helper functions every generated policy calls. They live in
|
|
379
|
+
// `rebase`, alongside the tables above — Rebase creates exactly one
|
|
380
|
+
// schema in a user's database. Advisory-locked so concurrent HMR
|
|
381
|
+
// reloads cannot race on `CREATE OR REPLACE`.
|
|
382
|
+
//
|
|
383
|
+
// The same statements the migration preamble carries, from the same
|
|
384
|
+
// constant — these definitions being identical across the boot path and
|
|
385
|
+
// the migration stream is the whole point of having them in one place.
|
|
386
|
+
// One call per statement: this handle speaks the extended query
|
|
387
|
+
// protocol, which rejects multi-command strings.
|
|
320
388
|
await db.transaction(async (tx) => {
|
|
321
389
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// resolving the principal. Keep in sync with AUTH_BOOTSTRAP_SQL.
|
|
326
|
-
await tx.execute(sql`
|
|
327
|
-
CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
|
|
328
|
-
SELECT COALESCE(
|
|
329
|
-
NULLIF(current_setting('app.uid', true), ''),
|
|
330
|
-
NULLIF(current_setting('app.user_id', true), '')
|
|
331
|
-
);
|
|
332
|
-
$$ LANGUAGE sql STABLE
|
|
333
|
-
`);
|
|
334
|
-
|
|
335
|
-
await tx.execute(sql`
|
|
336
|
-
CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$
|
|
337
|
-
SELECT COALESCE(
|
|
338
|
-
NULLIF(current_setting('app.jwt', true), ''),
|
|
339
|
-
'{}'
|
|
340
|
-
)::jsonb;
|
|
341
|
-
$$ LANGUAGE sql STABLE
|
|
342
|
-
`);
|
|
343
|
-
|
|
344
|
-
await tx.execute(sql`
|
|
345
|
-
CREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$
|
|
346
|
-
SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
|
|
347
|
-
$$ LANGUAGE sql STABLE
|
|
348
|
-
`);
|
|
390
|
+
for (const statement of RLS_BOOTSTRAP_STATEMENTS) {
|
|
391
|
+
await tx.execute(sql.raw(statement));
|
|
392
|
+
}
|
|
349
393
|
});
|
|
350
394
|
|
|
351
395
|
// Seed default roles if none exist
|
|
@@ -356,26 +400,204 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
356
400
|
// database provisioned by an older framework era is missing every
|
|
357
401
|
// column added since. Each column the auth services read or write must
|
|
358
402
|
// be back-filled here, or upgraded deployments break on the first
|
|
359
|
-
// statement that references it.
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
403
|
+
// statement that references it.
|
|
404
|
+
//
|
|
405
|
+
// `email` is skipped: it has existed since the first era, so it is never
|
|
406
|
+
// the missing one, and `ADD COLUMN … NOT NULL` with no default fails on
|
|
407
|
+
// a table with rows.
|
|
408
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
409
|
+
if (spec.column === "email") continue;
|
|
410
|
+
await db.execute(sql`
|
|
411
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
412
|
+
ADD COLUMN IF NOT EXISTS ${sql.raw(`${spec.column} ${authUsersColumnSql(spec)}`)}
|
|
413
|
+
`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Which of the columns below the table actually has. An adopted table —
|
|
417
|
+
// one this framework did not create, which the column-name resolution in
|
|
418
|
+
// `services.ts` exists to support — may be missing any of them, and every
|
|
419
|
+
// statement past this point has to tolerate that rather than abort the
|
|
420
|
+
// whole migration block.
|
|
421
|
+
const usersColumns = await db.execute(sql`
|
|
422
|
+
SELECT column_name, data_type, is_nullable, column_default
|
|
423
|
+
FROM information_schema.columns
|
|
424
|
+
WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
|
|
425
|
+
`);
|
|
426
|
+
type UsersColumnRow = {
|
|
427
|
+
column_name: string;
|
|
428
|
+
data_type: string;
|
|
429
|
+
is_nullable: "YES" | "NO";
|
|
430
|
+
column_default: string | null;
|
|
431
|
+
};
|
|
432
|
+
const usersColumnRows = usersColumns.rows as UsersColumnRow[];
|
|
433
|
+
const usersColumnTypes = new Map(usersColumnRows.map(row => [row.column_name, row.data_type]));
|
|
434
|
+
const usersColumnState = new Map(usersColumnRows.map(row => [row.column_name, row]));
|
|
435
|
+
|
|
436
|
+
// ── Migration: restore defaults and NOT NULL that another creator dropped ──
|
|
437
|
+
// `ADD COLUMN IF NOT EXISTS` above only creates what is MISSING. A column
|
|
438
|
+
// that exists with the wrong shape stays wrong forever — and until
|
|
439
|
+
// AUTH_USERS_COLUMNS became the single source, that was the normal
|
|
440
|
+
// outcome rather than an edge case: whichever of `db push`, boot-ensure
|
|
441
|
+
// and this function reached the table first decided its constraints, so
|
|
442
|
+
// a managed deploy ended up with a nullable `email`, a `roles` with no
|
|
443
|
+
// `'{}'` default, and an `email_verified` that could be NULL.
|
|
444
|
+
//
|
|
445
|
+
// Ordered DEFAULT → back-fill → SET NOT NULL, because SET NOT NULL is
|
|
446
|
+
// checked against existing rows: without the back-fill it throws on the
|
|
447
|
+
// very databases that need it. `email` can carry no default, so a NULL
|
|
448
|
+
// there is not repairable automatically — say so and leave the column
|
|
449
|
+
// alone rather than inventing an address.
|
|
450
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
451
|
+
const state = usersColumnState.get(spec.column);
|
|
452
|
+
if (!state) continue;
|
|
453
|
+
|
|
454
|
+
if (spec.default !== undefined && state.column_default === null) {
|
|
455
|
+
await db.execute(sql`
|
|
456
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
457
|
+
ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET DEFAULT ${sql.raw(spec.default)}
|
|
458
|
+
`);
|
|
459
|
+
logger.info(`🔧 Restored the default on ${usersTableName}.${spec.column}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (!spec.notNull || state.is_nullable !== "YES") continue;
|
|
463
|
+
|
|
464
|
+
if (spec.default !== undefined) {
|
|
465
|
+
await db.execute(sql`
|
|
466
|
+
UPDATE ${sql.raw(usersTableName)}
|
|
467
|
+
SET ${sql.raw(`"${spec.column}"`)} = ${sql.raw(spec.default)}
|
|
468
|
+
WHERE ${sql.raw(`"${spec.column}"`)} IS NULL
|
|
469
|
+
`);
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
await db.execute(sql`
|
|
473
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
474
|
+
ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET NOT NULL
|
|
475
|
+
`);
|
|
476
|
+
logger.info(`🔧 Restored NOT NULL on ${usersTableName}.${spec.column}`);
|
|
477
|
+
} catch (err) {
|
|
478
|
+
logger.warn(
|
|
479
|
+
`⚠️ ${usersTableName}.${spec.column} should be NOT NULL but still holds NULLs, so the ` +
|
|
480
|
+
"constraint was not applied. Fill or remove those rows and restart: " +
|
|
481
|
+
(err instanceof Error ? err.message : String(err))
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── Migration: VARCHAR(n) → TEXT on the users string columns ────────
|
|
487
|
+
// Tables created before the widths came off still carry them. Postgres
|
|
488
|
+
// treats varchar(n) → text as binary-coercible with no stricter
|
|
489
|
+
// constraint, so this is a catalogue-only change: no table rewrite, no
|
|
490
|
+
// index rebuild, just a brief ACCESS EXCLUSIVE lock. Guarded on the
|
|
491
|
+
// current type so it runs once and is a pure catalogue read thereafter.
|
|
492
|
+
for (const column of ["email", "display_name", "photo_url", "password_hash", "email_verification_token"]) {
|
|
493
|
+
if (usersColumnTypes.get(column) !== "character varying") continue;
|
|
376
494
|
await db.execute(sql`
|
|
377
495
|
ALTER TABLE ${sql.raw(usersTableName)}
|
|
378
|
-
|
|
496
|
+
ALTER COLUMN ${sql.raw(`"${column}"`)} TYPE TEXT
|
|
497
|
+
`);
|
|
498
|
+
logger.info(`🔧 Widened ${usersTableName}.${column} from VARCHAR(n) to TEXT`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ── Migration: case-insensitive email identity ──────────────────────
|
|
502
|
+
// `getUserByEmail` has always searched `email.toLowerCase()` while the
|
|
503
|
+
// write path stored whatever it was handed, leaving normalisation to a
|
|
504
|
+
// convention every caller had to remember. A row that reached the table
|
|
505
|
+
// with mixed case is then invisible to every lookup — the account exists,
|
|
506
|
+
// login reports no such user, and the plain UNIQUE on `email` does not
|
|
507
|
+
// stop a second row differing only in case, because it compares bytes.
|
|
508
|
+
//
|
|
509
|
+
// Fixed on both sides: `mapPayload` now folds on write, and this index
|
|
510
|
+
// makes the database agree. A unique index on lower(email) is strictly
|
|
511
|
+
// stronger than the byte-exact UNIQUE that older tables carry, so the
|
|
512
|
+
// old constraint is left alone — it can no longer fire on anything the
|
|
513
|
+
// new one would allow.
|
|
514
|
+
//
|
|
515
|
+
// Deliberately no AUTH_SCHEMA_VERSION bump: a runtime that predates this
|
|
516
|
+
// migration keeps working against the migrated table (all of its own
|
|
517
|
+
// write paths already lower-cased), which is exactly the additive case
|
|
518
|
+
// the version stamp is documented not to cover.
|
|
519
|
+
if (usersColumnTypes.has("email")) {
|
|
520
|
+
const indexPresent = await db.execute(sql`
|
|
521
|
+
SELECT 1 FROM pg_class c
|
|
522
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
523
|
+
WHERE n.nspname = ${usersSchema} AND c.relname = ${emailLowerUniqueIndex} AND c.relkind = 'i'
|
|
524
|
+
`);
|
|
525
|
+
if (indexPresent.rows.length === 0) {
|
|
526
|
+
// Case-collisions already in the table would make the unique
|
|
527
|
+
// index impossible to build. Report them and leave the table
|
|
528
|
+
// alone: the next boot retries, so fixing the rows is all the
|
|
529
|
+
// operator has to do. Failing loudly beats folding the emails
|
|
530
|
+
// and letting CREATE INDEX pick which account survives.
|
|
531
|
+
const collisions = await db.execute(sql`
|
|
532
|
+
SELECT lower(email) AS normalized, count(*)::int AS occurrences
|
|
533
|
+
FROM ${sql.raw(usersTableName)}
|
|
534
|
+
WHERE email IS NOT NULL
|
|
535
|
+
GROUP BY lower(email)
|
|
536
|
+
HAVING count(*) > 1
|
|
537
|
+
LIMIT 10
|
|
538
|
+
`);
|
|
539
|
+
if (collisions.rows.length > 0) {
|
|
540
|
+
const sample = (collisions.rows as { normalized: string; occurrences: number }[])
|
|
541
|
+
.map(row => `${row.normalized} (×${row.occurrences})`)
|
|
542
|
+
.join(", ");
|
|
543
|
+
logger.error(
|
|
544
|
+
`❌ Cannot enforce case-insensitive email uniqueness on ${usersTableName}: ` +
|
|
545
|
+
`these addresses already exist more than once, differing only in case — ${sample}. ` +
|
|
546
|
+
"Merge or delete the duplicates and restart; until then two accounts can share " +
|
|
547
|
+
"one address and only the lower-cased one is reachable by login."
|
|
548
|
+
);
|
|
549
|
+
} else {
|
|
550
|
+
const folded = await db.execute(sql`
|
|
551
|
+
UPDATE ${sql.raw(usersTableName)}
|
|
552
|
+
SET email = lower(email)
|
|
553
|
+
WHERE email IS NOT NULL AND email <> lower(email)
|
|
554
|
+
`);
|
|
555
|
+
if (folded.rowCount) {
|
|
556
|
+
logger.info(`🔧 Lower-cased ${folded.rowCount} email address(es) in ${usersTableName}`);
|
|
557
|
+
}
|
|
558
|
+
await db.execute(sql`
|
|
559
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${sql.raw(`"${emailLowerUniqueIndex}"`)}
|
|
560
|
+
ON ${sql.raw(usersTableName)} (lower(email))
|
|
561
|
+
`);
|
|
562
|
+
logger.info(`✅ Email uniqueness on ${usersTableName} is now case-insensitive`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ── Migration: bound the email column's length ──────────────────────
|
|
568
|
+
// The only length limit on this table worth keeping. 320 is the RFC 5321
|
|
569
|
+
// maximum (64-char local part + @ + 255-char domain), and it matters here
|
|
570
|
+
// beyond tidiness: `email` carries a btree index, and a sufficiently long
|
|
571
|
+
// value fails index insertion with an error that says nothing about
|
|
572
|
+
// email. NOT VALID so an adopted table with a long row still migrates —
|
|
573
|
+
// it binds all new writes, which is the part that matters.
|
|
574
|
+
if (usersColumnTypes.has("email")) {
|
|
575
|
+
const checkPresent = await db.execute(sql`
|
|
576
|
+
SELECT 1 FROM pg_constraint c
|
|
577
|
+
JOIN pg_class t ON t.oid = c.conrelid
|
|
578
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
579
|
+
WHERE n.nspname = ${usersSchema}
|
|
580
|
+
AND t.relname = ${resolvedTable}
|
|
581
|
+
AND c.conname = ${authIdentifier("email_length_check")}
|
|
582
|
+
`);
|
|
583
|
+
if (checkPresent.rows.length === 0) {
|
|
584
|
+
await db.execute(sql`
|
|
585
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
586
|
+
ADD CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320) NOT VALID
|
|
587
|
+
`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── Index: email verification token lookups ─────────────────────────
|
|
592
|
+
// `getUserByVerificationToken` filters on this column, which had no
|
|
593
|
+
// index — every click of a verification link was a sequential scan of
|
|
594
|
+
// the whole users table. Partial, because the column is NULL for every
|
|
595
|
+
// user who is not mid-verification, which is nearly all of them.
|
|
596
|
+
if (usersColumnTypes.has("email_verification_token")) {
|
|
597
|
+
await db.execute(sql`
|
|
598
|
+
CREATE INDEX IF NOT EXISTS ${sql.raw(`"${verificationTokenIndex}"`)}
|
|
599
|
+
ON ${sql.raw(usersTableName)} (email_verification_token)
|
|
600
|
+
WHERE email_verification_token IS NOT NULL
|
|
379
601
|
`);
|
|
380
602
|
}
|
|
381
603
|
|
|
@@ -562,15 +784,22 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
562
784
|
// registration after an upgrade fails with SQLSTATE 42501. Reconcile
|
|
563
785
|
// on boot; only tables actually flagged get the ALTER (and its lock).
|
|
564
786
|
try {
|
|
787
|
+
// Every table this function creates, not a subset. `magic_link_tokens`
|
|
788
|
+
// and `schema_meta` were missing here while their six siblings were
|
|
789
|
+
// listed — so on a database carrying FORCE from the older RLS model,
|
|
790
|
+
// magic-link sign-in kept failing 42501 after the upgrade that was
|
|
791
|
+
// supposed to fix exactly that, and only for the one auth method.
|
|
565
792
|
const authTablePairs: [string, string][] = [
|
|
566
793
|
[usersSchema, resolvedTable],
|
|
567
794
|
[authSchema, "user_identities"],
|
|
568
795
|
[authSchema, "refresh_tokens"],
|
|
569
796
|
[authSchema, "password_reset_tokens"],
|
|
797
|
+
[authSchema, "magic_link_tokens"],
|
|
570
798
|
[authSchema, "app_config"],
|
|
571
799
|
[authSchema, "mfa_factors"],
|
|
572
800
|
[authSchema, "mfa_challenges"],
|
|
573
|
-
[authSchema, "recovery_codes"]
|
|
801
|
+
[authSchema, "recovery_codes"],
|
|
802
|
+
[authSchema, "schema_meta"]
|
|
574
803
|
];
|
|
575
804
|
for (const [schemaName, tableName] of authTablePairs) {
|
|
576
805
|
const forced = await db.execute(sql`
|
|
@@ -601,10 +830,35 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
601
830
|
);
|
|
602
831
|
}
|
|
603
832
|
|
|
604
|
-
// Stamped last, so a boot that died partway through
|
|
605
|
-
// leaves the older stamp in place and the next boot runs
|
|
833
|
+
// Stamped last of the MIGRATIONS, so a boot that died partway through
|
|
834
|
+
// the ones above leaves the older stamp in place and the next boot runs
|
|
835
|
+
// them again.
|
|
606
836
|
await stampAuthSchemaVersion(db, authSchema);
|
|
607
837
|
|
|
838
|
+
// ── Keep the end-user role out of auth's tables ─────────────────────
|
|
839
|
+
// These carry session token hashes, TOTP secrets and recovery codes, and
|
|
840
|
+
// none of them has RLS — they are not collections, so nothing ever
|
|
841
|
+
// compiled a policy for them. Meanwhile the role provisioning grants
|
|
842
|
+
// `rebase_user` DML on every table in this schema, and its
|
|
843
|
+
// ALTER DEFAULT PRIVILEGES reaches the ones created right here, after it
|
|
844
|
+
// ran. So the grant has to come back off; see `revokeInternalTableSql`
|
|
845
|
+
// for why a revoke rather than an empty RLS policy set.
|
|
846
|
+
//
|
|
847
|
+
// After the stamp deliberately: `schema_meta` is created BY the stamp,
|
|
848
|
+
// so revoking first would leave the one table holding this database's
|
|
849
|
+
// schema version writable by every signed-in user until the next boot.
|
|
850
|
+
// Nothing below re-runs the migrations, so the stamp's guarantee holds.
|
|
851
|
+
await revokeInternalTableAccess(
|
|
852
|
+
async (text) => { await db.execute(sql.raw(text)); },
|
|
853
|
+
authSchema,
|
|
854
|
+
{
|
|
855
|
+
onError: (table, err) => logger.warn(
|
|
856
|
+
`🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` +
|
|
857
|
+
(err instanceof Error ? err.message : String(err))
|
|
858
|
+
)
|
|
859
|
+
}
|
|
860
|
+
);
|
|
861
|
+
|
|
608
862
|
logger.info("✅ Auth tables ready");
|
|
609
863
|
} catch (error) {
|
|
610
864
|
// The one failure that must not be survived. Continuing here is what
|
package/src/auth/services.ts
CHANGED
|
@@ -54,6 +54,28 @@ function getColumn(table: RebasePgTable | undefined, ...keys: string[]): RebaseP
|
|
|
54
54
|
return key ? table[key] : undefined;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The single definition of what an email address looks like in storage.
|
|
59
|
+
*
|
|
60
|
+
* Reads have always folded case; writes did not, and normalising was left to
|
|
61
|
+
* each caller. That asymmetry is only ever one forgotten `.toLowerCase()` away
|
|
62
|
+
* from a row no lookup can find — the account exists, every sign-in path
|
|
63
|
+
* reports no such user, and the byte-exact UNIQUE on the column does not stop a
|
|
64
|
+
* duplicate differing only in case. Applied on both sides here so the guarantee
|
|
65
|
+
* belongs to the repository rather than to its callers' discipline; the
|
|
66
|
+
* `lower(email)` unique index added in `ensureAuthTablesExist` is the database
|
|
67
|
+
* half of the same rule.
|
|
68
|
+
*
|
|
69
|
+
* Whitespace goes too: a trailing space survives the fold and reproduces the
|
|
70
|
+
* problem exactly.
|
|
71
|
+
*
|
|
72
|
+
* Re-exported rather than defined here: `@rebasepro/server` and
|
|
73
|
+
* `@rebasepro/server-mongo` write this column too, and a second copy of this
|
|
74
|
+
* rule is the defect it exists to prevent.
|
|
75
|
+
*/
|
|
76
|
+
import { normalizeEmail } from "@rebasepro/common";
|
|
77
|
+
export { normalizeEmail };
|
|
78
|
+
|
|
57
79
|
/**
|
|
58
80
|
* PostgreSQL implementation of UserRepository.
|
|
59
81
|
* Handles all user-related database operations using Drizzle ORM.
|
|
@@ -184,7 +206,7 @@ export class UserService implements UserRepository {
|
|
|
184
206
|
const metadataKey = getColumnKey(this.usersTable, "metadata") || "metadata";
|
|
185
207
|
|
|
186
208
|
if ("id" in data) payload[idKey] = data.id;
|
|
187
|
-
if ("email" in data) payload[emailKey] = data.email;
|
|
209
|
+
if ("email" in data) payload[emailKey] = normalizeEmail(data.email);
|
|
188
210
|
if ("passwordHash" in data) payload[passwordHashKey] = data.passwordHash;
|
|
189
211
|
if ("displayName" in data) payload[displayNameKey] = data.displayName;
|
|
190
212
|
if ("photoUrl" in data) payload[photoUrlKey] = data.photoUrl;
|
|
@@ -244,7 +266,7 @@ export class UserService implements UserRepository {
|
|
|
244
266
|
async getUserByEmail(email: string): Promise<UserData | null> {
|
|
245
267
|
const emailCol = getColumn(this.usersTable, "email");
|
|
246
268
|
if (!emailCol) return null;
|
|
247
|
-
const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, email
|
|
269
|
+
const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, normalizeEmail(email)));
|
|
248
270
|
return row ? this.mapRowToUser(row as Record<string, unknown>) : null;
|
|
249
271
|
}
|
|
250
272
|
|
package/src/backup/backup-cli.ts
CHANGED
|
@@ -100,6 +100,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
|
|
|
100
100
|
"--out": String,
|
|
101
101
|
"--exclude-schema": [String],
|
|
102
102
|
"--no-owner": Boolean,
|
|
103
|
+
"--enable-row-security": Boolean,
|
|
104
|
+
"--row-security-role": String,
|
|
103
105
|
"-o": "--out"
|
|
104
106
|
},
|
|
105
107
|
{ argv: rawArgs.slice(2), permissive: true }
|
|
@@ -129,6 +131,23 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
|
|
|
129
131
|
}
|
|
130
132
|
logger.info(chalk.gray(` Using pg_dump ${pf.toolMajor} against server ${pf.serverMajor}.`));
|
|
131
133
|
|
|
134
|
+
// Opt-in, and loud. With row security on, pg_dump stops refusing to read
|
|
135
|
+
// rows it cannot see and starts leaving them out — an exit-0 backup that
|
|
136
|
+
// is quietly short. Anyone choosing that should know they chose it.
|
|
137
|
+
const rowSecurity = args["--enable-row-security"]
|
|
138
|
+
? { uid: "rebase-db-backup", roles: [args["--row-security-role"] || "admin"] }
|
|
139
|
+
: undefined;
|
|
140
|
+
|
|
141
|
+
if (rowSecurity) {
|
|
142
|
+
logger.warn("");
|
|
143
|
+
logger.warn(chalk.yellow(" ⚠ Dumping with row-level security ON."));
|
|
144
|
+
logger.warn(chalk.gray(` Reading as roles [${rowSecurity.roles.join(", ")}], which satisfies the generated`));
|
|
145
|
+
logger.warn(chalk.gray(" `admin_full_access` policy. This backup contains exactly the rows those"));
|
|
146
|
+
logger.warn(chalk.gray(" policies admit — a table whose policies have no admin rule comes out short,"));
|
|
147
|
+
logger.warn(chalk.gray(" and pg_dump will not say so. Prefer granting the dumping role BYPASSRLS."));
|
|
148
|
+
logger.warn("");
|
|
149
|
+
}
|
|
150
|
+
|
|
132
151
|
try {
|
|
133
152
|
if (dest.kind === "local") {
|
|
134
153
|
// Honour an explicit `…/name.dump` path; otherwise treat it as a
|
|
@@ -141,7 +160,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
|
|
|
141
160
|
fileName: explicitFile ? path.basename(dest.path) : undefined,
|
|
142
161
|
excludeSchemas: args["--exclude-schema"],
|
|
143
162
|
noOwner: args["--no-owner"],
|
|
144
|
-
inheritStdio: true
|
|
163
|
+
inheritStdio: true,
|
|
164
|
+
rowSecurity
|
|
145
165
|
});
|
|
146
166
|
await assertDumpValid(dump.localFile);
|
|
147
167
|
logger.info("");
|
|
@@ -156,7 +176,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
|
|
|
156
176
|
dbName,
|
|
157
177
|
excludeSchemas: args["--exclude-schema"],
|
|
158
178
|
noOwner: args["--no-owner"],
|
|
159
|
-
inheritStdio: true
|
|
179
|
+
inheritStdio: true,
|
|
180
|
+
rowSecurity
|
|
160
181
|
});
|
|
161
182
|
try {
|
|
162
183
|
await assertDumpValid(dump.localFile);
|
|
@@ -395,6 +416,24 @@ ${chalk.green.bold("Options")}
|
|
|
395
416
|
${chalk.blue("--out, -o")} <dest> Local path or s3://…/gs://… URL (default: ./backups)
|
|
396
417
|
${chalk.blue("--exclude-schema")} <s> Exclude a schema (repeatable)
|
|
397
418
|
${chalk.blue("--no-owner")} Omit ownership commands from the dump
|
|
419
|
+
${chalk.blue("--enable-row-security")} Dump as an admin subject instead of failing on RLS
|
|
420
|
+
${chalk.red("(may produce a partial dump — see below)")}
|
|
421
|
+
${chalk.blue("--row-security-role")} <r> Role to read as with the flag above (default: admin)
|
|
422
|
+
|
|
423
|
+
${chalk.green.bold("Row-level security")}
|
|
424
|
+
On a managed Postgres the dumping role usually owns nothing and has no
|
|
425
|
+
BYPASSRLS, so pg_dump refuses:
|
|
426
|
+
|
|
427
|
+
ERROR: query would be affected by row-level security policy for table "..."
|
|
428
|
+
|
|
429
|
+
That refusal is the safe behaviour. --enable-row-security replaces it by
|
|
430
|
+
reading as an admin subject: Rebase sets app.uid/app.user_roles so the
|
|
431
|
+
generated admin_full_access policy admits the dump. The dump then contains
|
|
432
|
+
exactly what those policies admit ${chalk.red("and no error is raised for what they do not")} —
|
|
433
|
+
a table whose policies lack an admin rule comes out short, silently.
|
|
434
|
+
|
|
435
|
+
Granting the dumping role BYPASSRLS is the option that keeps a backup
|
|
436
|
+
meaning "every row".
|
|
398
437
|
|
|
399
438
|
${chalk.green.bold("Notes")}
|
|
400
439
|
Backups may contain secrets and PII. Use private storage destinations and
|