@rebasepro/common 0.12.0 → 0.12.1-canary.g06f263c

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.
@@ -20,3 +20,21 @@ export declare function getEffectiveSecurityRules(collection: CollectionConfig):
20
20
  * DDL, which policies are injected and how to take them off.
21
21
  */
22
22
  export declare function getInjectedSecurityRules(collection: CollectionConfig): SecurityRule[];
23
+ /**
24
+ * Every policy name `rebase db push` would write for a collection.
25
+ *
26
+ * This is the answer to "did the codebase produce this live policy?", and it is
27
+ * more than `securityRules.map(r => r.name)` for two reasons:
28
+ *
29
+ * - a rule without an explicit `name` compiles to `<table>_<op>_<hash>`, one
30
+ * per operation, so comparing `rule.name` to `policyname` never matches it;
31
+ * - the generator also injects the safe-by-default baseline
32
+ * (`<table>_default_admin_*`), which is in no collection's `securityRules`.
33
+ *
34
+ * Every UI that flags drift has to get both right, and each one that derived it
35
+ * by hand got a different subset — which is how four policies *Rebase itself
36
+ * wrote* came to be badged as hand-written drift on every table in a project,
37
+ * with a button offering to import them back into the codebase that produced
38
+ * them. There is one derivation now, and this is it.
39
+ */
40
+ export declare function getGeneratedPolicyNames(collection: CollectionConfig): Set<string>;
@@ -16,3 +16,4 @@ export * from "./auth-default-policies";
16
16
  export * from "./junction-policies";
17
17
  export * from "./conditions";
18
18
  export * from "./pg-column-to-property";
19
+ export * from "./string-column-length";
@@ -18,7 +18,12 @@ export interface AnonymousGrantRisk {
18
18
  * is how the trusted *server* context is recognised), so:
19
19
  *
20
20
  * - `auth.uid() IS NOT NULL` is a tautology on the user path, and
21
- * - `auth.uid() != 'anon'` compares against a string no caller ever has.
21
+ * - `auth.uid() != 'anon'` excludes one spelling of anonymous and admits the
22
+ * other. This one is not hypothetical and was not only a foreign habit:
23
+ * rebase's own request path reported `'anon'` while everything that compiled
24
+ * or checked a policy used `'anonymous'`, so whichever literal an author
25
+ * picked, half the anonymous callers walked through. See
26
+ * {@link ANONYMOUS_USER_IDS}.
22
27
  *
23
28
  * Either one turns a lockdown into a full grant, and neither looks wrong. No
24
29
  * real user id is ever one of these literals, and a user-context request is
@@ -0,0 +1,24 @@
1
+ import type { StringProperty } from "@rebasepro/types";
2
+ /**
3
+ * The length a bounded string column is declared with when the property does
4
+ * not say. Historical: it is what the DDL generator hardcoded, kept so that
5
+ * regenerating an existing schema does not silently redefine its columns.
6
+ */
7
+ export declare const DEFAULT_STRING_COLUMN_LENGTH = 255;
8
+ /**
9
+ * How wide a `varchar`/`char` column should be for a given property.
10
+ *
11
+ * One definition, three call sites, because they used to disagree. For the same
12
+ * `columnType: "varchar"` property the DDL generator emitted `VARCHAR(255)`
13
+ * while the Drizzle generator emitted a bare `varchar("col")` — which Postgres
14
+ * reads as *unbounded* — so which of the two you ran decided whether the column
15
+ * had a limit at all. Introspection then dropped the length entirely, so reading
16
+ * an existing `character varying(500)` column back and regenerating it produced
17
+ * a `VARCHAR(255)`: a silent narrowing of a column with data already in it.
18
+ *
19
+ * `validation.max` is the property's own statement about how long the value may
20
+ * be, so it is the only sensible source for the column's width — and it keeps
21
+ * the constraint the database enforces in step with the one the app enforces,
22
+ * rather than inventing a second, different limit underneath it.
23
+ */
24
+ export declare function resolveStringColumnLength(prop: Pick<StringProperty, "validation">): number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/common",
3
3
  "type": "module",
