@rebasepro/server-postgres 0.17.3 → 0.18.0

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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,189 +1,10 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  import "process";
3
3
  __createRequire(import.meta.url);
4
- import { Y as ANONYMOUS_USER_ID, _ as securityRuleToConditions, v as findAnonymousGrants } from "./collection-index-BRUg10H5.js";
5
- import { r as REBASE_SCHEMA, s as usesLegacyRlsFunctions } from "./src-DiB5RP2Z.js";
6
4
  import { logger } from "@rebasepro/server";
7
5
  import { sql } from "drizzle-orm";
8
- //#region ../common/src/util/internal-tables.ts
9
- /**
10
- * The tables Rebase creates for its own bookkeeping, and the SQL that keeps the
11
- * end-user role away from them.
12
- *
13
- * ## Why this exists
14
- *
15
- * Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role
16
- * provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table
17
- * in the schemas a project uses — including `rebase`, because a project's own
18
- * collections are allowed to live there (the scaffold puts `users` there). It
19
- * also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the
20
- * migrating role inherits the same grant.
21
- *
22
- * Every framework-internal table is created later: auth's tables come up during
23
- * `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first
24
- * job registers, `idempotency_keys` on the first request that carries a key. So
25
- * they all inherited full DML for the end-user role — and none of them enables
26
- * row-level security, because none of them is a collection with
27
- * `securityRules`. Measured on a freshly provisioned database, `SET ROLE
28
- * rebase_user` could read `rebase.refresh_tokens` (session token hashes),
29
- * `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and
30
- * `rebase.api_keys` (including its `admin` flag), and insert into
31
- * `rebase.app_config`.
32
- *
33
- * Nothing routes a user-context query at those tables today, so this was not
34
- * reachable over the API. That is the wrong thing to depend on: the documented
35
- * model is that RLS is the authorization boundary, and these tables sat outside
36
- * it. The boundary is now a privilege boundary instead — the role simply cannot
37
- * address them.
38
- *
39
- * ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY
40
- *
41
- * RLS with no policy denies every row, which is the same outcome, but it is the
42
- * *weaker* statement: it leaves the grant in place, so a later policy — or a
43
- * `FORCE` flag cleared by some future migration — reopens the table. There is no
44
- * row of `refresh_tokens` any end user should ever reach, so the honest encoding
45
- * is "this role has no privilege here at all". It also keeps the owner
46
- * connection (which auth actually runs on) completely unaffected.
47
- *
48
- * ## Keeping it true
49
- *
50
- * `packages/rls-check` scans the `rebase` schema — it used to skip it as a
51
- * "platform" schema — and its `rls-disabled` check fires on exactly the
52
- * condition this module removes: RLS off *and* a DML grant to a reachable role.
53
- * So a table added here without a revoke is caught by `pnpm rls:check`, not by
54
- * someone re-reading this file.
55
- */
56
- /**
57
- * The Postgres role authenticated requests run as.
58
- *
59
- * Defined here rather than in the Postgres driver because both the driver (which
60
- * provisions the role) and this module (which revokes on its behalf) need it,
61
- * and a second spelling of a role name is a silent no-op waiting to happen.
62
- */
63
- var REBASE_USER_ROLE = "rebase_user";
64
- /**
65
- * Framework-internal table names, unqualified.
66
- *
67
- * Deliberately NOT including `users`: the auth user table is also a collection,
68
- * with `securityRules`, RLS enabled and policies applied. Users read their own
69
- * row through it — revoking there would break sign-in. `revokeInternalTableSql`
70
- * now skips any table with RLS enabled, so that exception is enforced rather
71
- * than merely remembered — and so is the same hazard for every other name here,
72
- * any of which a project may legitimately use for a collection of its own.
73
- *
74
- * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
75
- * because `db migrate apply` passes `--revisions-schema rebase`.
76
- *
77
- * Every entry here must also be revoked by whatever creates it, and vice versa:
78
- * the creation-time revoke fires once, on the boot that first makes the table,
79
- * so it cannot help a database provisioned before that revoke existed. This
80
- * list is what the boot-time sweep in `ensureAppRole` iterates, and the sweep
81
- * is the only thing that can repair an already-granted table. A table revoked
82
- * at creation but missing here is therefore permanently stranded on any
83
- * database that predates its revoke.
84
- *
85
- * These names are unqualified, and the boot-time sweep applies them to every
86
- * schema a project uses — so an entry here is a claim on that name in `public`
87
- * as much as in `rebase`. `jobs` is Rebase's queue at `rebase.jobs` AND a
88
- * perfectly ordinary collection name, and revoking `public.jobs` from a project
89
- * that owns it leaves every read failing 42501 with correct policies applied
90
- * and nothing in the RLS logs to explain it. The `relrowsecurity` guard in
91
- * `revokeInternalTableSql` is what makes a common noun safe here — it is not a
92
- * licence to claim more of them.
93
- */
94
- var REBASE_INTERNAL_TABLES = [
95
- "user_identities",
96
- "refresh_tokens",
97
- "password_reset_tokens",
98
- "magic_link_tokens",
99
- "mfa_factors",
100
- "mfa_challenges",
101
- "recovery_codes",
102
- "app_config",
103
- "schema_meta",
104
- "api_keys",
105
- "cron_logs",
106
- "cron_claims",
107
- "jobs",
108
- "rate_limit_hits",
109
- "idempotency_keys",
110
- "entity_history",
111
- "branches",
112
- "metric_samples",
113
- "channel_messages",
114
- "channel_cursors",
115
- "channel_presence",
116
- "atlas_schema_revisions"
117
- ];
118
- /** Postgres identifiers this module is willing to interpolate. */
119
- var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
120
- /**
121
- * A single statement that takes every privilege on `schema.table` away from the
122
- * end-user role.
123
- *
124
- * Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which
125
- * happen in practice:
126
- *
127
- * - the role does not exist when the connection is unprivileged (Rebase then
128
- * relies on native RLS rather than a role switch), and a bare `REVOKE` on a
129
- * missing role is an error, not a no-op;
130
- * - the table may not exist yet — `cron_logs` never appears in a project with
131
- * no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
132
- *
133
- * The third guard is the one that decides whether the *right* table is being
134
- * revoked. The names in {@link REBASE_INTERNAL_TABLES} are unqualified, and the
135
- * boot-time sweep in `ensureAppRole` applies all of them to every schema a
136
- * project uses — including the schema its own collections live in. A project is
137
- * free to call a collection `jobs`, `branches` or `api_keys`, and when it does,
138
- * the sweep was revoking `rebase_user`'s DML on the project's table on every
139
- * single boot. That is not a subtle degradation: the collection's whole API
140
- * answers 500 `permission denied for table …` from then on, which is what
141
- * happened to a public job board whose vacancies live in `public.jobs`.
142
- *
143
- * `relrowsecurity` separates the two cleanly, and it is the same fact this
144
- * module already relies on. Framework-internal tables carry no RLS — that is the
145
- * premise stated at the top of this file, and the reason a revoke is needed at
146
- * all. Every collection table has it enabled, because that is how Rebase
147
- * enforces `securityRules`. So "RLS is off" is exactly "this is not somebody's
148
- * collection", and the guard also subsumes the hand-carved `users` exception:
149
- * the auth user table is a collection, has RLS, and would now be skipped on its
150
- * own merits rather than by being kept off a list.
151
- *
152
- * One command, so it is safe on handles that speak the extended query protocol
153
- * and reject multi-statement strings.
154
- */
155
- function revokeInternalTableSql(schema, table) {
156
- if (!SAFE_IDENTIFIER.test(schema)) throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);
157
- if (!SAFE_IDENTIFIER.test(table)) throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);
158
- const qualified = `"${schema}"."${table}"`;
159
- return `
160
- DO $rebase_revoke$
161
- BEGIN
162
- IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')
163
- AND to_regclass('${qualified}') IS NOT NULL
164
- AND NOT (SELECT relrowsecurity FROM pg_class WHERE oid = to_regclass('${qualified}')) THEN
165
- EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';
166
- END IF;
167
- END
168
- $rebase_revoke$;
169
- `.trim();
170
- }
171
- /**
172
- * Revoke on every internal table in `schema`, one statement at a time.
173
- *
174
- * Best-effort per table: a connection that does not own one of them (a
175
- * pre-provisioned database, a platform-managed ledger) cannot revoke on it, and
176
- * that must not take down a boot. The caller decides how loud to be — `onError`
177
- * exists so the driver can warn without this module importing a logger.
178
- */
179
- async function revokeInternalTableAccess(execute, schema, options) {
180
- for (const table of options?.tables ?? REBASE_INTERNAL_TABLES) try {
181
- await execute(revokeInternalTableSql(schema, table));
182
- } catch (error) {
183
- options?.onError?.(table, error);
184
- }
185
- }
186
- //#endregion
6
+ import { ANONYMOUS_USER_ID, REBASE_SCHEMA, usesLegacyRlsFunctions } from "@rebasepro/types";
7
+ import { REBASE_USER_ROLE, findAnonymousGrants, revokeInternalTableAccess, securityRuleToConditions } from "@rebasepro/common";
187
8
  //#region src/security/rls-enforcement.ts
