@rebasepro/server-postgres 0.16.1-canary.ge71347e → 0.16.1-canary.gef08a6e

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.
@@ -6,6 +6,46 @@ import { DrizzleClient } from "./interfaces.js";
6
6
  import { DatabaseAdmin, DataDriver, DeleteProps, CollectionConfig, FetchCollectionProps, FetchOneProps, ListenCollectionProps, ListenOneProps, RebaseClient, RebaseSdkData, RestFetchService, SaveManyProps, SaveProps, UpdateManyProps, DeleteManyProps, TableMetadata, User } from "@rebasepro/types";
7
7
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry.js";
8
8
  import { HistoryService } from "./history/HistoryService.js";
9
+ /**
10
+ * Has an operator opted out of database role switching entirely?
11
+ *
12
+ * `DISABLE_DB_ROLE_SWITCHING=true` is documented (README, the configuration
13
+ * page, the backend skill) as "run Studio SQL Editor queries as the connection
14
+ * owner", for deployments whose application roles have no database role behind
15
+ * them. It is the only sanctioned way a statement that named a role runs
16
+ * without it — every other route now refuses.
17
+ *
18
+ * Exact `"true"` on purpose, matching the check this replaced. `=1` and `=yes`
19
+ * silently do nothing, which `docs/audits/80-config-and-env.md` already records
20
+ * as a finding across the env surface; fixing it here alone would make this one
21
+ * variable disagree with the rest.
22
+ */
23
+ export declare function isRoleSwitchingOptedOut(): boolean;
24
+ /**
25
+ * The role a statement will actually have run as, given the role it asked for.
26
+ *
27
+ * For the audit log, which recorded `options.role` — the *requested* role — and
28
+ * so restated the caller's request as though it were the outcome. The two part
29
+ * company exactly when {@link isRoleSwitchingOptedOut} holds, because every
30
+ * other divergence is now an error rather than a quiet substitution.
31
+ */
32
+ export declare function effectiveSqlRole(requestedRole?: string): string;
33
+ /** How {@link effectiveSqlRole} names "whatever role the connection holds". */
34
+ export declare const CONNECTION_OWNER = "<connection owner>";
35
+ /**
36
+ * A statement named a database role the connection cannot assume.
37
+ *
38
+ * Its own type because the tempting recovery — run it anyway, as the owner — is
39
+ * the one thing that must not happen. Callers that catch this should report it,
40
+ * not retry unscoped.
41
+ */
42
+ export declare class RoleSwitchUnavailableError extends Error {
43
+ readonly code = "ROLE_SWITCH_UNAVAILABLE";
44
+ readonly role: string;
45
+ /** The underlying Postgres error, when the refusal came from a live attempt. */
46
+ readonly pgError?: unknown;
47
+ constructor(role: string, pgError?: unknown);
48
+ }
9
49
  export declare class PostgresBackendDriver implements DataDriver {
10
50
  db: DrizzleClient;
11
51
  readonly registry: PostgresCollectionRegistry;
@@ -20,12 +60,18 @@ export declare class PostgresBackendDriver implements DataDriver {
20
60
  data: RebaseSdkData;
21
61
  client?: RebaseClient;
22
62
  /**
23
- * Auto-set to `true` when a SET LOCAL ROLE fails with insufficient
24
- * privileges, so subsequent queries skip the doomed attempt.
25
- * Mirrors the static `DISABLE_DB_ROLE_SWITCHING` env var but is
26
- * learned at runtime.
63
+ * Auto-set to `true` once a `SET LOCAL ROLE` has failed with insufficient
64
+ * privileges, so later statements refuse without spending the round trip
65
+ * to be refused again.
66
+ *
67
+ * Deliberately NOT a mirror of `DISABLE_DB_ROLE_SWITCHING`, which it used
68
+ * to be described as. The env var is an operator saying "run these as the
69
+ * connection owner"; this flag is the database saying "I cannot give you
70
+ * the role you asked for". The first is a decision and permits the
71
+ * fallback, the second is a failure and must not — see the SECURITY note
72
+ * in {@link executeSql}.
27
73
  */
28
- private _roleSwitchingDisabled;
74
+ private _roleSwitchingUnavailable;
29
75
  /**
30
76
  * Restricted role that authenticated (user-context) requests run as (via
31
77
  * `SET LOCAL ROLE`) so RLS binds every statement — reads *and* writes. Set
@@ -163,6 +209,14 @@ export declare class PostgresBackendDriver implements DataDriver {
163
209
  checkUniqueField(path: string, name: string, value: unknown, id?: string, collection?: CollectionConfig): Promise<boolean>;
164
210
  count<M extends Record<string, unknown>>({ path, collection, filter, logical, searchString, vectorSearch }: FetchCollectionProps<M>): Promise<number>;
165
211
  private getTargetDb;
212
+ /**
213
+ * Build one statement, binding `$n` placeholders as real parameters.
214
+ *
215
+ * Shared by the role-switched path (inside a transaction) and the
216
+ * unswitched one. They held byte-identical copies of this loop, which is
217
+ * exactly the shape where a fix lands in one copy and not the other.
218
+ */
219
+ private buildStatement;
166
220
  executeSql(sqlText: string, options?: {
167
221
  database?: string;
168
222
  role?: string;
@@ -1,9 +1,9 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  import "process";
3
3
  __createRequire(import.meta.url);
4
- import { c as planCollectionPolicies, r as readExistingSchema } from "./ensure-collection-tables-C_Gr59le.js";
4
+ import { c as planCollectionPolicies, r as readExistingSchema } from "./ensure-collection-tables-DpGX_25A.js";
5
5
  import "./src-DiDgtX8P.js";
6
- import { c as REBASE_USER_ROLE } from "./rls-enforcement-HLy7w5hL.js";
6
+ import { c as REBASE_USER_ROLE } from "./rls-enforcement-da7ekLw-.js";
7
7
  //#region src/security/policy-drift.ts
8
8
  /**
9
9
  * Does this name look like one the generator produced for this table?
@@ -122,4 +122,4 @@ async function ensureCollectionPolicies(client, collections, log) {
122
122
  //#endregion
123
123
  export { ensureCollectionPolicies };
124
124
 
125
- //# sourceMappingURL=ensure-collection-policies-CMYAvFpM.js.map
125
+ //# sourceMappingURL=ensure-collection-policies-RK8-SFLs.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ensure-collection-policies-CMYAvFpM.js","names":[],"sources":["../src/security/policy-drift.ts","../src/schema/ensure-collection-policies.ts"],"sourcesContent":["/**\n * Compare the RLS policies a database actually has against the ones the\n * collections describe.\n *\n * Policies live in Postgres; the collection config is only their *source*.\n * Nothing reconciled the two, which is how the demo database served empty\n * collections indefinitely: its policies granted `TO authenticated` (a Supabase\n * role name) while requests run as `rebase_user`, so RLS filtered every row.\n * The config was later corrected and the database never noticed — an empty\n * table is indistinguishable from a table with no data.\n *\n * Expected policies are parsed from `generatePostgresPoliciesDdl`, the same\n * function `db push` uses to write `drizzle/policies.sql`, so this compares\n * against exactly what would be applied rather than a reimplementation.\n */\nimport { RLS_UID_SQL, type CollectionConfig } from \"@rebasepro/types\";\n\nimport { generatePostgresPoliciesDdl } from \"../schema/generate-postgres-ddl-logic\";\n\nexport interface PolicyRef {\n schema: string;\n table: string;\n name: string;\n /** Roles in the TO clause. */\n roles: string[];\n /** SELECT / INSERT / UPDATE / DELETE / ALL. */\n command: string;\n /** Whether a USING clause is present at all (not what it says). */\n hasUsing: boolean;\n /** Whether a WITH CHECK clause is present at all (not what it says). */\n hasWithCheck: boolean;\n /**\n * PERMISSIVE or RESTRICTIVE — the `AS` clause.\n *\n * An exact catalogue value on both sides, so it belongs with roles and\n * command rather than with the expression text. It matters more than either:\n * permissive policies are ORed together and restrictive ones ANDed, so a rule\n * declared `mode: \"restrictive\"` whose live policy is PERMISSIVE has had its\n * gate turned from a requirement into an alternative — the maximally\n * permissive way for this to be wrong.\n *\n * The DDL regex captured this from the start and the destructuring threw it\n * away; `pg_policies.permissive` was never selected.\n */\n mode?: \"PERMISSIVE\" | \"RESTRICTIVE\";\n /**\n * The live clause text, when read from `pg_policies`. Present only for live\n * policies (the expected side is parsed from DDL and does not carry it).\n * Used solely for the insecure-tautology scan, not for divergence — Postgres\n * rewrites this text, so it is not safe to diff against expected.\n */\n qual?: string | null;\n withCheck?: string | null;\n}\n\nexport interface PolicyDrift {\n /** Described by the collections, absent from the database. */\n missing: PolicyRef[];\n /** In the database, described by no collection — stale pushes live here. */\n orphaned: PolicyRef[];\n /** Same policy name, different roles or command. */\n diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[] }[];\n /**\n * A live policy whose expression is the known-permissive tautology\n * `rebase.uid() IS NOT NULL` — true for anonymous visitors too, because the\n * user path coerces a blank id to the `'anonymous'` sentinel. This is what\n * `policy.authenticated()` used to compile to, so a database pushed before\n * that fix carries it, and neither the name, roles, command nor clause\n * *presence* differs from the corrected policy — the only thing that changed\n * is the expression text, which this checker otherwise (correctly) ignores.\n * So it is the one drift that hides from every other check here.\n *\n * @see reason a sentence naming the clause and what to do.\n */\n insecure: { policy: PolicyRef; reason: string }[];\n /**\n * A table the collections describe whose RLS switch is off.\n *\n * `ALTER TABLE posts DISABLE ROW LEVEL SECURITY` leaves every row in\n * `pg_policies` untouched, so before this category every expected policy\n * still matched on name, roles, command and clause presence and the checker\n * reported clean — on a table Postgres was applying no filter to at all.\n * Requests run as `rebase_user`, which holds full DML, so the table is wide\n * open while `doctor` certifies it.\n *\n * `forced` reports `relforcerowsecurity`, which is what also subjects the\n * table's *owner* to its policies. Its absence is not drift on its own —\n * Rebase does not connect as the owner in the request path — so it is\n * reported for context rather than raised as a failure.\n */\n rlsDisabled: { schema: string; table: string; forced: boolean }[];\n}\n\nexport interface Queryable {\n query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }>;\n}\n\n// The trailing group captures whichever clause follows the TO list, which is\n// what tells us the clause is present.\nconst CREATE_POLICY = /CREATE POLICY \"([^\"]+)\" ON \"([^\"]+)\"\\.\"([^\"]+)\"\\s+AS (\\w+)\\s+FOR (\\w+)\\s+TO ([^\\n]+?)(\\s+USING\\s*\\(|\\s+WITH CHECK\\s*\\(|;)/gi;\n\nconst WITH_CHECK_NEXT = /^\\s+WITH CHECK\\s*\\(/i;\n\n/**\n * Index just past the `)` closing a clause whose `(` ends at `open`.\n *\n * Needed because a policy expression nests parens and can contain a quoted\n * literal holding either character, so \"find the next `)`\" would stop early and\n * miss the `WITH CHECK` that follows.\n */\nfunction clauseEnd(ddl: string, open: number): number {\n let depth = 1;\n let inQuote = false;\n for (let i = open; i < ddl.length; i++) {\n const c = ddl[i];\n if (inQuote) {\n // '' is an escaped quote inside a string, not a close.\n if (c === \"'\") {\n if (ddl[i + 1] === \"'\") i++;\n else inQuote = false;\n }\n continue;\n }\n if (c === \"'\") inQuote = true;\n else if (c === \"(\") depth++;\n else if (c === \")\" && --depth === 0) return i + 1;\n }\n return ddl.length;\n}\n\n/**\n * `AS PERMISSIVE` / `AS RESTRICTIVE` from either side, or undefined.\n *\n * The DDL spells it as a bare word; `pg_policies.permissive` is the string\n * `\"PERMISSIVE\"`/`\"RESTRICTIVE\"` on modern Postgres but a boolean on some\n * drivers and older servers, so both are accepted. Anything unrecognised\n * becomes `undefined` and the comparison below skips it rather than inventing\n * drift from a shape we did not anticipate.\n */\nfunction normalizeMode(raw: unknown): \"PERMISSIVE\" | \"RESTRICTIVE\" | undefined {\n if (typeof raw === \"boolean\") return raw ? \"PERMISSIVE\" : \"RESTRICTIVE\";\n if (typeof raw !== \"string\") return undefined;\n const upper = raw.trim().toUpperCase();\n if (upper === \"PERMISSIVE\" || upper === \"TRUE\" || upper === \"T\") return \"PERMISSIVE\";\n if (upper === \"RESTRICTIVE\" || upper === \"FALSE\" || upper === \"F\") return \"RESTRICTIVE\";\n return undefined;\n}\n\n/** Parse the generated DDL rather than rebuilding the shape by hand. */\nexport function parseExpectedPolicies(ddl: string): PolicyRef[] {\n const found: PolicyRef[] = [];\n for (const m of ddl.matchAll(CREATE_POLICY)) {\n const [, name, schema, table, modeRaw, command, rolesRaw, clause] = m;\n const roles = rolesRaw\n .split(\",\")\n .map((r) => r.trim().replace(/^\"|\"$/g, \"\"))\n .filter(Boolean);\n\n // The generator emits USING before WITH CHECK, so what follows the TO\n // list settles USING; WITH CHECK is then whatever follows that clause.\n const hasUsing = /USING/i.test(clause);\n const hasWithCheck = hasUsing\n ? WITH_CHECK_NEXT.test(ddl.slice(clauseEnd(ddl, m.index + m[0].length)))\n : /WITH CHECK/i.test(clause);\n\n found.push({\n schema, table, name, roles,\n command: command.toUpperCase(),\n hasUsing, hasWithCheck,\n mode: normalizeMode(modeRaw)\n });\n }\n return found;\n}\n\nasync function readLivePolicies(client: Queryable, schemas: string[]): Promise<PolicyRef[]> {\n const { rows } = await client.query<{\n schemaname: string; tablename: string; policyname: string; roles: string[] | string; cmd: string;\n qual: string | null; with_check: string | null; permissive: string | boolean | null;\n }>(\n `SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check, permissive\n FROM pg_policies\n WHERE schemaname = ANY($1)`,\n [schemas]\n );\n\n return rows.map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n name: r.policyname,\n // node-postgres yields text[] as an array; some drivers hand back \"{a,b}\".\n roles: Array.isArray(r.roles)\n ? r.roles\n : String(r.roles ?? \"\").replace(/^\\{|\\}$/g, \"\").split(\",\").filter(Boolean),\n command: (r.cmd ?? \"ALL\").toUpperCase(),\n // Presence only. Postgres rewrites the text, but it does not invent or\n // drop a clause: NULL here means the policy genuinely has none.\n hasUsing: r.qual != null,\n hasWithCheck: r.with_check != null,\n mode: normalizeMode(r.permissive),\n qual: r.qual,\n withCheck: r.with_check\n }));\n}\n\n/**\n * Tables the collections describe whose RLS switch is off.\n *\n * Read from `pg_class` because `pg_policies` cannot answer it: disabling RLS\n * leaves the policy rows in place, so every other comparison in this file keeps\n * matching while Postgres applies none of them.\n *\n * Only tables that expect at least one policy are considered. A table with no\n * declared rules is not asserting anything about RLS, and reporting it would\n * make the check noisy on exactly the projects least able to judge the noise.\n */\nasync function readTablesWithRlsOff(\n client: Queryable,\n tables: { schema: string; table: string }[]\n): Promise<{ schema: string; table: string; forced: boolean }[]> {\n if (tables.length === 0) return [];\n const { rows } = await client.query<{\n schemaname: string; tablename: string; relrowsecurity: boolean; relforcerowsecurity: boolean;\n }>(\n `SELECT n.nspname AS schemaname,\n c.relname AS tablename,\n c.relrowsecurity,\n c.relforcerowsecurity\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE c.relkind = 'r'\n AND (n.nspname || '.' || c.relname) = ANY($1)`,\n [tables.map((t) => `${t.schema}.${t.table}`)]\n );\n\n return rows\n .filter((r) => !r.relrowsecurity)\n .map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n forced: !!r.relforcerowsecurity\n }));\n}\n\n/**\n * The permissive tautology `rebase.uid() IS NOT NULL`, without the\n * `<> 'anonymous'` guard that makes it mean \"signed in\".\n *\n * Whitespace varies with Postgres's rewrite, so match on a collapsed form. The\n * guard clause (`<> 'anonymous'`, in any spelling) is what distinguishes the\n * corrected policy from the stale one, so its presence clears the text.\n *\n * Both schema spellings are matched. The helpers moved from `auth` to `rebase`\n * in 1.0, and this check reads policies *as the database stored them*: a\n * database pushed before the move still holds `auth.uid()` until it is\n * recompiled, while everything written since — including raw `securityRules`\n * SQL, which the compiler rewrites on the way in — stores `rebase.uid()`.\n * Matching only the pre-1.0 name made the check blind to every policy a current\n * release could write, which is precisely the set still worth scanning.\n */\nfunction isPermissiveAuthTautology(clause: string | null | undefined): boolean {\n if (!clause) return false;\n const flat = clause.toLowerCase().replace(/\\s+/g, \" \");\n if (!/\\b(?:rebase|auth)\\.uid\\s*\\(\\s*\\)\\s*is not null/.test(flat)) return false;\n // The fix appends `AND rebase.uid() <> 'anonymous'`; Postgres may store the\n // literal as `'anonymous'::text`. Either spelling means it is the corrected\n // policy, not the tautology.\n return !/<>\\s*'anonymous'/.test(flat) && !/!=\\s*'anonymous'/.test(flat);\n}\n\nconst keyOf = (p: PolicyRef) => `${p.schema}.${p.table}.${p.name}`;\nconst sameRoles = (a: string[], b: string[]) =>\n a.length === b.length && [...a].sort().join(\",\") === [...b].sort().join(\",\");\n\n/**\n * Diff expected against live.\n *\n * Compares names, roles, command, and whether each clause exists — all exact\n * values. Policy expression *text* is deliberately not compared: Postgres\n * rewrites `qual`/`with_check` when storing them (parenthesising, casting,\n * schema-qualifying), so text comparison reports drift that does not exist, and\n * a check that cries wolf gets ignored.\n *\n * Presence is not text, though. A NULL `qual` is not a rewrite of an\n * expression, it is the absence of one, and absence has no false-positive risk:\n * either the generator emitted a clause or it did not. That distinction is worth\n * the extra comparison — a production database was found with a SELECT policy\n * whose `qual` was NULL, matching on every field this checked and denying 100%\n * of reads. The same blindness would hide a policy that fails open.\n */\nexport async function checkPolicyDrift(\n client: Queryable,\n collections: CollectionConfig[]\n): Promise<PolicyDrift> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const schemas = [...new Set(expected.map((p) => p.schema))];\n // Nothing expected means nothing to reconcile against; scanning every\n // schema would report the whole database as orphaned.\n if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n const live = await readLivePolicies(client, schemas);\n const liveByKey = new Map(live.map((p) => [keyOf(p), p]));\n const expectedByKey = new Map(expected.map((p) => [keyOf(p), p]));\n\n const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n // Before comparing policy against policy: is the switch even on? A table\n // with RLS disabled matches every expected policy on every field compared\n // below, so this has to be asked separately or it cannot be asked at all.\n const expectedTables = [...new Map(\n expected.map((p) => [`${p.schema}.${p.table}`, { schema: p.schema, table: p.table }])\n ).values()];\n drift.rlsDisabled = await readTablesWithRlsOff(client, expectedTables);\n\n // Scan every live policy for the permissive tautology. This is deliberately\n // independent of the name-keyed diff below: a database pushed before the\n // `authenticated()` fix matches its expected policy on name, roles, command\n // and clause presence, so nothing else here would flag it.\n for (const p of live) {\n const clause = isPermissiveAuthTautology(p.qual)\n ? \"USING\"\n : isPermissiveAuthTautology(p.withCheck) ? \"WITH CHECK\" : null;\n if (clause) {\n drift.insecure.push({\n policy: p,\n reason: `${clause} is \\`${RLS_UID_SQL} IS NOT NULL\\`, which is true for anonymous ` +\n `visitors too — this grants access to signed-out requests. It predates the ` +\n `\\`policy.authenticated()\\` fix; re-run \\`rebase db push\\` to tighten it.`\n });\n }\n }\n\n for (const [key, want] of expectedByKey) {\n const got = liveByKey.get(key);\n if (!got) {\n drift.missing.push(want);\n continue;\n }\n const differences: string[] = [];\n if (!sameRoles(want.roles, got.roles)) {\n differences.push(`roles: expected [${want.roles.join(\", \")}], database has [${got.roles.join(\", \")}]`);\n }\n if (want.command !== got.command) {\n differences.push(`command: expected ${want.command}, database has ${got.command}`);\n }\n // Skipped when either side is unknown: an unrecognised catalogue shape\n // should not manufacture drift. See `normalizeMode`.\n if (want.mode && got.mode && want.mode !== got.mode) {\n differences.push(\n `mode: expected ${want.mode}, database has ${got.mode}` +\n (got.mode === \"PERMISSIVE\"\n ? \" — a RESTRICTIVE rule is ANDed with the others, a PERMISSIVE one is ORed, so this policy now widens access instead of narrowing it\"\n : \" — a PERMISSIVE rule is ORed with the others, a RESTRICTIVE one is ANDed, so this policy now narrows access instead of widening it\")\n );\n }\n for (const clause of [\"USING\", \"WITH CHECK\"] as const) {\n const key = clause === \"USING\" ? \"hasUsing\" : \"hasWithCheck\";\n if (want[key] === got[key]) continue;\n differences.push(want[key]\n ? `${clause}: expected an expression, database has none — this policy matches no rows`\n : `${clause}: expected none, database has an expression`);\n }\n if (differences.length > 0) drift.diverged.push({ expected: want, actual: got, differences });\n }\n\n for (const [key, got] of liveByKey) {\n if (!expectedByKey.has(key)) drift.orphaned.push(got);\n }\n\n return drift;\n}\n\n/**\n * Does this name look like one the generator produced for this table?\n *\n * Unnamed rules compile to `<table>_<op>_<sha1[0:7]>` (plus `_<idx>` when one\n * rule spans several operations), and the hash covers the rule's semantics — so\n * *editing* a rule renames its policy. The policy under the old name is left\n * behind by `db push`, which only DROPs the names it is about to CREATE, and\n * Postgres ORs PERMISSIVE policies together: a superseded `USING (true)` keeps\n * granting everything no matter how tight its replacement is.\n *\n * Matching the shape is what makes dropping them safe. A hand-written policy\n * would have to collide with a 7-hex digest to be mistaken for generated one;\n * a policy named anything else is left alone and merely reported, because a\n * custom name is indistinguishable from one someone wrote in SQL on purpose.\n */\nexport function isGeneratedPolicyName(name: string, table: string): boolean {\n return new RegExp(`^${table.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}_(select|insert|update|delete|all)_[0-9a-f]{7}(_\\\\d+)?$`)\n .test(name);\n}\n\nexport interface OrphanCleanup {\n /** Superseded generated policies that were dropped. */\n dropped: PolicyRef[];\n /** Orphans left in place because their names are not generator-shaped. */\n kept: PolicyRef[];\n}\n\n/**\n * Drop the policies an earlier push superseded but never removed.\n *\n * Only touches tables the collections describe — a table with no expected\n * policy is not ours to reconcile, and scanning by schema alone would sweep up\n * policies belonging to something else sharing the database.\n */\nexport async function dropOrphanedPolicies(\n client: Queryable,\n drift: PolicyDrift,\n collections: CollectionConfig[]\n): Promise<OrphanCleanup> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const managed = new Set(expected.map((p) => `${p.schema}.${p.table}`));\n\n const cleanup: OrphanCleanup = { dropped: [], kept: [] };\n for (const p of drift.orphaned) {\n if (!managed.has(`${p.schema}.${p.table}`) || !isGeneratedPolicyName(p.name, p.table)) {\n cleanup.kept.push(p);\n continue;\n }\n // Identifiers are quoted, and the name came from pg_policies rather than\n // from user input, so it is already a valid identifier.\n await client.query(`DROP POLICY IF EXISTS \"${p.name}\" ON \"${p.schema}\".\"${p.table}\"`);\n cleanup.dropped.push(p);\n }\n return cleanup;\n}\n\nexport const hasDrift = (d: PolicyDrift): boolean =>\n d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0\n || d.rlsDisabled.length > 0;\n\n/** Human-readable report; empty string when the database matches the config. */\nexport function formatPolicyDrift(drift: PolicyDrift): string {\n if (!hasDrift(drift)) return \"\";\n const lines: string[] = [];\n\n // First, because it subsumes every other finding on the same table: if RLS\n // is off, the policies listed below are not being applied at all.\n if (drift.rlsDisabled.length > 0) {\n lines.push(\" RLS DISABLED — the table has policies, and Postgres is applying none of them:\");\n for (const t of drift.rlsDisabled) {\n lines.push(` • ${t.schema}.${t.table}${t.forced ? \"\" : \" (and FORCE is off)\"}`);\n }\n lines.push(\" Every row is readable and writable by any request that reaches the table.\");\n lines.push(` Fix: ALTER TABLE \"<schema>\".\"<table>\" ENABLE ROW LEVEL SECURITY; or re-run \\`rebase db push\\`.`);\n }\n if (drift.missing.length > 0) {\n lines.push(\" Missing — described by your collections, absent from the database:\");\n for (const p of drift.missing) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Run `rebase db push` to apply them.\");\n }\n if (drift.orphaned.length > 0) {\n lines.push(\" Orphaned — in the database, described by no collection:\");\n for (const p of drift.orphaned) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Left behind by an earlier push. These still filter rows.\");\n }\n if (drift.diverged.length > 0) {\n lines.push(\" Diverged — same policy, different definition:\");\n for (const d of drift.diverged) {\n lines.push(` • ${d.expected.schema}.${d.expected.table} → \"${d.expected.name}\"`);\n for (const diff of d.differences) lines.push(` ${diff}`);\n }\n }\n if (drift.insecure.length > 0) {\n lines.push(\" Insecure — a live policy grants access it should not:\");\n for (const i of drift.insecure) {\n lines.push(` • ${i.policy.schema}.${i.policy.table} → \"${i.policy.name}\"`);\n lines.push(` ${i.reason}`);\n }\n }\n return lines.join(\"\\n\");\n}\n","/**\n * Applying a bundle's RLS policies to a database at boot, idempotently.\n *\n * ## Why this exists\n *\n * {@link ensureCollectionTables} creates the collection *tables* a managed\n * runtime boots against, but a table with row-level security disabled and no\n * policies is not servable: authenticated requests run as the restricted\n * `rebase_user` role, so a read with no `SELECT` policy returns nothing (a\n * public collection answered 401) and a write with no `INSERT`/`UPDATE` policy\n * is denied. The policies live in the collections' `securityRules`; nothing at\n * boot applied them. `rebase db push` does — but it drives Atlas against a\n * local `DATABASE_URL`, and a managed tenant's database is reachable only from\n * inside the cluster, by the runtime that is already connected to it. So the\n * runtime is the only thing that *can* apply them, and this is where it does.\n *\n * ## Why this is safe to run on every boot\n *\n * Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once\n * enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by\n * a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds\n * and replaces; it never drops data. (It does not *reconcile* — a policy a\n * previous push left behind under an old name is not removed here; that stays a\n * `db push` / `db migrate` concern, alongside destructive schema changes.)\n *\n * Unlike table creation, a failure here is not fatal: RLS stays enabled, so a\n * table whose policies could not be applied fails **closed** (denies) rather\n * than leaking rows. One collection's policy failing (e.g. a rule that\n * references a table a real migration has not created yet) must not crash-loop\n * the whole deployment and take the other collections' working routes down with\n * it. Failures are reported loudly and per-table so the operator can see\n * exactly which collection is not yet servable and why.\n */\nimport { type CollectionConfig } from \"@rebasepro/types\";\nimport { planCollectionPolicies, type CollectionPolicyPlan } from \"./generate-postgres-ddl-logic\";\nimport { isGeneratedPolicyName } from \"../security/policy-drift\";\nimport { readExistingSchema, type Queryable } from \"./ensure-collection-tables\";\nimport { REBASE_USER_ROLE } from \"../security/rls-enforcement\";\n\nexport interface PolicyEnsureResult {\n /** `CREATE POLICY` statements that ran successfully. */\n policiesApplied: number;\n /** Tables that had RLS enabled. */\n tablesSecured: number;\n /** Declared tables absent from the database — left to a real migration. */\n skipped: { table: string; reason: string }[];\n /**\n * Tables that have RLS on but did not get every policy. They deny — RLS\n * with no matching policy is deny-all — so they are safe but not servable.\n */\n failures: { table: string; error: string }[];\n /**\n * Tables RLS could not be enabled on, whose DML grant was withdrawn instead.\n *\n * This state had no name, and that was the bug: `ENABLE ROW LEVEL SECURITY`\n * failing was recorded as a `failure` and reported with the same \"it stays\n * locked (denies)\" wording as a failed policy — but the two are opposites.\n * A policy statement failing leaves RLS on and the table denying. `enableRls`\n * failing leaves RLS *off*, and the schema-wide grant to the user role has\n * already been made by `ensureRlsEnforcement`, so the table is readable and\n * writable by every authenticated request with no row filtering at all.\n */\n unsecured: { table: string; error: string; grantWithdrawn: boolean }[];\n /**\n * Generated policies removed because no current rule produces them.\n *\n * A policy's name embeds a hash of the rule's semantics, so editing a rule\n * does not update a policy — it creates a new one and abandons the old.\n * Postgres ORs permissive policies, so the abandoned one keeps granting:\n * a `USING (true)` tightened to an owner check went on admitting everyone,\n * forever, while the deploy logged success.\n *\n * `db push` reconciles this, and cannot reach a managed tenant's in-cluster\n * database — which is the reason this module exists. So boot has to do it.\n */\n orphansDropped: number;\n}\n\nconst isCreatePolicy = (statement: string): boolean => /^\\s*CREATE POLICY/i.test(statement);\n\n/**\n * Bring the declared collections' RLS policies up to date. Returns what it did.\n *\n * Only tables that already exist are touched: the boot-time table creator runs\n * first, so anything still missing is a table this additive path is not allowed\n * to create (a junction, or a relation left to a migration). Enabling RLS on a\n * non-existent table would error, so those are recorded as skipped, not failed.\n */\n\n/**\n * Remove generated policies on this table that the current plan does not\n * produce.\n *\n * Scoped hard, because dropping a policy is destructive: only this one table,\n * only names matching the generated `<table>_<op>_<sha1>` shape, and only names\n * absent from the statements just applied. A hand-written policy, or one\n * belonging to another table, is never touched — `isGeneratedPolicyName` is the\n * same predicate `db push` uses to draw that line.\n */\nasync function dropOrphanedPoliciesOn(client: Queryable, plan: CollectionPolicyPlan): Promise<number> {\n const expected = new Set<string>();\n for (const statement of plan.policyStatements) {\n const match = /^\\s*CREATE POLICY\\s+\"([^\"]+)\"/i.exec(statement);\n if (match) expected.add(match[1]);\n }\n\n const existing = await client.query<{ policyname: string }>(\n `SELECT policyname FROM pg_policies WHERE schemaname = '${plan.schema.replace(/'/g, \"''\")}' ` +\n `AND tablename = '${plan.table.replace(/'/g, \"''\")}'`\n );\n\n let dropped = 0;\n for (const row of existing.rows) {\n const name = row.policyname;\n if (expected.has(name)) continue;\n if (!isGeneratedPolicyName(name, plan.table)) continue;\n await client.query(`DROP POLICY IF EXISTS \"${name}\" ON \"${plan.schema}\".\"${plan.table}\"`);\n dropped++;\n }\n return dropped;\n}\n\nexport async function ensureCollectionPolicies(\n client: Queryable,\n collections: CollectionConfig[],\n log?: (message: string) => void\n): Promise<PolicyEnsureResult> {\n const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [], unsecured: [], orphansDropped: 0 };\n\n const plans = planCollectionPolicies(collections);\n if (plans.length === 0) return result;\n\n const schemas = Array.from(new Set(plans.map(p => p.schema)));\n const existing = await readExistingSchema(client, schemas);\n\n for (const plan of plans) {\n if (!existing.tables.has(plan.qualified)) {\n result.skipped.push({\n table: plan.qualified,\n reason: \"table is not present in the database; create it with `rebase db push` / `rebase db migrate`\"\n });\n continue;\n }\n\n // Enabling RLS is its own step, because its failure is the opposite of\n // every other failure here. Once RLS is on, anything that goes wrong\n // afterwards leaves the table denying; while it is off, the grant made\n // earlier in boot leaves the table wide open. Sharing one `try` meant\n // the dangerous case was reported in the safe case's words.\n try {\n await client.query(plan.enableRls);\n result.tablesSecured++;\n } catch (err) {\n const error = err instanceof Error ? err.message : String(err);\n // Take the privilege back rather than serve an unprotected table.\n // This is the fail-closed step the old code assumed it already had:\n // per-table, so one collection cannot take the rest of the\n // deployment down, but leaving nothing readable without RLS.\n let grantWithdrawn = false;\n try {\n await client.query(`REVOKE ALL PRIVILEGES ON ${plan.qualified} FROM ${REBASE_USER_ROLE}`);\n grantWithdrawn = true;\n } catch {\n // Fall through: reported below with `grantWithdrawn: false`,\n // which the caller escalates. There is nothing else this\n // function can do to make the table safe.\n }\n result.unsecured.push({ table: plan.qualified,\nerror,\ngrantWithdrawn });\n continue;\n }\n\n try {\n let created = 0;\n for (const statement of plan.policyStatements) {\n await client.query(statement);\n if (isCreatePolicy(statement)) {\n result.policiesApplied++;\n created++;\n }\n }\n\n const orphans = await dropOrphanedPoliciesOn(client, plan);\n result.orphansDropped += orphans;\n\n log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied` +\n (orphans > 0 ? `, ${orphans} orphan(s) dropped` : \"\"));\n } catch (err) {\n result.failures.push({\n table: plan.qualified,\n error: err instanceof Error ? err.message : String(err)\n });\n }\n }\n\n return result;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAmYA,SAAgB,sBAAsB,MAAc,OAAwB;CACxE,OAAO,IAAI,OAAO,IAAI,MAAM,QAAQ,uBAAuB,MAAM,EAAE,wDAAwD,CAAC,CACvH,KAAK,IAAI;AAClB;;;ACxTA,IAAM,kBAAkB,cAA+B,qBAAqB,KAAK,SAAS;;;;;;;;;;;;;;;;;;;AAqB1F,eAAe,uBAAuB,QAAmB,MAA6C;CAClG,MAAM,2BAAW,IAAI,IAAY;CACjC,KAAK,MAAM,aAAa,KAAK,kBAAkB;EAC3C,MAAM,QAAQ,iCAAiC,KAAK,SAAS;EAC7D,IAAI,OAAO,SAAS,IAAI,MAAM,EAAE;CACpC;CAEA,MAAM,WAAW,MAAM,OAAO,MAC1B,0DAA0D,KAAK,OAAO,QAAQ,MAAM,IAAI,EAAE,qBACtE,KAAK,MAAM,QAAQ,MAAM,IAAI,EAAE,EACvD;CAEA,IAAI,UAAU;CACd,KAAK,MAAM,OAAO,SAAS,MAAM;EAC7B,MAAM,OAAO,IAAI;EACjB,IAAI,SAAS,IAAI,IAAI,GAAG;EACxB,IAAI,CAAC,sBAAsB,MAAM,KAAK,KAAK,GAAG;EAC9C,MAAM,OAAO,MAAM,0BAA0B,KAAK,QAAQ,KAAK,OAAO,KAAK,KAAK,MAAM,EAAE;EACxF;CACJ;CACA,OAAO;AACX;AAEA,eAAsB,yBAClB,QACA,aACA,KAC2B;CAC3B,MAAM,SAA6B;EAAE,iBAAiB;EAAG,eAAe;EAAG,SAAS,CAAC;EAAG,UAAU,CAAC;EAAG,WAAW,CAAC;EAAG,gBAAgB;CAAE;CAEvI,MAAM,QAAQ,uBAAuB,WAAW;CAChD,IAAI,MAAM,WAAW,GAAG,OAAO;CAG/B,MAAM,WAAW,MAAM,mBAAmB,QAD1B,MAAM,KAAK,IAAI,IAAI,MAAM,KAAI,MAAK,EAAE,MAAM,CAAC,CACT,CAAO;CAEzD,KAAK,MAAM,QAAQ,OAAO;EACtB,IAAI,CAAC,SAAS,OAAO,IAAI,KAAK,SAAS,GAAG;GACtC,OAAO,QAAQ,KAAK;IAChB,OAAO,KAAK;IACZ,QAAQ;GACZ,CAAC;GACD;EACJ;EAOA,IAAI;GACA,MAAM,OAAO,MAAM,KAAK,SAAS;GACjC,OAAO;EACX,SAAS,KAAK;GACV,MAAM,QAAQ,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAK7D,IAAI,iBAAiB;GACrB,IAAI;IACA,MAAM,OAAO,MAAM,4BAA4B,KAAK,UAAU,QAAQ,kBAAkB;IACxF,iBAAiB;GACrB,QAAQ,CAIR;GACA,OAAO,UAAU,KAAK;IAAE,OAAO,KAAK;IAChD;IACA;GAAe,CAAC;GACJ;EACJ;EAEA,IAAI;GACA,IAAI,UAAU;GACd,KAAK,MAAM,aAAa,KAAK,kBAAkB;IAC3C,MAAM,OAAO,MAAM,SAAS;IAC5B,IAAI,eAAe,SAAS,GAAG;KAC3B,OAAO;KACP;IACJ;GACJ;GAEA,MAAM,UAAU,MAAM,uBAAuB,QAAQ,IAAI;GACzD,OAAO,kBAAkB;GAEzB,MAAM,GAAG,KAAK,UAAU,iBAAiB,QAAQ,yBAC5C,UAAU,IAAI,KAAK,QAAQ,sBAAsB,GAAG;EAC7D,SAAS,KAAK;GACV,OAAO,SAAS,KAAK;IACjB,OAAO,KAAK;IACZ,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAC1D,CAAC;EACL;CACJ;CAEA,OAAO;AACX"}
1
+ {"version":3,"file":"ensure-collection-policies-RK8-SFLs.js","names":[],"sources":["../src/security/policy-drift.ts","../src/schema/ensure-collection-policies.ts"],"sourcesContent":["/**\n * Compare the RLS policies a database actually has against the ones the\n * collections describe.\n *\n * Policies live in Postgres; the collection config is only their *source*.\n * Nothing reconciled the two, which is how the demo database served empty\n * collections indefinitely: its policies granted `TO authenticated` (a Supabase\n * role name) while requests run as `rebase_user`, so RLS filtered every row.\n * The config was later corrected and the database never noticed — an empty\n * table is indistinguishable from a table with no data.\n *\n * Expected policies are parsed from `generatePostgresPoliciesDdl`, the same\n * function `db push` uses to write `drizzle/policies.sql`, so this compares\n * against exactly what would be applied rather than a reimplementation.\n */\nimport { RLS_UID_SQL, type CollectionConfig } from \"@rebasepro/types\";\n\nimport { generatePostgresPoliciesDdl } from \"../schema/generate-postgres-ddl-logic\";\n\nexport interface PolicyRef {\n schema: string;\n table: string;\n name: string;\n /** Roles in the TO clause. */\n roles: string[];\n /** SELECT / INSERT / UPDATE / DELETE / ALL. */\n command: string;\n /** Whether a USING clause is present at all (not what it says). */\n hasUsing: boolean;\n /** Whether a WITH CHECK clause is present at all (not what it says). */\n hasWithCheck: boolean;\n /**\n * PERMISSIVE or RESTRICTIVE — the `AS` clause.\n *\n * An exact catalogue value on both sides, so it belongs with roles and\n * command rather than with the expression text. It matters more than either:\n * permissive policies are ORed together and restrictive ones ANDed, so a rule\n * declared `mode: \"restrictive\"` whose live policy is PERMISSIVE has had its\n * gate turned from a requirement into an alternative — the maximally\n * permissive way for this to be wrong.\n *\n * The DDL regex captured this from the start and the destructuring threw it\n * away; `pg_policies.permissive` was never selected.\n */\n mode?: \"PERMISSIVE\" | \"RESTRICTIVE\";\n /**\n * The live clause text, when read from `pg_policies`. Present only for live\n * policies (the expected side is parsed from DDL and does not carry it).\n * Used solely for the insecure-tautology scan, not for divergence — Postgres\n * rewrites this text, so it is not safe to diff against expected.\n */\n qual?: string | null;\n withCheck?: string | null;\n}\n\nexport interface PolicyDrift {\n /** Described by the collections, absent from the database. */\n missing: PolicyRef[];\n /** In the database, described by no collection — stale pushes live here. */\n orphaned: PolicyRef[];\n /** Same policy name, different roles or command. */\n diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[] }[];\n /**\n * A live policy whose expression is the known-permissive tautology\n * `rebase.uid() IS NOT NULL` — true for anonymous visitors too, because the\n * user path coerces a blank id to the `'anonymous'` sentinel. This is what\n * `policy.authenticated()` used to compile to, so a database pushed before\n * that fix carries it, and neither the name, roles, command nor clause\n * *presence* differs from the corrected policy — the only thing that changed\n * is the expression text, which this checker otherwise (correctly) ignores.\n * So it is the one drift that hides from every other check here.\n *\n * @see reason a sentence naming the clause and what to do.\n */\n insecure: { policy: PolicyRef; reason: string }[];\n /**\n * A table the collections describe whose RLS switch is off.\n *\n * `ALTER TABLE posts DISABLE ROW LEVEL SECURITY` leaves every row in\n * `pg_policies` untouched, so before this category every expected policy\n * still matched on name, roles, command and clause presence and the checker\n * reported clean — on a table Postgres was applying no filter to at all.\n * Requests run as `rebase_user`, which holds full DML, so the table is wide\n * open while `doctor` certifies it.\n *\n * `forced` reports `relforcerowsecurity`, which is what also subjects the\n * table's *owner* to its policies. Its absence is not drift on its own —\n * Rebase does not connect as the owner in the request path — so it is\n * reported for context rather than raised as a failure.\n */\n rlsDisabled: { schema: string; table: string; forced: boolean }[];\n}\n\nexport interface Queryable {\n query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }>;\n}\n\n// The trailing group captures whichever clause follows the TO list, which is\n// what tells us the clause is present.\nconst CREATE_POLICY = /CREATE POLICY \"([^\"]+)\" ON \"([^\"]+)\"\\.\"([^\"]+)\"\\s+AS (\\w+)\\s+FOR (\\w+)\\s+TO ([^\\n]+?)(\\s+USING\\s*\\(|\\s+WITH CHECK\\s*\\(|;)/gi;\n\nconst WITH_CHECK_NEXT = /^\\s+WITH CHECK\\s*\\(/i;\n\n/**\n * Index just past the `)` closing a clause whose `(` ends at `open`.\n *\n * Needed because a policy expression nests parens and can contain a quoted\n * literal holding either character, so \"find the next `)`\" would stop early and\n * miss the `WITH CHECK` that follows.\n */\nfunction clauseEnd(ddl: string, open: number): number {\n let depth = 1;\n let inQuote = false;\n for (let i = open; i < ddl.length; i++) {\n const c = ddl[i];\n if (inQuote) {\n // '' is an escaped quote inside a string, not a close.\n if (c === \"'\") {\n if (ddl[i + 1] === \"'\") i++;\n else inQuote = false;\n }\n continue;\n }\n if (c === \"'\") inQuote = true;\n else if (c === \"(\") depth++;\n else if (c === \")\" && --depth === 0) return i + 1;\n }\n return ddl.length;\n}\n\n/**\n * `AS PERMISSIVE` / `AS RESTRICTIVE` from either side, or undefined.\n *\n * The DDL spells it as a bare word; `pg_policies.permissive` is the string\n * `\"PERMISSIVE\"`/`\"RESTRICTIVE\"` on modern Postgres but a boolean on some\n * drivers and older servers, so both are accepted. Anything unrecognised\n * becomes `undefined` and the comparison below skips it rather than inventing\n * drift from a shape we did not anticipate.\n */\nfunction normalizeMode(raw: unknown): \"PERMISSIVE\" | \"RESTRICTIVE\" | undefined {\n if (typeof raw === \"boolean\") return raw ? \"PERMISSIVE\" : \"RESTRICTIVE\";\n if (typeof raw !== \"string\") return undefined;\n const upper = raw.trim().toUpperCase();\n if (upper === \"PERMISSIVE\" || upper === \"TRUE\" || upper === \"T\") return \"PERMISSIVE\";\n if (upper === \"RESTRICTIVE\" || upper === \"FALSE\" || upper === \"F\") return \"RESTRICTIVE\";\n return undefined;\n}\n\n/** Parse the generated DDL rather than rebuilding the shape by hand. */\nexport function parseExpectedPolicies(ddl: string): PolicyRef[] {\n const found: PolicyRef[] = [];\n for (const m of ddl.matchAll(CREATE_POLICY)) {\n const [, name, schema, table, modeRaw, command, rolesRaw, clause] = m;\n const roles = rolesRaw\n .split(\",\")\n .map((r) => r.trim().replace(/^\"|\"$/g, \"\"))\n .filter(Boolean);\n\n // The generator emits USING before WITH CHECK, so what follows the TO\n // list settles USING; WITH CHECK is then whatever follows that clause.\n const hasUsing = /USING/i.test(clause);\n const hasWithCheck = hasUsing\n ? WITH_CHECK_NEXT.test(ddl.slice(clauseEnd(ddl, m.index + m[0].length)))\n : /WITH CHECK/i.test(clause);\n\n found.push({\n schema, table, name, roles,\n command: command.toUpperCase(),\n hasUsing, hasWithCheck,\n mode: normalizeMode(modeRaw)\n });\n }\n return found;\n}\n\nasync function readLivePolicies(client: Queryable, schemas: string[]): Promise<PolicyRef[]> {\n const { rows } = await client.query<{\n schemaname: string; tablename: string; policyname: string; roles: string[] | string; cmd: string;\n qual: string | null; with_check: string | null; permissive: string | boolean | null;\n }>(\n `SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check, permissive\n FROM pg_policies\n WHERE schemaname = ANY($1)`,\n [schemas]\n );\n\n return rows.map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n name: r.policyname,\n // node-postgres yields text[] as an array; some drivers hand back \"{a,b}\".\n roles: Array.isArray(r.roles)\n ? r.roles\n : String(r.roles ?? \"\").replace(/^\\{|\\}$/g, \"\").split(\",\").filter(Boolean),\n command: (r.cmd ?? \"ALL\").toUpperCase(),\n // Presence only. Postgres rewrites the text, but it does not invent or\n // drop a clause: NULL here means the policy genuinely has none.\n hasUsing: r.qual != null,\n hasWithCheck: r.with_check != null,\n mode: normalizeMode(r.permissive),\n qual: r.qual,\n withCheck: r.with_check\n }));\n}\n\n/**\n * Tables the collections describe whose RLS switch is off.\n *\n * Read from `pg_class` because `pg_policies` cannot answer it: disabling RLS\n * leaves the policy rows in place, so every other comparison in this file keeps\n * matching while Postgres applies none of them.\n *\n * Only tables that expect at least one policy are considered. A table with no\n * declared rules is not asserting anything about RLS, and reporting it would\n * make the check noisy on exactly the projects least able to judge the noise.\n */\nasync function readTablesWithRlsOff(\n client: Queryable,\n tables: { schema: string; table: string }[]\n): Promise<{ schema: string; table: string; forced: boolean }[]> {\n if (tables.length === 0) return [];\n const { rows } = await client.query<{\n schemaname: string; tablename: string; relrowsecurity: boolean; relforcerowsecurity: boolean;\n }>(\n `SELECT n.nspname AS schemaname,\n c.relname AS tablename,\n c.relrowsecurity,\n c.relforcerowsecurity\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE c.relkind = 'r'\n AND (n.nspname || '.' || c.relname) = ANY($1)`,\n [tables.map((t) => `${t.schema}.${t.table}`)]\n );\n\n return rows\n .filter((r) => !r.relrowsecurity)\n .map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n forced: !!r.relforcerowsecurity\n }));\n}\n\n/**\n * The permissive tautology `rebase.uid() IS NOT NULL`, without the\n * `<> 'anonymous'` guard that makes it mean \"signed in\".\n *\n * Whitespace varies with Postgres's rewrite, so match on a collapsed form. The\n * guard clause (`<> 'anonymous'`, in any spelling) is what distinguishes the\n * corrected policy from the stale one, so its presence clears the text.\n *\n * Both schema spellings are matched. The helpers moved from `auth` to `rebase`\n * in 1.0, and this check reads policies *as the database stored them*: a\n * database pushed before the move still holds `auth.uid()` until it is\n * recompiled, while everything written since — including raw `securityRules`\n * SQL, which the compiler rewrites on the way in — stores `rebase.uid()`.\n * Matching only the pre-1.0 name made the check blind to every policy a current\n * release could write, which is precisely the set still worth scanning.\n */\nfunction isPermissiveAuthTautology(clause: string | null | undefined): boolean {\n if (!clause) return false;\n const flat = clause.toLowerCase().replace(/\\s+/g, \" \");\n if (!/\\b(?:rebase|auth)\\.uid\\s*\\(\\s*\\)\\s*is not null/.test(flat)) return false;\n // The fix appends `AND rebase.uid() <> 'anonymous'`; Postgres may store the\n // literal as `'anonymous'::text`. Either spelling means it is the corrected\n // policy, not the tautology.\n return !/<>\\s*'anonymous'/.test(flat) && !/!=\\s*'anonymous'/.test(flat);\n}\n\nconst keyOf = (p: PolicyRef) => `${p.schema}.${p.table}.${p.name}`;\nconst sameRoles = (a: string[], b: string[]) =>\n a.length === b.length && [...a].sort().join(\",\") === [...b].sort().join(\",\");\n\n/**\n * Diff expected against live.\n *\n * Compares names, roles, command, and whether each clause exists — all exact\n * values. Policy expression *text* is deliberately not compared: Postgres\n * rewrites `qual`/`with_check` when storing them (parenthesising, casting,\n * schema-qualifying), so text comparison reports drift that does not exist, and\n * a check that cries wolf gets ignored.\n *\n * Presence is not text, though. A NULL `qual` is not a rewrite of an\n * expression, it is the absence of one, and absence has no false-positive risk:\n * either the generator emitted a clause or it did not. That distinction is worth\n * the extra comparison — a production database was found with a SELECT policy\n * whose `qual` was NULL, matching on every field this checked and denying 100%\n * of reads. The same blindness would hide a policy that fails open.\n */\nexport async function checkPolicyDrift(\n client: Queryable,\n collections: CollectionConfig[]\n): Promise<PolicyDrift> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const schemas = [...new Set(expected.map((p) => p.schema))];\n // Nothing expected means nothing to reconcile against; scanning every\n // schema would report the whole database as orphaned.\n if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n const live = await readLivePolicies(client, schemas);\n const liveByKey = new Map(live.map((p) => [keyOf(p), p]));\n const expectedByKey = new Map(expected.map((p) => [keyOf(p), p]));\n\n const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n // Before comparing policy against policy: is the switch even on? A table\n // with RLS disabled matches every expected policy on every field compared\n // below, so this has to be asked separately or it cannot be asked at all.\n const expectedTables = [...new Map(\n expected.map((p) => [`${p.schema}.${p.table}`, { schema: p.schema, table: p.table }])\n ).values()];\n drift.rlsDisabled = await readTablesWithRlsOff(client, expectedTables);\n\n // Scan every live policy for the permissive tautology. This is deliberately\n // independent of the name-keyed diff below: a database pushed before the\n // `authenticated()` fix matches its expected policy on name, roles, command\n // and clause presence, so nothing else here would flag it.\n for (const p of live) {\n const clause = isPermissiveAuthTautology(p.qual)\n ? \"USING\"\n : isPermissiveAuthTautology(p.withCheck) ? \"WITH CHECK\" : null;\n if (clause) {\n drift.insecure.push({\n policy: p,\n reason: `${clause} is \\`${RLS_UID_SQL} IS NOT NULL\\`, which is true for anonymous ` +\n `visitors too — this grants access to signed-out requests. It predates the ` +\n `\\`policy.authenticated()\\` fix; re-run \\`rebase db push\\` to tighten it.`\n });\n }\n }\n\n for (const [key, want] of expectedByKey) {\n const got = liveByKey.get(key);\n if (!got) {\n drift.missing.push(want);\n continue;\n }\n const differences: string[] = [];\n if (!sameRoles(want.roles, got.roles)) {\n differences.push(`roles: expected [${want.roles.join(\", \")}], database has [${got.roles.join(\", \")}]`);\n }\n if (want.command !== got.command) {\n differences.push(`command: expected ${want.command}, database has ${got.command}`);\n }\n // Skipped when either side is unknown: an unrecognised catalogue shape\n // should not manufacture drift. See `normalizeMode`.\n if (want.mode && got.mode && want.mode !== got.mode) {\n differences.push(\n `mode: expected ${want.mode}, database has ${got.mode}` +\n (got.mode === \"PERMISSIVE\"\n ? \" — a RESTRICTIVE rule is ANDed with the others, a PERMISSIVE one is ORed, so this policy now widens access instead of narrowing it\"\n : \" — a PERMISSIVE rule is ORed with the others, a RESTRICTIVE one is ANDed, so this policy now narrows access instead of widening it\")\n );\n }\n for (const clause of [\"USING\", \"WITH CHECK\"] as const) {\n const key = clause === \"USING\" ? \"hasUsing\" : \"hasWithCheck\";\n if (want[key] === got[key]) continue;\n differences.push(want[key]\n ? `${clause}: expected an expression, database has none — this policy matches no rows`\n : `${clause}: expected none, database has an expression`);\n }\n if (differences.length > 0) drift.diverged.push({ expected: want, actual: got, differences });\n }\n\n for (const [key, got] of liveByKey) {\n if (!expectedByKey.has(key)) drift.orphaned.push(got);\n }\n\n return drift;\n}\n\n/**\n * Does this name look like one the generator produced for this table?\n *\n * Unnamed rules compile to `<table>_<op>_<sha1[0:7]>` (plus `_<idx>` when one\n * rule spans several operations), and the hash covers the rule's semantics — so\n * *editing* a rule renames its policy. The policy under the old name is left\n * behind by `db push`, which only DROPs the names it is about to CREATE, and\n * Postgres ORs PERMISSIVE policies together: a superseded `USING (true)` keeps\n * granting everything no matter how tight its replacement is.\n *\n * Matching the shape is what makes dropping them safe. A hand-written policy\n * would have to collide with a 7-hex digest to be mistaken for generated one;\n * a policy named anything else is left alone and merely reported, because a\n * custom name is indistinguishable from one someone wrote in SQL on purpose.\n */\nexport function isGeneratedPolicyName(name: string, table: string): boolean {\n return new RegExp(`^${table.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}_(select|insert|update|delete|all)_[0-9a-f]{7}(_\\\\d+)?$`)\n .test(name);\n}\n\nexport interface OrphanCleanup {\n /** Superseded generated policies that were dropped. */\n dropped: PolicyRef[];\n /** Orphans left in place because their names are not generator-shaped. */\n kept: PolicyRef[];\n}\n\n/**\n * Drop the policies an earlier push superseded but never removed.\n *\n * Only touches tables the collections describe — a table with no expected\n * policy is not ours to reconcile, and scanning by schema alone would sweep up\n * policies belonging to something else sharing the database.\n */\nexport async function dropOrphanedPolicies(\n client: Queryable,\n drift: PolicyDrift,\n collections: CollectionConfig[]\n): Promise<OrphanCleanup> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const managed = new Set(expected.map((p) => `${p.schema}.${p.table}`));\n\n const cleanup: OrphanCleanup = { dropped: [], kept: [] };\n for (const p of drift.orphaned) {\n if (!managed.has(`${p.schema}.${p.table}`) || !isGeneratedPolicyName(p.name, p.table)) {\n cleanup.kept.push(p);\n continue;\n }\n // Identifiers are quoted, and the name came from pg_policies rather than\n // from user input, so it is already a valid identifier.\n await client.query(`DROP POLICY IF EXISTS \"${p.name}\" ON \"${p.schema}\".\"${p.table}\"`);\n cleanup.dropped.push(p);\n }\n return cleanup;\n}\n\nexport const hasDrift = (d: PolicyDrift): boolean =>\n d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0\n || d.rlsDisabled.length > 0;\n\n/** Human-readable report; empty string when the database matches the config. */\nexport function formatPolicyDrift(drift: PolicyDrift): string {\n if (!hasDrift(drift)) return \"\";\n const lines: string[] = [];\n\n // First, because it subsumes every other finding on the same table: if RLS\n // is off, the policies listed below are not being applied at all.\n if (drift.rlsDisabled.length > 0) {\n lines.push(\" RLS DISABLED — the table has policies, and Postgres is applying none of them:\");\n for (const t of drift.rlsDisabled) {\n lines.push(` • ${t.schema}.${t.table}${t.forced ? \"\" : \" (and FORCE is off)\"}`);\n }\n lines.push(\" Every row is readable and writable by any request that reaches the table.\");\n lines.push(` Fix: ALTER TABLE \"<schema>\".\"<table>\" ENABLE ROW LEVEL SECURITY; or re-run \\`rebase db push\\`.`);\n }\n if (drift.missing.length > 0) {\n lines.push(\" Missing — described by your collections, absent from the database:\");\n for (const p of drift.missing) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Run `rebase db push` to apply them.\");\n }\n if (drift.orphaned.length > 0) {\n lines.push(\" Orphaned — in the database, described by no collection:\");\n for (const p of drift.orphaned) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Left behind by an earlier push. These still filter rows.\");\n }\n if (drift.diverged.length > 0) {\n lines.push(\" Diverged — same policy, different definition:\");\n for (const d of drift.diverged) {\n lines.push(` • ${d.expected.schema}.${d.expected.table} → \"${d.expected.name}\"`);\n for (const diff of d.differences) lines.push(` ${diff}`);\n }\n }\n if (drift.insecure.length > 0) {\n lines.push(\" Insecure — a live policy grants access it should not:\");\n for (const i of drift.insecure) {\n lines.push(` • ${i.policy.schema}.${i.policy.table} → \"${i.policy.name}\"`);\n lines.push(` ${i.reason}`);\n }\n }\n return lines.join(\"\\n\");\n}\n","/**\n * Applying a bundle's RLS policies to a database at boot, idempotently.\n *\n * ## Why this exists\n *\n * {@link ensureCollectionTables} creates the collection *tables* a managed\n * runtime boots against, but a table with row-level security disabled and no\n * policies is not servable: authenticated requests run as the restricted\n * `rebase_user` role, so a read with no `SELECT` policy returns nothing (a\n * public collection answered 401) and a write with no `INSERT`/`UPDATE` policy\n * is denied. The policies live in the collections' `securityRules`; nothing at\n * boot applied them. `rebase db push` does — but it drives Atlas against a\n * local `DATABASE_URL`, and a managed tenant's database is reachable only from\n * inside the cluster, by the runtime that is already connected to it. So the\n * runtime is the only thing that *can* apply them, and this is where it does.\n *\n * ## Why this is safe to run on every boot\n *\n * Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once\n * enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by\n * a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds\n * and replaces; it never drops data. (It does not *reconcile* — a policy a\n * previous push left behind under an old name is not removed here; that stays a\n * `db push` / `db migrate` concern, alongside destructive schema changes.)\n *\n * Unlike table creation, a failure here is not fatal: RLS stays enabled, so a\n * table whose policies could not be applied fails **closed** (denies) rather\n * than leaking rows. One collection's policy failing (e.g. a rule that\n * references a table a real migration has not created yet) must not crash-loop\n * the whole deployment and take the other collections' working routes down with\n * it. Failures are reported loudly and per-table so the operator can see\n * exactly which collection is not yet servable and why.\n */\nimport { type CollectionConfig } from \"@rebasepro/types\";\nimport { planCollectionPolicies, type CollectionPolicyPlan } from \"./generate-postgres-ddl-logic\";\nimport { isGeneratedPolicyName } from \"../security/policy-drift\";\nimport { readExistingSchema, type Queryable } from \"./ensure-collection-tables\";\nimport { REBASE_USER_ROLE } from \"../security/rls-enforcement\";\n\nexport interface PolicyEnsureResult {\n /** `CREATE POLICY` statements that ran successfully. */\n policiesApplied: number;\n /** Tables that had RLS enabled. */\n tablesSecured: number;\n /** Declared tables absent from the database — left to a real migration. */\n skipped: { table: string; reason: string }[];\n /**\n * Tables that have RLS on but did not get every policy. They deny — RLS\n * with no matching policy is deny-all — so they are safe but not servable.\n */\n failures: { table: string; error: string }[];\n /**\n * Tables RLS could not be enabled on, whose DML grant was withdrawn instead.\n *\n * This state had no name, and that was the bug: `ENABLE ROW LEVEL SECURITY`\n * failing was recorded as a `failure` and reported with the same \"it stays\n * locked (denies)\" wording as a failed policy — but the two are opposites.\n * A policy statement failing leaves RLS on and the table denying. `enableRls`\n * failing leaves RLS *off*, and the schema-wide grant to the user role has\n * already been made by `ensureRlsEnforcement`, so the table is readable and\n * writable by every authenticated request with no row filtering at all.\n */\n unsecured: { table: string; error: string; grantWithdrawn: boolean }[];\n /**\n * Generated policies removed because no current rule produces them.\n *\n * A policy's name embeds a hash of the rule's semantics, so editing a rule\n * does not update a policy — it creates a new one and abandons the old.\n * Postgres ORs permissive policies, so the abandoned one keeps granting:\n * a `USING (true)` tightened to an owner check went on admitting everyone,\n * forever, while the deploy logged success.\n *\n * `db push` reconciles this, and cannot reach a managed tenant's in-cluster\n * database — which is the reason this module exists. So boot has to do it.\n */\n orphansDropped: number;\n}\n\nconst isCreatePolicy = (statement: string): boolean => /^\\s*CREATE POLICY/i.test(statement);\n\n/**\n * Bring the declared collections' RLS policies up to date. Returns what it did.\n *\n * Only tables that already exist are touched: the boot-time table creator runs\n * first, so anything still missing is a table this additive path is not allowed\n * to create (a junction, or a relation left to a migration). Enabling RLS on a\n * non-existent table would error, so those are recorded as skipped, not failed.\n */\n\n/**\n * Remove generated policies on this table that the current plan does not\n * produce.\n *\n * Scoped hard, because dropping a policy is destructive: only this one table,\n * only names matching the generated `<table>_<op>_<sha1>` shape, and only names\n * absent from the statements just applied. A hand-written policy, or one\n * belonging to another table, is never touched — `isGeneratedPolicyName` is the\n * same predicate `db push` uses to draw that line.\n */\nasync function dropOrphanedPoliciesOn(client: Queryable, plan: CollectionPolicyPlan): Promise<number> {\n const expected = new Set<string>();\n for (const statement of plan.policyStatements) {\n const match = /^\\s*CREATE POLICY\\s+\"([^\"]+)\"/i.exec(statement);\n if (match) expected.add(match[1]);\n }\n\n const existing = await client.query<{ policyname: string }>(\n `SELECT policyname FROM pg_policies WHERE schemaname = '${plan.schema.replace(/'/g, \"''\")}' ` +\n `AND tablename = '${plan.table.replace(/'/g, \"''\")}'`\n );\n\n let dropped = 0;\n for (const row of existing.rows) {\n const name = row.policyname;\n if (expected.has(name)) continue;\n if (!isGeneratedPolicyName(name, plan.table)) continue;\n await client.query(`DROP POLICY IF EXISTS \"${name}\" ON \"${plan.schema}\".\"${plan.table}\"`);\n dropped++;\n }\n return dropped;\n}\n\nexport async function ensureCollectionPolicies(\n client: Queryable,\n collections: CollectionConfig[],\n log?: (message: string) => void\n): Promise<PolicyEnsureResult> {\n const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [], unsecured: [], orphansDropped: 0 };\n\n const plans = planCollectionPolicies(collections);\n if (plans.length === 0) return result;\n\n const schemas = Array.from(new Set(plans.map(p => p.schema)));\n const existing = await readExistingSchema(client, schemas);\n\n for (const plan of plans) {\n if (!existing.tables.has(plan.qualified)) {\n result.skipped.push({\n table: plan.qualified,\n reason: \"table is not present in the database; create it with `rebase db push` / `rebase db migrate`\"\n });\n continue;\n }\n\n // Enabling RLS is its own step, because its failure is the opposite of\n // every other failure here. Once RLS is on, anything that goes wrong\n // afterwards leaves the table denying; while it is off, the grant made\n // earlier in boot leaves the table wide open. Sharing one `try` meant\n // the dangerous case was reported in the safe case's words.\n try {\n await client.query(plan.enableRls);\n result.tablesSecured++;\n } catch (err) {\n const error = err instanceof Error ? err.message : String(err);\n // Take the privilege back rather than serve an unprotected table.\n // This is the fail-closed step the old code assumed it already had:\n // per-table, so one collection cannot take the rest of the\n // deployment down, but leaving nothing readable without RLS.\n let grantWithdrawn = false;\n try {\n await client.query(`REVOKE ALL PRIVILEGES ON ${plan.qualified} FROM ${REBASE_USER_ROLE}`);\n grantWithdrawn = true;\n } catch {\n // Fall through: reported below with `grantWithdrawn: false`,\n // which the caller escalates. There is nothing else this\n // function can do to make the table safe.\n }\n result.unsecured.push({ table: plan.qualified,\nerror,\ngrantWithdrawn });\n continue;\n }\n\n try {\n let created = 0;\n for (const statement of plan.policyStatements) {\n await client.query(statement);\n if (isCreatePolicy(statement)) {\n result.policiesApplied++;\n created++;\n }\n }\n\n const orphans = await dropOrphanedPoliciesOn(client, plan);\n result.orphansDropped += orphans;\n\n log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied` +\n (orphans > 0 ? `, ${orphans} orphan(s) dropped` : \"\"));\n } catch (err) {\n result.failures.push({\n table: plan.qualified,\n error: err instanceof Error ? err.message : String(err)\n });\n }\n }\n\n return result;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAmYA,SAAgB,sBAAsB,MAAc,OAAwB;CACxE,OAAO,IAAI,OAAO,IAAI,MAAM,QAAQ,uBAAuB,MAAM,EAAE,wDAAwD,CAAC,CACvH,KAAK,IAAI;AAClB;;;ACxTA,IAAM,kBAAkB,cAA+B,qBAAqB,KAAK,SAAS;;;;;;;;;;;;;;;;;;;AAqB1F,eAAe,uBAAuB,QAAmB,MAA6C;CAClG,MAAM,2BAAW,IAAI,IAAY;CACjC,KAAK,MAAM,aAAa,KAAK,kBAAkB;EAC3C,MAAM,QAAQ,iCAAiC,KAAK,SAAS;EAC7D,IAAI,OAAO,SAAS,IAAI,MAAM,EAAE;CACpC;CAEA,MAAM,WAAW,MAAM,OAAO,MAC1B,0DAA0D,KAAK,OAAO,QAAQ,MAAM,IAAI,EAAE,qBACtE,KAAK,MAAM,QAAQ,MAAM,IAAI,EAAE,EACvD;CAEA,IAAI,UAAU;CACd,KAAK,MAAM,OAAO,SAAS,MAAM;EAC7B,MAAM,OAAO,IAAI;EACjB,IAAI,SAAS,IAAI,IAAI,GAAG;EACxB,IAAI,CAAC,sBAAsB,MAAM,KAAK,KAAK,GAAG;EAC9C,MAAM,OAAO,MAAM,0BAA0B,KAAK,QAAQ,KAAK,OAAO,KAAK,KAAK,MAAM,EAAE;EACxF;CACJ;CACA,OAAO;AACX;AAEA,eAAsB,yBAClB,QACA,aACA,KAC2B;CAC3B,MAAM,SAA6B;EAAE,iBAAiB;EAAG,eAAe;EAAG,SAAS,CAAC;EAAG,UAAU,CAAC;EAAG,WAAW,CAAC;EAAG,gBAAgB;CAAE;CAEvI,MAAM,QAAQ,uBAAuB,WAAW;CAChD,IAAI,MAAM,WAAW,GAAG,OAAO;CAG/B,MAAM,WAAW,MAAM,mBAAmB,QAD1B,MAAM,KAAK,IAAI,IAAI,MAAM,KAAI,MAAK,EAAE,MAAM,CAAC,CACT,CAAO;CAEzD,KAAK,MAAM,QAAQ,OAAO;EACtB,IAAI,CAAC,SAAS,OAAO,IAAI,KAAK,SAAS,GAAG;GACtC,OAAO,QAAQ,KAAK;IAChB,OAAO,KAAK;IACZ,QAAQ;GACZ,CAAC;GACD;EACJ;EAOA,IAAI;GACA,MAAM,OAAO,MAAM,KAAK,SAAS;GACjC,OAAO;EACX,SAAS,KAAK;GACV,MAAM,QAAQ,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAK7D,IAAI,iBAAiB;GACrB,IAAI;IACA,MAAM,OAAO,MAAM,4BAA4B,KAAK,UAAU,QAAQ,kBAAkB;IACxF,iBAAiB;GACrB,QAAQ,CAIR;GACA,OAAO,UAAU,KAAK;IAAE,OAAO,KAAK;IAChD;IACA;GAAe,CAAC;GACJ;EACJ;EAEA,IAAI;GACA,IAAI,UAAU;GACd,KAAK,MAAM,aAAa,KAAK,kBAAkB;IAC3C,MAAM,OAAO,MAAM,SAAS;IAC5B,IAAI,eAAe,SAAS,GAAG;KAC3B,OAAO;KACP;IACJ;GACJ;GAEA,MAAM,UAAU,MAAM,uBAAuB,QAAQ,IAAI;GACzD,OAAO,kBAAkB;GAEzB,MAAM,GAAG,KAAK,UAAU,iBAAiB,QAAQ,yBAC5C,UAAU,IAAI,KAAK,QAAQ,sBAAsB,GAAG;EAC7D,SAAS,KAAK;GACV,OAAO,SAAS,KAAK;IACjB,OAAO,KAAK;IACZ,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAC1D,CAAC;EACL;CACJ;CAEA,OAAO;AACX"}
@@ -2,10 +2,51 @@ import { createRequire as __createRequire } from "module";
2
2
  import "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { d as __require, l as __commonJSMin, u as __exportAll } from "./connection-GOKU3Hu5.js";
5
- import { a as policy, i as ANONYMOUS_USER_IDS, r as ANONYMOUS_USER_ID } from "./data_driver-ULAyJEi9.js";
6
5
  import { _ as isRelationAggregateSort, a as rewriteLegacyRlsFunctions, b as toCanonicalOp, c as isPostgresCollectionConfig, d as getDataSourceCapabilities, f as ALL_WHERE_FILTER_OPS, h as REST_TO_CANONICAL, i as RLS_UID_SQL, l as isRelationalCollectionConfig, m as NULL_OPS, n as REBASE_SCHEMA, p as CANONICAL_TO_REST, r as RLS_ROLES_SQL, s as getDeclaredSubcollections, y as sortKeyToString } from "./src-DiDgtX8P.js";
7
6
  import { isConcurrentDdlRace, isDuplicateObjectRace, logger } from "@rebasepro/server";
8
7
  import { createHash } from "node:crypto";
8
+ //#region ../types/src/errors.ts
9
+ /**
10
+ * The single error type thrown across the entire Rebase client surface —
11
+ * HTTP data/control-plane calls, realtime/WebSocket operations, and
12
+ * client-side logic errors (e.g. an unknown collection accessor). A `catch`
13
+ * block only ever needs to check for this one class:
14
+ *
15
+ * ```ts
16
+ * import { RebaseApiError } from "@rebasepro/client"; // re-exported
17
+ *
18
+ * try {
19
+ * await client.data.products.update(id, { price: 9 });
20
+ * } catch (e) {
21
+ * if (e instanceof RebaseApiError) {
22
+ * if (e.status === 404) { ... } // HTTP failures carry a status
23
+ * console.error(e.code, e.details);
24
+ * }
25
+ * }
26
+ * ```
27
+ *
28
+ * `status` is present for HTTP failures and `undefined` otherwise, so its
29
+ * presence distinguishes transport-level errors from realtime/logic errors.
30
+ *
31
+ * @group Errors
32
+ */
33
+ var RebaseApiError = class extends Error {
34
+ /** HTTP status code, or `undefined` for non-HTTP errors. */
35
+ status;
36
+ /** Stable machine-readable error code, when the server supplied one. See {@link RebaseErrorCode}. */
37
+ code;
38
+ /** Structured error payload from the server, when present. */
39
+ details;
40
+ constructor(message, init = {}) {
41
+ super(message);
42
+ this.name = "RebaseApiError";
43
+ this.status = init.status;
44
+ this.code = init.code;
45
+ this.details = init.details;
46
+ if (init.cause !== void 0) this.cause = init.cause;
47
+ }
48
+ };
49
+ //#endregion
9
50
  //#region ../types/src/types/entities.ts
10
51
  /**
11
52
  * Class used to create a reference to a entity in a different path
@@ -58,6 +99,151 @@ function isManyToMany(relation) {
58
99
  return relation.kind === "manyToMany";
59
100
  }
60
101
  //#endregion
102
+ //#region ../types/src/types/policy.ts
103
+ /**
104
+ * The id a request without a logged-in user reports as `rebase.uid()`.
105
+ *
106
+ * A user-context request always sets `app.uid`: blank would read back as
107
+ * `NULL`, and `NULL` is how the trusted server context is recognised, so an
108
+ * anonymous visitor would be promoted to server privileges. The driver
109
+ * therefore substitutes this sentinel at the single chokepoint where the GUC
110
+ * is set.
111
+ *
112
+ * The consequence for policy authors is that **`rebase.uid() IS NOT NULL` is a
113
+ * tautology on the user path** — it is true for anonymous visitors too. Use
114
+ * {@link policy.authenticated} to mean "signed in", and
115
+ * {@link policy.serverContext} to mean "the trusted server context". Do not
116
+ * hand-write the comparison: see {@link ANONYMOUS_USER_IDS} for why one
117
+ * literal is not enough.
118
+ *
119
+ * @group Models
120
+ */
121
+ var ANONYMOUS_USER_ID = "anonymous";
122
+ /**
123
+ * Every uid that has ever meant "nobody is signed in" — newest first.
124
+ *
125
+ * There are two because there were two. The types, the policy compiler, the
126
+ * JavaScript evaluator and the linter were all built on
127
+ * {@link ANONYMOUS_USER_ID}, while the request path scoped unauthenticated
128
+ * callers as `'anon'` — so `policy.authenticated()`, which compiled to
129
+ * `rebase.uid() <> 'anonymous'`, was *true* for an anonymous visitor. The
130
+ * sanctioned way to write "signed in" granted to everyone, and the linter
131
+ * flagged the spelling that actually worked as a foreign convention.
132
+ *
133
+ * The request path now reports {@link ANONYMOUS_USER_ID}. `'anon'` stays here
134
+ * because policies outlive the server that generated them: a database still
135
+ * holding policies from before the fix, or a project whose server has not been
136
+ * upgraded yet, must not become a grant in either direction. Compile against
137
+ * this list, not against a single literal.
138
+ *
139
+ * No real user id is ever one of these, so a match is always "not signed in".
140
+ *
141
+ * @group Models
142
+ */
143
+ var ANONYMOUS_USER_IDS = [ANONYMOUS_USER_ID, "anon"];
144
+ /** @group Models */
145
+ var policy = {
146
+ true: () => ({ kind: "true" }),
147
+ false: () => ({ kind: "false" }),
148
+ and: (...operands) => ({
149
+ kind: "and",
150
+ operands
151
+ }),
152
+ or: (...operands) => ({
153
+ kind: "or",
154
+ operands
155
+ }),
156
+ not: (operand) => ({
157
+ kind: "not",
158
+ operand
159
+ }),
160
+ compare: (left, op, right) => ({
161
+ kind: "compare",
162
+ op,
163
+ left,
164
+ right
165
+ }),
166
+ rolesOverlap: (roles) => ({
167
+ kind: "rolesOverlap",
168
+ roles
169
+ }),
170
+ rolesContain: (roles) => ({
171
+ kind: "rolesContain",
172
+ roles
173
+ }),
174
+ authenticated: () => ({ kind: "authenticated" }),
175
+ serverContext: () => ({ kind: "serverContext" }),
176
+ existsIn: (args) => ({
177
+ kind: "existsIn",
178
+ collection: args.collection,
179
+ where: args.where
180
+ }),
181
+ raw: (sql) => ({
182
+ kind: "raw",
183
+ sql
184
+ }),
185
+ field: (name) => ({
186
+ kind: "field",
187
+ name
188
+ }),
189
+ outerField: (name) => ({
190
+ kind: "outerField",
191
+ name
192
+ }),
193
+ literal: (value) => ({
194
+ kind: "literal",
195
+ value
196
+ }),
197
+ authUid: () => ({ kind: "authUid" }),
198
+ authRoles: () => ({ kind: "authRoles" })
199
+ };
200
+ /**
201
+ * Thrown by {@link resolveClientListLimit} for a `limit` the platform will not
202
+ * serve. Carries an HTTP status so an ingress that speaks HTTP can forward it
203
+ * verbatim, and `maxLimit` so one can be built without re-deriving the ceiling.
204
+ *
205
+ * @group Errors
206
+ */
207
+ var ListLimitError = class ListLimitError extends RebaseApiError {
208
+ /** The ceiling that was exceeded — what the caller should page by instead. */
209
+ maxLimit;
210
+ constructor(message, maxLimit) {
211
+ super(message, {
212
+ status: 400,
213
+ code: "INVALID_LIMIT"
214
+ });
215
+ this.name = "ListLimitError";
216
+ this.maxLimit = maxLimit;
217
+ Object.setPrototypeOf(this, ListLimitError.prototype);
218
+ }
219
+ };
220
+ /**
221
+ * Resolve a client-supplied list `limit` into a safe, always-defined value.
222
+ *
223
+ * - An absent / blank limit falls back to the mode default:
224
+ * `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
225
+ * - A limit that is present must be an integer in `[1, maxLimit]`. Anything
226
+ * else — `0`, a negative, `1.5`, `abc`, `100000000` — throws
227
+ * {@link ListLimitError} rather than being coerced into range, because every
228
+ * coercion answers a question the caller did not ask with a page it cannot
229
+ * tell apart from the whole collection.
230
+ *
231
+ * The return is never `undefined` — no ingress that routes its client limit
232
+ * through this can produce an unbounded read.
233
+ *
234
+ * @throws {ListLimitError} when a present `limit` is not an integer in range.
235
+ */
236
+ function resolveClientListLimit(rawLimit, opts = {}) {
237
+ const maxLimit = opts.maxLimit ?? 1e3;
238
+ if (rawLimit != null && String(rawLimit).trim() !== "") {
239
+ const parsed = typeof rawLimit === "number" ? rawLimit : Number(String(rawLimit).trim());
240
+ if (!Number.isInteger(parsed) || parsed < 1) throw new ListLimitError(`Invalid \`limit\`: ${String(rawLimit)}. Expected a whole number between 1 and ${maxLimit}.`, maxLimit);
241
+ if (parsed > maxLimit) throw new ListLimitError(`\`limit\` ${parsed} is above the maximum of ${maxLimit}. Ask for at most ${maxLimit} rows per read and page through the rest with \`offset\` — answering with a smaller page would be indistinguishable from there being no more rows.`, maxLimit);
242
+ return parsed;
243
+ }
244
+ return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
245
+ }
246
+ //#endregion
61
247
  //#region ../common/src/util/common.ts
62
248
  var DEFAULT_ONE_OF_TYPE = "type";
63
249
  var DEFAULT_ONE_OF_VALUE = "value";
@@ -7172,6 +7358,6 @@ async function applyAction(client, action) {
7172
7358
  }
7173
7359
  }
7174
7360
  //#endregion
7175
- export { camelCase as $, fieldKeyForColumn as A, parseIdValues as B, getJunctionCollectionConfig as C, policyToPostgres as D, getEffectiveSecurityRules as E, getTableVarName as F, updateDateAutoValues as G, createRelationRef as H, resolveCollectionRelations as I, legacyForeignKeyName as J, firstFreeKey as K, buildCompositeId as L, getColumnName as M, getEnumVarName as N, securityRuleToConditions as O, getTableName as P, mergeDeep as Q, getDeclaredPrimaryKeys as R, resolveStringColumnLength as S, resolveJunctionSpecs as T, createRelationRefWithData as U, sortCollectionsBySlug as V, normalizeToEntityRelation as W, getPolicyNamesForRule as X, toWireKey as Y, isPrototypePollutingKey as Z, OrderBySpecError as _, generatePostgresDdl as a, Vector as at, CollectionRegistry as b, planCollectionPolicies as c, authUsersColumnSql as d, toSnakeCase as et, SEARCH_UNACCENT_FN as f, buildSdkData as g, visibleColumnProjection as h, readSchemaFactsFor as i, isManyToMany as it, findRelation as j, findAnonymousGrants as k, resolveColumnName as l, hiddenColumnsOption as m, planCollectionSchemaEnsure as n, DEFAULT_ONE_OF_VALUE as nt, generatePostgresPoliciesDdl as o, buildSearchColumnSpec as p, generateForeignKeyName as q, readExistingSchema as r, hasForeignKeyOnTarget as rt, generatePostgresSearchDdl as s, ensure_collection_tables_exports as t, DEFAULT_ONE_OF_TYPE as tt, AUTH_USERS_COLUMNS as u, normalizeDriverOrderBy as v, getJunctionSecurityRules as w, relationalCollections as x, parseOrderBySpecStrict as y, isAddressableId as z };
7361
+ export { camelCase as $, fieldKeyForColumn as A, parseIdValues as B, getJunctionCollectionConfig as C, policyToPostgres as D, getEffectiveSecurityRules as E, getTableVarName as F, updateDateAutoValues as G, createRelationRef as H, resolveCollectionRelations as I, legacyForeignKeyName as J, firstFreeKey as K, buildCompositeId as L, getColumnName as M, getEnumVarName as N, securityRuleToConditions as O, getTableName as P, mergeDeep as Q, getDeclaredPrimaryKeys as R, resolveStringColumnLength as S, resolveJunctionSpecs as T, createRelationRefWithData as U, sortCollectionsBySlug as V, normalizeToEntityRelation as W, getPolicyNamesForRule as X, toWireKey as Y, isPrototypePollutingKey as Z, OrderBySpecError as _, generatePostgresDdl as a, ANONYMOUS_USER_ID as at, CollectionRegistry as b, planCollectionPolicies as c, Vector as ct, authUsersColumnSql as d, toSnakeCase as et, SEARCH_UNACCENT_FN as f, buildSdkData as g, visibleColumnProjection as h, readSchemaFactsFor as i, resolveClientListLimit as it, findRelation as j, findAnonymousGrants as k, resolveColumnName as l, hiddenColumnsOption as m, planCollectionSchemaEnsure as n, DEFAULT_ONE_OF_VALUE as nt, generatePostgresPoliciesDdl as o, hasForeignKeyOnTarget as ot, buildSearchColumnSpec as p, generateForeignKeyName as q, readExistingSchema as r, ListLimitError as rt, generatePostgresSearchDdl as s, isManyToMany as st, ensure_collection_tables_exports as t, DEFAULT_ONE_OF_TYPE as tt, AUTH_USERS_COLUMNS as u, normalizeDriverOrderBy as v, getJunctionSecurityRules as w, relationalCollections as x, parseOrderBySpecStrict as y, isAddressableId as z };
7176
7362
 
7177
- //# sourceMappingURL=ensure-collection-tables-C_Gr59le.js.map
7363
+ //# sourceMappingURL=ensure-collection-tables-DpGX_25A.js.map