4
- "version": "0.12.0",
4
+ "version": "0.12.1-canary.g06f263c",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -38,26 +38,26 @@
38
38
  "./package.json": "./package.json"
39
39
  },
40
40
  "dependencies": {
41
- "fast-equals": "6.0.0",
41
+ "fast-equals": "6.0.2",
42
42
  "json-logic-js": "^2.0.5",
43
- "@rebasepro/types": "0.12.0",
44
- "@rebasepro/utils": "0.12.0"
43
+ "@rebasepro/types": "0.12.1-canary.g06f263c",
44
+ "@rebasepro/utils": "0.12.1-canary.g06f263c"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@jest/globals": "^30.4.1",
48
48
  "@testing-library/react": "^16.3.2",
49
49
  "@testing-library/user-event": "^14.6.1",
50
50
  "@types/jest": "^30.0.0",
51
- "@types/node": "^25.9.3",
51
+ "@types/node": "^26.1.2",
52
52
  "@types/object-hash": "^3.0.6",
53
53
  "@types/react-measure": "^2.0.12",
54
54
  "babel-plugin-react-compiler": "beta",
55
55
  "cross-env": "^10.1.0",
56
56
  "jest": "^30.4.2",
57
- "ts-jest": "^29.4.11",
57
+ "ts-jest": "^29.4.12",
58
58
  "tsd": "^0.33.0",
59
59
  "typescript": "^6.0.3",
60
- "vite": "^8.0.16"
60
+ "vite": "^8.1.5"
61
61
  },
62
62
  "files": [
63
63
  "dist",
@@ -1,5 +1,6 @@
1
1
  import { CollectionConfig, SecurityRule, SecurityOperation, AuthCollectionConfig, PolicyExpression, isPostgresCollectionConfig, policy } from "@rebasepro/types";
2
2
  import { getTableName } from "./relations";
3
+ import { getPolicyNamesForRules } from "@rebasepro/utils";
3
4
 
4
5
  /**
5
6
  * Default RLS policies injected by the schema generator.
@@ -150,3 +151,24 @@ export function getInjectedSecurityRules(collection: CollectionConfig): Security
150
151
  // so everything past the author's count is injected.
151
152
  return getEffectiveSecurityRules(collection).slice(explicitCount);
152
153
  }
154
+
155
+ /**
156
+ * Every policy name `rebase db push` would write for a collection.
157
+ *
158
+ * This is the answer to "did the codebase produce this live policy?", and it is
159
+ * more than `securityRules.map(r => r.name)` for two reasons:
160
+ *
161
+ * - a rule without an explicit `name` compiles to `<table>_<op>_<hash>`, one
162
+ * per operation, so comparing `rule.name` to `policyname` never matches it;
163
+ * - the generator also injects the safe-by-default baseline
164
+ * (`<table>_default_admin_*`), which is in no collection's `securityRules`.
165
+ *
166
+ * Every UI that flags drift has to get both right, and each one that derived it
167
+ * by hand got a different subset — which is how four policies *Rebase itself
168
+ * wrote* came to be badged as hand-written drift on every table in a project,
169
+ * with a button offering to import them back into the codebase that produced
170
+ * them. There is one derivation now, and this is it.
171
+ */
172
+ export function getGeneratedPolicyNames(collection: CollectionConfig): Set<string> {
173
+ return getPolicyNamesForRules(getEffectiveSecurityRules(collection), getTableName(collection));
174
+ }
package/src/util/index.ts CHANGED
@@ -16,3 +16,4 @@ export * from "./auth-default-policies";
16
16
  export * from "./junction-policies";
17
17
  export * from "./conditions";
18
18
  export * from "./pg-column-to-property";
19
+ export * from "./string-column-length";
@@ -42,6 +42,7 @@ function pgTypeToRebaseProperty(column: TableColumnInfo): Property | null {
42
42
  udt_name,
43
43
  is_nullable,
44
44
  column_default,
45
+ character_maximum_length,
45
46
  enum_values
46
47
  } = column;
47
48
 
@@ -78,11 +79,23 @@ label: prettifyIdentifier(v) })),
78
79
  let colType: "varchar" | "text" | "char" = "varchar";
