@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g06dbe5b
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 +48 -1
- package/dist/auth/services.d.ts +19 -0
- package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
- package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
- package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
- package/dist/index.es.js +1365 -338
- package/dist/index.es.js.map +1 -1
- 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 +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +23 -25
- package/dist/schema/ensure-collection-tables.d.ts +61 -7
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +34 -7
- package/dist/services/RelationService.d.ts +30 -0
- package/dist/services/collection-helpers.d.ts +26 -0
- package/dist/services/dataService.d.ts +5 -0
- package/dist/services/realtimeService.d.ts +131 -21
- package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
- package/dist/websocket-D1qbmLZ2.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +55 -6
- package/src/auth/ensure-tables.ts +207 -86
- package/src/auth/services.ts +69 -5
- package/src/cli-helpers.ts +129 -10
- package/src/cli.ts +232 -30
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +120 -17
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +12 -63
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +135 -76
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +132 -42
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-logic.ts +66 -34
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/rls-enforcement.ts +149 -6
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +175 -108
- package/src/services/PersistService.ts +38 -2
- package/src/services/RelationService.ts +110 -67
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +54 -1
- package/src/services/dataService.ts +5 -0
- package/src/services/realtimeService.ts +344 -79
- package/src/utils/drizzle-conditions.ts +365 -23
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.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
|
|
@@ -129,22 +142,24 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
129
142
|
// and long signed URLs that OAuth providers hand back. A limit worth
|
|
130
143
|
// having is a CHECK — alterable without a table rewrite, unlike a type
|
|
131
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 ");
|
|
132
159
|
await db.execute(sql`
|
|
133
160
|
CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
|
|
134
161
|
id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
|
|
135
|
-
|
|
136
|
-
display_name TEXT,
|
|
137
|
-
photo_url TEXT,
|
|
138
|
-
roles TEXT[] DEFAULT '{}' NOT NULL,
|
|
139
|
-
password_hash TEXT,
|
|
140
|
-
email_verified BOOLEAN DEFAULT FALSE NOT NULL,
|
|
141
|
-
email_verification_token TEXT,
|
|
142
|
-
email_verification_sent_at TIMESTAMP WITH TIME ZONE,
|
|
143
|
-
is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
|
|
144
|
-
metadata JSONB DEFAULT '{}' NOT NULL,
|
|
145
|
-
tokens_valid_after TIMESTAMP WITH TIME ZONE,
|
|
146
|
-
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
147
|
-
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
162
|
+
${sql.raw(usersColumnDdl)}
|
|
148
163
|
)
|
|
149
164
|
`);
|
|
150
165
|
|
|
@@ -164,26 +179,51 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
164
179
|
// column once no old backend remains (phase 2, contract).
|
|
165
180
|
//
|
|
166
181
|
// Idempotent throughout: every step is guarded on catalogue state.
|
|
167
|
-
|
|
168
|
-
CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
|
|
169
|
-
BEGIN
|
|
170
|
-
IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
|
|
171
|
-
NEW.uid := NEW.user_id;
|
|
172
|
-
ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
|
|
173
|
-
NEW.user_id := NEW.uid;
|
|
174
|
-
END IF;
|
|
175
|
-
RETURN NEW;
|
|
176
|
-
END $$ LANGUAGE plpgsql
|
|
177
|
-
`);
|
|
178
|
-
|
|
179
|
-
for (const authTable of [
|
|
182
|
+
const legacyFkTables = [
|
|
180
183
|
"user_identities",
|
|
181
184
|
"refresh_tokens",
|
|
182
185
|
"password_reset_tokens",
|
|
183
186
|
"magic_link_tokens",
|
|
184
187
|
"mfa_factors",
|
|
185
188
|
"recovery_codes"
|
|
186
|
-
]
|
|
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 : []) {
|
|
187
227
|
const qualified = `"${authSchema}"."${authTable}"`;
|
|
188
228
|
await db.execute(sql`
|
|
189
229
|
DO $$
|
|
@@ -335,39 +375,21 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
335
375
|
)
|
|
336
376
|
`);
|
|
337
377
|
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
//
|
|
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.
|
|
342
388
|
await db.transaction(async (tx) => {
|
|
343
389
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
// resolving the principal. Keep in sync with AUTH_BOOTSTRAP_SQL.
|
|
348
|
-
await tx.execute(sql`
|
|
349
|
-
CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
|
|
350
|
-
SELECT COALESCE(
|
|
351
|
-
NULLIF(current_setting('app.uid', true), ''),
|
|
352
|
-
NULLIF(current_setting('app.user_id', true), '')
|
|
353
|
-
);
|
|
354
|
-
$$ LANGUAGE sql STABLE
|
|
355
|
-
`);
|
|
356
|
-
|
|
357
|
-
await tx.execute(sql`
|
|
358
|
-
CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$
|
|
359
|
-
SELECT COALESCE(
|
|
360
|
-
NULLIF(current_setting('app.jwt', true), ''),
|
|
361
|
-
'{}'
|
|
362
|
-
)::jsonb;
|
|
363
|
-
$$ LANGUAGE sql STABLE
|
|
364
|
-
`);
|
|
365
|
-
|
|
366
|
-
await tx.execute(sql`
|
|
367
|
-
CREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$
|
|
368
|
-
SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
|
|
369
|
-
$$ LANGUAGE sql STABLE
|
|
370
|
-
`);
|
|
390
|
+
for (const statement of RLS_BOOTSTRAP_STATEMENTS) {
|
|
391
|
+
await tx.execute(sql.raw(statement));
|
|
392
|
+
}
|
|
371
393
|
});
|
|
372
394
|
|
|
373
395
|
// Seed default roles if none exist
|
|
@@ -378,26 +400,16 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
378
400
|
// database provisioned by an older framework era is missing every
|
|
379
401
|
// column added since. Each column the auth services read or write must
|
|
380
402
|
// be back-filled here, or upgraded deployments break on the first
|
|
381
|
-
// statement that references it.
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
"
|
|
388
|
-
"email_verified BOOLEAN DEFAULT FALSE NOT NULL",
|
|
389
|
-
"email_verification_token TEXT",
|
|
390
|
-
"email_verification_sent_at TIMESTAMP WITH TIME ZONE",
|
|
391
|
-
"is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
|
|
392
|
-
"metadata JSONB DEFAULT '{}' NOT NULL",
|
|
393
|
-
"tokens_valid_after TIMESTAMP WITH TIME ZONE",
|
|
394
|
-
"created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
|
|
395
|
-
"updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
|
|
396
|
-
];
|
|
397
|
-
for (const columnDef of userColumnBackfills) {
|
|
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;
|
|
398
410
|
await db.execute(sql`
|
|
399
411
|
ALTER TABLE ${sql.raw(usersTableName)}
|
|
400
|
-
ADD COLUMN IF NOT EXISTS ${sql.raw(
|
|
412
|
+
ADD COLUMN IF NOT EXISTS ${sql.raw(`${spec.column} ${authUsersColumnSql(spec)}`)}
|
|
401
413
|
`);
|
|
402
414
|
}
|
|
403
415
|
|
|
@@ -407,14 +419,69 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
407
419
|
// statement past this point has to tolerate that rather than abort the
|
|
408
420
|
// whole migration block.
|
|
409
421
|
const usersColumns = await db.execute(sql`
|
|
410
|
-
SELECT column_name, data_type
|
|
422
|
+
SELECT column_name, data_type, is_nullable, column_default
|
|
411
423
|
FROM information_schema.columns
|
|
412
424
|
WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
|
|
413
425
|
`);
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
+
}
|
|
418
485
|
|
|
419
486
|
// ── Migration: VARCHAR(n) → TEXT on the users string columns ────────
|
|
420
487
|
// Tables created before the widths came off still carry them. Postgres
|
|
@@ -574,6 +641,12 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
574
641
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
|
|
575
642
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
|
|
576
643
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
|
|
644
|
+
// Nullable with no default and no back-fill: a row written
|
|
645
|
+
// before this column existed says nothing about whether a
|
|
646
|
+
// second factor was presented, and the reader treats "says
|
|
647
|
+
// nothing" as `aal1` — the restrictive answer. Stamping
|
|
648
|
+
// every existing row would be inventing evidence.
|
|
649
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS aal TEXT`);
|
|
577
650
|
|
|
578
651
|
// One session per pre-existing row: under the old model a row
|
|
579
652
|
// WAS a device session, and there is no record of which rows
|
|
@@ -663,6 +736,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
663
736
|
secret_encrypted TEXT NOT NULL,
|
|
664
737
|
friendly_name TEXT,
|
|
665
738
|
verified BOOLEAN DEFAULT FALSE,
|
|
739
|
+
last_used_counter BIGINT,
|
|
666
740
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
667
741
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
668
742
|
)
|
|
@@ -682,6 +756,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
682
756
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
683
757
|
verified_at TIMESTAMP WITH TIME ZONE,
|
|
684
758
|
ip_address TEXT,
|
|
759
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
685
760
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
|
|
686
761
|
)
|
|
687
762
|
`);
|
|
@@ -692,6 +767,20 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
692
767
|
ON ${sql.raw(mfaChallengesTableName)}(factor_id)
|
|
693
768
|
`);
|
|
694
769
|
|
|
770
|
+
// ── Migration: replay and brute-force state on the MFA tables ───────
|
|
771
|
+
// Both are additive and nullable-or-defaulted, so a runtime that
|
|
772
|
+
// predates them reads the tables unchanged. `last_used_counter` records
|
|
773
|
+
// the TOTP step a factor has already spent (RFC 6238 §5.2); `attempts`
|
|
774
|
+
// bounds how many guesses one challenge will take before it is dead.
|
|
775
|
+
// Without them the code paths degrade to "no replay protection, rate
|
|
776
|
+
// limiters only" rather than failing, which is why this is a warn.
|
|
777
|
+
try {
|
|
778
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(mfaFactorsTableName)} ADD COLUMN IF NOT EXISTS last_used_counter BIGINT`);
|
|
779
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(mfaChallengesTableName)} ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`);
|
|
780
|
+
} catch (mfaMigrationError: unknown) {
|
|
781
|
+
logger.warn(`⚠️ MFA hardening columns skipped: ${mfaMigrationError instanceof Error ? mfaMigrationError.message : String(mfaMigrationError)}`);
|
|
782
|
+
}
|
|
783
|
+
|
|
695
784
|
// Create recovery_codes table
|
|
696
785
|
await db.execute(sql`
|
|
697
786
|
CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
|
|
@@ -717,15 +806,22 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
717
806
|
// registration after an upgrade fails with SQLSTATE 42501. Reconcile
|
|
718
807
|
// on boot; only tables actually flagged get the ALTER (and its lock).
|
|
719
808
|
try {
|
|
809
|
+
// Every table this function creates, not a subset. `magic_link_tokens`
|
|
810
|
+
// and `schema_meta` were missing here while their six siblings were
|
|
811
|
+
// listed — so on a database carrying FORCE from the older RLS model,
|
|
812
|
+
// magic-link sign-in kept failing 42501 after the upgrade that was
|
|
813
|
+
// supposed to fix exactly that, and only for the one auth method.
|
|
720
814
|
const authTablePairs: [string, string][] = [
|
|
721
815
|
[usersSchema, resolvedTable],
|
|
722
816
|
[authSchema, "user_identities"],
|
|
723
817
|
[authSchema, "refresh_tokens"],
|
|
724
818
|
[authSchema, "password_reset_tokens"],
|
|
819
|
+
[authSchema, "magic_link_tokens"],
|
|
725
820
|
[authSchema, "app_config"],
|
|
726
821
|
[authSchema, "mfa_factors"],
|
|
727
822
|
[authSchema, "mfa_challenges"],
|
|
728
|
-
[authSchema, "recovery_codes"]
|
|
823
|
+
[authSchema, "recovery_codes"],
|
|
824
|
+
[authSchema, "schema_meta"]
|
|
729
825
|
];
|
|
730
826
|
for (const [schemaName, tableName] of authTablePairs) {
|
|
731
827
|
const forced = await db.execute(sql`
|
|
@@ -756,10 +852,35 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
|
|
|
756
852
|
);
|
|
757
853
|
}
|
|
758
854
|
|
|
759
|
-
// Stamped last, so a boot that died partway through
|
|
760
|
-
// leaves the older stamp in place and the next boot runs
|
|
855
|
+
// Stamped last of the MIGRATIONS, so a boot that died partway through
|
|
856
|
+
// the ones above leaves the older stamp in place and the next boot runs
|
|
857
|
+
// them again.
|
|
761
858
|
await stampAuthSchemaVersion(db, authSchema);
|
|
762
859
|
|
|
860
|
+
// ── Keep the end-user role out of auth's tables ─────────────────────
|
|
861
|
+
// These carry session token hashes, TOTP secrets and recovery codes, and
|
|
862
|
+
// none of them has RLS — they are not collections, so nothing ever
|
|
863
|
+
// compiled a policy for them. Meanwhile the role provisioning grants
|
|
864
|
+
// `rebase_user` DML on every table in this schema, and its
|
|
865
|
+
// ALTER DEFAULT PRIVILEGES reaches the ones created right here, after it
|
|
866
|
+
// ran. So the grant has to come back off; see `revokeInternalTableSql`
|
|
867
|
+
// for why a revoke rather than an empty RLS policy set.
|
|
868
|
+
//
|
|
869
|
+
// After the stamp deliberately: `schema_meta` is created BY the stamp,
|
|
870
|
+
// so revoking first would leave the one table holding this database's
|
|
871
|
+
// schema version writable by every signed-in user until the next boot.
|
|
872
|
+
// Nothing below re-runs the migrations, so the stamp's guarantee holds.
|
|
873
|
+
await revokeInternalTableAccess(
|
|
874
|
+
async (text) => { await db.execute(sql.raw(text)); },
|
|
875
|
+
authSchema,
|
|
876
|
+
{
|
|
877
|
+
onError: (table, err) => logger.warn(
|
|
878
|
+
`🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` +
|
|
879
|
+
(err instanceof Error ? err.message : String(err))
|
|
880
|
+
)
|
|
881
|
+
}
|
|
882
|
+
);
|
|
883
|
+
|
|
763
884
|
logger.info("✅ Auth tables ready");
|
|
764
885
|
} catch (error) {
|
|
765
886
|
// The one failure that must not be survived. Continuing here is what
|
package/src/auth/services.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
RoleData as Role
|
|
26
26
|
} from "@rebasepro/server";
|
|
27
27
|
import { toSnakeCase, camelCase } from "@rebasepro/utils";
|
|
28
|
+
import { escapeLikePattern } from "../utils/drizzle-conditions";
|
|
28
29
|
|
|
29
30
|
export type { Role };
|
|
30
31
|
|
|
@@ -368,7 +369,11 @@ export class UserService implements UserRepository {
|
|
|
368
369
|
conditions.push(sql`${roleId} = ANY(${sql.raw(usersTableName)}.roles)`);
|
|
369
370
|
}
|
|
370
371
|
if (search) {
|
|
371
|
-
|
|
372
|
+
// `search` is a substring search over the admin user list, not a
|
|
373
|
+
// pattern the caller writes: the same reasoning as the collection
|
|
374
|
+
// search path, so it shares that path's helper rather than growing
|
|
375
|
+
// a second copy that can drift. See `escapeLikePattern`.
|
|
376
|
+
const pattern = `%${escapeLikePattern(search)}%`;
|
|
372
377
|
conditions.push(sql`(${sql.raw(usersTableName)}.${sql.raw(emailColumn)} ILIKE ${pattern} OR ${sql.raw(usersTableName)}.${sql.raw(displayNameColumn)} ILIKE ${pattern})`);
|
|
373
378
|
}
|
|
374
379
|
|
|
@@ -591,7 +596,7 @@ export class RefreshTokenService {
|
|
|
591
596
|
userAgent: this.refreshTokensTable.userAgent,
|
|
592
597
|
ipAddress: this.refreshTokensTable.ipAddress
|
|
593
598
|
} as unknown as Record<string, never>;
|
|
594
|
-
for (const optional of ["sessionId", "rotatedAt", "revoked", "sessionStartedAt"]) {
|
|
599
|
+
for (const optional of ["sessionId", "rotatedAt", "revoked", "sessionStartedAt", "aal"]) {
|
|
595
600
|
if (this.has(optional)) selection[optional] = this.col(optional);
|
|
596
601
|
}
|
|
597
602
|
return selection;
|
|
@@ -624,6 +629,11 @@ export class RefreshTokenService {
|
|
|
624
629
|
};
|
|
625
630
|
if (session && this.has("sessionId")) values.sessionId = session.id;
|
|
626
631
|
if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
|
|
632
|
+
// Written on every token of the session, including the ones rotation
|
|
633
|
+
// mints, because refresh reads the level off whichever row was
|
|
634
|
+
// presented. A table without the column degrades to `aal1` on read,
|
|
635
|
+
// which is the restrictive answer rather than a bypass.
|
|
636
|
+
if (session?.aal && this.has("aal")) values.aal = session.aal;
|
|
627
637
|
|
|
628
638
|
await this.db.insert(this.refreshTokensTable).values(values);
|
|
629
639
|
}
|
|
@@ -1297,6 +1307,14 @@ collectionPermissions: null }
|
|
|
1297
1307
|
async hasVerifiedMfaFactors(uid: string): Promise<boolean> {
|
|
1298
1308
|
return this.getMfaService().hasVerifiedMfaFactors(uid);
|
|
1299
1309
|
}
|
|
1310
|
+
|
|
1311
|
+
async claimMfaFactorCounter(factorId: string, counter: number): Promise<boolean> {
|
|
1312
|
+
return this.getMfaService().claimMfaFactorCounter(factorId, counter);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
async recordMfaChallengeAttempt(challengeId: string): Promise<number> {
|
|
1316
|
+
return this.getMfaService().recordMfaChallengeAttempt(challengeId);
|
|
1317
|
+
}
|
|
1300
1318
|
}
|
|
1301
1319
|
|
|
1302
1320
|
// =============================================================================
|
|
@@ -1362,7 +1380,7 @@ export class MfaService implements MfaRepository {
|
|
|
1362
1380
|
async getMfaFactorById(factorId: string): Promise<(MfaFactor & { secretEncrypted: string }) | null> {
|
|
1363
1381
|
const tableName = this.qualify("mfa_factors");
|
|
1364
1382
|
const result = await this.db.execute(sql`
|
|
1365
|
-
SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
|
|
1383
|
+
SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, last_used_counter, created_at, updated_at
|
|
1366
1384
|
FROM ${sql.raw(tableName)}
|
|
1367
1385
|
WHERE id = ${factorId}
|
|
1368
1386
|
`);
|
|
@@ -1377,11 +1395,36 @@ export class MfaService implements MfaRepository {
|
|
|
1377
1395
|
secretEncrypted: row.secret_encrypted as string,
|
|
1378
1396
|
friendlyName: (row.friendly_name as string | null) ?? undefined,
|
|
1379
1397
|
verified: row.verified as boolean,
|
|
1398
|
+
// BIGINT comes back as a string from node-postgres.
|
|
1399
|
+
lastUsedCounter: row.last_used_counter === null || row.last_used_counter === undefined
|
|
1400
|
+
? null
|
|
1401
|
+
: Number(row.last_used_counter),
|
|
1380
1402
|
createdAt: new Date(row.created_at as string),
|
|
1381
1403
|
updatedAt: new Date(row.updated_at as string)
|
|
1382
1404
|
};
|
|
1383
1405
|
}
|
|
1384
1406
|
|
|
1407
|
+
/**
|
|
1408
|
+
* Spend a TOTP time step, once and only once.
|
|
1409
|
+
*
|
|
1410
|
+
* One statement: the `WHERE` is the check, the `UPDATE` is the act, and
|
|
1411
|
+
* `RETURNING` reports which of two concurrent requests carrying the same
|
|
1412
|
+
* six digits won. Reading the counter and then writing it would let both
|
|
1413
|
+
* pass — the exact replay this closes.
|
|
1414
|
+
*/
|
|
1415
|
+
async claimMfaFactorCounter(factorId: string, counter: number): Promise<boolean> {
|
|
1416
|
+
const tableName = this.qualify("mfa_factors");
|
|
1417
|
+
const result = await this.db.execute(sql`
|
|
1418
|
+
UPDATE ${sql.raw(tableName)}
|
|
1419
|
+
SET last_used_counter = ${counter}, updated_at = NOW()
|
|
1420
|
+
WHERE id = ${factorId}
|
|
1421
|
+
AND (last_used_counter IS NULL OR last_used_counter < ${counter})
|
|
1422
|
+
RETURNING id
|
|
1423
|
+
`);
|
|
1424
|
+
|
|
1425
|
+
return result.rows.length > 0;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1385
1428
|
async verifyMfaFactor(factorId: string): Promise<void> {
|
|
1386
1429
|
const tableName = this.qualify("mfa_factors");
|
|
1387
1430
|
await this.db.execute(sql`
|
|
@@ -1422,7 +1465,7 @@ export class MfaService implements MfaRepository {
|
|
|
1422
1465
|
async getMfaChallengeById(challengeId: string): Promise<MfaChallengeInfo | null> {
|
|
1423
1466
|
const tableName = this.qualify("mfa_challenges");
|
|
1424
1467
|
const result = await this.db.execute(sql`
|
|
1425
|
-
SELECT id, factor_id, created_at, verified_at, ip_address, expires_at
|
|
1468
|
+
SELECT id, factor_id, created_at, verified_at, ip_address, attempts, expires_at
|
|
1426
1469
|
FROM ${sql.raw(tableName)}
|
|
1427
1470
|
WHERE id = ${challengeId} AND expires_at > NOW() AND verified_at IS NULL
|
|
1428
1471
|
`);
|
|
@@ -1435,10 +1478,31 @@ export class MfaService implements MfaRepository {
|
|
|
1435
1478
|
factorId: row.factor_id as string,
|
|
1436
1479
|
createdAt: new Date(row.created_at as string),
|
|
1437
1480
|
verifiedAt: row.verified_at ? new Date(row.verified_at as string) : undefined,
|
|
1438
|
-
ipAddress: (row.ip_address as string | null) ?? undefined
|
|
1481
|
+
ipAddress: (row.ip_address as string | null) ?? undefined,
|
|
1482
|
+
attempts: Number(row.attempts ?? 0)
|
|
1439
1483
|
};
|
|
1440
1484
|
}
|
|
1441
1485
|
|
|
1486
|
+
/**
|
|
1487
|
+
* Count one failed guess against a challenge and report the new total.
|
|
1488
|
+
*
|
|
1489
|
+
* Incremented in the database rather than in the route so that guesses
|
|
1490
|
+
* arriving in parallel — the shape any real brute-force takes — cannot
|
|
1491
|
+
* share a single increment.
|
|
1492
|
+
*/
|
|
1493
|
+
async recordMfaChallengeAttempt(challengeId: string): Promise<number> {
|
|
1494
|
+
const tableName = this.qualify("mfa_challenges");
|
|
1495
|
+
const result = await this.db.execute(sql`
|
|
1496
|
+
UPDATE ${sql.raw(tableName)}
|
|
1497
|
+
SET attempts = attempts + 1
|
|
1498
|
+
WHERE id = ${challengeId}
|
|
1499
|
+
RETURNING attempts
|
|
1500
|
+
`);
|
|
1501
|
+
|
|
1502
|
+
if (result.rows.length === 0) return 0;
|
|
1503
|
+
return Number((result.rows[0] as { attempts: number | string }).attempts);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1442
1506
|
async verifyMfaChallenge(challengeId: string): Promise<void> {
|
|
1443
1507
|
const tableName = this.qualify("mfa_challenges");
|
|
1444
1508
|
await this.db.execute(sql`
|