@rebasepro/server-postgres 0.13.0 → 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.
Files changed (63) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/{src-DlPBctw_.js → auth-users-columns-Do9mw5Y5.js} +318 -42
  3. package/dist/auth-users-columns-Do9mw5Y5.js.map +1 -0
  4. package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
  5. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
  6. package/dist/cli-helpers.d.ts +1 -1
  7. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-DMUdRQdM.js} +2 -2
  8. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-DMUdRQdM.js.map} +1 -1
  9. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DSIxvLLD.js} +59 -15
  10. package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
  11. package/dist/index.es.js +534 -200
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/{policy-CeA1JcxP.js → policy-CPkCqVTz.js} +4 -4
  14. package/dist/policy-CPkCqVTz.js.map +1 -0
  15. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  16. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  17. package/dist/schema/auth-users-columns.d.ts +97 -0
  18. package/dist/schema/ensure-collection-tables.d.ts +1 -1
  19. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  20. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
  21. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  22. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  23. package/dist/security/rls-enforcement.d.ts +53 -2
  24. package/dist/services/FetchService.d.ts +10 -7
  25. package/dist/services/dataService.d.ts +2 -0
  26. package/dist/services/realtimeService.d.ts +25 -21
  27. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  28. package/dist/src-C_wvdMnl.js.map +1 -0
  29. package/dist/{websocket-B2LsrINK.js → websocket-D0TBU3ia.js} +3 -3
  30. package/dist/{websocket-B2LsrINK.js.map → websocket-D0TBU3ia.js.map} +1 -1
  31. package/package.json +9 -8
  32. package/src/PostgresBackendDriver.ts +165 -3
  33. package/src/PostgresBootstrapper.ts +41 -2
  34. package/src/auth/ensure-tables.ts +185 -86
  35. package/src/cli-helpers.ts +22 -9
  36. package/src/cli.ts +175 -30
  37. package/src/collections/validate-relations.ts +124 -17
  38. package/src/data-transformer.ts +13 -3
  39. package/src/history/ensure-history-table.ts +7 -0
  40. package/src/schema/auth-users-columns.ts +131 -0
  41. package/src/schema/doctor.ts +7 -5
  42. package/src/schema/ensure-collection-tables.ts +88 -19
  43. package/src/schema/generate-drizzle-schema-logic.ts +8 -2
  44. package/src/schema/generate-postgres-ddl-logic.ts +97 -13
  45. package/src/schema/generated-schema-staleness.ts +169 -0
  46. package/src/schema/introspect-db-logic.ts +1 -1
  47. package/src/schema/non-sql-collections.test.ts +131 -0
  48. package/src/schema/rls-bootstrap-sql.ts +288 -0
  49. package/src/security/anonymous-grants.test.ts +4 -2
  50. package/src/security/rls-enforcement.ts +141 -3
  51. package/src/services/BranchService.ts +5 -0
  52. package/src/services/FetchService.ts +10 -93
  53. package/src/services/PersistService.ts +14 -1
  54. package/src/services/channel-history.ts +8 -0
  55. package/src/services/channel-presence.ts +6 -0
  56. package/src/services/dataService.ts +2 -0
  57. package/src/services/realtimeService.ts +36 -33
  58. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  59. package/dist/policy-CeA1JcxP.js.map +0 -1
  60. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  61. package/dist/src-DlPBctw_.js.map +0 -1
  62. package/dist/src-DoU9yPqq.js.map +0 -1
  63. package/src/schema/auth-bootstrap-sql.ts +0 -47