79
80
  if (dt === "text" || dt === "citext") colType = "text";
80
81
  if (dt === "char" || dt === "character") colType = "char";
82
+ // Carry the declared width across. Dropping it made introspection
83
+ // lossy in the one direction that costs data: a `character
84
+ // varying(500)` column read back as a bare `varchar` regenerates as
85
+ // `VARCHAR(255)`, narrowing a column that already holds longer
86
+ // values. TEXT has no width, and reporting one would invent a limit
87
+ // the database does not have.
88
+ const declaredLength = colType === "text" ? null : character_maximum_length;
81
89
  const prop: StringProperty = {
82
90
  type: "string",
83
91
  name: prettifiedName,
84
92
  columnType: colType,
85
- validation: required ? { required: true } : undefined
93
+ validation: required || declaredLength
94
+ ? {
95
+ ...(required ? { required: true } : {}),
96
+ ...(declaredLength ? { max: declaredLength } : {})
97
+ }
98
+ : undefined
86
99
  };
87
100
  if (isAutoId) {
88
101
  prop.isId = "manual";
@@ -1,4 +1,4 @@
1
- import { ANONYMOUS_USER_ID, Entity, PolicyCompareOperator, PolicyExpression, PolicyOperand } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_ID, isAnonymousUid, Entity, PolicyCompareOperator, PolicyExpression, PolicyOperand } from "@rebasepro/types";
2
2
 