188
9
  var quoteIdent = (name) => `"${name.replace(/"/g, "\"\"")}"`;
189
10
  /** DML the user role holds on managed tables (RLS still filters per row). */
@@ -228,28 +49,68 @@ async function warnOnRoleSchemaCollision(run) {
228
49
  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".`);
229
50
  } catch {}
230
51
  }
231
- async function detectConnectionPosture(run) {
52
+ /**
53
+ * Would RLS actually constrain this connection?
54
+ *
55
+ * Four ways the answer is no, and only two of them are obvious. Superuser and
56
+ * BYPASSRLS announce themselves in `pg_roles`. The other two are about
57
+ * ownership, and both used to read as "no bypass here":
58
+ *
59
+ * - **Ownership through membership.** The old query compared
60
+ * `pg_tables.tableowner` to `current_user` as a string. Postgres does not: a
61
+ * role that INHERITs the owner passes every ownership test the planner makes,
62
+ * including the one that decides whether a policy applies. So a perfectly
63
+ * ordinary "app role that is a member of the owner role" setup reported
64
+ * unprivileged and bypassed every policy — permanently, since no restart
65
+ * changes a string comparison's answer.
66
+ *
67
+ * - **Ownership that has not happened yet.** The posture is decided once, at
68
+ * boot, and on a fresh database there is nothing in the catalogue to find. The
69
+ * same process then creates every table and owns all of them. The first boot
70
+ * of a new deployment therefore served every request unconstrained, the log
71
+ * said RLS was fine, and the SECOND boot silently fixed it — which is the
72
+ * worst possible shape for a security check, because the state that reproduces
73
+ * it is gone by the time anyone looks. Asking "may this role create tables
74
+ * where the server provisions?" answers about the process's whole life rather
75
+ * than about one instant of it.
76
+ *
77
+ * `schemas` narrows the CREATE question to where the server actually writes.
78
+ * Omitted, it asks about every schema the role can see, which is the safe
79
+ * direction: over-reporting costs a role switch that was not strictly needed,
80
+ * under-reporting costs the entire authorization model.
81
+ */
82
+ async function detectConnectionPosture(run, schemas) {
232
83
  const row = (await run(`
233
84
  SELECT current_user AS role,
234
85
  r.rolsuper AS superuser,
235
86
  r.rolbypassrls AS bypassrls,
236
87
  EXISTS (
237
88
  SELECT 1 FROM pg_tables t
238
- WHERE t.tableowner = current_user
89
+ WHERE pg_has_role(current_user, t.tableowner, 'USAGE')
239
90
  AND t.schemaname NOT IN ('pg_catalog', 'information_schema')
240
- ) AS owns_tables
91
+ ) AS owns_tables,
92
+ EXISTS (
93
+ SELECT 1 FROM pg_namespace n
94
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
95
+ AND n.nspname NOT LIKE 'pg\\_toast%'
96
+ AND n.nspname NOT LIKE 'pg\\_temp%'
97
+ ${schemas?.length ? `AND n.nspname IN (${Array.from(new Set(schemas)).map((s) => `'${s.replace(/'/g, "''")}'`).join(", ")})` : ""}
98
+ AND has_schema_privilege(current_user, n.oid, 'CREATE')
99
+ ) AS can_create
241
100
  FROM pg_roles r
242
101
  WHERE r.rolname = current_user
243
102
  `))[0] ?? {};
244
103
  const superuser = row.superuser === true;
245
104
  const bypassRLS = row.bypassrls === true;
246
105
  const ownsTables = row.owns_tables === true;
106
+ const canCreateTables = row.can_create === true;
247
107
  return {
248
108
  role: String(row.role ?? "unknown"),
249
109
  superuser,
250
110
  bypassRLS,
251
111
  ownsTables,
252
- privileged: superuser || bypassRLS || ownsTables
112
+ canCreateTables,
113
+ privileged: superuser || bypassRLS || ownsTables || canCreateTables
253
114
  };
254
115
  }
255
116
  /**
@@ -278,7 +139,7 @@ function appRoleSetupInstructions(connectionRole, schemas) {
278
139
  */
279
140
  async function ensureAppRole(run, schemas) {
280
141
  const uniqueSchemas = Array.from(new Set(schemas.filter(Boolean)));
281
- if ((await run(`SELECT 1 FROM pg_roles WHERE rolname = 'rebase_user'`)).length === 0) try {
142
+ if ((await run(`SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}'`)).length === 0) try {
282
143
  await run(`CREATE ROLE ${REBASE_USER_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT`);
283
144
  } catch (err) {
284
145
  throw new Error(`Failed to create the "${REBASE_USER_ROLE}" role: ${err instanceof Error ? err.message : String(err)}\n\n` + appRoleSetupInstructions("current connection role", uniqueSchemas));
@@ -334,14 +195,17 @@ async function ensureAppRole(run, schemas) {
334
195
  async function applyAuthContext(tx, auth, userRole) {
335
196
  const uid = typeof auth.uid === "string" && auth.uid.trim() !== "" ? auth.uid : ANONYMOUS_USER_ID;
336
197
  const normalizedRoles = auth.roles.map((r) => typeof r === "string" ? r : r?.id ?? String(r));
198
+ const isAnonymous = auth.isAnonymous === true ? "true" : "false";
337
199
  await tx.execute(sql`
338
200
  SELECT
339
201
  set_config('app.uid', ${uid}, true),
340
202
  set_config('app.user_id', ${uid}, true),
341
203
  set_config('app.user_roles', ${normalizedRoles.join(",")}, true),
204
+ set_config('app.is_anonymous', ${isAnonymous}, true),
342
205
  set_config('app.jwt', ${JSON.stringify({
343
206
  sub: uid,
344
- roles: auth.roles
207
+ roles: auth.roles,
208
+ is_anonymous: auth.isAnonymous === true
345
209
  })}, true)
346
210
  `);
347
211
  if (userRole) await tx.execute(sql.raw(`SET LOCAL ROLE ${quoteIdent(userRole)}`));
@@ -462,6 +326,6 @@ async function validatePolicyPgRoles(run, collections, requestRole = REBASE_USER
462
326
  if (problems.length > 0) throw new Error(`Security rules target PostgreSQL roles this server cannot use. Requests run as "${requestRole}", so these policies would never apply and every row would be filtered out — the collections would look empty rather than error.\n\n` + problems.join("\n\n") + "\n");
463
327
  }
464
328
  //#endregion
465
- export { warnOnAnonymousGrants as a, REBASE_USER_ROLE as c, validatePolicyPgRoles as i, revokeInternalTableAccess as l, detectConnectionPosture as n, warnOnLegacyRlsFunctions as o, ensureAppRole as r, warnOnRoleSchemaCollision as s, applyAuthContext as t, revokeInternalTableSql as u };
329
+ export { validatePolicyPgRoles as a, warnOnRoleSchemaCollision as c, ensureAppRole as i, applyAuthContext as n, warnOnAnonymousGrants as o, detectConnectionPosture as r, warnOnLegacyRlsFunctions as s, REBASE_USER_ROLE as t };
466
330
 
467
- //# sourceMappingURL=rls-enforcement-DvAbL9YJ.js.map
331
+ //# sourceMappingURL=rls-enforcement-ClJoTboT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rls-enforcement-ClJoTboT.js","names":[],"sources":["../src/security/rls-enforcement.ts"],"sourcesContent":["import { sql as drizzleSql, SQL } from \"drizzle-orm\";\nimport { ANONYMOUS_USER_ID, PolicyExpression, SecurityRule } from \"@rebasepro/types\";\nimport {\n AnonymousGrantRisk,\n findAnonymousGrants,\n REBASE_USER_ROLE,\n revokeInternalTableAccess,\n securityRuleToConditions\n} from \"@rebasepro/common\";\nimport { REBASE_SCHEMA, usesLegacyRlsFunctions } from \"@rebasepro/types\";\nimport { logger } from \"@rebasepro/server\";\n\n/**\n * Unified RLS enforcement — the \"user context vs server context\" model.\n *\n * Every operation runs in one of two contexts:\n *\n * - **User context** — a request authenticated (or anonymous) via\n * `driver.withAuth(user)`. Runs as the restricted `rebase_user` role: a\n * non-owner, NOSUPERUSER, NOBYPASSRLS role, so Postgres RLS binds *every*\n * statement (SELECT, INSERT, UPDATE, DELETE). The collection's\n * `securityRules` are the whole authorization model; app-layer callbacks\n * are validation/side-effects, not a security boundary.\n *\n * - **Server context** — the base (owner) connection: auth flows, migrations,\n * and raw `rebase.sql`. As table owner it bypasses RLS. This is the trusted\n * plane, equivalent to Supabase's `service_role`.\n *\n * `rebase.dataAsAdmin` is **not** in it, despite the name. `init.ts` scopes\n * that driver with `withAuth(SERVICE_IDENTITY)`, so it arrives as user\n * context above — `rebase_user`, `app.uid = 'service'`, policies evaluated —\n * and clears the default policies through their admin arm rather than the\n * `rebase.uid() IS NULL` one.\n *\n * This module provides the three pieces:\n *\n * 1. {@link detectConnectionPosture} — is the connection subject to RLS at\n * all? (superuser / BYPASSRLS / table owner ⇒ no)\n * 2. {@link ensureAppRole} — idempotently provision `rebase_user` with\n * SELECT/INSERT/UPDATE/DELETE grants (+ default privileges so future\n * tables stay covered).\n * 3. {@link applyAuthContext} — per-transaction: set the `app.*` GUCs the\n * policies read (`rebase.uid()` etc.) and `SET LOCAL ROLE rebase_user` so\n * RLS binds. Transaction-scoped, so it composes with poolers.\n *\n * Provisioning runs from the framework's own bootstrap/migrate (which already\n * self-creates the `auth` schema and functions) — enforcement is default-on,\n * not an operator opt-in.\n */\n\n/**\n * The restricted role every authenticated (user-context) request runs as.\n *\n * Re-exported, not re-declared: the same name is needed by\n * `@rebasepro/common`'s internal-table revokes, and two spellings of a role name\n * fail as a silent no-op rather than an error.\n */\nexport { REBASE_USER_ROLE };\n\n/** Minimal SQL runner so callers can adapt drizzle or pg.Client. */\nexport type RawSqlRunner = (sqlText: string) => Promise<Record<string, unknown>[]>;\n\n/** Minimal transaction surface needed by {@link applyAuthContext}. */\nexport interface SqlTx {\n execute(query: SQL): Promise<unknown>;\n}\n\nexport interface ConnectionPosture {\n /** The connection's `current_user`. */\n role: string;\n superuser: boolean;\n bypassRLS: boolean;\n /**\n * Owns at least one user table — owners bypass non-FORCE RLS.\n *\n * \"Owns\" in Postgres's sense, not by name: a role that INHERITs the owner is\n * the owner for every ownership test the planner makes, including the one\n * that decides whether a policy applies at all.\n */\n ownsTables: boolean;\n /**\n * May create tables somewhere the server writes — and would then own them.\n *\n * The reason this is part of the posture rather than a separate question:\n * the posture is decided ONCE, at boot, and on a fresh database the\n * catalogue has nothing to report yet. The process that asks then goes on to\n * create every table in the schema and owns all of them for the rest of its\n * life. Asking only what is already there answers about the wrong instant.\n */\n canCreateTables: boolean;\n /** True when RLS would NOT constrain this connection. */\n privileged: boolean;\n}\n\nexport interface AuthContext {\n uid: string;\n /** Raw roles as carried on the user (strings or `{ id }` objects). */\n roles: unknown[];\n /**\n * Whether this session came from anonymous sign-in rather than an account.\n *\n * Anonymous sign-in mints a real user row and a real uid, so a guest was\n * indistinguishable from a registered account inside a policy: same\n * `rebase.uid()`, same default role. Every rule meaning \"a signed-in\n * person\" was therefore also a rule about anybody who had called\n * `POST /auth/anonymous`, which asks for no email, no password and no\n * agreement to anything.\n *\n * Optional, and absent reads as `false`: a caller that predates this — a\n * realtime subscription, a custom validator — keeps the behaviour it had\n * rather than having every one of its users reclassified as guests.\n */\n isAnonymous?: boolean;\n}\n\nconst quoteIdent = (name: string): string => `\"${name.replace(/\"/g, \"\\\"\\\"\")}\"`;\n\n/** DML the user role holds on managed tables (RLS still filters per row). */\nconst USER_TABLE_PRIVILEGES = \"SELECT, INSERT, UPDATE, DELETE\";\n\n/**\n * Warn when the connection role shares its name with an existing schema.\n *\n * Postgres resolves unqualified names through `search_path`, which defaults to\n * `\"$user\", public` — and `$user` is the connection ROLE. When a schema of that\n * name exists it sits ahead of `public`, so every unqualified statement\n * silently operates on it instead:\n *\n * CREATE TABLE posts (...); -- you meant public.posts; you got <role>.posts\n *\n * Nothing errors. You get a second table of the same name in the wrong schema,\n * and reads that pin `public` cannot see it — which reads as \"missing table\" and\n * sends people to re-run a push that creates a *third* copy. The bootstrapper\n * has a whole branch dedicated to recognising the symptom after the fact.\n *\n * Rebase shipped straight into this: it creates a schema named `rebase` while\n * every template named the database role `rebase` too. The scaffold uses\n * `rebase_app` now, and every pool Rebase opens pins `search_path=public`\n * (`pinSearchPath`), which covers the paths the framework controls. This covers\n * the ones it does not — `psql`, `pg_dump`, drizzle-kit, a colleague's script,\n * a hand-written migration — because the hazard is a property of the two NAMES,\n * not of any one connection.\n *\n * A warning rather than a boot failure: the database works, the framework's own\n * traffic is pinned, and refusing to start over a naming choice a user may have\n * inherited would be worse than the risk.\n */\nexport async function warnOnRoleSchemaCollision(run: RawSqlRunner): Promise<void> {\n try {\n const rows = await run(`\n SELECT current_user AS role,\n EXISTS (\n SELECT 1 FROM pg_namespace n WHERE n.nspname = current_user\n ) AS collides\n `);\n if (rows[0]?.collides !== true) return;\n const role = String(rows[0]?.role ?? \"the connection role\");\n logger.warn(\n `⚠️ The database role \"${role}\" has the same name as a schema. Postgres resolves unqualified ` +\n `names through \\`search_path\\`, which defaults to \\`\"$user\", public\\` — so \"${role}\" is searched ` +\n `BEFORE public, and any unqualified \\`CREATE TABLE\\`/\\`SELECT\\` from a tool that does not pin the ` +\n `path (psql, pg_dump, drizzle-kit, a hand-written migration) silently lands in \"${role}\" instead. ` +\n `Rebase's own connections pin \\`search_path=public\\`, so the server is unaffected. To remove the ` +\n `hazard entirely, connect as a role whose name is not also a schema — the scaffold uses ` +\n `\"rebase_app\".`\n );\n } catch {\n // A diagnostic must never be the reason a boot fails.\n }\n}\n\n/**\n * Would RLS actually constrain this connection?\n *\n * Four ways the answer is no, and only two of them are obvious. Superuser and\n * BYPASSRLS announce themselves in `pg_roles`. The other two are about\n * ownership, and both used to read as \"no bypass here\":\n *\n * - **Ownership through membership.** The old query compared\n * `pg_tables.tableowner` to `current_user` as a string. Postgres does not: a\n * role that INHERITs the owner passes every ownership test the planner makes,\n * including the one that decides whether a policy applies. So a perfectly\n * ordinary \"app role that is a member of the owner role\" setup reported\n * unprivileged and bypassed every policy — permanently, since no restart\n * changes a string comparison's answer.\n *\n * - **Ownership that has not happened yet.** The posture is decided once, at\n * boot, and on a fresh database there is nothing in the catalogue to find. The\n * same process then creates every table and owns all of them. The first boot\n * of a new deployment therefore served every request unconstrained, the log\n * said RLS was fine, and the SECOND boot silently fixed it — which is the\n * worst possible shape for a security check, because the state that reproduces\n * it is gone by the time anyone looks. Asking \"may this role create tables\n * where the server provisions?\" answers about the process's whole life rather\n * than about one instant of it.\n *\n * `schemas` narrows the CREATE question to where the server actually writes.\n * Omitted, it asks about every schema the role can see, which is the safe\n * direction: over-reporting costs a role switch that was not strictly needed,\n * under-reporting costs the entire authorization model.\n */\nexport async function detectConnectionPosture(\n run: RawSqlRunner,\n schemas?: string[]\n): Promise<ConnectionPosture> {\n // Identifiers, and they reach SQL as literals in a catalogue comparison —\n // but they arrive from collection configs, so they are quoted as string\n // literals rather than interpolated as names, and a quote inside one is\n // escaped rather than trusted.\n const schemaFilter = schemas?.length\n ? `AND n.nspname IN (${Array.from(new Set(schemas))\n .map((s) => `'${s.replace(/'/g, \"''\")}'`)\n .join(\", \")})`\n : \"\";\n\n const rows = await run(`\n SELECT current_user AS role,\n r.rolsuper AS superuser,\n r.rolbypassrls AS bypassrls,\n EXISTS (\n SELECT 1 FROM pg_tables t\n WHERE pg_has_role(current_user, t.tableowner, 'USAGE')\n AND t.schemaname NOT IN ('pg_catalog', 'information_schema')\n ) AS owns_tables,\n EXISTS (\n SELECT 1 FROM pg_namespace n\n WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')\n AND n.nspname NOT LIKE 'pg\\\\_toast%'\n AND n.nspname NOT LIKE 'pg\\\\_temp%'\n ${schemaFilter}\n AND has_schema_privilege(current_user, n.oid, 'CREATE')\n ) AS can_create\n FROM pg_roles r\n WHERE r.rolname = current_user\n `);\n const row = rows[0] ?? {};\n const superuser = row.superuser === true;\n const bypassRLS = row.bypassrls === true;\n const ownsTables = row.owns_tables === true;\n const canCreateTables = row.can_create === true;\n return {\n role: String(row.role ?? \"unknown\"),\n superuser,\n bypassRLS,\n ownsTables,\n canCreateTables,\n privileged: superuser || bypassRLS || ownsTables || canCreateTables\n };\n}\n\n/**\n * Human-actionable instructions for when the connection cannot provision the\n * user role itself (no CREATEROLE and role not pre-created by the platform).\n */\nexport function appRoleSetupInstructions(connectionRole: string, schemas: string[]): string {\n const grants = schemas.map((s) =>\n `GRANT USAGE ON SCHEMA ${quoteIdent(s)} TO ${REBASE_USER_ROLE};\\n` +\n `GRANT ${USER_TABLE_PRIVILEGES} ON ALL TABLES IN SCHEMA ${quoteIdent(s)} TO ${REBASE_USER_ROLE};\\n` +\n `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${quoteIdent(s)} TO ${REBASE_USER_ROLE};`\n ).join(\"\\n\");\n return (\n `Rebase enforces row-level security by running authenticated requests as ` +\n `the restricted role \"${REBASE_USER_ROLE}\", but the connection role ` +\n `\"${connectionRole}\" bypasses RLS and cannot create that role itself.\\n` +\n `Run the following as a database administrator, then restart:\\n\\n` +\n `CREATE ROLE ${REBASE_USER_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT;\\n` +\n `GRANT ${REBASE_USER_ROLE} TO ${quoteIdent(connectionRole)};\\n` +\n grants\n );\n}\n\n/**\n * Idempotently provision the `rebase_user` role, membership for the current\n * connection role, and DML grants (+ default privileges for future tables)\n * on every existing schema in `schemas`.\n *\n * Split into privilege tiers so it works both when the connection is a\n * superuser (creates everything) and when the platform pre-created the role\n * and membership (e.g. CNPG `postInitApplicationSQL`) and the connection is\n * merely the table owner — owners can always run the grant tier themselves.\n *\n * RLS still filters every row: these grants only make the tables *reachable*\n * by the role; the policies decide which rows/commands actually pass.\n *\n * Throws with precise setup instructions when the role is missing and the\n * connection cannot create it.\n */\nexport async function ensureAppRole(run: RawSqlRunner, schemas: string[]): Promise<void> {\n const uniqueSchemas = Array.from(new Set(schemas.filter(Boolean)));\n\n // Tier 1 — role existence.\n const roleRows = await run(`SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}'`);\n if (roleRows.length === 0) {\n try {\n await run(`CREATE ROLE ${REBASE_USER_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT`);\n } catch (err) {\n throw new Error(\n `Failed to create the \"${REBASE_USER_ROLE}\" role: ${err instanceof Error ? err.message : String(err)}\\n\\n` +\n appRoleSetupInstructions(\"current connection role\", uniqueSchemas)\n );\n }\n }\n\n // Tier 2 — membership, so a non-superuser connection may SET ROLE to it.\n const memberRows = await run(`\n SELECT (pg_has_role(current_user, '${REBASE_USER_ROLE}', 'MEMBER')\n OR (SELECT rolsuper FROM pg_roles WHERE rolname = current_user)) AS can_set,\n current_user AS role\n `);\n if (memberRows[0]?.can_set !== true) {\n try {\n await run(`GRANT ${REBASE_USER_ROLE} TO CURRENT_USER`);\n } catch (err) {\n throw new Error(\n `The connection role is not a member of \"${REBASE_USER_ROLE}\" and cannot grant itself membership: ` +\n `${err instanceof Error ? err.message : String(err)}\\n\\n` +\n appRoleSetupInstructions(String(memberRows[0]?.role ?? \"current connection role\"), uniqueSchemas)\n );\n }\n }\n\n // Tier 3 — grants. Table owners (the expected non-superuser posture) can\n // always grant on their own objects, so this tier needs no extra privilege.\n const nspRows = await run(\"SELECT nspname FROM pg_namespace\");\n const existing = new Set(nspRows.map((r) => String(r.nspname)));\n for (const schema of uniqueSchemas) {\n if (!existing.has(schema)) continue;\n const s = quoteIdent(schema);\n await run(`GRANT USAGE ON SCHEMA ${s} TO ${REBASE_USER_ROLE}`);\n await run(`GRANT ${USER_TABLE_PRIVILEGES} ON ALL TABLES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);\n await run(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);\n // Cover objects created later by the CURRENT role (the role that runs\n // migrations), so a migrate can never strand the user role.\n await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);\n await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);\n\n // The grants above are deliberately schema-wide — a project's own\n // collections may live in `rebase`, and future tables must be reachable\n // or a migration strands the role. Rebase's OWN tables are the exception:\n // refresh tokens, MFA secrets, API keys and the rest carry no RLS and no\n // row an end user should ever address. Taking the privilege back here\n // covers every table that already exists; each creator revokes on the\n // table it just made, for the boot that creates them for the first time.\n await revokeInternalTableAccess(async (text) => { await run(text); }, schema, {\n onError: (table, error) => logger.warn(\n `🔐 [rls] Could not revoke \"${REBASE_USER_ROLE}\" access to \"${schema}\".\"${table}\" — ` +\n \"it stays reachable by authenticated requests: \" +\n (error instanceof Error ? error.message : String(error))\n )\n });\n }\n\n logger.debug(`🔐 [rls] User role \"${REBASE_USER_ROLE}\" provisioned (schemas: ${uniqueSchemas.join(\", \")})`);\n}\n\n/**\n * Apply the authenticated context to a transaction: the `app.*` GUCs that RLS\n * policies read via `rebase.uid()` / `rebase.roles()` / `rebase.jwt()`, and — when\n * `userRole` is set — `SET LOCAL ROLE` so RLS binds every statement in this\n * transaction (reads *and* writes).\n *\n * GUCs are set with `is_local = true` and the role switch is `LOCAL`: both\n * reset at commit/rollback, so pooled connections are never polluted.\n *\n * Fails closed by construction: if the role switch errors, the transaction\n * aborts instead of proceeding privileged.\n *\n * SECURITY: this function is only ever called on the **user** path (the server\n * context uses the base/owner driver and never calls it). The default policies\n * treat `rebase.uid() IS NULL` as the trusted server context, and `rebase.uid()`\n * is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would\n * be read as NULL and silently escalate a user request to server privileges.\n * Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint,\n * rather than trusting every caller (e.g. realtime subscription auth) to do it.\n * That sentinel is exported from `@rebasepro/types` because it leaks into rule\n * semantics: it is why `rebase.uid() IS NOT NULL` is true for anonymous requests.\n */\nexport async function applyAuthContext(tx: SqlTx, auth: AuthContext, userRole?: string): Promise<void> {\n const uid = typeof auth.uid === \"string\" && auth.uid.trim() !== \"\" ? auth.uid : ANONYMOUS_USER_ID;\n const normalizedRoles = auth.roles.map((r: unknown) =>\n typeof r === \"string\" ? r : (r as Record<string, unknown>)?.id ?? String(r)\n );\n // `app.user_id` is the pre-rename spelling, still written because policies\n // are data: a database provisioned before the rename holds rules compiled\n // to `current_setting('app.user_id')`, and those predicates would evaluate\n // to NULL — failing open or locking out — if we stopped setting it. Drop\n // the alias only once no live database carries a legacy policy.\n // A guest is a signed-in caller with no account behind them, and until this\n // the database could not tell the difference — see `rebase.is_anonymous()`.\n // Written as a string because that is what `set_config` takes.\n const isAnonymous = auth.isAnonymous === true ? \"true\" : \"false\";\n\n await tx.execute(drizzleSql`\n SELECT\n set_config('app.uid', ${uid}, true),\n set_config('app.user_id', ${uid}, true),\n set_config('app.user_roles', ${normalizedRoles.join(\",\")}, true),\n set_config('app.is_anonymous', ${isAnonymous}, true),\n set_config('app.jwt', ${JSON.stringify({\n sub: uid,\n roles: auth.roles,\n // In the claims too, so a policy reading `rebase.jwt()` — which is\n // how a rule reaches anything that is not uid or roles — sees the\n // same fact as `rebase.is_anonymous()`. Two sources that could\n // disagree would be worse than one.\n is_anonymous: auth.isAnonymous === true\n })}, true)\n `);\n if (userRole) {\n await tx.execute(drizzleSql.raw(`SET LOCAL ROLE ${quoteIdent(userRole)}`));\n }\n}\n\n/** Role names from other BaaS platforms that people reach for out of habit. */\nconst FOREIGN_CONVENTION_ROLES: Record<string, string> = {\n authenticated: \"Supabase\",\n anon: \"Supabase\",\n service_role: \"Supabase\"\n};\n\n/**\n * Warn about rules that read as \"signed-in users only\" but admit anonymous\n * callers — `rebase.uid() IS NOT NULL`, or a comparison against another\n * platform's magic user id such as `'anon'`.\n *\n * The sibling of {@link validatePolicyPgRoles}, for the more dangerous spelling\n * of the same habit. A foreign `pgRoles` value makes a policy unreachable and\n * the table reads empty — loud, and that guard throws. These do the opposite:\n * the rule compiles to a grant, and nothing looks wrong until the data is\n * already public.\n *\n * Warns rather than throws. Unlike an unreachable `pgRoles`, these rules are\n * serving traffic today: refusing to boot would take an app offline to report a\n * problem it already has, and on the read path it would take it offline\n * *because* its data was exposed. Rewriting the author's SQL is not an option\n * either — this is the escape hatch whose whole promise is that it means what it\n * says. So: say so, loudly, and leave the rule alone.\n */\nexport function warnOnAnonymousGrants(\n collections: { slug?: string; securityRules?: readonly SecurityRule[] }[]\n): void {\n // Grouped by the mistake, not by the rule: one habit typically repeats\n // across every collection an author wrote, and a per-rule list would repeat\n // the same paragraph dozens of times and get skimmed.\n const byRisk = new Map<string, { risk: AnonymousGrantRisk; sites: string[] }>();\n\n for (const collection of collections) {\n for (const rule of collection.securityRules ?? []) {\n const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);\n const risks = [usingExpr, withCheckExpr]\n .filter((e): e is PolicyExpression => e !== null)\n .flatMap(findAnonymousGrants);\n\n for (const risk of risks) {\n const key = `${risk.pattern}:${risk.detail}`;\n const site = `${collection.slug ?? \"(unnamed)\"} → \"${rule.name ?? \"(unnamed rule)\"}\"`;\n const entry = byRisk.get(key) ?? { risk, sites: [] };\n if (!entry.sites.includes(site)) entry.sites.push(site);\n byRisk.set(key, entry);\n }\n }\n }\n\n if (byRisk.size === 0) return;\n\n const problems = [...byRisk.values()].map(({ risk, sites }) =>\n ` • ${risk.explanation}\\n ${sites.length} rule(s): ${sites.join(\", \")}`\n );\n\n logger.warn(\n `Security rules that read as a lockdown but grant access to anonymous requests. Every caller from a ` +\n `client carries a user id ('${ANONYMOUS_USER_ID}' when nobody is signed in), so these clauses are ` +\n `true for everyone:\\n\\n` +\n problems.join(\"\\n\\n\") + \"\\n\"\n );\n}\n\n/**\n * Name the collections whose raw policy SQL still calls the pre-1.0 helpers.\n *\n * The compiler rewrites `auth.uid()` to `rebase.uid()` on the way into the\n * database, so nothing is broken and no policy is wrong — which is exactly why\n * this has to be said out loud. A silent rewrite that works forever is not a\n * migration, it is a second supported spelling nobody wrote down, and the next\n * person to read those rules will copy the old one.\n *\n * Only `raw` expressions can carry it. Structured rules (`policy.authUid()`,\n * `policy.rolesOverlap(...)`) compile from the model and were never affected.\n */\nexport function warnOnLegacyRlsFunctions(\n collections: { slug?: string; securityRules?: readonly SecurityRule[] }[]\n): void {\n const sites: string[] = [];\n\n for (const collection of collections) {\n for (const rule of collection.securityRules ?? []) {\n const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);\n const carriesLegacy = [usingExpr, withCheckExpr]\n .filter((e): e is PolicyExpression => e !== null)\n .some(containsLegacyRlsCall);\n if (!carriesLegacy) continue;\n\n const site = `${collection.slug ?? \"(unnamed)\"} → \"${rule.name ?? \"(unnamed rule)\"}\"`;\n if (!sites.includes(site)) sites.push(site);\n }\n }\n\n if (sites.length === 0) return;\n\n logger.warn(\n `These security rules call the pre-1.0 RLS helpers (\\`auth.uid()\\`, \\`auth.roles()\\`, \\`auth.jwt()\\`). ` +\n `They still work — the compiler rewrites them — but the functions now live in the \\`rebase\\` schema, ` +\n `and the \\`auth\\` one is Supabase's. Update the raw SQL in these rules to \\`${REBASE_SCHEMA}.uid()\\` ` +\n `and friends, or switch them to the structured helpers (\\`policy.authUid()\\`, \\`policy.rolesOverlap()\\`), ` +\n `which never had to be spelled by hand:\\n\\n` +\n sites.map(s => ` • ${s}`).join(\"\\n\") + \"\\n\"\n );\n}\n\n/** Whether any `raw` expression in the tree calls a pre-1.0 helper. */\nfunction containsLegacyRlsCall(expr: PolicyExpression): boolean {\n switch (expr.kind) {\n case \"raw\":\n return usesLegacyRlsFunctions(expr.sql);\n case \"and\":\n case \"or\":\n return expr.operands.some(containsLegacyRlsCall);\n case \"not\":\n return containsLegacyRlsCall(expr.operand);\n case \"existsIn\":\n return containsLegacyRlsCall(expr.where);\n default:\n return false;\n }\n}\n\n/**\n * Reject `pgRoles` that this server can never satisfy.\n *\n * `pgRoles` sets the `TO` clause of a generated policy, so a policy naming a\n * role the request never runs as simply never applies — and RLS then filters\n * every row. The table reads as empty, which is indistinguishable from having\n * no data, so the mistake survives review and ships.\n *\n * Requests run as `rebase_user`, so a policy is only reachable if it targets\n * `public` or a role `rebase_user` holds. Anything else is a configuration\n * error worth failing the boot for.\n */\nexport async function validatePolicyPgRoles(\n run: RawSqlRunner,\n collections: { slug?: string; securityRules?: readonly { name?: string; pgRoles?: readonly string[] }[] }[],\n /** The role requests actually run as: `rebase_user` when the connection is\n * privileged enough to switch, otherwise the connection role itself. */\n requestRole: string = REBASE_USER_ROLE\n): Promise<void> {\n const wanted = new Map<string, string[]>();\n for (const collection of collections) {\n for (const rule of collection.securityRules ?? []) {\n for (const role of rule.pgRoles ?? []) {\n if (role === \"public\") continue;\n wanted.set(role, [...(wanted.get(role) ?? []), collection.slug ?? \"(unnamed)\"]);\n }\n }\n }\n if (wanted.size === 0) return;\n\n const names = [...wanted.keys()].map((r) => `'${r.replace(/'/g, \"''\")}'`).join(\",\");\n const escapedRequestRole = requestRole.replace(/'/g, \"''\");\n const rows = await run(`\n SELECT r.rolname AS role,\n COALESCE(pg_has_role(to_regrole('${escapedRequestRole}'), r.oid, 'MEMBER'), false) AS reachable\n FROM pg_roles r\n WHERE r.rolname IN (${names})\n `);\n\n const reachable = new Map(rows.map((row) => [String(row.role), row.reachable === true]));\n const problems: string[] = [];\n\n for (const [role, slugs] of wanted) {\n if (reachable.get(role) === true) continue;\n\n const why = reachable.has(role)\n ? `\"${requestRole}\" is not a member of it`\n : \"no such role exists in this database\";\n const platform = FOREIGN_CONVENTION_ROLES[role];\n const hint = platform\n ? `\"${role}\" is a ${platform} convention, not a PostgreSQL role. Application roles belong in \\`roles: [\"${role === \"service_role\" ? \"admin\" : role}\"]\\`, which is checked inside the policy via rebase.roles().`\n : `Either grant it (GRANT ${role} TO ${requestRole}) or drop \\`pgRoles\\` so the policy targets \\`public\\`.`;\n\n problems.push(\n ` • pgRoles: [\"${role}\"] on ${slugs.join(\", \")} — ${why}.\\n ${hint}`\n );\n }\n\n if (problems.length > 0) {\n throw new Error(\n `Security rules target PostgreSQL roles this server cannot use. Requests run as ` +\n `\"${requestRole}\", so these policies would never apply and every row would be ` +\n `filtered out — the collections would look empty rather than error.\\n\\n` +\n problems.join(\"\\n\\n\") + \"\\n\"\n );\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * A column whose type in the database is not the type the collection declares.
3
+ *
4
+ * The boot ensure is additive: it creates tables, columns, enum values and
5
+ * indexes, and it never changes an existing column's type. That is the right
6
+ * default — an automatic `ALTER COLUMN … TYPE` against customer data, decided
7
+ * by a process nobody is watching, is not a thing to do quietly.
8
+ *
9
+ * What was wrong is that it happened *silently*. A property's `columnType`
10
+ * changed after the first deploy, the deploy reported success, the column stayed
11
+ * as it was, and the divergence surfaced later as writes failing:
12
+ *
13
+ * Invalid data format in "listing_observations":
14
+ * malformed array literal: "[0,0,0.0755,…]"
15
+ *
16
+ * ...after a 10,690-row import, from a deployment that had said "success". The
17
+ * two ends disagreed about the schema, so no value satisfied both: the declared
18
+ * shape was rejected by Postgres and the Postgres shape was rejected by the
19
+ * API's own validator, first. There is no encoding out of that, only a report.
20
+ *
21
+ * ## Why the comparison is deliberately blunt
22
+ *
23
+ * A drift report that cries wolf is worse than none, because the boot that says
24
+ * it every time is the boot nobody reads. So types are compared by *family*, and
25
+ * a family is only split from another when the difference changes what the
26
+ * column can hold:
27
+ *
28
+ * - every numeric type is one family. `int4`, `numeric` and `float8` all arrive
29
+ * as JavaScript numbers and accept each other's values; a database adopted
30
+ * from an existing schema legitimately has `int4` where `type: "number"`
31
+ * generates `NUMERIC`, and that is not news.
32
+ * - every character type is one family, for the same reason: `text`,
33
+ * `varchar(255)` and `char(3)` differ in what they *reject*, not in shape.
34
+ * - `jsonb` and an array are different families, and that is the case this
35
+ * exists for.
36
+ *
37
+ * Anything this module does not recognise — an enum type, a domain, a PostGIS
38
+ * geometry, an extension type it has never met — returns `null` and is compared
39
+ * with nothing. Silence on an unknown type is the deliberate choice: the cost of
40
+ * a missed report is a question somebody asks once, and the cost of a false one
41
+ * is a warning everybody learns to skip.
42
+ */
43
+ /** A column whose live type and declared type are in different families. */
44
+ export interface ColumnTypeDrift {
45
+ /** `schema.table`. */
46
+ table: string;
47
+ /** The column name as it exists in the database. */
48
+ column: string;
49
+ /** What the collection says the column should be, e.g. `JSONB`. */
50
+ declared: string;
51
+ /** What the database actually has, as `udt_name` reports it, e.g. `_numeric`. */
52
+ actual: string;
53
+ }
54
+ /**
55
+ * One SQL or `udt_name` spelling reduced to its family, or `null` when this
56
+ * module has no opinion about it.
57
+ *
58
+ * `null` is not a failure. It is the answer for an enum type, a domain, a
59
+ * PostGIS column, a quoted qualified name — everything whose identity is a
60
+ * *name* rather than a shape, where two spellings being different says nothing
61
+ * about whether the values are compatible.
62
+ */
63
+ export declare function typeFamily(raw: string | undefined | null): string | null;
64
+ /**
65
+ * Do these two spellings describe columns that hold the same kind of value?
66
+ *
67
+ * `true` whenever the answer is not confidently "no" — an unrecognised type on
68
+ * either side agrees with everything, because a report needs evidence and an
69
+ * unknown type is the absence of it.
70
+ */
71
+ export declare function typesAgree(declared: string, actual: string): boolean;
72
+ /**
73
+ * The sentence a boot log says about one drifted column.
74
+ *
75
+ * Written to be actionable in the place it is read, which is a runtime log with
76
+ * no way to run a migration from it: it names both types, says which surface
77
+ * will break first, and gives the statement that resolves it.
78
+ */
79
+ export declare function columnTypeDriftMessage(drift: ColumnTypeDrift): string;
@@ -41,6 +41,17 @@ export interface DoctorPhase {
41
41
  * a comparison, and no comparison happened.
42
42
  */
43
43
  notApplicable?: string;
44
+ /**
45
+ * The phase was skipped by a fault rather than by a choice.
46
+ *
47
+ * `skipped` covers both: "no DATABASE_URL, so nothing to compare against"
48
+ * is a local situation a developer may be perfectly happy with, and "the
49
+ * DATABASE_URL you set refuses connections" is not. The report renders them
50
+ * the same way — a check that did not run is a check that did not run — but
51
+ * the exit code must not, or `rebase doctor` in CI goes green against a
52
+ * database it never reached.
53
+ */
54
+ blocked?: boolean;
44
55
  }
45
56
  export interface DoctorReport {
46
57
  collectionsToSchema: DoctorPhase;
@@ -64,6 +75,37 @@ export declare function getExpectedColumnType(prop: Property): string | null;
64
75
  export declare function loadCollections(collectionsPath: string): Promise<CollectionConfig[]>;
65
76
  export declare function checkCollectionsVsSchema(collections: CollectionConfig[], schemaFilePath: string): Promise<DoctorPhase>;
66
77
  export declare function checkCollectionsVsSdk(collections: CollectionConfig[], sdkFilePath: string): Promise<DoctorPhase>;
78
+ /**
79
+ * Extensions the collections need, that the database does not have installed.
80
+ *
81
+ * Only `vector` today, and only because a `{ type: "vector" }` property plans a
82
+ * column of a type that will not exist. Rebase installs it only where a project
83
+ * has said it may (`database({ extensions: ["vector"] })`), so the absence is
84
+ * often correct configuration and a wrong deployment — which is exactly why the
85
+ * report has to name it rather than the first failing INSERT.
86
+ *
87
+ * Exported for its test.
88
+ */
89
+ export declare function missingExtensionIssues(collections: CollectionConfig[], installed: ReadonlySet<string>): DoctorIssue[];
90
+ /**
91
+ * The stamp the runtime wrote, against the collections on disk.
92
+ *
93
+ * A stamp that disagrees means this database was provisioned from a different
94
+ * set of collections than the ones in this checkout — a colleague's branch, an
95
+ * older deploy, a tenant somebody else migrated. Everything else in this report
96
+ * is then describing a schema somebody else created, which is worth knowing
97
+ * before acting on any of it.
98
+ *
99
+ * A warning, not an error, and never a direction: the stamp is a hash, so it
100
+ * can say the two disagree and never which is ahead. `null` — never stamped —
101
+ * is not a finding: every database provisioned before the stamp existed reads
102
+ * that way, and so does every fresh one.
103
+ */
104
+ export declare function schemaStampIssues(pool: {
105
+ query<T>(text: string): Promise<{
106
+ rows: T[];
107
+ }>;
108
+ }, collections: CollectionConfig[]): Promise<DoctorIssue[]>;
67
109
  export declare function checkCollectionsVsDatabase(collections: CollectionConfig[], databaseUrl: string): Promise<DoctorPhase>;
68
110
  export declare function renderReport(report: DoctorReport): void;
69
111
  export declare function runDoctor(options: {
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import { type CollectionConfig } from "@rebasepro/types";
29
29
  import { type SkippedVectorIndex } from "./vector-index.js";
30
+ import { type ColumnTypeDrift } from "./column-type-drift.js";
30
31
  /**
31
32
  * The subset of a database handle this needs: run a statement, get rows back.
32
33
  *
@@ -75,6 +76,15 @@ export interface ExistingSchema {
75
76
  enumValues?: Map<string, string[]>;
76
77
  /** `schema.table.column` for every column the database marks NOT NULL. */
77
78
  notNullColumns?: Set<string>;
79
+ /**
80
+ * `schema.table.column` → the column's `udt_name` as the database reports
81
+ * it (`int4`, `jsonb`, `_numeric` for an array of numeric).
82
+ *
83
+ * The only evidence that a column's declared type and its real type have
84
+ * parted company. Absent is read as "unknown", which reports no drift —
85
+ * see `column-type-drift.ts` for why silence is the right unknown here.
86
+ */
87
+ columnTypes?: Map<string, string>;
78
88
  /**
79
89
  * Tables known to hold at least one row.
80
90
  *
@@ -85,6 +95,14 @@ export interface ExistingSchema {
85
95
  * constraint rather than attempting one that aborts the boot.
86
96
  */
87
97
  populatedTables?: Set<string>;
98
+ /**
99
+ * `schema.table.column` for every column the database gives a DEFAULT.
100
+ *
101
+ * Paired with {@link notNullColumns} to decide whether a column no property
102
+ * declares can still accept a write: NOT NULL with a default can, NOT NULL
103
+ * without one cannot. Absent is read as "unknown", which reports nothing.
104
+ */
105
+ columnDefaults?: Set<string>;
88
106
  }
89
107
  /**
90
108
  * How far the planner may go in making the database's constraints match the
@@ -179,6 +197,52 @@ export interface EnsurePlan {
179
197
  * to read.
180
198
  */
181
199
  withheldConstraints: WithheldConstraint[];
200
+ /**
201
+ * Columns whose type in the database is in a different family from the one
202
+ * the collection declares.
203
+ *
204
+ * Reported and never acted on — see `column-type-drift.ts`. This is the one
205
+ * divergence that presents as a *working* deploy: the statement was never
206
+ * planned, so nothing failed, and the first evidence is a write rejected
207
+ * hours later by whichever end validates first.
208
+ */
209
+ columnTypeDrift: ColumnTypeDrift[];
210
+ /**
211
+ * Columns that are NOT NULL, have no default, and that no property declares
212
+ * any more — so every insert through the API is rejected for a field the
213
+ * author cannot name, because they already deleted it.
214
+ *
215
+ * This is what a **rename** looks like to an additive provisioner. Rename
216
+ * `title` to `headline` and boot does exactly what it promises: it adds
217
+ * `headline` (nullable, correctly, and says why) and leaves `title` alone,
218
+ * because dropping a column is destructive and this runs unattended. The
219
+ * table is then unwritable:
220
+ *
221
+ * POST /api/data/posts -> 400 PG_23502
222
+ * Missing required field: "title" in "posts" cannot be empty.
223
+ *
224
+ * naming a field that is no longer in the collection at all. On the default
225
+ * first-run path there is no way out inside the product either: the managed
226
+ * database is PGlite, and `db push` — the documented repair — refuses there
227
+ * because Atlas needs a second database to diff against.
228
+ *
229
+ * Reported, never acted on. Dropping the column is the right fix roughly
230
+ * always and destructive exactly once, which is not a decision to take
231
+ * unattended.
232
+ */
233
+ orphanedRequiredColumns: OrphanedRequiredColumn[];
234
+ }
235
+ /**
236
+ * A NOT NULL column with no default that no property declares.
237
+ * @see EnsurePlan.orphanedRequiredColumns
238
+ */
239
+ export interface OrphanedRequiredColumn {
240
+ /** `schema.table`. */
241
+ table: string;
242
+ /** The column left behind. */
243
+ column: string;
244
+ /** The collection that no longer declares it. */
245
+ slug: string;
182
246
  }
183
247
  /** A constraint the configuration asks for that the planner is not applying. */
184
248
  export interface WithheldConstraint {
@@ -276,3 +340,29 @@ export declare function readSchemaFactsFor(client: Queryable, collections: Colle
276
340
  * The error is surfaced with the statement that caused it.
277
341
  */
278
342
  export declare function ensureCollectionTables(client: Queryable, collections: CollectionConfig[], log?: (message: string) => void, options?: EnsureOptions): Promise<EnsureOutcome>;
343
+ /**
344
+ * What actually went wrong, rather than what the ORM said about it.
345
+ *
346
+ * Drizzle wraps every driver error, and its `message` is literally
347
+ * ``Failed query: ${sql}\nparams: ${params}`` — the statement we already know,
348
+ * and not one word of Postgres's answer. Reporting `err.message` therefore
349
+ * produced a failure that named the statement twice and the *reason* zero
350
+ * times, so four unrelated boot failures — a missing extension, a wrong
351
+ * privilege, a bad index method, a constraint the rows violate — all read
352
+ * identically to whoever had to fix them.
353
+ *
354
+ * Two consequences, both observed in the field before this existed:
355
+ *
356
+ * 1. On a managed deployment the reason never reaches anybody. Boot dies, the
357
+ * pod never goes ready, and the deploy reports a readiness timeout — so the
358
+ * one line that says why is the one line that was thrown away here.
359
+ * 2. {@link vectorExtensionHint} matches on the error text. Against the
360
+ * wrapper it matched nothing, so the hint written specifically to explain a
361
+ * missing pgvector never fired on the path that raises it.
362
+ *
363
+ * `detail` and `hint` come along because they are the fields Postgres uses to
364
+ * say which object it meant and what to do about it — `CREATE EXTENSION` names
365
+ * the missing library there, and a rejected constraint names the row. This
366
+ * writes into the tenant's own boot log, about the tenant's own database.
367
+ */
368
+ export declare function describeDriverError(err: unknown): string;