package/dist/index.es.js CHANGED
@@ -2,11 +2,12 @@ import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { a as guardPoolAgainstDirtyRelease, i as createReadReplicaConnection, n as createDirectDatabaseConnection, o as pinSearchPath, r as createPostgresDatabaseConnection } from "./connection-BuZ97wsr.js";
5
- import { A as toSnakeCase, C as normalizeToEntityRelation, D as getPolicyNamesForRule, F as Vector, N as hasForeignKeyOnTarget, O as mergeDeep, P as isManyToMany, S as createRelationRefWithData, T as generateForeignKeyName, _ as buildCompositeId, a as getJunctionSecurityRules, b as parseIdValues, c as policyToPostgres, d as findRelation, f as getColumnName, g as resolveCollectionRelations, h as getTableVarName, i as getJunctionCollectionConfig, k as camelCase, l as securityRuleToConditions, m as getTableName$1, n as CollectionRegistry, o as resolveJunctionSpecs, p as getEnumVarName, r as resolveStringColumnLength, s as getEffectiveSecurityRules, t as buildSdkData, u as findAnonymousGrants, v as getDeclaredPrimaryKeys, w as updateDateAutoValues, x as createRelationRef, y as isAddressableId } from "./src-DlPBctw_.js";
6
- import { n as isPostgresCollectionConfig, r as isRelationalCollectionConfig } from "./src-DoU9yPqq.js";
7
- import { t as ANONYMOUS_USER_ID } from "./policy-CeA1JcxP.js";
8
- import { t as createPostgresWebSocket } from "./websocket-B2LsrINK.js";
9
- import { A as parsePgToolMajor, C as checkToolServerCompatibility, D as parseBackupDestination, E as joinStorageKey, M as serverVersionNumToMajor, N as splitGlobalsStatements, O as parseBackupTimestamp, P as withDatabaseName, S as buildRowSecurityPgOptions, T as globalsFileForDump, _ as buildBackupFilename, a as detectToolMajor, b as buildPgRestoreArgs, c as listBackups, d as resolvePgBinary, f as restoreDump, g as selectBackupsToPrune, h as require_source, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as preflight, m as validateDump, n as applyGlobals, o as ensureDatabaseExists, p as uploadBackup, s as getServerVersionMajor, t as BackupToolError, u as pruneBackups, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-CD8o_1Sl.js";
5
+ import { A as generateForeignKeyName, B as hasForeignKeyOnTarget, C as getDeclaredPrimaryKeys, D as createRelationRefWithData, E as createRelationRef, F as camelCase, H as Vector, I as toSnakeCase, M as getPolicyNamesForRule, N as isPrototypePollutingKey, O as normalizeToEntityRelation, P as mergeDeep, S as buildCompositeId, T as parseIdValues, V as isManyToMany, _ as getColumnName, a as buildSdkData, b as getTableVarName, c as resolveStringColumnLength, d as resolveJunctionSpecs, f as getEffectiveSecurityRules, g as findRelation, h as findAnonymousGrants, j as legacyForeignKeyName, k as updateDateAutoValues, l as getJunctionCollectionConfig, m as securityRuleToConditions, o as CollectionRegistry, p as policyToPostgres, r as authUsersColumnSql, s as relationalCollections, t as AUTH_USERS_COLUMNS, u as getJunctionSecurityRules, v as getEnumVarName, w as isAddressableId, x as resolveCollectionRelations, y as getTableName$1, z as resolveClientListLimit } from "./auth-users-columns-Do9mw5Y5.js";
6
+ import { c as isPostgresCollectionConfig, l as isRelationalCollectionConfig, n as REBASE_SCHEMA, o as usesLegacyRlsFunctions } from "./src-C_wvdMnl.js";
7
+ import { t as ANONYMOUS_USER_ID } from "./policy-CPkCqVTz.js";
8
+ import { t as createPostgresWebSocket } from "./websocket-D0TBU3ia.js";
9
+ import { A as parsePgToolMajor, C as checkToolServerCompatibility, D as parseBackupDestination, E as joinStorageKey, M as serverVersionNumToMajor, N as splitGlobalsStatements, O as parseBackupTimestamp, P as withDatabaseName, S as buildRowSecurityPgOptions, T as globalsFileForDump, _ as buildBackupFilename, a as detectToolMajor, b as buildPgRestoreArgs, c as listBackups, d as resolvePgBinary, f as restoreDump, g as selectBackupsToPrune, h as require_source, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as preflight, m as validateDump, n as applyGlobals, o as ensureDatabaseExists, p as uploadBackup, s as getServerVersionMajor, t as BackupToolError, u as pruneBackups, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-Bww-Lg0s.js";
10
+ import { t as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-Bpv3nUZo.js";
10
11
  import { Client, Pool } from "pg";
11
12
  import { drizzle } from "drizzle-orm/node-postgres";
12
13
  import { ApiError, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
@@ -30,25 +31,6 @@ import { randomUUID } from "crypto";
30
31
  function isChannelBusInstance(setting) {
31
32
  return typeof setting?.publish === "function";
32
33
  }
33
- /**
34
- * Resolve a client-supplied list `limit` into a safe, always-defined value.
35
- *
36
- * - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
37
- * so `0`, negatives, and absurd values can never bypass the cap.
38
- * - An absent / blank / non-numeric limit falls back to the mode default:
39
- * `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
40
- *
41
- * The return is never `undefined` — no ingress that routes its client limit
42
- * through this can produce an unbounded read.
43
- */
44
- function resolveClientListLimit(rawLimit, opts = {}) {
45
- const maxLimit = opts.maxLimit ?? 1e3;
46
- if (rawLimit != null && String(rawLimit).trim() !== "") {
47
- const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
48
- if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
49
- }
50
- return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
51
- }
52
34
  //#endregion
53
35
  //#region ../common/src/util/email.ts
54
36
  /**
@@ -152,6 +134,142 @@ var buildPropertyCallbacks = (properties) => {
152
134
  return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
153
135
  };
154
136
  //#endregion
137
+ //#region ../common/src/util/internal-tables.ts
138
+ /**
139
+ * The tables Rebase creates for its own bookkeeping, and the SQL that keeps the
140
+ * end-user role away from them.
141
+ *
142
+ * ## Why this exists
143
+ *
144
+ * Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role
145
+ * provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table
146
+ * in the schemas a project uses — including `rebase`, because a project's own
147
+ * collections are allowed to live there (the scaffold puts `users` there). It
148
+ * also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the
149
+ * migrating role inherits the same grant.
150
+ *
151
+ * Every framework-internal table is created later: auth's tables come up during
152
+ * `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first
153
+ * job registers, `idempotency_keys` on the first request that carries a key. So
154
+ * they all inherited full DML for the end-user role — and none of them enables
155
+ * row-level security, because none of them is a collection with
156
+ * `securityRules`. Measured on a freshly provisioned database, `SET ROLE
157
+ * rebase_user` could read `rebase.refresh_tokens` (session token hashes),
158
+ * `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and
159
+ * `rebase.api_keys` (including its `admin` flag), and insert into
160
+ * `rebase.app_config`.
161
+ *
162
+ * Nothing routes a user-context query at those tables today, so this was not
163
+ * reachable over the API. That is the wrong thing to depend on: the documented
164
+ * model is that RLS is the authorization boundary, and these tables sat outside
165
+ * it. The boundary is now a privilege boundary instead — the role simply cannot
166
+ * address them.
167
+ *
168
+ * ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY
169
+ *
170
+ * RLS with no policy denies every row, which is the same outcome, but it is the
171
+ * *weaker* statement: it leaves the grant in place, so a later policy — or a
172
+ * `FORCE` flag cleared by some future migration — reopens the table. There is no
173
+ * row of `refresh_tokens` any end user should ever reach, so the honest encoding
174
+ * is "this role has no privilege here at all". It also keeps the owner
175
+ * connection (which auth actually runs on) completely unaffected.
176
+ *
177
+ * ## Keeping it true
178
+ *
179
+ * `packages/rls-check` scans the `rebase` schema — it used to skip it as a
180
+ * "platform" schema — and its `rls-disabled` check fires on exactly the
181
+ * condition this module removes: RLS off *and* a DML grant to a reachable role.
182
+ * So a table added here without a revoke is caught by `pnpm rls:check`, not by
183
+ * someone re-reading this file.
184
+ */
185
+ /**
186
+ * The Postgres role authenticated requests run as.
187
+ *
188
+ * Defined here rather than in the Postgres driver because both the driver (which
189
+ * provisions the role) and this module (which revokes on its behalf) need it,
190
+ * and a second spelling of a role name is a silent no-op waiting to happen.
191
+ */
192
+ var REBASE_USER_ROLE = "rebase_user";
193
+ /**
194
+ * Framework-internal table names, unqualified.
195
+ *
196
+ * Deliberately NOT including `users`: the auth user table is also a collection,
197
+ * with `securityRules`, RLS enabled and policies applied. Users read their own
198
+ * row through it — revoking there would break sign-in.
199
+ *
200
+ * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
201
+ * because `db migrate apply` passes `--revisions-schema rebase`.
202
+ */
203
+ var REBASE_INTERNAL_TABLES = [
204
+ "user_identities",
205
+ "refresh_tokens",
206
+ "password_reset_tokens",
207
+ "magic_link_tokens",
208
+ "mfa_factors",
209
+ "mfa_challenges",
210
+ "recovery_codes",
211
+ "app_config",
212
+ "schema_meta",
213
+ "api_keys",
214
+ "cron_logs",
215
+ "cron_claims",
216
+ "idempotency_keys",
217
+ "entity_history",
218
+ "branches",
219
+ "channel_messages",
220
+ "channel_cursors",
221
+ "channel_presence",
222
+ "atlas_schema_revisions"
223
+ ];
224
+ /** Postgres identifiers this module is willing to interpolate. */
225
+ var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
226
+ /**
227
+ * A single statement that takes every privilege on `schema.table` away from the
228
+ * end-user role.
229
+ *
230
+ * Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which
231
+ * happen in practice:
232
+ *
233
+ * - the role does not exist when the connection is unprivileged (Rebase then
234
+ * relies on native RLS rather than a role switch), and a bare `REVOKE` on a
235
+ * missing role is an error, not a no-op;
236
+ * - the table may not exist yet — `cron_logs` never appears in a project with
237
+ * no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
238
+ *
239
+ * One command, so it is safe on handles that speak the extended query protocol
240
+ * and reject multi-statement strings.
241
+ */
242
+ function revokeInternalTableSql(schema, table) {
243
+ if (!SAFE_IDENTIFIER.test(schema)) throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);
244
+ if (!SAFE_IDENTIFIER.test(table)) throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);
245
+ const qualified = `"${schema}"."${table}"`;
246
+ return `
247
+ DO $rebase_revoke$
248
+ BEGIN
249
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')
250
+ AND to_regclass('${qualified}') IS NOT NULL THEN
251
+ EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';
252
+ END IF;
253
+ END
254
+ $rebase_revoke$;
255
+ `.trim();
256
+ }
257
+ /**
258
+ * Revoke on every internal table in `schema`, one statement at a time.
259
+ *
260
+ * Best-effort per table: a connection that does not own one of them (a
261
+ * pre-provisioned database, a platform-managed ledger) cannot revoke on it, and
262
+ * that must not take down a boot. The caller decides how loud to be — `onError`
263
+ * exists so the driver can warn without this module importing a logger.
264
+ */
265
+ async function revokeInternalTableAccess(execute, schema, options) {
266
+ for (const table of options?.tables ?? REBASE_INTERNAL_TABLES) try {
267
+ await execute(revokeInternalTableSql(schema, table));
268
+ } catch (error) {
269
+ options?.onError?.(table, error);
270
+ }
271
+ }
272
+ //#endregion
155
273
  //#region ../common/src/table-classification.ts
156
274
  /** Schemas that are always considered Rebase-internal. */
157
275
  var REBASE_INTERNAL_SCHEMAS = ["rebase", "auth"];
@@ -1276,7 +1394,11 @@ function sanitizeAndConvertDates(obj) {
1276
1394
  if (obj instanceof Date) return obj.toISOString();
1277
1395
  if (typeof obj === "object") {
1278
1396
  const newObj = {};
1279
- for (const key in obj) if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = sanitizeAndConvertDates(obj[key]);
1397
+ for (const key in obj) {
1398
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
1399
+ if (isPrototypePollutingKey(key)) continue;
1400
+ newObj[key] = sanitizeAndConvertDates(obj[key]);
1401
+ }
1280
1402
  return newObj;
1281
1403
  }
1282
1404
  if (typeof obj === "string") {
@@ -1305,6 +1427,7 @@ function serializeDataToServer(row, properties, collection, registry) {
1305
1427
  if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
1306
1428
  });
1307
1429
  for (const [key, value] of Object.entries(row)) {
1430
+ if (isPrototypePollutingKey(key)) continue;
1308
1431
  const property = properties[key];
1309
1432
  const effectiveValue = foreignKeys.has(key) && value === "" ? null : value;
1310
1433
  if (!property) {
@@ -3796,51 +3919,6 @@ var FetchService = class {
3796
3919
  return !!this.getQueryBuilder(tableName);
3797
3920
  }
3798
3921
  /**
3799
- * Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
3800
- * for efficient JOIN-based relation loading.
3801
- * Returns null if the API is not available or the query fails.
3802
- * Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
3803
- */
3804
- async fetchWithDrizzleQuery(collectionPath, collection, options, include, idInfo, idInfoArray) {
3805
- try {
3806
- const table = getTableForCollection(collection, this.registry);
3807
- const tableName = getTableName(table);
3808
- const queryTarget = this.getQueryBuilder(tableName);
3809
- if (!queryTarget?.findMany) return null;
3810
- const resolvedRelations = resolveCollectionRelations(collection);
3811
- const withConfig = {};
3812
- for (const [key, relation] of Object.entries(resolvedRelations)) if (include[0] === "*" || include.includes(key)) {
3813
- const drizzleRelName = relation.relationName || key;
3814
- withConfig[drizzleRelName] = true;
3815
- }
3816
- const queryOpts = { with: withConfig };
3817
- if (options.limit) queryOpts.limit = options.limit;
3818
- if (options.filter) {
3819
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
3820
- if (filterConditions.length > 0) queryOpts.where = and(...filterConditions);
3821
- }
3822
- if (options.orderBy) {
3823
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
3824
- if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
3825
- }
3826
- return (await queryTarget.findMany(queryOpts)).map((row) => {
3827
- const flat = {};
3828
- for (const [k, v] of Object.entries(row)) if (Array.isArray(v)) flat[k] = v.map((item) => {
3829
- const keys = Object.keys(item);
3830
- const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
3831
- if (nestedObj && keys.length <= 3) return { ...item[nestedObj] };
3832
- return { ...item };
3833
- });
3834
- else if (typeof v === "object" && v !== null) flat[k] = { ...v };
3835
- else flat[k] = v;
3836
- return flat;
3837
- });
3838
- } catch (e) {
3839
- logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
3840
- return null;
3841
- }
3842
- }
3843
- /**
3844
3922
  * Fallback path used when db.query is unavailable.
3845
3923
  * The primary path uses db.query.findMany with `with` config, which
3846
3924
  * loads all relations in a single query.
@@ -4091,7 +4169,8 @@ var PersistService = class {
4091
4169
  if (error instanceof ApiError || error?.name === "ApiError") return error;
4092
4170
  const pgError = extractPgError(error);
4093
4171
  if (pgError) {
4094
- const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
4172
+ const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
4173
+ if (/^2[23]/.test(code)) return code === "23505" ? ApiError.conflict(message, `PG_${code}`) : ApiError.badRequest(message, `PG_${code}`);
4095
4174
  return new Error(message);
4096
4175
  }
4097
4176
  const causeMessage = extractCauseMessage(error);
@@ -4316,6 +4395,7 @@ var BranchService = class {
4316
4395
  metadata JSONB DEFAULT '{}'
4317
4396
  );
4318
4397
  `));
4398
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "branches")));
4319
4399
  }
4320
4400
  /**
4321
4401
  * Create a new branch database by templating the source database.
@@ -4423,43 +4503,49 @@ var BranchService = class {
4423
4503
  };
4424
4504
  //#endregion
4425
4505
  //#region src/security/rls-enforcement.ts
4506
+ var quoteIdent$1 = (name) => `"${name.replace(/"/g, "\"\"")}"`;
4507
+ /** DML the user role holds on managed tables (RLS still filters per row). */
4508
+ var USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
4426
4509
  /**
4427
- * Unified RLS enforcement the "user context vs server context" model.
4428
- *
4429
- * Every operation runs in one of two contexts:
4510
+ * Warn when the connection role shares its name with an existing schema.
4430
4511
  *
4431
- * - **User context** a request authenticated (or anonymous) via
4432
- * `driver.withAuth(user)`. Runs as the restricted `rebase_user` role: a
4433
- * non-owner, NOSUPERUSER, NOBYPASSRLS role, so Postgres RLS binds *every*
4434
- * statement (SELECT, INSERT, UPDATE, DELETE). The collection's
4435
- * `securityRules` are the whole authorization model; app-layer callbacks
4436
- * are validation/side-effects, not a security boundary.
4512
+ * Postgres resolves unqualified names through `search_path`, which defaults to
4513
+ * `"$user", public` and `$user` is the connection ROLE. When a schema of that
4514
+ * name exists it sits ahead of `public`, so every unqualified statement
4515
+ * silently operates on it instead:
4437
4516
  *
4438
- * - **Server context** the base (owner) connection: auth flows, migrations,
4439
- * background jobs, and the explicit `rebase.dataAsAdmin` accessor. As table
4440
- * owner it bypasses RLS. This is the trusted plane, equivalent to
4441
- * Supabase's `service_role`.
4517
+ * CREATE TABLE posts (...); -- you meant public.posts; you got <role>.posts
4442
4518
  *
4443
- * This module provides the three pieces:
4519
+ * Nothing errors. You get a second table of the same name in the wrong schema,
4520
+ * and reads that pin `public` cannot see it — which reads as "missing table" and
4521
+ * sends people to re-run a push that creates a *third* copy. The bootstrapper
4522
+ * has a whole branch dedicated to recognising the symptom after the fact.
4444
4523
  *
4445
- * 1. {@link detectConnectionPosture} is the connection subject to RLS at
4446
- * all? (superuser / BYPASSRLS / table owner no)
4447
- * 2. {@link ensureAppRole} idempotently provision `rebase_user` with
4448
- * SELECT/INSERT/UPDATE/DELETE grants (+ default privileges so future
4449
- * tables stay covered).
4450
- * 3. {@link applyAuthContext}per-transaction: set the `app.*` GUCs the
4451
- * policies read (`auth.uid()` etc.) and `SET LOCAL ROLE rebase_user` so
4452
- * RLS binds. Transaction-scoped, so it composes with poolers.
4524
+ * Rebase shipped straight into this: it creates a schema named `rebase` while
4525
+ * every template named the database role `rebase` too. The scaffold uses
4526
+ * `rebase_app` now, and every pool Rebase opens pins `search_path=public`
4527
+ * (`pinSearchPath`), which covers the paths the framework controls. This covers
4528
+ * the ones it does not — `psql`, `pg_dump`, drizzle-kit, a colleague's script,
4529
+ * a hand-written migration because the hazard is a property of the two NAMES,
4530
+ * not of any one connection.
4453
4531
  *
4454
- * Provisioning runs from the framework's own bootstrap/migrate (which already
4455
- * self-creates the `auth` schema and functions) enforcement is default-on,
4456
- * not an operator opt-in.
4532
+ * A warning rather than a boot failure: the database works, the framework's own
4533
+ * traffic is pinned, and refusing to start over a naming choice a user may have
4534
+ * inherited would be worse than the risk.
4457
4535
  */
4458
- /** The restricted role every authenticated (user-context) request runs as. */
4459
- var REBASE_USER_ROLE = "rebase_user";
4460
- var quoteIdent$1 = (name) => `"${name.replace(/"/g, "\"\"")}"`;
4461
- /** DML the user role holds on managed tables (RLS still filters per row). */
4462
- var USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
4536
+ async function warnOnRoleSchemaCollision(run) {
4537
+ try {
4538
+ const rows = await run(`
4539
+ SELECT current_user AS role,
4540
+ EXISTS (
4541
+ SELECT 1 FROM pg_namespace n WHERE n.nspname = current_user
4542
+ ) AS collides
4543
+ `);
4544
+ if (rows[0]?.collides !== true) return;
4545
+ const role = String(rows[0]?.role ?? "the connection role");
4546
+ logger.warn(`⚠️ The database role "${role}" has the same name as a schema. Postgres resolves unqualified names through \`search_path\`, which defaults to \`"$user", public\` — so "${role}" is searched BEFORE public, and any unqualified \`CREATE TABLE\`/\`SELECT\` from a tool that does not pin the path (psql, pg_dump, drizzle-kit, a hand-written migration) silently lands in "${role}" instead. Rebase's own connections pin \`search_path=public\`, so the server is unaffected. To remove the hazard entirely, connect as a role whose name is not also a schema — the scaffold uses "rebase_app".`);
4547
+ } catch {}
4548
+ }
4463
4549
  async function detectConnectionPosture(run) {
4464
4550
  const row = (await run(`
4465
4551
  SELECT current_user AS role,
@@ -4535,6 +4621,9 @@ async function ensureAppRole(run, schemas) {
4535
4621
  await run(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
4536
4622
  await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
4537
4623
  await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);
4624
+ await revokeInternalTableAccess(async (text) => {
4625
+ await run(text);
4626
+ }, schema, { onError: (table, error) => logger.warn(`🔐 [rls] Could not revoke "${REBASE_USER_ROLE}" access to "${schema}"."${table}" — it stays reachable by authenticated requests: ` + (error instanceof Error ? error.message : String(error))) });
4538
4627
  }
4539
4628
  logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
4540
4629
  }
@@ -4620,6 +4709,40 @@ function warnOnAnonymousGrants(collections) {
4620
4709
  logger.warn(`Security rules that read as a lockdown but grant access to anonymous requests. Every caller from a client carries a user id ('${ANONYMOUS_USER_ID}' when nobody is signed in), so these clauses are true for everyone:\n\n` + problems.join("\n\n") + "\n");
4621
4710
  }
4622
4711
  /**
4712
+ * Name the collections whose raw policy SQL still calls the pre-1.0 helpers.
4713
+ *
4714
+ * The compiler rewrites `auth.uid()` to `rebase.uid()` on the way into the
4715
+ * database, so nothing is broken and no policy is wrong — which is exactly why
4716
+ * this has to be said out loud. A silent rewrite that works forever is not a
4717
+ * migration, it is a second supported spelling nobody wrote down, and the next
4718
+ * person to read those rules will copy the old one.
4719
+ *
4720
+ * Only `raw` expressions can carry it. Structured rules (`policy.authUid()`,
4721
+ * `policy.rolesOverlap(...)`) compile from the model and were never affected.
4722
+ */
4723
+ function warnOnLegacyRlsFunctions(collections) {
4724
+ const sites = [];
4725
+ for (const collection of collections) for (const rule of collection.securityRules ?? []) {
4726
+ const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
4727
+ if (![usingExpr, withCheckExpr].filter((e) => e !== null).some(containsLegacyRlsCall)) continue;
4728
+ const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
4729
+ if (!sites.includes(site)) sites.push(site);
4730
+ }
4731
+ if (sites.length === 0) return;
4732
+ logger.warn(`These security rules call the pre-1.0 RLS helpers (\`auth.uid()\`, \`auth.roles()\`, \`auth.jwt()\`). They still work — the compiler rewrites them — but the functions now live in the \`rebase\` schema, and the \`auth\` one is Supabase's. Update the raw SQL in these rules to \`${REBASE_SCHEMA}.uid()\` and friends, or switch them to the structured helpers (\`policy.authUid()\`, \`policy.rolesOverlap()\`), which never had to be spelled by hand:\n\n` + sites.map((s) => ` • ${s}`).join("\n") + "\n");
4733
+ }
4734
+ /** Whether any `raw` expression in the tree calls a pre-1.0 helper. */
4735
+ function containsLegacyRlsCall(expr) {
4736
+ switch (expr.kind) {
4737
+ case "raw": return usesLegacyRlsFunctions(expr.sql);
4738
+ case "and":
4739
+ case "or": return expr.operands.some(containsLegacyRlsCall);
4740
+ case "not": return containsLegacyRlsCall(expr.operand);
4741
+ case "existsIn": return containsLegacyRlsCall(expr.where);
4742
+ default: return false;
4743
+ }
4744
+ }
4745
+ /**
4623
4746
  * Reject `pgRoles` that this server can never satisfy.
4624
4747
  *
4625
4748
  * `pgRoles` sets the `TO` clause of a generated policy, so a policy naming a
@@ -4749,6 +4872,25 @@ var PostgresBackendDriver = class PostgresBackendDriver {
4749
4872
  }
4750
4873
  };
4751
4874
  }
4875
+ /**
4876
+ * Build the context handed to every collection callback.
4877
+ *
4878
+ * Note `data: this.data` — `this` is whichever driver is running the
4879
+ * operation, so the callback's data plane inherits that driver's privilege.
4880
+ * On a user request `AuthenticatedPostgresBackendDriver.withTransaction`
4881
+ * constructs a fresh base driver bound to the RLS-scoped transaction and
4882
+ * runs the operation on it, so `this.data` speaks through that connection
4883
+ * and policies apply. On server-context work `this` is the base driver on
4884
+ * the owner connection, and they do not. Pinned by the
4885
+ * `"scopes context.data to the caller"` case in the `rls-enforcement` e2e
4886
+ * suite, because it is the kind of property that is easy to break from a
4887
+ * distance and impossible to notice.
4888
+ *
4889
+ * Previously returned through `as unknown as RebaseCallContext`, which
4890
+ * disabled checking for the whole object and let `driver` — documented in
4891
+ * the callbacks guide — sit on the runtime context while absent from the
4892
+ * contract. Both are declared now, so this is a plain typed return.
4893
+ */
4752
4894
  buildCallContext() {
4753
4895
  return {
4754
4896
  user: this.user,
@@ -5204,6 +5346,106 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5204
5346
  return saved;
5205
5347
  });
5206
5348
  }
5349
+ /**
5350
+ * Update many rows through the same pipeline as {@link save}, in one
5351
+ * transaction.
5352
+ *
5353
+ * Structurally the mirror of {@link saveMany} — same tx-bound sub-driver,
5354
+ * same deferred notifications, same per-row error labelling — but it calls
5355
+ * `save` with an explicit `id` and `status: "existing"`, which is precisely
5356
+ * what `saveMany` cannot do: that one passes `status: "new"` and keeps the
5357
+ * key inside `values`, so it inserts or upserts and can never target a
5358
+ * particular row.
5359
+ *
5360
+ * All-or-nothing, so an id matching no row aborts the batch. A partial
5361
+ * update is the outcome with no good recovery: the caller cannot tell which
5362
+ * half landed without re-reading everything.
5363
+ */
5364
+ async updateMany({ path, updates, collection }) {
5365
+ return this.db.transaction(async (tx) => {
5366
+ const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
5367
+ txDriver.dataService = new DataService(tx, this.registry);
5368
+ txDriver.client = this.client;
5369
+ txDriver._deferNotifications = this._deferNotifications;
5370
+ txDriver._pendingNotifications = this._pendingNotifications;
5371
+ const saved = [];
5372
+ for (let i = 0; i < updates.length; i++) {
5373
+ const { id, values } = updates[i];
5374
+ try {
5375
+ if (!await txDriver.fetchOne({
5376
+ path,
5377
+ id: String(id),
5378
+ collection
5379
+ })) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
5380
+ statusCode: 404,
5381
+ code: "NOT_FOUND"
5382
+ });
5383
+ saved.push(await txDriver.save({
5384
+ path,
5385
+ id: String(id),
5386
+ values,
5387
+ collection,
5388
+ status: "existing"
5389
+ }));
5390
+ } catch (error) {
5391
+ throw Object.assign(new Error(`Update ${i} of ${updates.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
5392
+ statusCode: error?.statusCode,
5393
+ code: error?.code,
5394
+ name: error?.name
5395
+ });
5396
+ }
5397
+ }
5398
+ return saved;
5399
+ });
5400
+ }
5401
+ /**
5402
+ * Delete many rows in one transaction, running the full delete pipeline —
5403
+ * `beforeDelete`, the delete, `afterDelete` — for each.
5404
+ *
5405
+ * Looping the single-row {@link delete} rather than emitting one
5406
+ * `DELETE ... WHERE id = ANY($1)` is the deliberate choice: a single
5407
+ * statement would be faster and would skip every callback, so a collection
5408
+ * relying on `beforeDelete` to veto or on `afterDelete` to clean up
5409
+ * dependents would behave differently depending on how many rows the caller
5410
+ * happened to delete at once. Same pipeline, one transaction.
5411
+ */
5412
+ async deleteMany({ path, ids, collection }) {
5413
+ await this.db.transaction(async (tx) => {
5414
+ const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
5415
+ txDriver.dataService = new DataService(tx, this.registry);
5416
+ txDriver.client = this.client;
5417
+ txDriver._deferNotifications = this._deferNotifications;
5418
+ txDriver._pendingNotifications = this._pendingNotifications;
5419
+ for (let i = 0; i < ids.length; i++) {
5420
+ const id = ids[i];
5421
+ try {
5422
+ const existing = await txDriver.fetchOne({
5423
+ path,
5424
+ id: String(id),
5425
+ collection
5426
+ });
5427
+ if (!existing) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
5428
+ statusCode: 404,
5429
+ code: "NOT_FOUND"
5430
+ });
5431
+ await txDriver.delete({
5432
+ row: {
5433
+ id: String(id),
5434
+ path,
5435
+ values: existing
5436
+ },
5437
+ collection
5438
+ });
5439
+ } catch (error) {
5440
+ throw Object.assign(new Error(`Delete ${i} of ${ids.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
5441
+ statusCode: error?.statusCode,
5442
+ code: error?.code,
5443
+ name: error?.name
5444
+ });
5445
+ }
5446
+ }
5447
+ });
5448
+ }
5207
5449
  async delete({ row, collection }) {
5208
5450
  const targetPath = row.path;
5209
5451
  const targetRow = { ...row.values ?? {} };
@@ -6168,7 +6410,8 @@ var computeSharedRelationName = (rel, sourceCollection, _collections) => {
6168
6410
  }
6169
6411
  return fallback;
6170
6412
  };
6171
- var generateSchema = async (collections, stripPolicies = false) => {
6413
+ var generateSchema = async (allCollections, stripPolicies = false) => {
6414
+ const collections = relationalCollections(allCollections);
6172
6415
  let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
6173
6416
  const hasUuid = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "string" && (p.autoValue === "uuid" || p.isId === "uuid")));
6174
6417
  const hasVector = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "vector"));
@@ -6982,6 +7225,8 @@ var ChannelHistoryStore = class {
6982
7225
  last_seq BIGINT NOT NULL
6983
7226
  )
6984
7227
  `);
7228
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")));
7229
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")));
6985
7230
  this.tablesReady = true;
6986
7231
  logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
6987
7232
  }
@@ -7165,6 +7410,7 @@ var ChannelPresenceStore = class {
7165
7410
  CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
7166
7411
  ON rebase.channel_presence (last_seen)
7167
7412
  `);
7413
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")));
7168
7414
  this.tablesReady = true;
7169
7415
  }
7170
7416
  /** Record (or refresh) a client's presence. */
@@ -7816,23 +8062,18 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7816
8062
  path: request.path,
7817
8063
  collectionRequest: {
7818
8064
  filter: request.filter,
8065
+ logical: request.logical,
7819
8066
  orderBy: request.orderBy,
7820
8067
  order: request.order,
7821
8068
  limit: boundedLimit,
8069
+ offset: request.offset,
7822
8070
  startAfter: request.startAfter,
7823
8071
  databaseId: request.collection?.databaseId,
7824
8072
  searchString: request.searchString
7825
8073
  },
7826
8074
  authContext
7827
8075
  });