3
3
  /**
4
4
  * Result of evaluating a policy client-side. `"unknown"` means the expression
@@ -61,7 +61,11 @@ export function evaluatePolicy(expr: PolicyExpression, ctx: PolicyEvalContext):
61
61
  return expr.roles.every(r => r === "public" || userRoles.includes(r));
62
62
  }
63
63
  case "authenticated":
64
- return ctx.uid != null && ctx.uid !== ANONYMOUS_USER_ID;
64
+ // Every anonymous spelling, matching what this node compiles to in
65
+ // Postgres — the two evaluators disagreeing about who is signed in
66
+ // is the client optimistically rendering a row the database will
67
+ // refuse, or hiding one it would have allowed.
68
+ return ctx.uid != null && !isAnonymousUid(ctx.uid);
65
69
  case "serverContext":
66
70
  // A client is never the server context. Postgres decides this by
67
71
  // `auth.uid() IS NULL`, which a client request can never produce:
@@ -1,4 +1,4 @@
1
- import { ANONYMOUS_USER_ID, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_IDS, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
2
2
  import { toSnakeCase } from "@rebasepro/utils";
3
3
  import { getTableName } from "../relations";
4
4
 
@@ -88,9 +88,15 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
88
88
  case "authenticated":
89
89
  // `IS NOT NULL` alone is a tautology on the user path: every
90
90
  // user-context request sets `app.uid`, and an anonymous one sets
91
- // it to the sentinel. Excluding the sentinel is what makes this mean
91
+ // it to a sentinel. Excluding the sentinels is what makes this mean
92
92
  // "signed in" rather than "anyone at all".
93
- return `auth.uid() IS NOT NULL AND auth.uid() <> ${quoteLiteral(ANONYMOUS_USER_ID)}`;
93
+ //
94
+ // Every sentinel, not just the current one. This clause is written
95
+ // into the database and outlives the server that generated it: a
96
+ // policy compiled here may be enforced against an older server that
97
+ // still reports `'anon'`, which is exactly how excluding one
98
+ // spelling turned this helper into a grant. See ANONYMOUS_USER_IDS.
99
+ return `auth.uid() IS NOT NULL AND auth.uid() NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
94
100
  case "serverContext":
95
101
  // Only the built-in server flows leave `app.uid` unset.
96
102
  return "auth.uid() IS NULL";
@@ -1,4 +1,4 @@
1
- import { ANONYMOUS_USER_ID, LiteralPolicyOperand, PolicyExpression, policy } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, LiteralPolicyOperand, PolicyExpression, policy } from "@rebasepro/types";
2
2
 
3
3
  /**
4
4
  * A tiny, regex-based SQL "parser" for security rules.
@@ -185,7 +185,12 @@ const UID_NOT_NULL = /auth\.uid\(\)\s+IS\s+NOT\s+NULL/i;
185
185
  * is how the trusted *server* context is recognised), so:
186
186
  *
187
187
  * - `auth.uid() IS NOT NULL` is a tautology on the user path, and
188
- * - `auth.uid() != 'anon'` compares against a string no caller ever has.
188
+ * - `auth.uid() != 'anon'` excludes one spelling of anonymous and admits the
189
+ * other. This one is not hypothetical and was not only a foreign habit:
190
+ * rebase's own request path reported `'anon'` while everything that compiled
191
+ * or checked a policy used `'anonymous'`, so whichever literal an author
192
+ * picked, half the anonymous callers walked through. See
193
+ * {@link ANONYMOUS_USER_IDS}.
189
194
  *
190
195
  * Either one turns a lockdown into a full grant, and neither looks wrong. No
191
196
  * real user id is ever one of these literals, and a user-context request is
@@ -231,7 +236,9 @@ export function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[
231
236
  detail: literal.value,
232
237
  explanation: `'${literal.value}' is a ${platform} convention. Rebase reports an anonymous ` +
233
238
  `request as '${ANONYMOUS_USER_ID}', so comparing against '${literal.value}' passes for ` +
234
- "every caller. Use `condition: policy.authenticated()` to mean \"signed in\"."
239
+ "every caller. Use `condition: policy.authenticated()` to mean \"signed in\" — it " +
240
+ `compiles to NOT IN (${ANONYMOUS_USER_IDS.map(v => `'${v}'`).join(", ")}), covering ` +
241
+ "every spelling rebase has reported rather than whichever one you remember."
235
242
  });
236
243
  return;
237
244
  }
@@ -0,0 +1,31 @@
1
+ import type { StringProperty } from "@rebasepro/types";
2
+
3
+ /**
4
+ * The length a bounded string column is declared with when the property does
5
+ * not say. Historical: it is what the DDL generator hardcoded, kept so that
6
+ * regenerating an existing schema does not silently redefine its columns.
7
+ */
8
+ export const DEFAULT_STRING_COLUMN_LENGTH = 255;
9
+
10
+ /**
11
+ * How wide a `varchar`/`char` column should be for a given property.
12
+ *
13
+ * One definition, three call sites, because they used to disagree. For the same
14
+ * `columnType: "varchar"` property the DDL generator emitted `VARCHAR(255)`
15
+ * while the Drizzle generator emitted a bare `varchar("col")` — which Postgres
16
+ * reads as *unbounded* — so which of the two you ran decided whether the column
17
+ * had a limit at all. Introspection then dropped the length entirely, so reading
18
+ * an existing `character varying(500)` column back and regenerating it produced
19
+ * a `VARCHAR(255)`: a silent narrowing of a column with data already in it.
20
+ *
21
+ * `validation.max` is the property's own statement about how long the value may
22
+ * be, so it is the only sensible source for the column's width — and it keeps
23
+ * the constraint the database enforces in step with the one the app enforces,
24
+ * rather than inventing a second, different limit underneath it.
25
+ */
26
+ export function resolveStringColumnLength(prop: Pick<StringProperty, "validation">): number {
27
+ const max = prop.validation?.max;
28
+ return typeof max === "number" && Number.isInteger(max) && max > 0
29
+ ? max
30
+ : DEFAULT_STRING_COLUMN_LENGTH;
31
+ }