@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g18cfeb7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +48 -1
- package/dist/{src-DlPBctw_.js → auth-users-columns-Do9mw5Y5.js} +318 -42
- package/dist/auth-users-columns-Do9mw5Y5.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
- package/dist/cli-helpers.d.ts +1 -1
- package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-DMUdRQdM.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-DMUdRQdM.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DSIxvLLD.js} +59 -15
- package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
- package/dist/index.es.js +534 -200
- package/dist/index.es.js.map +1 -1
- package/dist/{policy-CeA1JcxP.js → policy-CPkCqVTz.js} +4 -4
- package/dist/policy-CPkCqVTz.js.map +1 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/ensure-collection-tables.d.ts +1 -1
- package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/security/rls-enforcement.d.ts +53 -2
- package/dist/services/FetchService.d.ts +10 -7
- package/dist/services/dataService.d.ts +2 -0
- package/dist/services/realtimeService.d.ts +25 -21
- package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/{websocket-B2LsrINK.js → websocket-D0TBU3ia.js} +3 -3
- package/dist/{websocket-B2LsrINK.js.map → websocket-D0TBU3ia.js.map} +1 -1
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +165 -3
- package/src/PostgresBootstrapper.ts +41 -2
- package/src/auth/ensure-tables.ts +185 -86
- package/src/cli-helpers.ts +22 -9
- package/src/cli.ts +175 -30
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +13 -3
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor.ts +7 -5
- package/src/schema/ensure-collection-tables.ts +88 -19
- package/src/schema/generate-drizzle-schema-logic.ts +8 -2
- package/src/schema/generate-postgres-ddl-logic.ts +97 -13
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-logic.ts +1 -1
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/rls-enforcement.ts +141 -3
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +10 -93
- package/src/services/PersistService.ts +14 -1
- package/src/services/channel-history.ts +8 -0
- package/src/services/channel-presence.ts +6 -0
- package/src/services/dataService.ts +2 -0
- package/src/services/realtimeService.ts +36 -33
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
|
@@ -3,7 +3,7 @@ import "process";
|
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
4
|
//#region ../types/src/types/policy.ts
|
|
5
5
|
/**
|
|
6
|
-
* The id a request without a logged-in user reports as `
|
|
6
|
+
* The id a request without a logged-in user reports as `rebase.uid()`.
|
|
7
7
|
*
|
|
8
8
|
* A user-context request always sets `app.uid`: blank would read back as
|
|
9
9
|
* `NULL`, and `NULL` is how the trusted server context is recognised, so an
|
|
@@ -11,7 +11,7 @@ __createRequire(import.meta.url);
|
|
|
11
11
|
* therefore substitutes this sentinel at the single chokepoint where the GUC
|
|
12
12
|
* is set.
|
|
13
13
|
*
|
|
14
|
-
* The consequence for policy authors is that **`
|
|
14
|
+
* The consequence for policy authors is that **`rebase.uid() IS NOT NULL` is a
|
|
15
15
|
* tautology on the user path** — it is true for anonymous visitors too. Use
|
|
16
16
|
* {@link policy.authenticated} to mean "signed in", and
|
|
17
17
|
* {@link policy.serverContext} to mean "the trusted server context". Do not
|
|
@@ -28,7 +28,7 @@ var ANONYMOUS_USER_ID = "anonymous";
|
|
|
28
28
|
* JavaScript evaluator and the linter were all built on
|
|
29
29
|
* {@link ANONYMOUS_USER_ID}, while the request path scoped unauthenticated
|
|
30
30
|
* callers as `'anon'` — so `policy.authenticated()`, which compiled to
|
|
31
|
-
* `
|
|
31
|
+
* `rebase.uid() <> 'anonymous'`, was *true* for an anonymous visitor. The
|
|
32
32
|
* sanctioned way to write "signed in" granted to everyone, and the linter
|
|
33
33
|
* flagged the spelling that actually worked as a foreign convention.
|
|
34
34
|
*
|
|
@@ -102,4 +102,4 @@ var policy = {
|
|
|
102
102
|
//#endregion
|
|
103
103
|
export { ANONYMOUS_USER_IDS as n, policy as r, ANONYMOUS_USER_ID as t };
|
|
104
104
|
|
|
105
|
-
//# sourceMappingURL=policy-
|
|
105
|
+
//# sourceMappingURL=policy-CPkCqVTz.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy-CPkCqVTz.js","names":[],"sources":["../../types/src/types/policy.ts"],"sourcesContent":["/**\n * Structured, engine-agnostic policy expressions.\n *\n * A {@link PolicyExpression} is the single source of truth for a row-level\n * security condition. It is compiled to Postgres `USING`/`WITH CHECK` SQL\n * (authoritative enforcement) and independently evaluated in JavaScript (to\n * drive the admin UI, and — in future — to enforce on engines without native\n * RLS such as MongoDB). Because both the SQL and the JS decision derive from\n * the *same* expression, the UI matches database enforcement by construction —\n * no drift between two hand-written implementations.\n *\n * The only escape hatch that cannot be evaluated client-side is the\n * {@link RawPolicyExpression} node (`{ kind: \"raw\" }`): it preserves full\n * PostgreSQL power but, being arbitrary SQL, is treated as *unknown* by the\n * JavaScript evaluator (never silently allowed) and reflected exactly in the UI\n * via server-computed capability flags.\n *\n * @group Models\n */\nexport type PolicyExpression =\n | TruePolicyExpression\n | FalsePolicyExpression\n | AndPolicyExpression\n | OrPolicyExpression\n | NotPolicyExpression\n | ComparePolicyExpression\n | RolesOverlapPolicyExpression\n | RolesContainPolicyExpression\n | AuthenticatedPolicyExpression\n | ServerContextPolicyExpression\n | ExistsInPolicyExpression\n | RawPolicyExpression;\n\n/**\n * The id a request without a logged-in user reports as `rebase.uid()`.\n *\n * A user-context request always sets `app.uid`: blank would read back as\n * `NULL`, and `NULL` is how the trusted server context is recognised, so an\n * anonymous visitor would be promoted to server privileges. The driver\n * therefore substitutes this sentinel at the single chokepoint where the GUC\n * is set.\n *\n * The consequence for policy authors is that **`rebase.uid() IS NOT NULL` is a\n * tautology on the user path** — it is true for anonymous visitors too. Use\n * {@link policy.authenticated} to mean \"signed in\", and\n * {@link policy.serverContext} to mean \"the trusted server context\". Do not\n * hand-write the comparison: see {@link ANONYMOUS_USER_IDS} for why one\n * literal is not enough.\n *\n * @group Models\n */\nexport const ANONYMOUS_USER_ID = \"anonymous\";\n\n/**\n * Every uid that has ever meant \"nobody is signed in\" — newest first.\n *\n * There are two because there were two. The types, the policy compiler, the\n * JavaScript evaluator and the linter were all built on\n * {@link ANONYMOUS_USER_ID}, while the request path scoped unauthenticated\n * callers as `'anon'` — so `policy.authenticated()`, which compiled to\n * `rebase.uid() <> 'anonymous'`, was *true* for an anonymous visitor. The\n * sanctioned way to write \"signed in\" granted to everyone, and the linter\n * flagged the spelling that actually worked as a foreign convention.\n *\n * The request path now reports {@link ANONYMOUS_USER_ID}. `'anon'` stays here\n * because policies outlive the server that generated them: a database still\n * holding policies from before the fix, or a project whose server has not been\n * upgraded yet, must not become a grant in either direction. Compile against\n * this list, not against a single literal.\n *\n * No real user id is ever one of these, so a match is always \"not signed in\".\n *\n * @group Models\n */\nexport const ANONYMOUS_USER_IDS: readonly string[] = [ANONYMOUS_USER_ID, \"anon\"];\n\n/**\n * Whether a uid stands for \"no one is signed in\", in any spelling rebase has\n * used. `null`/`undefined` is the trusted server context, not an anonymous\n * caller, and is therefore **not** anonymous — see {@link ANONYMOUS_USER_ID}.\n *\n * @group Models\n */\nexport function isAnonymousUid(uid: string | null | undefined): boolean {\n return typeof uid === \"string\" && ANONYMOUS_USER_IDS.includes(uid);\n}\n\n/** Always allows. Compiles to `true`. @group Models */\nexport interface TruePolicyExpression {\n kind: \"true\";\n}\n\n/** Always denies. Compiles to `false`. @group Models */\nexport interface FalsePolicyExpression {\n kind: \"false\";\n}\n\n/** Logical AND — every operand must pass. @group Models */\nexport interface AndPolicyExpression {\n kind: \"and\";\n operands: readonly PolicyExpression[];\n}\n\n/** Logical OR — at least one operand must pass. @group Models */\nexport interface OrPolicyExpression {\n kind: \"or\";\n operands: readonly PolicyExpression[];\n}\n\n/** Logical negation. @group Models */\nexport interface NotPolicyExpression {\n kind: \"not\";\n operand: PolicyExpression;\n}\n\n/** Comparison operators available to {@link ComparePolicyExpression}. @group Models */\nexport type PolicyCompareOperator = \"eq\" | \"neq\" | \"lt\" | \"lte\" | \"gt\" | \"gte\";\n\n/**\n * Compares two operands, e.g. `owner_id = rebase.uid()`.\n * @group Models\n */\nexport interface ComparePolicyExpression {\n kind: \"compare\";\n op: PolicyCompareOperator;\n left: PolicyOperand;\n right: PolicyOperand;\n}\n\n/**\n * True when the user holds *at least one* of the given application roles.\n * Compiles to `string_to_array(rebase.roles(), ',') && ARRAY[...]`.\n * @group Models\n */\nexport interface RolesOverlapPolicyExpression {\n kind: \"rolesOverlap\";\n roles: readonly string[];\n}\n\n/**\n * True when the user holds *all* of the given application roles.\n * Compiles to `string_to_array(rebase.roles(), ',') @> ARRAY[...]`.\n * @group Models\n */\nexport interface RolesContainPolicyExpression {\n kind: \"rolesContain\";\n roles: readonly string[];\n}\n\n/**\n * True when a signed-in user is making the request. Compiles to\n * `rebase.uid() IS NOT NULL AND rebase.uid() <> 'anonymous'`.\n *\n * Both halves are load-bearing. `IS NOT NULL` excludes the server context;\n * the {@link ANONYMOUS_USER_ID} comparison excludes anonymous visitors, who\n * *do* carry a non-null `rebase.uid()`. Checking only `IS NOT NULL` grants to\n * everyone — see {@link ANONYMOUS_USER_ID}.\n *\n * `policy.not(policy.authenticated())` therefore means \"anonymous visitor or\n * the server context\". To single out the server context, use\n * {@link ServerContextPolicyExpression}.\n * @group Models\n */\nexport interface AuthenticatedPolicyExpression {\n kind: \"authenticated\";\n}\n\n/**\n * True only in the trusted **server context** — the built-in flows that run\n * without a user (signup, migrations, `dataAsAdmin`) set no user GUC, so\n * `rebase.uid()` is `NULL` for them and only for them. Compiles to\n * `rebase.uid() IS NULL`.\n *\n * This is what lets the owner connection satisfy a policy even under FORCE RLS.\n * It is deliberately a primitive rather than `not(authenticated())`: the two\n * meant the same thing while `authenticated` ignored {@link ANONYMOUS_USER_ID},\n * and conflating them is what turns a server-only grant into an anonymous one.\n *\n * The JavaScript evaluator always returns `false` for this node — a client is\n * never the server context.\n * @group Models\n */\nexport interface ServerContextPolicyExpression {\n kind: \"serverContext\";\n}\n\n/**\n * Membership / relational access: true when at least one row exists in another\n * collection (a join/membership table) matching `where`. This is what lets you\n * scope reads to \"rows whose team the caller belongs to\" without an N+1\n * per-row lookup — it compiles to a single correlated `EXISTS` subquery.\n *\n * Inside `where`, {@link FieldPolicyOperand} (`policy.field`) references a column\n * of the joined collection, while {@link OuterFieldPolicyOperand}\n * (`policy.outerField`) references a column of the row being checked (the outer\n * table under RLS). Combine with {@link AuthUidPolicyOperand} to correlate to\n * the caller.\n *\n * @example\n * ```ts\n * // documents visible only to members of the document's team:\n * policy.existsIn({\n * collection: \"team_members\",\n * where: policy.and(\n * policy.compare(policy.field(\"team_id\"), \"eq\", policy.outerField(\"team_id\")),\n * policy.compare(policy.field(\"user_id\"), \"eq\", policy.authUid()),\n * ),\n * })\n * // → EXISTS (SELECT 1 FROM team_members _ex0\n * // WHERE _ex0.team_id = documents.team_id AND _ex0.user_id = rebase.uid())\n * ```\n *\n * Postgres-authoritative: like {@link RawPolicyExpression}, the JavaScript\n * evaluator treats it as *unknown* (it cannot run a subquery client-side), so\n * enforcement is always the database's.\n * @group Models\n */\nexport interface ExistsInPolicyExpression {\n kind: \"existsIn\";\n /** Slug of the collection to search (the join / membership table). */\n collection: string;\n /** Condition evaluated against the joined collection's rows. */\n where: PolicyExpression;\n}\n\n/**\n * A raw PostgreSQL boolean expression — the full-power escape hatch.\n *\n * Columns can be referenced as `{column_name}`. This is Postgres-only and\n * **server-authoritative**: the JavaScript evaluator cannot evaluate arbitrary\n * SQL, so it treats this node as *unknown* rather than guessing.\n * @group Models\n */\nexport interface RawPolicyExpression {\n kind: \"raw\";\n sql: string;\n}\n\n/**\n * An operand referenced by a {@link ComparePolicyExpression}.\n * @group Models\n */\nexport type PolicyOperand =\n | FieldPolicyOperand\n | OuterFieldPolicyOperand\n | LiteralPolicyOperand\n | AuthUidPolicyOperand\n | AuthRolesPolicyOperand;\n\n/** A column value on the row being evaluated. @group Models */\nexport interface FieldPolicyOperand {\n kind: \"field\";\n /** The property/column name (resolved to its DB column when compiled). */\n name: string;\n}\n\n/**\n * A column value on the *outer* row when used inside {@link ExistsInPolicyExpression}\n * — i.e. the row the RLS policy is being evaluated for, referenced from within the\n * subquery. Outside an `existsIn` it is equivalent to {@link FieldPolicyOperand}.\n * @group Models\n */\nexport interface OuterFieldPolicyOperand {\n kind: \"outerField\";\n /** The property/column name on the outer collection. */\n name: string;\n}\n\n/** A constant value. @group Models */\nexport interface LiteralPolicyOperand {\n kind: \"literal\";\n value: string | number | boolean | null;\n}\n\n/** The current user's id — compiles to `rebase.uid()`. @group Models */\nexport interface AuthUidPolicyOperand {\n kind: \"authUid\";\n}\n\n/**\n * The current user's roles as an array — compiles to\n * `string_to_array(rebase.roles(), ',')`.\n * @group Models\n */\nexport interface AuthRolesPolicyOperand {\n kind: \"authRoles\";\n}\n\n// ── Constructor helpers ──────────────────────────────────────────────\n// Small, dependency-free builders so callers (and the desugaring in\n// `@rebasepro/common`) can assemble expressions without object-literal noise.\n\n/** @group Models */\nexport const policy = {\n true: (): TruePolicyExpression => ({ kind: \"true\" }),\n false: (): FalsePolicyExpression => ({ kind: \"false\" }),\n and: (...operands: readonly PolicyExpression[]): AndPolicyExpression => ({ kind: \"and\",\noperands: operands as PolicyExpression[] }),\n or: (...operands: readonly PolicyExpression[]): OrPolicyExpression => ({ kind: \"or\",\noperands: operands as PolicyExpression[] }),\n not: (operand: PolicyExpression): NotPolicyExpression => ({ kind: \"not\",\noperand }),\n compare: (left: PolicyOperand, op: PolicyCompareOperator, right: PolicyOperand): ComparePolicyExpression =>\n ({ kind: \"compare\",\nop,\nleft,\nright }),\n rolesOverlap: (roles: readonly string[]): RolesOverlapPolicyExpression => ({ kind: \"rolesOverlap\",\nroles: roles as string[] }),\n rolesContain: (roles: readonly string[]): RolesContainPolicyExpression => ({ kind: \"rolesContain\",\nroles: roles as string[] }),\n authenticated: (): AuthenticatedPolicyExpression => ({ kind: \"authenticated\" }),\n serverContext: (): ServerContextPolicyExpression => ({ kind: \"serverContext\" }),\n existsIn: (args: { collection: string; where: PolicyExpression }): ExistsInPolicyExpression =>\n ({ kind: \"existsIn\",\ncollection: args.collection,\nwhere: args.where }),\n raw: (sql: string): RawPolicyExpression => ({ kind: \"raw\",\nsql }),\n field: (name: string): FieldPolicyOperand => ({ kind: \"field\",\nname }),\n outerField: (name: string): OuterFieldPolicyOperand => ({ kind: \"outerField\",\nname }),\n literal: (value: string | number | boolean | null): LiteralPolicyOperand => ({ kind: \"literal\",\nvalue }),\n authUid: (): AuthUidPolicyOperand => ({ kind: \"authUid\" }),\n authRoles: (): AuthRolesPolicyOperand => ({ kind: \"authRoles\" })\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAmDA,IAAa,oBAAoB;;;;;;;;;;;;;;;;;;;;;;AAuBjC,IAAa,qBAAwC,CAAC,mBAAmB,MAAM;;AA2N/E,IAAa,SAAS;CAClB,aAAmC,EAAE,MAAM,OAAO;CAClD,cAAqC,EAAE,MAAM,QAAQ;CACrD,MAAM,GAAG,cAAgE;EAAE,MAAM;EAC3E;CAA+B;CACrC,KAAK,GAAG,cAA+D;EAAE,MAAM;EACzE;CAA+B;CACrC,MAAM,aAAoD;EAAE,MAAM;EACtE;CAAQ;CACJ,UAAU,MAAqB,IAA2B,WACrD;EAAE,MAAM;EACjB;EACA;EACA;CAAM;CACF,eAAe,WAA4D;EAAE,MAAM;EAChF;CAAkB;CACrB,eAAe,WAA4D;EAAE,MAAM;EAChF;CAAkB;CACrB,sBAAqD,EAAE,MAAM,gBAAgB;CAC7E,sBAAqD,EAAE,MAAM,gBAAgB;CAC7E,WAAW,UACN;EAAE,MAAM;EACjB,YAAY,KAAK;EACjB,OAAO,KAAK;CAAM;CACd,MAAM,SAAsC;EAAE,MAAM;EACxD;CAAI;CACA,QAAQ,UAAsC;EAAE,MAAM;EAC1D;CAAK;CACD,aAAa,UAA2C;EAAE,MAAM;EACpE;CAAK;CACD,UAAU,WAAmE;EAAE,MAAM;EACzF;CAAM;CACF,gBAAsC,EAAE,MAAM,UAAU;CACxD,kBAA0C,EAAE,MAAM,YAAY;AAClE"}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from "module";
|
|
2
|
+
import "process";
|
|
3
|
+
__createRequire(import.meta.url);
|
|
4
|
+
import { c as __exportAll } from "./connection-BuZ97wsr.js";
|
|
5
|
+
import { n as REBASE_SCHEMA, t as LEGACY_RLS_SCHEMA } from "./src-C_wvdMnl.js";
|
|
6
|
+
//#region src/schema/rls-bootstrap-sql.ts
|
|
7
|
+
var rls_bootstrap_sql_exports = /* @__PURE__ */ __exportAll({
|
|
8
|
+
DROP_LEGACY_AUTH_SCHEMA_SQL: () => DROP_LEGACY_AUTH_SCHEMA_SQL,
|
|
9
|
+
LEGACY_RLS_DEPENDENTS_SQL: () => LEGACY_RLS_DEPENDENTS_SQL,
|
|
10
|
+
LEGACY_RLS_FUNCTION_DEPENDENTS_SQL: () => LEGACY_RLS_FUNCTION_DEPENDENTS_SQL,
|
|
11
|
+
RLS_BOOTSTRAP_STATEMENTS: () => RLS_BOOTSTRAP_STATEMENTS,
|
|
12
|
+
dropLegacyAuthSchema: () => dropLegacyAuthSchema
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* Canonical SQL bootstrap for the RLS helper functions.
|
|
16
|
+
*
|
|
17
|
+
* Generated RLS policies reference `rebase.uid()` / `rebase.roles()` /
|
|
18
|
+
* `rebase.jwt()`, so any SQL stream that can contain policies must be
|
|
19
|
+
* self-contained: it has to (re)create these helpers first. This matters for
|
|
20
|
+
* the migration directory in particular — Atlas replays migrations against a
|
|
21
|
+
* clean dev database where no out-of-band bootstrap has ever run, so a
|
|
22
|
+
* migration carrying policies without this preamble fails with
|
|
23
|
+
* "function rebase.uid() does not exist".
|
|
24
|
+
*
|
|
25
|
+
* Idempotent (`IF NOT EXISTS` / `OR REPLACE`) so it can be prepended to every
|
|
26
|
+
* policies block and re-applied freely. The runtime boot path
|
|
27
|
+
* (`auth/ensure-tables.ts`) creates the same functions under an advisory lock
|
|
28
|
+
* for HMR-safety; keep the definitions in sync.
|
|
29
|
+
*
|
|
30
|
+
* ## Creating the `rebase` schema here is now safe, and required
|
|
31
|
+
*
|
|
32
|
+
* It deliberately did not, once. These functions lived in a schema called
|
|
33
|
+
* `auth`, and the note here read: creating `rebase` would leak it into Atlas's
|
|
34
|
+
* replayed migration state, and — absent from the desired `schema.sql` — Atlas
|
|
35
|
+
* would then plan `DROP SCHEMA "rebase" CASCADE`, taking the auth tables with
|
|
36
|
+
* it. That reasoning still holds; what changed is the second half of it. The
|
|
37
|
+
* DDL generator now emits `CREATE SCHEMA IF NOT EXISTS "rebase"`
|
|
38
|
+
* unconditionally, so the schema is always in the desired state and the diff is
|
|
39
|
+
* empty. (It used to appear only when some collection happened to declare
|
|
40
|
+
* `schema: "rebase"` — true for the scaffold's users collection, and not a
|
|
41
|
+
* property anything guaranteed.) `db push` additionally excludes the whole
|
|
42
|
+
* schema from the declarative apply.
|
|
43
|
+
*
|
|
44
|
+
* See `@rebasepro/types`' `rls-functions` for why the functions moved out of
|
|
45
|
+
* `auth` at all: the short version is that the name was Supabase's, and
|
|
46
|
+
* `CREATE OR REPLACE FUNCTION auth.uid() RETURNS text` cannot be applied over
|
|
47
|
+
* Supabase's `RETURNS uuid` — Postgres refuses, and the refusal used to be
|
|
48
|
+
* swallowed.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* The bootstrap as individual statements.
|
|
52
|
+
*
|
|
53
|
+
* Kept as an array because the two consumers need different shapes and only one
|
|
54
|
+
* of them can take a multi-command string: the migration preamble is written to
|
|
55
|
+
* a file and replayed by Atlas, but the boot path runs through drizzle, whose
|
|
56
|
+
* node-postgres handle speaks the extended query protocol and rejects more than
|
|
57
|
+
* one command per call. Splitting a joined string back apart on `$$;` would be
|
|
58
|
+
* a parser for a problem that does not need one.
|
|
59
|
+
*/
|
|
60
|
+
var RLS_BOOTSTRAP_STATEMENTS = [
|
|
61
|
+
`CREATE SCHEMA IF NOT EXISTS ${REBASE_SCHEMA}`,
|
|
62
|
+
`CREATE OR REPLACE FUNCTION ${REBASE_SCHEMA}.uid() RETURNS text AS $$
|
|
63
|
+
SELECT COALESCE(
|
|
64
|
+
NULLIF(current_setting('app.uid', true), ''),
|
|
65
|
+
NULLIF(current_setting('app.user_id', true), '')
|
|
66
|
+
);
|
|
67
|
+
$$ LANGUAGE sql STABLE`,
|
|
68
|
+
`CREATE OR REPLACE FUNCTION ${REBASE_SCHEMA}.jwt() RETURNS jsonb AS $$
|
|
69
|
+
SELECT COALESCE(
|
|
70
|
+
NULLIF(current_setting('app.jwt', true), ''),
|
|
71
|
+
'{}'
|
|
72
|
+
)::jsonb;
|
|
73
|
+
$$ LANGUAGE sql STABLE`,
|
|
74
|
+
`CREATE OR REPLACE FUNCTION ${REBASE_SCHEMA}.roles() RETURNS text AS $$
|
|
75
|
+
SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
|
|
76
|
+
$$ LANGUAGE sql STABLE`
|
|
77
|
+
];
|
|
78
|
+
"" + RLS_BOOTSTRAP_STATEMENTS.map((s) => `${s};`).join("\n\n");
|
|
79
|
+
/**
|
|
80
|
+
* Removes the pre-1.0 `auth` schema, but only when Rebase is what put it there.
|
|
81
|
+
*
|
|
82
|
+
* ## Why this is safe against a Supabase database
|
|
83
|
+
*
|
|
84
|
+
* Two independent guards, and both have to pass:
|
|
85
|
+
*
|
|
86
|
+
* 1. **Each function is identified before it is dropped.** Ours returns `text`
|
|
87
|
+
* and reads the `app.uid` GUC; Supabase's returns `uuid` and reads
|
|
88
|
+
* `request.jwt.claims`. Nothing is dropped on a signature we did not write,
|
|
89
|
+
* so a Supabase database — where our `CREATE OR REPLACE` could never have
|
|
90
|
+
* succeeded in the first place, Postgres refusing to change a return type —
|
|
91
|
+
* matches nothing and this is a no-op.
|
|
92
|
+
* 2. **`DROP SCHEMA … RESTRICT`**, never CASCADE. If anything else at all still
|
|
93
|
+
* lives in `auth` (Supabase's `users` table, its other helpers), the drop
|
|
94
|
+
* fails and the schema stays. CASCADE here would be unrecoverable.
|
|
95
|
+
*
|
|
96
|
+
* ## Why it cannot run too early
|
|
97
|
+
*
|
|
98
|
+
* Postgres records a dependency from every RLS policy to the functions its body
|
|
99
|
+
* calls, so `DROP FUNCTION auth.uid()` fails for as long as a single policy
|
|
100
|
+
* still references it. That is the interlock, and it is load-bearing: the drop
|
|
101
|
+
* can only succeed once every policy has been recompiled to \`rebase.uid()\`.
|
|
102
|
+
* Callers therefore run this *after* applying policies, and treat a failure as
|
|
103
|
+
* "not yet — try again next boot" rather than as an error.
|
|
104
|
+
*/
|
|
105
|
+
var DROP_LEGACY_AUTH_SCHEMA_SQL = `
|
|
106
|
+
DO $rebase_drop_legacy$
|
|
107
|
+
DECLARE
|
|
108
|
+
dropped_any boolean := false;
|
|
109
|
+
BEGIN
|
|
110
|
+
-- Each function is matched on its own result type and body, so a schema
|
|
111
|
+
-- that merely shares the name keeps everything it has.
|
|
112
|
+
IF EXISTS (
|
|
113
|
+
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
114
|
+
WHERE n.nspname = 'auth' AND p.proname = 'uid'
|
|
115
|
+
AND pg_get_function_result(p.oid) = 'text'
|
|
116
|
+
AND p.prosrc LIKE '%app.uid%'
|
|
117
|
+
) THEN
|
|
118
|
+
DROP FUNCTION auth.uid();
|
|
119
|
+
dropped_any := true;
|
|
120
|
+
END IF;
|
|
121
|
+
|
|
122
|
+
IF EXISTS (
|
|
123
|
+
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
124
|
+
WHERE n.nspname = 'auth' AND p.proname = 'jwt'
|
|
125
|
+
AND pg_get_function_result(p.oid) = 'jsonb'
|
|
126
|
+
AND p.prosrc LIKE '%app.jwt%'
|
|
127
|
+
) THEN
|
|
128
|
+
DROP FUNCTION auth.jwt();
|
|
129
|
+
dropped_any := true;
|
|
130
|
+
END IF;
|
|
131
|
+
|
|
132
|
+
IF EXISTS (
|
|
133
|
+
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
134
|
+
WHERE n.nspname = 'auth' AND p.proname = 'roles'
|
|
135
|
+
AND pg_get_function_result(p.oid) = 'text'
|
|
136
|
+
AND p.prosrc LIKE '%app.user_roles%'
|
|
137
|
+
) THEN
|
|
138
|
+
DROP FUNCTION auth.roles();
|
|
139
|
+
dropped_any := true;
|
|
140
|
+
END IF;
|
|
141
|
+
|
|
142
|
+
-- RESTRICT: only an empty schema goes. Anything else in there — including a
|
|
143
|
+
-- Supabase installation left untouched above — keeps it.
|
|
144
|
+
IF dropped_any THEN
|
|
145
|
+
BEGIN
|
|
146
|
+
EXECUTE 'DROP SCHEMA auth RESTRICT';
|
|
147
|
+
EXCEPTION WHEN OTHERS THEN
|
|
148
|
+
NULL;
|
|
149
|
+
END;
|
|
150
|
+
END IF;
|
|
151
|
+
END
|
|
152
|
+
$rebase_drop_legacy$;
|
|
153
|
+
`;
|
|
154
|
+
/**
|
|
155
|
+
* Policies whose body still calls `auth.uid()` / `auth.roles()` / `auth.jwt()`.
|
|
156
|
+
*
|
|
157
|
+
* Postgres will not drop a function a policy depends on, so this is exactly the
|
|
158
|
+
* set standing between a database and losing the legacy schema. Rebase's own
|
|
159
|
+
* policies leave the list on the next push or boot, when they are recompiled —
|
|
160
|
+
* anything still here afterwards is hand-written, will never be recompiled by
|
|
161
|
+
* anybody, and is the reason the drop keeps being skipped. Silence there would
|
|
162
|
+
* leave an operator staring at a schema the release notes said would go.
|
|
163
|
+
*/
|
|
164
|
+
var LEGACY_RLS_DEPENDENTS_SQL = `
|
|
165
|
+
SELECT n.nspname AS schema, c.relname AS "table", p.polname AS policy
|
|
166
|
+
FROM pg_policy p
|
|
167
|
+
JOIN pg_class c ON c.oid = p.polrelid
|
|
168
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
169
|
+
WHERE pg_get_expr(p.polqual, p.polrelid) ~* '\\m${LEGACY_RLS_SCHEMA}\\.(uid|jwt|roles)\\s*\\('
|
|
170
|
+
OR pg_get_expr(p.polwithcheck, p.polrelid) ~* '\\m${LEGACY_RLS_SCHEMA}\\.(uid|jwt|roles)\\s*\\('
|
|
171
|
+
ORDER BY 1, 2, 3
|
|
172
|
+
`;
|
|
173
|
+
/**
|
|
174
|
+
* Functions whose body calls `auth.uid()` / `auth.roles()` / `auth.jwt()`.
|
|
175
|
+
*
|
|
176
|
+
* This is the half `DROP FUNCTION ... RESTRICT` cannot see, and the reason it
|
|
177
|
+
* needs its own query. Postgres records a dependency for a *policy* that calls a
|
|
178
|
+
* function, which is why the drop is safe against the policies above — but a
|
|
179
|
+
* `LANGUAGE sql` function whose body is a **string literal** is not parsed when
|
|
180
|
+
* it is created, so nothing is recorded and `RESTRICT` has nothing to refuse on.
|
|
181
|
+
* The drop succeeds and the caller is left pointing at a function that no longer
|
|
182
|
+
* exists, which fails at *query* time rather than at boot.
|
|
183
|
+
*
|
|
184
|
+
* A downstream project building on these helpers is not hypothetical: the Rebase
|
|
185
|
+
* control plane defines `auth.is_org_member(uuid)` and `auth.is_org_admin(uuid)`
|
|
186
|
+
* in this very schema, each calling `auth.uid()` in a string body, and eleven of
|
|
187
|
+
* its row-level-security policies go through them. Every one of those would have
|
|
188
|
+
* started failing the first time a recompile left no policy referencing
|
|
189
|
+
* `auth.uid()` directly — the drop's own precondition.
|
|
190
|
+
*
|
|
191
|
+
* Matching on the body text is the only option available, and it is deliberately
|
|
192
|
+
* broad: a false positive costs a schema that stays one release longer and says
|
|
193
|
+
* why, while a false negative costs somebody their policies.
|
|
194
|
+
*/
|
|
195
|
+
var LEGACY_RLS_FUNCTION_DEPENDENTS_SQL = `
|
|
196
|
+
SELECT n.nspname AS schema, p.proname AS function
|
|
197
|
+
FROM pg_proc p
|
|
198
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
199
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
200
|
+
AND NOT (n.nspname = '${LEGACY_RLS_SCHEMA}' AND p.proname IN ('uid', 'jwt', 'roles'))
|
|
201
|
+
AND p.prosrc ~* '\\m${LEGACY_RLS_SCHEMA}\\.(uid|jwt|roles)\\s*\\('
|
|
202
|
+
ORDER BY 1, 2
|
|
203
|
+
`;
|
|
204
|
+
/**
|
|
205
|
+
* Retire the pre-1.0 `auth` schema, reporting what is holding it back.
|
|
206
|
+
*
|
|
207
|
+
* The shared implementation behind the CLI's post-push step and the runtime's
|
|
208
|
+
* post-policy step. Both used to just fire {@link DROP_LEGACY_AUTH_SCHEMA_SQL}
|
|
209
|
+
* and swallow whatever came back, which is right for the ordinary case — a
|
|
210
|
+
* table not recompiled *yet* — and wrong for the one that never resolves: a
|
|
211
|
+
* hand-written policy nothing will ever rewrite. Then the schema stays forever
|
|
212
|
+
* and nothing ever says why.
|
|
213
|
+
*/
|
|
214
|
+
async function dropLegacyAuthSchema(run, report) {
|
|
215
|
+
let blockers;
|
|
216
|
+
try {
|
|
217
|
+
blockers = await run(LEGACY_RLS_DEPENDENTS_SQL);
|
|
218
|
+
} catch {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (blockers.length > 0) {
|
|
222
|
+
report.warn(`The pre-1.0 \`${LEGACY_RLS_SCHEMA}\` schema cannot be removed yet: ${blockers.length} ${blockers.length === 1 ? "policy still calls" : "policies still call"} \`${LEGACY_RLS_SCHEMA}.uid()\` and friends, and Postgres will not drop a function a policy depends on. Rebase's own policies are rewritten automatically — anything listed here is hand-written SQL that has to be updated to \`${REBASE_SCHEMA}.uid()\` by hand, after which the schema goes on its own:\n` + blockers.map((b) => ` • ${b.schema}.${b.table} → "${b.policy}"`).join("\n"));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
let borrowers;
|
|
226
|
+
try {
|
|
227
|
+
borrowers = await run(LEGACY_RLS_FUNCTION_DEPENDENTS_SQL);
|
|
228
|
+
} catch {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (borrowers.length > 0) {
|
|
232
|
+
report.warn(`The pre-1.0 \`${LEGACY_RLS_SCHEMA}\` schema cannot be removed yet: ${borrowers.length} ${borrowers.length === 1 ? "function calls" : "functions call"} \`${LEGACY_RLS_SCHEMA}.uid()\` and friends from its own body. Postgres records no dependency for that — a SQL body written as a string literal is never parsed — so dropping the helpers would leave these pointing at nothing, and they would fail when a query reached them rather than now. Repoint them at \`${REBASE_SCHEMA}.uid()\`, after which the schema goes on its own:\n` + borrowers.map((b) => ` • ${b.schema}.${b.function}()`).join("\n"));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
await run(DROP_LEGACY_AUTH_SCHEMA_SQL);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
report.info(`Left the \`${LEGACY_RLS_SCHEMA}\` schema in place: ` + (err instanceof Error ? err.message : String(err)));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
//#endregion
|
|
242
|
+
export { rls_bootstrap_sql_exports as n, RLS_BOOTSTRAP_STATEMENTS as t };
|
|
243
|
+
|
|
244
|
+
//# sourceMappingURL=rls-bootstrap-sql-Bpv3nUZo.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rls-bootstrap-sql-Bpv3nUZo.js","names":[],"sources":["../src/schema/rls-bootstrap-sql.ts"],"sourcesContent":["import { LEGACY_RLS_SCHEMA, REBASE_SCHEMA } from \"@rebasepro/types\";\n\n/**\n * Canonical SQL bootstrap for the RLS helper functions.\n *\n * Generated RLS policies reference `rebase.uid()` / `rebase.roles()` /\n * `rebase.jwt()`, so any SQL stream that can contain policies must be\n * self-contained: it has to (re)create these helpers first. This matters for\n * the migration directory in particular — Atlas replays migrations against a\n * clean dev database where no out-of-band bootstrap has ever run, so a\n * migration carrying policies without this preamble fails with\n * \"function rebase.uid() does not exist\".\n *\n * Idempotent (`IF NOT EXISTS` / `OR REPLACE`) so it can be prepended to every\n * policies block and re-applied freely. The runtime boot path\n * (`auth/ensure-tables.ts`) creates the same functions under an advisory lock\n * for HMR-safety; keep the definitions in sync.\n *\n * ## Creating the `rebase` schema here is now safe, and required\n *\n * It deliberately did not, once. These functions lived in a schema called\n * `auth`, and the note here read: creating `rebase` would leak it into Atlas's\n * replayed migration state, and — absent from the desired `schema.sql` — Atlas\n * would then plan `DROP SCHEMA \"rebase\" CASCADE`, taking the auth tables with\n * it. That reasoning still holds; what changed is the second half of it. The\n * DDL generator now emits `CREATE SCHEMA IF NOT EXISTS \"rebase\"`\n * unconditionally, so the schema is always in the desired state and the diff is\n * empty. (It used to appear only when some collection happened to declare\n * `schema: \"rebase\"` — true for the scaffold's users collection, and not a\n * property anything guaranteed.) `db push` additionally excludes the whole\n * schema from the declarative apply.\n *\n * See `@rebasepro/types`' `rls-functions` for why the functions moved out of\n * `auth` at all: the short version is that the name was Supabase's, and\n * `CREATE OR REPLACE FUNCTION auth.uid() RETURNS text` cannot be applied over\n * Supabase's `RETURNS uuid` — Postgres refuses, and the refusal used to be\n * swallowed.\n */\n/**\n * The bootstrap as individual statements.\n *\n * Kept as an array because the two consumers need different shapes and only one\n * of them can take a multi-command string: the migration preamble is written to\n * a file and replayed by Atlas, but the boot path runs through drizzle, whose\n * node-postgres handle speaks the extended query protocol and rejects more than\n * one command per call. Splitting a joined string back apart on `$$;` would be\n * a parser for a problem that does not need one.\n */\nexport const RLS_BOOTSTRAP_STATEMENTS: readonly string[] = [\n `CREATE SCHEMA IF NOT EXISTS ${REBASE_SCHEMA}`,\n\n // Falls back to the pre-rename `app.user_id` so a database that has taken\n // the new schema but is still served by an older backend keeps resolving\n // the principal. Drop the COALESCE once no such deployment remains.\n `CREATE OR REPLACE FUNCTION ${REBASE_SCHEMA}.uid() RETURNS text AS $$\n SELECT COALESCE(\n NULLIF(current_setting('app.uid', true), ''),\n NULLIF(current_setting('app.user_id', true), '')\n );\n$$ LANGUAGE sql STABLE`,\n\n `CREATE OR REPLACE FUNCTION ${REBASE_SCHEMA}.jwt() RETURNS jsonb AS $$\n SELECT COALESCE(\n NULLIF(current_setting('app.jwt', true), ''),\n '{}'\n )::jsonb;\n$$ LANGUAGE sql STABLE`,\n\n `CREATE OR REPLACE FUNCTION ${REBASE_SCHEMA}.roles() RETURNS text AS $$\n SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');\n$$ LANGUAGE sql STABLE`\n];\n\n/** The same statements as one script, for migration files and raw clients. */\nexport const RLS_BOOTSTRAP_SQL =\n \"-- Rebase schema + RLS helper functions (required by the policies below)\\n\" +\n RLS_BOOTSTRAP_STATEMENTS.map(s => `${s};`).join(\"\\n\\n\") + \"\\n\";\n\n/**\n * Removes the pre-1.0 `auth` schema, but only when Rebase is what put it there.\n *\n * ## Why this is safe against a Supabase database\n *\n * Two independent guards, and both have to pass:\n *\n * 1. **Each function is identified before it is dropped.** Ours returns `text`\n * and reads the `app.uid` GUC; Supabase's returns `uuid` and reads\n * `request.jwt.claims`. Nothing is dropped on a signature we did not write,\n * so a Supabase database — where our `CREATE OR REPLACE` could never have\n * succeeded in the first place, Postgres refusing to change a return type —\n * matches nothing and this is a no-op.\n * 2. **`DROP SCHEMA … RESTRICT`**, never CASCADE. If anything else at all still\n * lives in `auth` (Supabase's `users` table, its other helpers), the drop\n * fails and the schema stays. CASCADE here would be unrecoverable.\n *\n * ## Why it cannot run too early\n *\n * Postgres records a dependency from every RLS policy to the functions its body\n * calls, so `DROP FUNCTION auth.uid()` fails for as long as a single policy\n * still references it. That is the interlock, and it is load-bearing: the drop\n * can only succeed once every policy has been recompiled to \\`rebase.uid()\\`.\n * Callers therefore run this *after* applying policies, and treat a failure as\n * \"not yet — try again next boot\" rather than as an error.\n */\nexport const DROP_LEGACY_AUTH_SCHEMA_SQL = `\nDO $rebase_drop_legacy$\nDECLARE\n dropped_any boolean := false;\nBEGIN\n -- Each function is matched on its own result type and body, so a schema\n -- that merely shares the name keeps everything it has.\n IF EXISTS (\n SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'auth' AND p.proname = 'uid'\n AND pg_get_function_result(p.oid) = 'text'\n AND p.prosrc LIKE '%app.uid%'\n ) THEN\n DROP FUNCTION auth.uid();\n dropped_any := true;\n END IF;\n\n IF EXISTS (\n SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'auth' AND p.proname = 'jwt'\n AND pg_get_function_result(p.oid) = 'jsonb'\n AND p.prosrc LIKE '%app.jwt%'\n ) THEN\n DROP FUNCTION auth.jwt();\n dropped_any := true;\n END IF;\n\n IF EXISTS (\n SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'auth' AND p.proname = 'roles'\n AND pg_get_function_result(p.oid) = 'text'\n AND p.prosrc LIKE '%app.user_roles%'\n ) THEN\n DROP FUNCTION auth.roles();\n dropped_any := true;\n END IF;\n\n -- RESTRICT: only an empty schema goes. Anything else in there — including a\n -- Supabase installation left untouched above — keeps it.\n IF dropped_any THEN\n BEGIN\n EXECUTE 'DROP SCHEMA auth RESTRICT';\n EXCEPTION WHEN OTHERS THEN\n NULL;\n END;\n END IF;\nEND\n$rebase_drop_legacy$;\n`;\n\n/** Somebody's policy that still calls a pre-1.0 helper. */\nexport interface LegacyRlsDependent {\n schema: string;\n table: string;\n policy: string;\n}\n\n/**\n * Policies whose body still calls `auth.uid()` / `auth.roles()` / `auth.jwt()`.\n *\n * Postgres will not drop a function a policy depends on, so this is exactly the\n * set standing between a database and losing the legacy schema. Rebase's own\n * policies leave the list on the next push or boot, when they are recompiled —\n * anything still here afterwards is hand-written, will never be recompiled by\n * anybody, and is the reason the drop keeps being skipped. Silence there would\n * leave an operator staring at a schema the release notes said would go.\n */\nexport const LEGACY_RLS_DEPENDENTS_SQL = `\n SELECT n.nspname AS schema, c.relname AS \"table\", p.polname AS policy\n FROM pg_policy p\n JOIN pg_class c ON c.oid = p.polrelid\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE pg_get_expr(p.polqual, p.polrelid) ~* '\\\\m${LEGACY_RLS_SCHEMA}\\\\.(uid|jwt|roles)\\\\s*\\\\('\n OR pg_get_expr(p.polwithcheck, p.polrelid) ~* '\\\\m${LEGACY_RLS_SCHEMA}\\\\.(uid|jwt|roles)\\\\s*\\\\('\n ORDER BY 1, 2, 3\n`;\n\n/** Somebody's *function* that still calls a pre-1.0 helper from its own body. */\nexport interface LegacyRlsFunctionDependent {\n schema: string;\n function: string;\n}\n\n/**\n * Functions whose body calls `auth.uid()` / `auth.roles()` / `auth.jwt()`.\n *\n * This is the half `DROP FUNCTION ... RESTRICT` cannot see, and the reason it\n * needs its own query. Postgres records a dependency for a *policy* that calls a\n * function, which is why the drop is safe against the policies above — but a\n * `LANGUAGE sql` function whose body is a **string literal** is not parsed when\n * it is created, so nothing is recorded and `RESTRICT` has nothing to refuse on.\n * The drop succeeds and the caller is left pointing at a function that no longer\n * exists, which fails at *query* time rather than at boot.\n *\n * A downstream project building on these helpers is not hypothetical: the Rebase\n * control plane defines `auth.is_org_member(uuid)` and `auth.is_org_admin(uuid)`\n * in this very schema, each calling `auth.uid()` in a string body, and eleven of\n * its row-level-security policies go through them. Every one of those would have\n * started failing the first time a recompile left no policy referencing\n * `auth.uid()` directly — the drop's own precondition.\n *\n * Matching on the body text is the only option available, and it is deliberately\n * broad: a false positive costs a schema that stays one release longer and says\n * why, while a false negative costs somebody their policies.\n */\nexport const LEGACY_RLS_FUNCTION_DEPENDENTS_SQL = `\n SELECT n.nspname AS schema, p.proname AS function\n FROM pg_proc p\n JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')\n AND NOT (n.nspname = '${LEGACY_RLS_SCHEMA}' AND p.proname IN ('uid', 'jwt', 'roles'))\n AND p.prosrc ~* '\\\\m${LEGACY_RLS_SCHEMA}\\\\.(uid|jwt|roles)\\\\s*\\\\('\n ORDER BY 1, 2\n`;\n\n/**\n * Retire the pre-1.0 `auth` schema, reporting what is holding it back.\n *\n * The shared implementation behind the CLI's post-push step and the runtime's\n * post-policy step. Both used to just fire {@link DROP_LEGACY_AUTH_SCHEMA_SQL}\n * and swallow whatever came back, which is right for the ordinary case — a\n * table not recompiled *yet* — and wrong for the one that never resolves: a\n * hand-written policy nothing will ever rewrite. Then the schema stays forever\n * and nothing ever says why.\n */\nexport async function dropLegacyAuthSchema(\n run: (sql: string) => Promise<Record<string, unknown>[]>,\n report: { info: (m: string) => void; warn: (m: string) => void }\n): Promise<void> {\n let blockers: LegacyRlsDependent[];\n try {\n blockers = (await run(LEGACY_RLS_DEPENDENTS_SQL)) as unknown as LegacyRlsDependent[];\n } catch {\n return; // No catalogue access; nothing here is worth failing a boot for.\n }\n\n if (blockers.length > 0) {\n report.warn(\n `The pre-1.0 \\`${LEGACY_RLS_SCHEMA}\\` schema cannot be removed yet: ${blockers.length} ` +\n `${blockers.length === 1 ? \"policy still calls\" : \"policies still call\"} ` +\n `\\`${LEGACY_RLS_SCHEMA}.uid()\\` and friends, and Postgres will not drop a function a policy ` +\n `depends on. Rebase's own policies are ` +\n `rewritten automatically — anything listed here is hand-written SQL that has to be updated to ` +\n `\\`${REBASE_SCHEMA}.uid()\\` by hand, after which the schema goes on its own:\\n` +\n blockers.map(b => ` • ${b.schema}.${b.table} → \"${b.policy}\"`).join(\"\\n\")\n );\n return;\n }\n\n // The half Postgres will not refuse on. See LEGACY_RLS_FUNCTION_DEPENDENTS_SQL:\n // a string-literal SQL body records no dependency, so `DROP FUNCTION` drops\n // out from under a caller that is still using it and nothing complains until\n // a query runs.\n let borrowers: LegacyRlsFunctionDependent[];\n try {\n borrowers = (await run(LEGACY_RLS_FUNCTION_DEPENDENTS_SQL)) as unknown as LegacyRlsFunctionDependent[];\n } catch {\n return;\n }\n\n if (borrowers.length > 0) {\n report.warn(\n `The pre-1.0 \\`${LEGACY_RLS_SCHEMA}\\` schema cannot be removed yet: ${borrowers.length} ` +\n `${borrowers.length === 1 ? \"function calls\" : \"functions call\"} ` +\n `\\`${LEGACY_RLS_SCHEMA}.uid()\\` and friends from its own body. Postgres records no dependency ` +\n `for that — a SQL body written as a string literal is never parsed — so dropping the helpers ` +\n `would leave these pointing at nothing, and they would fail when a query reached them rather ` +\n `than now. Repoint them at \\`${REBASE_SCHEMA}.uid()\\`, after which the schema goes on its own:\\n` +\n borrowers.map(b => ` • ${b.schema}.${b.function}()`).join(\"\\n\")\n );\n return;\n }\n\n try {\n await run(DROP_LEGACY_AUTH_SCHEMA_SQL);\n } catch (err) {\n // Something else depends on it, or the connection does not own it.\n // Not fatal: the schema is inert either way now that no policy calls it.\n report.info(\n `Left the \\`${LEGACY_RLS_SCHEMA}\\` schema in place: ` +\n (err instanceof Error ? err.message : String(err))\n );\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgDA,IAAa,2BAA8C;CACvD,+BAA+B;CAK/B,8BAA8B,cAAc;;;;;;CAO5C,8BAA8B,cAAc;;;;;;CAO5C,8BAA8B,cAAc;;;AAGhD;AAII,KACA,yBAAyB,KAAI,MAAK,GAAG,EAAE,EAAE,CAAC,CAAC,KAAK,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4B1D,IAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmE3C,IAAa,4BAA4B;;;;;sDAKa,kBAAkB;2DACb,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;AAgC7E,IAAa,qCAAqC;;;;;8BAKpB,kBAAkB;4BACpB,kBAAkB;;;;;;;;;;;;;AAc9C,eAAsB,qBAClB,KACA,QACa;CACb,IAAI;CACJ,IAAI;EACA,WAAY,MAAM,IAAI,yBAAyB;CACnD,QAAQ;EACJ;CACJ;CAEA,IAAI,SAAS,SAAS,GAAG;EACrB,OAAO,KACH,iBAAiB,kBAAkB,mCAAmC,SAAS,OAAO,GACnF,SAAS,WAAW,IAAI,uBAAuB,sBAAsB,KACnE,kBAAkB,4MAGlB,cAAc,+DACnB,SAAS,KAAI,MAAK,OAAO,EAAE,OAAO,GAAG,EAAE,MAAM,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC,KAAK,IAAI,CAC7E;EACA;CACJ;CAMA,IAAI;CACJ,IAAI;EACA,YAAa,MAAM,IAAI,kCAAkC;CAC7D,QAAQ;EACJ;CACJ;CAEA,IAAI,UAAU,SAAS,GAAG;EACtB,OAAO,KACH,iBAAiB,kBAAkB,mCAAmC,UAAU,OAAO,GACpF,UAAU,WAAW,IAAI,mBAAmB,iBAAiB,KAC3D,kBAAkB,6RAGQ,cAAc,uDAC7C,UAAU,KAAI,MAAK,OAAO,EAAE,OAAO,GAAG,EAAE,SAAS,GAAG,CAAC,CAAC,KAAK,IAAI,CACnE;EACA;CACJ;CAEA,IAAI;EACA,MAAM,IAAI,2BAA2B;CACzC,SAAS,KAAK;EAGV,OAAO,KACH,cAAc,kBAAkB,yBAC/B,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,EACpD;CACJ;AACJ"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one description of what an auth user table's columns must be.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Three different code paths create `rebase.users`, and before this module they
|
|
7
|
+
* disagreed about it:
|
|
8
|
+
*
|
|
9
|
+
* | column | `db push` (generator) | `ensureAuthTablesExist` | `ensureCollectionTables` |
|
|
10
|
+
* |------------------|-----------------------|-------------------------|--------------------------|
|
|
11
|
+
* | `email` | `TEXT UNIQUE NOT NULL`| `TEXT NOT NULL` + CHECK | `TEXT` (nullable) |
|
|
12
|
+
* | `roles` | `TEXT[]` | `NOT NULL DEFAULT '{}'` | nullable, no default |
|
|
13
|
+
* | `email_verified` | `BOOLEAN` | `NOT NULL DEFAULT FALSE`| nullable |
|
|
14
|
+
* | `created_at` | `DEFAULT now()` | `NOT NULL DEFAULT NOW()`| no default |
|
|
15
|
+
*
|
|
16
|
+
* They are all `CREATE TABLE IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`, so
|
|
17
|
+
* whichever ran first decided the table and the other two silently no-op'd.
|
|
18
|
+
* Boot order therefore chose the constraints: a managed deploy (collection
|
|
19
|
+
* tables first) got a users table whose `email` was nullable and whose
|
|
20
|
+
* `email_verified` had no default, while the same project pushed from a
|
|
21
|
+
* checkout got the strict one. In the same table, `is_anonymous` came out
|
|
22
|
+
* `NOT NULL DEFAULT false` — because *that* column existed in only one of the
|
|
23
|
+
* three lists, so its owner's definition won by default.
|
|
24
|
+
*
|
|
25
|
+
* A second consequence was drift in the other direction. The scaffold's
|
|
26
|
+
* `users.ts` describes 12 columns; auth needs 14. `db push` is declarative and
|
|
27
|
+
* builds its desired state from the collection alone, so the two columns only
|
|
28
|
+
* auth knows about (`is_anonymous`, `tokens_valid_after`) read as unmanaged
|
|
29
|
+
* drift, and a push run after the server had booted once planned to DROP them.
|
|
30
|
+
*
|
|
31
|
+
* Both problems are the same problem: no single place said what this table is.
|
|
32
|
+
* This is that place. Every creator asks here first and only then falls back to
|
|
33
|
+
* the collection's own property definitions, so a column auth owns has one
|
|
34
|
+
* definition regardless of who gets there first, and a column the developer
|
|
35
|
+
* added to their users collection still behaves like any other field.
|
|
36
|
+
*
|
|
37
|
+
* ## What belongs here
|
|
38
|
+
*
|
|
39
|
+
* Only columns the auth services read or write. A developer's own additions to
|
|
40
|
+
* their users collection (`bio`, `stripe_customer_id`) are ordinary columns and
|
|
41
|
+
* must NOT be listed — they are generated from the collection like every other
|
|
42
|
+
* field, and listing them here would freeze a user's schema.
|
|
43
|
+
*
|
|
44
|
+
* `id` is deliberately absent: its type comes from the collection's id property
|
|
45
|
+
* (uuid / increment / text) and each creator already derives it.
|
|
46
|
+
*
|
|
47
|
+
* Keep in step with {@link AUTH_SCHEMA_VERSION} when a change here makes an
|
|
48
|
+
* older runtime unable to work against a migrated table.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* A column auth owns.
|
|
52
|
+
*
|
|
53
|
+
* Structured rather than one SQL string because the same facts are needed in
|
|
54
|
+
* three grammars — a `CREATE TABLE` column list, an `ADD COLUMN IF NOT EXISTS`,
|
|
55
|
+
* and a pair of `ALTER COLUMN … SET DEFAULT` / `SET NOT NULL` reconciles for a
|
|
56
|
+
* table that already exists with the wrong shape. Parsing a string back apart
|
|
57
|
+
* for the third of those is how the copies drifted in the first place.
|
|
58
|
+
*/
|
|
59
|
+
export interface AuthUsersColumn {
|
|
60
|
+
/** Physical column name. */
|
|
61
|
+
column: string;
|
|
62
|
+
/** Postgres type. */
|
|
63
|
+
type: string;
|
|
64
|
+
/** Default expression, verbatim, or absent for no default. */
|
|
65
|
+
default?: string;
|
|
66
|
+
/** Whether the column is NOT NULL. */
|
|
67
|
+
notNull?: boolean;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
|
|
71
|
+
* — it synthesizes `anon_<32 hex>@anonymous.local` rather than inserting NULL.
|
|
72
|
+
* The 320-char bound (RFC 5321) is a CHECK rather than a `VARCHAR(n)`, added
|
|
73
|
+
* separately by `ensureAuthTablesExist` so it can be `NOT VALID` on an adopted
|
|
74
|
+
* table that already holds a longer row.
|
|
75
|
+
*/
|
|
76
|
+
export declare const AUTH_USERS_COLUMNS: readonly AuthUsersColumn[];
|
|
77
|
+
/** Type + inline constraints, as they appear after the column name. */
|
|
78
|
+
export declare function authUsersColumnSql(spec: AuthUsersColumn): string;
|
|
79
|
+
/**
|
|
80
|
+
* The auth-owned definition for a physical column name, or `undefined` when
|
|
81
|
+
* auth does not own it.
|
|
82
|
+
*
|
|
83
|
+
* Callers pass the RESOLVED column name (after `columnName` mapping), because
|
|
84
|
+
* that is the only name the three creators agree on: the scaffold's users
|
|
85
|
+
* collection spells the property `displayName` and the column `display_name`.
|
|
86
|
+
*/
|
|
87
|
+
export declare function authUsersColumnDefinition(column: string): string | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* Whether a collection is an auth collection, i.e. whether the definitions in
|
|
90
|
+
* this module apply to its table at all.
|
|
91
|
+
*
|
|
92
|
+
* Duplicated in shape from `@rebasepro/common`'s policy defaults on purpose:
|
|
93
|
+
* that one takes a `CollectionConfig`, this one is called from DDL code paths
|
|
94
|
+
* that hold looser objects, and both spellings must accept `auth: true` as well
|
|
95
|
+
* as `auth: { enabled: true }`.
|
|
96
|
+
*/
|
|
97
|
+
export declare function isAuthCollection(collection: unknown): boolean;
|
|
@@ -107,7 +107,7 @@ export interface EnsureOutcome extends EnsurePlan {
|
|
|
107
107
|
* that reference them, tables before the columns added to other tables (a new
|
|
108
108
|
* table may be the target of a relation), and nothing is emitted twice.
|
|
109
109
|
*/
|
|
110
|
-
export declare function planCollectionSchemaEnsure(
|
|
110
|
+
export declare function planCollectionSchemaEnsure(allCollections: CollectionConfig[], existing: ExistingSchema): EnsurePlan;
|
|
111
111
|
/** Read what the database has, for the schemas the collections live in. */
|
|
112
112
|
export declare function readExistingSchema(client: Queryable, schemas: string[]): Promise<ExistingSchema>;
|
|
113
113
|
/**
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import { CollectionConfig } from "@rebasepro/types";
|
|
2
|
-
export declare const generateSchema: (
|
|
2
|
+
export declare const generateSchema: (allCollections: CollectionConfig[], stripPolicies?: boolean) => Promise<string>;
|
|
@@ -29,7 +29,7 @@ export declare const generatePolicyStatements: (collection: CollectionConfig, ru
|
|
|
29
29
|
*/
|
|
30
30
|
export declare const quoteSqlLiteral: (value: string) => string;
|
|
31
31
|
export declare const getSqlColumnType: (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]) => string;
|
|
32
|
-
export declare const generatePostgresDdl: (
|
|
32
|
+
export declare const generatePostgresDdl: (allCollections: CollectionConfig[], options?: {
|
|
33
33
|
includePolicies?: boolean;
|
|
34
34
|
}) => Promise<string>;
|
|
35
35
|
/** The RLS statements one declared collection's table needs, ready to run. */
|
|
@@ -100,7 +100,7 @@ export interface JunctionTablePlan {
|
|
|
100
100
|
* is unknown yields the column without a constraint. Both mirror the generator
|
|
101
101
|
* exactly — a divergence here is a schema fork between boot and `db push`.
|
|
102
102
|
*/
|
|
103
|
-
export declare const planRelationalColumns: (
|
|
103
|
+
export declare const planRelationalColumns: (allCollections: CollectionConfig[]) => RelationalColumnPlan[];
|
|
104
104
|
/**
|
|
105
105
|
* The junction tables a bundle's many-to-many relations imply.
|
|
106
106
|
*
|
|
@@ -109,7 +109,7 @@ export declare const planRelationalColumns: (collections: CollectionConfig[]) =>
|
|
|
109
109
|
* junction with row-level security left off is readable and writable by every
|
|
110
110
|
* signed-in user, which is why the two must ship together.
|
|
111
111
|
*/
|
|
112
|
-
export declare const planJunctionTables: (
|
|
112
|
+
export declare const planJunctionTables: (allCollections: CollectionConfig[]) => JunctionTablePlan[];
|
|
113
113
|
export interface CollectionPolicyPlan {
|
|
114
114
|
/** The table's schema (e.g. `public`, `rebase`). */
|
|
115
115
|
schema: string;
|
|
@@ -137,6 +137,6 @@ export interface CollectionPolicyPlan {
|
|
|
137
137
|
* writable by every signed-in user. A junction whose table is still absent is
|
|
138
138
|
* skipped by the applier, not planned away here.
|
|
139
139
|
*/
|
|
140
|
-
export declare const planCollectionPolicies: (
|
|
141
|
-
export declare const generatePostgresPoliciesDdl: (
|
|
140
|
+
export declare const planCollectionPolicies: (allCollections: CollectionConfig[]) => CollectionPolicyPlan[];
|
|
141
|
+
export declare const generatePostgresPoliciesDdl: (allCollections: CollectionConfig[]) => string;
|
|
142
142
|
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CollectionConfig } from "@rebasepro/types";
|
|
2
|
+
/**
|
|
3
|
+
* Notice a generated Drizzle schema that a *library upgrade* invalidated.
|
|
4
|
+
*
|
|
5
|
+
* `rebase dev` already watches `config/collections` and warns when a collection
|
|
6
|
+
* file changes. That covers drift the developer caused. It cannot cover this one,
|
|
7
|
+
* because nothing the developer owns changed: 0.13 derives `category_id` where
|
|
8
|
+
* 0.12 derived `categorie_id`, from the same unedited collection. The watcher
|
|
9
|
+
* never fires, and `backend/src/schema.generated.ts` quietly stops describing the
|
|
10
|
+
* schema the runtime expects.
|
|
11
|
+
*
|
|
12
|
+
* The consequence is not cosmetic. Boot-ensure renames the column in the
|
|
13
|
+
* database, then relation validation reads the stale module and refuses to
|
|
14
|
+
* start — on that boot and every boot after it, because the rename is already
|
|
15
|
+
* applied and will not be attempted again.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately narrow: this answers "does the generated schema name a foreign key
|
|
18
|
+
* the way the previous rule did", not "is this file what we would generate now".
|
|
19
|
+
* The wide question would report every whitespace change in the generator as a
|
|
20
|
+
* fatal staleness, and a check that cries wolf gets switched off.
|
|
21
|
+
*/
|
|
22
|
+
/** One column the generated schema names under the pre-0.13 rule. */
|
|
23
|
+
export interface LegacyForeignKeyName {
|
|
24
|
+
/** Table whose column declaration is stale. */
|
|
25
|
+
table: string;
|
|
26
|
+
/** The name the generated schema declares. */
|
|
27
|
+
legacy: string;
|
|
28
|
+
/** The name this release derives, and which the database now carries. */
|
|
29
|
+
current: string;
|
|
30
|
+
/** `<collection>.<relation>` that derives it, for the message. */
|
|
31
|
+
relation: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* @param generatedSource contents of `backend/src/schema.generated.ts`
|
|
35
|
+
* @param collections the project's collections, as this release reads them
|
|
36
|
+
*/
|
|
37
|
+
export declare function findLegacyForeignKeyNames(generatedSource: string, collections: CollectionConfig[]): LegacyForeignKeyName[];
|
|
38
|
+
/** One-line summary for a log or a CLI notice. */
|
|
39
|
+
export declare function describeLegacyForeignKeyNames(found: LegacyForeignKeyName[]): string;
|