7828
- const rows = await this.fetchCollectionWithAuth(request.path, {
7829
- filter: request.filter,
7830
- orderBy: request.orderBy,
7831
- order: request.order,
7832
- limit: boundedLimit,
7833
- startAfter: request.startAfter,
7834
- searchString: request.searchString
7835
- }, authContext);
8076
+ const rows = await this.fetchCollectionWithAuth(request.path, this._subscriptions.get(subscriptionId).collectionRequest, authContext);
7836
8077
  this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
7837
8078
  } catch (error) {
7838
8079
  const sanitized = sanitizeErrorForClient(error, request.path);
@@ -8016,6 +8257,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8016
8257
  });
8017
8258
  else fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
8018
8259
  filter: collectionRequest.filter,
8260
+ logical: collectionRequest.logical,
8019
8261
  orderBy: collectionRequest.orderBy,
8020
8262
  order: collectionRequest.order,
8021
8263
  limit: collectionRequest.limit,
@@ -9131,7 +9373,7 @@ function createBackupCron(config) {
9131
9373
  enabled: config.enabled ?? true,
9132
9374
  timeoutSeconds: 3600,
9133
9375
  async handler({ log }) {
9134
- const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-CD8o_1Sl.js").then((n) => n.r);
9376
+ const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-Bww-Lg0s.js").then((n) => n.r);
9135
9377
  const { destination } = config;
9136
9378
  if (destination.kind !== "local" && !config.storage) throw new Error(`Backup destination is ${destination.kind} but no storage controller was provided. Pass the backend's configured StorageController to createBackupCron({ storage }).`);
9137
9379
  log(`Starting backup of "${dbName}"…`);
@@ -9203,6 +9445,54 @@ var quote = (xs) => Array.from(xs).map((s) => `\`${s}\``).join(", ");
9203
9445
  /** `on.from` / `on.to` accept a single column or a composite tuple. */
9204
9446
  var asColumns = (value) => Array.isArray(value) ? value : [value];
9205
9447
  /**
9448
+ * Distinguish "this column name is wrong" from "the generated schema is old".
9449
+ *
9450
+ * They present identically here — a relation asks for a column the registered
9451
+ * table does not have — but they are opposite problems with opposite fixes, and
9452
+ * getting them the wrong way round is how the 0.12 → 0.13 upgrade bricked
9453
+ * projects.
9454
+ *
9455
+ * The registered table is not the database. It comes from the project's
9456
+ * checked-in `backend/src/schema.generated.ts`, and 0.13 changed the rule that
9457
+ * derives foreign-key names: `categories` yields `category_id` where it used to
9458
+ * yield `categorie_id`. Boot-ensure renames the database column to match, so by
9459
+ * the time this runs the *database* is correct and the *generated module* is the
9460
+ * stale one. Reporting "not a column" then points at the wrong artifact, and the
9461
+ * generic fix — "set `through.targetColumn` to one of: …", listing the legacy
9462
+ * name because that is what the stale module still has — talks the reader into
9463
+ * pinning a column that no longer exists.
9464
+ *
9465
+ * So when the wanted name is what the current rule derives, and the table
9466
+ * carries what the *previous* rule would have derived from the same source, say
9467
+ * that instead.
9468
+ *
9469
+ * @param wanted the column the relation asks for
9470
+ * @param available every column the registered table has
9471
+ * @param sources names the default could have been derived from (a slug, a
9472
+ * relation name) — checking against these rather than guessing
9473
+ * backwards from `wanted` keeps the match exact
9474
+ */
9475
+ function staleCodegenRename(wanted, available, sources) {
9476
+ for (const source of sources) {
9477
+ if (!source) continue;
9478
+ const current = generateForeignKeyName(source);
9479
+ const legacy = legacyForeignKeyName(source);
9480
+ if (current !== wanted || legacy === current) continue;
9481
+ if (available.has(legacy) && !available.has(current)) return {
9482
+ legacy,
9483
+ current
9484
+ };
9485
+ }
9486
+ return null;
9487
+ }
9488
+ /** The shared explanation, so every relation kind reports it identically. */
9489
+ function staleCodegenDefect(table, { legacy, current }) {
9490
+ return {
9491
+ problem: `the generated Drizzle schema still declares \`${legacy}\` on \`${table}\`, but this release derives \`${current}\` — the generated schema predates the foreign-key naming fix and no longer describes the database`,
9492
+ fix: `regenerate it with \`rebase schema generate\` (or \`pnpm run schema:generate\`). The database column has already been renamed for you at boot, so nothing else is needed. To keep \`${legacy}\` instead, name it explicitly on the relation and regenerate.`
9493
+ };
9494
+ }
9495
+ /**
9206
9496
  * Relations whose names do not resolve against the registered schema.
9207
9497
  *
9208
9498
  * Fails open wherever it cannot see enough to be sure — an unregistered source
@@ -9249,19 +9539,31 @@ function findRelationDefects(collections, registry) {
9249
9539
  const targetColumns = columnNames(targetTable);
9250
9540
  switch (relation.kind) {
9251
9541
  case "belongsTo":
9252
- if (!sourceColumns.has(relation.localKey)) defects.push({
9253
- ...at,
9254
- problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
9255
- fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
9256
- });
9542
+ if (!sourceColumns.has(relation.localKey)) {
9543
+ const stale = staleCodegenRename(relation.localKey, sourceColumns, [relation.relationName, targetCollection.slug]);
9544
+ defects.push(stale ? {
9545
+ ...at,
9546
+ ...staleCodegenDefect(sourceTableName, stale)
9547
+ } : {
9548
+ ...at,
9549
+ problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
9550
+ fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
9551
+ });
9552
+ }
9257
9553
  break;
9258
9554
  case "hasOne":
9259
9555
  case "hasMany":
9260
- if (!targetColumns.has(relation.foreignKeyOnTarget)) defects.push({
9261
- ...at,
9262
- problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
9263
- fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
9264
- });
9556
+ if (!targetColumns.has(relation.foreignKeyOnTarget)) {
9557
+ const stale = staleCodegenRename(relation.foreignKeyOnTarget, targetColumns, [collection.slug]);
9558
+ defects.push(stale ? {
9559
+ ...at,
9560
+ ...staleCodegenDefect(targetTableName, stale)
9561
+ } : {
9562
+ ...at,
9563
+ problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
9564
+ fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
9565
+ });
9566
+ }
9265
9567
  if (relation.sourceKey && !sourceColumns.has(relation.sourceKey)) defects.push({
9266
9568
  ...at,
9267
9569
  problem: `\`sourceKey: "${relation.sourceKey}"\` is not a column on \`${sourceTableName}\``,
@@ -9280,11 +9582,21 @@ function findRelationDefects(collections, registry) {
9280
9582
  break;
9281
9583
  }
9282
9584
  const junctionColumns = columnNames(junction);
9283
- for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]]) if (!junctionColumns.has(column)) defects.push({
9284
- ...at,
9285
- problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
9286
- fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` + (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
9287
- });
9585
+ const derivedFrom = {
9586
+ sourceColumn: [collection.slug],
9587
+ targetColumn: [targetCollection.slug]
9588
+ };
9589
+ for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]]) if (!junctionColumns.has(column)) {
9590
+ const stale = staleCodegenRename(column, junctionColumns, [...derivedFrom[label]]);
9591
+ defects.push(stale ? {
9592
+ ...at,
9593
+ ...staleCodegenDefect(table, stale)
9594
+ } : {
9595
+ ...at,
9596
+ problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
9597
+ fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` + (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
9598
+ });
9599
+ }
9288
9600
  break;
9289
9601
  }
9290
9602
  case "via": {
@@ -9356,7 +9668,16 @@ function assertRelationsResolve(collections, registry) {
9356
9668
  const defects = findRelationDefects(collections, registry);
9357
9669
  if (defects.length === 0) return;
9358
9670
  const lines = defects.map((d) => ` • ${d.collection}.${d.relationName} (${d.kind})\n ${d.problem}\n fix: ${d.fix}`);
9359
- throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against the database schema.\n\nEach of these would return no rows at query time rather than reporting an error, so they are fatal at boot instead.
9671
+ throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against \`backend/src/schema.generated.ts\`.
9672
+
9673
+ Each of these would return no rows at query time rather than reporting an error, so they are fatal at boot instead.
9674
+
9675
+ If the database was migrated recently — an upgrade, a \`db push\`, a restore — this file is
9676
+ probably older than the schema it describes. Regenerate it before changing anything else:
9677
+
9678
+ rebase schema generate
9679
+
9680
+ If it is already current, then the collection is what disagrees with it:
9360
9681
 
9361
9682
  ` + lines.join("\n\n") + "\n");
9362
9683
  }
@@ -9567,7 +9888,7 @@ async function ensureAuthTablesExist(db, collection) {
9567
9888
  if (idProp) {
9568
9889
  const isId = "isId" in idProp ? idProp.isId : void 0;
9569
9890
  if (isId === "uuid") userIdType = "UUID";
9570
- else if (isId === "autoincrement") userIdType = "INTEGER";
9891
+ else if (isId === "increment") userIdType = "INTEGER";
9571
9892
  }
9572
9893
  }
9573
9894
  try {
@@ -9600,43 +9921,42 @@ async function ensureAuthTablesExist(db, collection) {
9600
9921
  const emailLengthConstraint = `"${authIdentifier("email_length_check")}"`;
9601
9922
  const emailLowerUniqueIndex = authIdentifier("email_lower_key");
9602
9923
  const verificationTokenIndex = authIdentifier("email_verification_token_idx");
9924
+ const usersColumnDdl = AUTH_USERS_COLUMNS.map((spec) => spec.column === "email" ? `${spec.column} ${authUsersColumnSql(spec)} CONSTRAINT ${emailLengthConstraint} CHECK (length(email) <= 320)` : `${spec.column} ${authUsersColumnSql(spec)}`).join(",\n ");
9603
9925
  await db.execute(sql`
9604
9926
  CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
9605
9927
  id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
9606
- email TEXT NOT NULL CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320),
9607
- display_name TEXT,
9608
- photo_url TEXT,
9609
- roles TEXT[] DEFAULT '{}' NOT NULL,
9610
- password_hash TEXT,
9611
- email_verified BOOLEAN DEFAULT FALSE NOT NULL,
9612
- email_verification_token TEXT,
9613
- email_verification_sent_at TIMESTAMP WITH TIME ZONE,
9614
- is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
9615
- metadata JSONB DEFAULT '{}' NOT NULL,
9616
- tokens_valid_after TIMESTAMP WITH TIME ZONE,
9617
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
9618
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
9928
+ ${sql.raw(usersColumnDdl)}
9619
9929
  )
9620
9930
  `);
9621
- await db.execute(sql`
9622
- CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
9623
- BEGIN
9624
- IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
9625
- NEW.uid := NEW.user_id;
9626
- ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
9627
- NEW.user_id := NEW.uid;
9628
- END IF;
9629
- RETURN NEW;
9630
- END $$ LANGUAGE plpgsql
9631
- `);
9632
- for (const authTable of [
9931
+ const legacyFkTables = [
9633
9932
  "user_identities",
9634
9933
  "refresh_tokens",
9635
9934
  "password_reset_tokens",
9636
9935
  "magic_link_tokens",
9637
9936
  "mfa_factors",
9638
9937
  "recovery_codes"
9639
- ]) {
9938
+ ];
9939
+ const legacyFkTableList = legacyFkTables.map((t) => `'${t}'`).join(", ");
9940
+ const legacyUserIdPresent = await db.execute(sql`
9941
+ SELECT 1
9942
+ FROM information_schema.columns
9943
+ WHERE table_schema = ${authSchema}
9944
+ AND table_name IN (${sql.raw(legacyFkTableList)})
9945
+ AND column_name = 'user_id'
9946
+ LIMIT 1
9947
+ `);
9948
+ if (legacyUserIdPresent.rows.length > 0) await db.execute(sql`
9949
+ CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
9950
+ BEGIN
9951
+ IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
9952
+ NEW.uid := NEW.user_id;
9953
+ ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
9954
+ NEW.user_id := NEW.uid;
9955
+ END IF;
9956
+ RETURN NEW;
9957
+ END $$ LANGUAGE plpgsql
9958
+ `);
9959
+ for (const authTable of legacyUserIdPresent.rows.length > 0 ? legacyFkTables : []) {
9640
9960
  const qualified = `"${authSchema}"."${authTable}"`;
9641
9961
  await db.execute(sql`
9642
9962
  DO $$
@@ -9757,54 +10077,50 @@ async function ensureAuthTablesExist(db, collection) {
9757
10077
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
9758
10078
  )
9759
10079
  `);
9760
- await db.execute(sql`CREATE SCHEMA IF NOT EXISTS auth`);
9761
10080
  await db.transaction(async (tx) => {
9762
10081
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
9763
- await tx.execute(sql`
9764
- CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
9765
- SELECT COALESCE(
9766
- NULLIF(current_setting('app.uid', true), ''),
9767
- NULLIF(current_setting('app.user_id', true), '')
9768
- );
9769
- $$ LANGUAGE sql STABLE
9770
- `);
9771
- await tx.execute(sql`
9772
- CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$
9773
- SELECT COALESCE(
9774
- NULLIF(current_setting('app.jwt', true), ''),
9775
- '{}'
9776
- )::jsonb;
9777
- $$ LANGUAGE sql STABLE
9778
- `);
9779
- await tx.execute(sql`
9780
- CREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$
9781
- SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
9782
- $$ LANGUAGE sql STABLE
9783
- `);
10082
+ for (const statement of RLS_BOOTSTRAP_STATEMENTS) await tx.execute(sql.raw(statement));
9784
10083
  });
9785
- for (const columnDef of [
9786
- "display_name TEXT",
9787
- "photo_url TEXT",
9788
- "roles TEXT[] DEFAULT '{}' NOT NULL",
9789
- "password_hash TEXT",
9790
- "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
9791
- "email_verification_token TEXT",
9792
- "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
9793
- "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
9794
- "metadata JSONB DEFAULT '{}' NOT NULL",
9795
- "tokens_valid_after TIMESTAMP WITH TIME ZONE",
9796
- "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
9797
- "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
9798
- ]) await db.execute(sql`
10084
+ for (const spec of AUTH_USERS_COLUMNS) {
10085
+ if (spec.column === "email") continue;
10086
+ await db.execute(sql`
9799
10087
  ALTER TABLE ${sql.raw(usersTableName)}
9800
- ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
10088
+ ADD COLUMN IF NOT EXISTS ${sql.raw(`${spec.column} ${authUsersColumnSql(spec)}`)}
9801
10089
  `);
9802
- const usersColumns = await db.execute(sql`
9803
- SELECT column_name, data_type
10090
+ }
10091
+ const usersColumnRows = (await db.execute(sql`
10092
+ SELECT column_name, data_type, is_nullable, column_default
9804
10093
  FROM information_schema.columns
9805
10094
  WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
9806
- `);
9807
- const usersColumnTypes = new Map(usersColumns.rows.map((row) => [row.column_name, row.data_type]));
10095
+ `)).rows;
10096
+ const usersColumnTypes = new Map(usersColumnRows.map((row) => [row.column_name, row.data_type]));
10097
+ const usersColumnState = new Map(usersColumnRows.map((row) => [row.column_name, row]));
10098
+ for (const spec of AUTH_USERS_COLUMNS) {
10099
+ const state = usersColumnState.get(spec.column);
10100
+ if (!state) continue;
10101
+ if (spec.default !== void 0 && state.column_default === null) {
10102
+ await db.execute(sql`
10103
+ ALTER TABLE ${sql.raw(usersTableName)}
10104
+ ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET DEFAULT ${sql.raw(spec.default)}
10105
+ `);
10106
+ logger.info(`🔧 Restored the default on ${usersTableName}.${spec.column}`);
10107
+ }
10108
+ if (!spec.notNull || state.is_nullable !== "YES") continue;
10109
+ if (spec.default !== void 0) await db.execute(sql`
10110
+ UPDATE ${sql.raw(usersTableName)}
10111
+ SET ${sql.raw(`"${spec.column}"`)} = ${sql.raw(spec.default)}
10112
+ WHERE ${sql.raw(`"${spec.column}"`)} IS NULL
10113
+ `);
10114
+ try {
10115
+ await db.execute(sql`
10116
+ ALTER TABLE ${sql.raw(usersTableName)}
10117
+ ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET NOT NULL
10118
+ `);
10119
+ logger.info(`🔧 Restored NOT NULL on ${usersTableName}.${spec.column}`);
10120
+ } catch (err) {
10121
+ logger.warn(`⚠️ ${usersTableName}.${spec.column} should be NOT NULL but still holds NULLs, so the constraint was not applied. Fill or remove those rows and restart: ` + (err instanceof Error ? err.message : String(err)));
10122
+ }
10123
+ }
9808
10124
  for (const column of [
9809
10125
  "email",
9810
10126
  "display_name",
@@ -9986,10 +10302,12 @@ async function ensureAuthTablesExist(db, collection) {
9986
10302
  [authSchema, "user_identities"],
9987
10303
  [authSchema, "refresh_tokens"],
9988
10304
  [authSchema, "password_reset_tokens"],
10305
+ [authSchema, "magic_link_tokens"],
9989
10306
  [authSchema, "app_config"],
9990
10307
  [authSchema, "mfa_factors"],
9991
10308
  [authSchema, "mfa_challenges"],
9992
- [authSchema, "recovery_codes"]
10309
+ [authSchema, "recovery_codes"],
10310
+ [authSchema, "schema_meta"]
9993
10311
  ];
9994
10312
  for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
9995
10313
  SELECT 1
@@ -10009,6 +10327,9 @@ async function ensureAuthTablesExist(db, collection) {
10009
10327
  logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
10010
10328
  }
10011
10329
  await stampAuthSchemaVersion(db, authSchema);
10330
+ await revokeInternalTableAccess(async (text) => {
10331
+ await db.execute(sql.raw(text));
10332
+ }, authSchema, { onError: (table, err) => logger.warn(`🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` + (err instanceof Error ? err.message : String(err))) });
10012
10333
  logger.info("✅ Auth tables ready");
10013
10334
  } catch (error) {
10014
10335
  if (error instanceof AuthSchemaVersionError) throw error;
@@ -11337,6 +11658,7 @@ async function ensureHistoryTableExists(db) {
11337
11658
  CREATE INDEX IF NOT EXISTS idx_history_time
11338
11659
  ON rebase.entity_history(table_name, entity_id, updated_at DESC)
11339
11660
  `);
11661
+ await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
11340
11662
  logger.info("✅ Entity history table ready");
11341
11663
  } catch (error) {
11342
11664
  logger.error("❌ Failed to create row history table", { error });
@@ -12058,12 +12380,12 @@ function createPostgresBootstrapper(pgConfig) {
12058
12380
  const runSql = async (text) => {
12059
12381
  return (await schemaAwareDb.execute(sql.raw(text))).rows ?? [];
12060
12382
  };
12383
+ await warnOnRoleSchemaCollision(runSql);
12061
12384
  const posture = await detectConnectionPosture(runSql);
12062
12385
  if (posture.privileged) {
12063
12386
  await ensureAppRole(runSql, [
12064
12387
  "public",
12065
12388
  "rebase",
12066
- "auth",
12067
12389
  ...registry.getCollections().map((c) => c.schema).filter((s) => typeof s === "string")
12068
12390
  ]);
12069
12391
  driver.rlsUserRole = REBASE_USER_ROLE;
@@ -12073,6 +12395,7 @@ function createPostgresBootstrapper(pgConfig) {
12073
12395
  } else logger.info(`🔐 RLS enforcement: connection role "${posture.role}" is subject to RLS natively; no role switch needed.`);
12074
12396
  await validatePolicyPgRoles(runSql, registry.getCollections(), driver.rlsUserRole ?? posture.role);
12075
12397
  warnOnAnonymousGrants(registry.getCollections());
12398
+ warnOnLegacyRlsFunctions(registry.getCollections());
12076
12399
  }
12077
12400
  if (driver.branchService) try {
12078
12401
  await driver.branchService.ensureBranchMetadataTable();
@@ -12288,7 +12611,7 @@ function createPostgresBootstrapper(pgConfig) {
12288
12611
  */
12289
12612
  async ensureCollectionSchema(collections, driverResult, log) {
12290
12613
  const internals = driverResult.internals;
12291
- const { ensureCollectionTables } = await import("./ensure-collection-tables-CBQdOETu.js");
12614
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-DSIxvLLD.js");
12292
12615
  const plan = await ensureCollectionTables({ async query(text) {
12293
12616
  const result = await internals.db.execute(sql.raw(text));
12294
12617
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
@@ -12313,13 +12636,24 @@ function createPostgresBootstrapper(pgConfig) {
12313
12636
  */
12314
12637
  async ensureCollectionPolicies(collections, driverResult, log) {
12315
12638
  const internals = driverResult.internals;
12316
- const { ensureCollectionPolicies } = await import("./ensure-collection-policies-ViG8XiPn.js");
12639
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-DMUdRQdM.js");
12317
12640
  const outcome = await ensureCollectionPolicies({ async query(text) {
12318
12641
  const result = await internals.db.execute(sql.raw(text));
12319
12642
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
12320
12643
  } }, collections, log);
12321
12644
  for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
12322
12645
  for (const failure of outcome.failures) logger.warn(`🔐 [rls] Could not fully apply policies to "${failure.table}" — it stays locked (denies) until this is resolved: ${failure.error}`);
12646
+ try {
12647
+ const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-Bpv3nUZo.js").then((n) => n.n);
12648
+ await dropLegacyAuthSchema(async (text) => {
12649
+ return (await internals.db.execute(sql.raw(text))).rows ?? [];
12650
+ }, {
12651
+ info: (m) => logger.info(m),
12652
+ warn: (m) => logger.warn(m)
12653
+ });
12654
+ } catch (err) {
12655
+ logger.info("Left the legacy `auth` schema in place: " + (err instanceof Error ? err.message : String(err)));
12656
+ }
12323
12657
  return { applied: outcome.policiesApplied };
12324
12658
  },
12325
12659
  getAdmin(driverResult) {
@@ -12327,7 +12661,7 @@ function createPostgresBootstrapper(pgConfig) {
12327
12661
  },
12328
12662
  mountRoutes(app, basePath, driverResult) {},
12329
12663
  async initializeWebsockets(server, realtimeService, driver, config, adapter) {
12330
- const { createPostgresWebSocket } = await import("./websocket-B2LsrINK.js").then((n) => n.n);
12664
+ const { createPostgresWebSocket } = await import("./websocket-D0TBU3ia.js").then((n) => n.n);
12331
12665
  createPostgresWebSocket(server, realtimeService, driver, config, adapter);
12332
12666
  }
12333
12667
  };