@rebasepro/common 0.17.3 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +4 -0
  2. package/dist/collections/CollectionRegistry.d.ts +1 -1
  3. package/dist/collections/default-collections.d.ts +15 -84
  4. package/dist/data/buildRebaseData.d.ts +1 -1
  5. package/dist/data/filter-dialect.d.ts +11 -0
  6. package/dist/data/sort-dialect.d.ts +15 -3
  7. package/dist/index.es.js +375 -63
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/util/builders.d.ts +69 -24
  10. package/dist/util/callback-errors.d.ts +77 -0
  11. package/dist/util/callback-errors.test.d.ts +1 -0
  12. package/dist/util/index.d.ts +1 -0
  13. package/dist/util/policy/evaluatePolicy.d.ts +6 -0
  14. package/dist/util/relations.d.ts +41 -0
  15. package/dist/util/table-name.test.d.ts +1 -0
  16. package/package.json +26 -22
  17. package/src/collections/CollectionRegistry.ts +0 -485
  18. package/src/collections/default-collections.ts +0 -109
  19. package/src/collections/index.ts +0 -2
  20. package/src/data/buildRebaseData.ts +0 -816
  21. package/src/data/buildRoutedRebaseData.ts +0 -103
  22. package/src/data/filter-conditions.ts +0 -46
  23. package/src/data/filter-dialect.ts +0 -737
  24. package/src/data/paginate.ts +0 -334
  25. package/src/data/query_builder.ts +0 -176
  26. package/src/data/resolveDataSource.ts +0 -135
  27. package/src/data/sort-dialect.ts +0 -237
  28. package/src/index.ts +0 -11
  29. package/src/table-classification.ts +0 -109
  30. package/src/types/json-logic-js.d.ts +0 -8
  31. package/src/util/auth-default-policies.ts +0 -215
  32. package/src/util/builders.ts +0 -82
  33. package/src/util/callbacks.ts +0 -122
  34. package/src/util/collections.ts +0 -117
  35. package/src/util/common.ts +0 -2
  36. package/src/util/conditions.ts +0 -168
  37. package/src/util/email.ts +0 -32
  38. package/src/util/entities.ts +0 -282
  39. package/src/util/enums.ts +0 -26
  40. package/src/util/identity.ts +0 -202
  41. package/src/util/index.ts +0 -21
  42. package/src/util/internal-tables.test.ts +0 -188
  43. package/src/util/internal-tables.ts +0 -197
  44. package/src/util/junction-policies.ts +0 -355
  45. package/src/util/paths.ts +0 -27
  46. package/src/util/permissions.test.ts +0 -866
  47. package/src/util/permissions.ts +0 -206
  48. package/src/util/pg-column-to-property.ts +0 -377
  49. package/src/util/policy/evaluatePolicy.ts +0 -194
  50. package/src/util/policy/index.ts +0 -4
  51. package/src/util/policy/policyToPostgres.ts +0 -263
  52. package/src/util/policy/securityRuleToConditions.ts +0 -67
  53. package/src/util/policy/sqlToPolicy.ts +0 -422
  54. package/src/util/relations.ts +0 -236
  55. package/src/util/resolutions.ts +0 -534
  56. package/src/util/resolve-relation.ts +0 -243
  57. package/src/util/storage.ts +0 -177
  58. package/src/util/string-column-length.ts +0 -31
@@ -1,237 +0,0 @@
1
- import type { OrderBySortTuple, OrderBySpec, OrderByTuple } from "@rebasepro/types";
2
- import { isRelationAggregateSort, sortKeyToString } from "@rebasepro/types";
3
-
4
- /**
5
- * Sort-order wire codec.
6
- *
7
- * This is the ONLY module that knows about the colon-delimited wire format
8
- * (`"field:direction"`) used in HTTP query parameters, and about the JSON-array
9
- * form that carries a multi-column sort over the same parameter.
10
- * Everything else speaks {@link OrderByTuple} exclusively.
11
- *
12
- * Mirrors the filter architecture in `filter-dialect.ts`.
13
- *
14
- * @module
15
- */
16
-
17
- /**
18
- * Collapse the one-key and many-key spellings of a sort into the list form.
19
- *
20
- * `["a", "desc"]` and `[["a", "desc"]]` mean the same thing and normalize to
21
- * the same value; the two are told apart by whether the first element is
22
- * itself an array, which no field name ever is.
23
- *
24
- * This is also where a {@link RelationAggregateSort} object stops being an
25
- * object. Above this function a sort key may be either spelling; below it,
26
- * every key is a string — which is what `OrderByTuple`, the REST parameter, the
27
- * driver contract and the cursor all already were. Doing it here means the one
28
- * place that already collapses the two *shapes* of a sort also collapses the
29
- * two *spellings* of a key, rather than every consumer learning about both.
30
- *
31
- * @returns The keys in order of significance, or `undefined` for no sort. An
32
- * empty list also returns `undefined` — "sort by nothing" is no sort, and
33
- * letting `[]` through would have every layer below re-deciding what it meant.
34
- */
35
- export function normalizeOrderBy(orderBy?: OrderBySpec): OrderByTuple[] | undefined {
36
- if (!orderBy || orderBy.length === 0) return undefined;
37
- // An aggregate key is an object, so the first element being an array still
38
- // tells the list form from the single-tuple one — no field name is an
39
- // array, and neither is an aggregate key.
40
- const list = Array.isArray(orderBy[0])
41
- ? orderBy as OrderBySortTuple[]
42
- : [orderBy as OrderBySortTuple];
43
- if (list.length === 0) return undefined;
44
- return list.map(([key, direction]) => [sortKeyToString(key), direction] as OrderByTuple);
45
- }
46
-
47
- /**
48
- * The most significant sort key, for a caller that can only express one —
49
- * a column header's arrow, a URL parameter, a driver that has not been taught
50
- * the list form.
51
- */
52
- export function primaryOrderBy(orderBy?: OrderBySpec): OrderByTuple | undefined {
53
- return normalizeOrderBy(orderBy)?.[0];
54
- }
55
-
56
- /**
57
- * Collapse the driver-level `{orderBy, order}` pair into the list form.
58
- *
59
- * The driver contract spells a single-column sort as a field name plus a
60
- * separate direction, and a multi-column one as a list of tuples that leaves
61
- * `order` meaningless. Every driver reads both through here so neither
62
- * spelling has to be handled twice.
63
- *
64
- * An absent direction means ascending — the same thing a bare `?orderBy=name`
65
- * has always meant over HTTP. The Postgres driver used to read the same pair as
66
- * *descending* while Mongo read it as ascending, so one field name and no
67
- * direction described two different queries depending on which database was
68
- * underneath. Neither had a caller: every path in the workspace passes a
69
- * direction, which is why the disagreement went unnoticed rather than being
70
- * load-bearing.
71
- */
72
- export function normalizeDriverOrderBy(
73
- orderBy?: string | OrderByTuple[],
74
- order?: "asc" | "desc"
75
- ): OrderByTuple[] | undefined {
76
- if (!orderBy) return undefined;
77
- if (typeof orderBy === "string") return [[orderBy, order === "desc" ? "desc" : "asc"]];
78
- return orderBy.length > 0 ? orderBy : undefined;
79
- }
80
-
81
- /** A sort whose *shape* is unusable, as opposed to one naming a field that does not exist. */
82
- export class OrderBySpecError extends Error {
83
- readonly code = "INVALID_ORDER_BY";
84
- constructor(detail: string) {
85
- super(
86
- `Invalid \`orderBy\`: ${detail}. Expected a field name, or a list of ` +
87
- "[field, direction] pairs like [[\"roles\",\"asc\"],[\"created_at\",\"desc\"]]"
88
- );
89
- this.name = "OrderBySpecError";
90
- }
91
- }
92
-
93
- /**
94
- * Validate an `orderBy` that arrived from outside this process — a WebSocket
95
- * subscribe frame, a driver call from untyped JavaScript — and return it in the
96
- * list form.
97
- *
98
- * Strict on purpose, in the same way the REST `parseOrderByParam` is: the
99
- * failure mode for a shape nobody checks is not a crash but a *silently
100
- * different query*. A malformed entry read as a field name resolves to no
101
- * column, and under the lenient unknown-field mode the sort is then dropped and
102
- * the rows come back in whatever order the database pleased — sorted, as far as
103
- * the subscriber can tell, by whatever they asked for.
104
- */
105
- export function parseOrderBySpecStrict(raw: unknown, order?: "asc" | "desc"): OrderByTuple[] | undefined {
106
- if (raw === undefined || raw === null || raw === "") return undefined;
107
- // The string spelling is the driver contract's, so it takes its direction
108
- // from the same companion `order` — and defaults the same way it does.
109
- if (typeof raw === "string") return normalizeDriverOrderBy(raw, order);
110
- if (!Array.isArray(raw) || raw.length === 0) {
111
- throw new OrderBySpecError(`${typeof raw} is not a field name or a list of sort keys`);
112
- }
113
-
114
- // The single-tuple spelling, `["created_at", "desc"]` — or the same shape
115
- // with an aggregate key in place of the field name.
116
- if (typeof raw[0] === "string" || isRelationAggregateSort(raw[0])) return [toStrictTuple(raw, 0)];
117
-
118
- return raw.map(toStrictTuple);
119
- }
120
-
121
- function toStrictTuple(raw: unknown, index: number): OrderByTuple {
122
- if (!Array.isArray(raw)) {
123
- throw new OrderBySpecError(`entry ${index} has no field name`);
124
- }
125
- // The object spelling of an aggregate key, from an untyped caller that did
126
- // not go through `normalizeOrderBy`. Encoded rather than refused: it is a
127
- // sort this understands, and rejecting the shape a typed caller writes
128
- // would be a distinction between the two spellings that nothing else makes.
129
- const key = isRelationAggregateSort(raw[0]) ? sortKeyToString(raw[0]) : raw[0];
130
- if (typeof key !== "string" || key.trim() === "") {
131
- throw new OrderBySpecError(`entry ${index} has no field name`);
132
- }
133
- const direction = raw[1];
134
- if (direction !== undefined && direction !== "asc" && direction !== "desc") {
135
- throw new OrderBySpecError(`entry ${index} has direction '${String(direction)}'`);
136
- }
137
- return [key, direction ?? "asc"];
138
- }
139
-
140
- /**
141
- * Serialize a sort to the wire.
142
- *
143
- * A single key keeps the `"field:direction"` shorthand it has always used —
144
- * short, readable in a URL, and what every existing client and test expects.
145
- * Several keys are emitted as the canonical JSON array the server already
146
- * accepts, because the shorthand has no separator to spare: a comma-joined
147
- * `"a:asc,b:desc"` parses as one field named `a` with the direction
148
- * `"asc,b:desc"`, which the server refuses.
149
- *
150
- * **Runtime tolerance:** if the input is already a well-formed wire string
151
- * (from an untyped JS caller), it is returned unchanged.
152
- * This is undocumented tolerance, not public API — don't rely on it.
153
- *
154
- * @param orderBy - A canonical tuple or list of tuples, or at runtime
155
- * possibly a pre-serialized string (undocumented tolerance).
156
- * @returns The wire-format string, or `undefined` if the input is falsy.
157
- *
158
- * @remarks
159
- * Field names containing `:` are representable in the tuple form but
160
- * **not** in the single-key wire encoding — this is an inherent limitation of
161
- * the colon-delimited shorthand and is not resolved here.
162
- */
163
- export function serializeOrderBy(orderBy?: OrderBySpec | string): string | undefined {
164
- if (!orderBy) return undefined;
165
- // Runtime tolerance: pass through a pre-serialized wire string unchanged.
166
- if (typeof orderBy === "string") return orderBy;
167
- // `normalizeOrderBy` has already encoded any aggregate key to its string
168
- // spelling, which is why the shorthand below can assume a string: neither
169
- // `min(applications.created_at)` nor `count(applications)` contains a `:`.
170
- const list = normalizeOrderBy(orderBy);
171
- if (!list) return undefined;
172
- if (list.length === 1) return `${list[0][0]}:${list[0][1]}`;
173
- return JSON.stringify(list.map(([field, direction]) => ({ field,
174
- direction })));
175
- }
176
-
177
- /**
178
- * Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
179
- *
180
- * Lenient parsing (matches existing server behaviour):
181
- * - Bare field name (no colon): `"name"` → `["name", "asc"]`
182
- * - Unknown direction: `"name:foo"` → `["name", "asc"]`
183
- * - Empty / falsy input: → `undefined`
184
- *
185
- * Reads the single-key shorthand only. For a value that may carry several keys,
186
- * use {@link deserializeOrderByList} — handed a JSON array this returns the
187
- * whole array as one nonsensical field name.
188
- *
189
- * @param raw - The wire-format string from an HTTP query parameter.
190
- * @returns The canonical tuple, or `undefined` if the input is empty/falsy.
191
- */
192
- export function deserializeOrderBy(raw?: string): OrderByTuple | undefined {
193
- if (!raw) return undefined;
194
- const idx = raw.indexOf(":");
195
- if (idx === -1) return [raw, "asc"];
196
- const field = raw.slice(0, idx);
197
- const dir = raw.slice(idx + 1);
198
- return [field, dir === "desc" ? "desc" : "asc"];
199
- }
200
-
201
- /**
202
- * Deserialize either wire spelling — the single-key shorthand or the JSON
203
- * array — into the list form.
204
- *
205
- * Lenient in the same way {@link deserializeOrderBy} is: this is the client end
206
- * of the codec, where the value was produced by {@link serializeOrderBy} a
207
- * moment earlier. The *server* end parses the same shapes strictly, in
208
- * `parseOrderByParam`, because there the value came from a stranger and a
209
- * direction it cannot read has to be refused rather than quietly turned into
210
- * `"asc"`.
211
- */
212
- export function deserializeOrderByList(raw?: string): OrderByTuple[] | undefined {
213
- if (!raw) return undefined;
214
- const trimmed = raw.trim();
215
- if (trimmed.startsWith("[")) {
216
- try {
217
- const parsed = JSON.parse(trimmed);
218
- if (Array.isArray(parsed)) {
219
- const list = parsed
220
- .map((entry): OrderByTuple | undefined => {
221
- if (typeof entry === "string") return deserializeOrderBy(entry);
222
- if (entry && typeof entry === "object" && typeof entry.field === "string") {
223
- return [entry.field, entry.direction === "desc" ? "desc" : "asc"];
224
- }
225
- return undefined;
226
- })
227
- .filter((entry): entry is OrderByTuple => entry !== undefined);
228
- return list.length > 0 ? list : undefined;
229
- }
230
- } catch {
231
- // Not JSON after all — fall through to the shorthand, which is what
232
- // a field name that merely begins with "[" would be.
233
- }
234
- }
235
- const single = deserializeOrderBy(trimmed);
236
- return single ? [single] : undefined;
237
- }
package/src/index.ts DELETED
@@ -1,11 +0,0 @@
1
- export * from "./util";
2
- export * from "./collections";
3
- export * from "./data/buildRebaseData";
4
- export * from "./data/buildRoutedRebaseData";
5
- export * from "./data/resolveDataSource";
6
- export * from "./data/query_builder";
7
- export * from "./data/paginate";
8
- export * from "./data/filter-conditions";
9
- export * from "./data/filter-dialect";
10
- export * from "./data/sort-dialect";
11
- export * from "./table-classification";
@@ -1,109 +0,0 @@
1
- /**
2
- * Table Classification
3
- *
4
- * Shared constants and pure functions for classifying database tables.
5
- * Used by both the server-side PostgresBackendDriver and the Studio RLS editor.
6
- */
7
-
8
- /** Possible categories a database table can belong to. */
9
- export type TableCategory = "rebase-internal" | "junction" | "user";
10
-
11
- /** Schemas that are always considered Rebase-internal. */
12
- export const REBASE_INTERNAL_SCHEMAS: readonly string[] = ["rebase", "auth"];
13
-
14
- /** Table-name prefixes that mark a table as Rebase-internal regardless of schema. */
15
- export const REBASE_INTERNAL_PREFIXES: readonly string[] = [
16
- "_rebase_",
17
- "_auth_",
18
- "drizzle_",
19
- ];
20
-
21
- /**
22
- * Synchronously classify a table based on naming conventions.
23
- *
24
- * @param tableName - The unqualified name of the table.
25
- * @param schemaName - The schema the table belongs to (e.g. `"public"`, `"rebase"`).
26
- * @returns `"rebase-internal"` when the table belongs to a reserved schema or
27
- * carries a reserved prefix; `"user"` otherwise.
28
- *
29
- * @remarks
30
- * Junction-table detection requires an async database query and is therefore
31
- * **not** handled by this function. Use {@link detectJunctionTables} to obtain
32
- * the set of junction tables, then reclassify as needed.
33
- */
34
- export function classifyTable(
35
- tableName: string,
36
- schemaName: string,
37
- ): TableCategory {
38
- if (
39
- REBASE_INTERNAL_SCHEMAS.includes(schemaName) ||
40
- REBASE_INTERNAL_PREFIXES.some((prefix) => tableName.startsWith(prefix))
41
- ) {
42
- return "rebase-internal";
43
- }
44
-
45
- return "user";
46
- }
47
-
48
- /**
49
- * Convenience predicate that checks whether a table is Rebase-internal.
50
- *
51
- * @param tableName - The unqualified name of the table.
52
- * @param schemaName - The schema the table belongs to.
53
- * @returns `true` if the table is classified as `"rebase-internal"`.
54
- */
55
- export function isRebaseInternalTable(
56
- tableName: string,
57
- schemaName: string,
58
- ): boolean {
59
- return classifyTable(tableName, schemaName) === "rebase-internal";
60
- }
61
-
62
- /** SQL query that detects junction tables in the `public` schema. */
63
- export const JUNCTION_TABLES_SQL = `
64
- SELECT t.table_name
65
- FROM information_schema.tables t
66
- WHERE t.table_schema = 'public'
67
- AND t.table_type = 'BASE TABLE'
68
- AND NOT EXISTS (
69
- SELECT 1
70
- FROM information_schema.columns c
71
- WHERE c.table_schema = t.table_schema
72
- AND c.table_name = t.table_name
73
- AND c.column_name NOT IN (
74
- SELECT kcu.column_name
75
- FROM information_schema.key_column_usage kcu
76
- JOIN information_schema.table_constraints tc
77
- ON tc.constraint_name = kcu.constraint_name
78
- AND tc.table_schema = kcu.table_schema
79
- WHERE tc.constraint_type = 'FOREIGN KEY'
80
- AND kcu.table_schema = t.table_schema
81
- AND kcu.table_name = t.table_name
82
- )
83
- )
84
- `;
85
-
86
- /**
87
- * Asynchronously detect junction (link) tables in the `public` schema.
88
- *
89
- * A junction table is defined as a table where **every** column participates in
90
- * at least one foreign-key constraint.
91
- *
92
- * @param executeSql - A callback that executes a raw SQL string and returns the
93
- * resulting rows.
94
- * @returns A `Set` containing the names of all detected junction tables.
95
- */
96
- export async function detectJunctionTables(
97
- executeSql: (sql: string) => Promise<Record<string, unknown>[]>,
98
- ): Promise<Set<string>> {
99
- const rows = await executeSql(JUNCTION_TABLES_SQL);
100
- const junctionTables = new Set<string>();
101
-
102
- for (const row of rows) {
103
- if (typeof row.table_name === "string") {
104
- junctionTables.add(row.table_name);
105
- }
106
- }
107
-
108
- return junctionTables;
109
- }
@@ -1,8 +0,0 @@
1
- declare module "json-logic-js" {
2
- interface JsonLogic {
3
- apply(logic: unknown, data?: unknown): unknown;
4
- add_operation<T extends unknown[]>(name: string, fn: (...args: T) => unknown): void;
5
- }
6
- const jsonLogic: JsonLogic;
7
- export default jsonLogic;
8
- }
@@ -1,215 +0,0 @@
1
- import { CollectionConfig, SecurityRule, SecurityOperation, AuthCollectionConfig, PolicyExpression, isPostgresCollectionConfig, policy } from "@rebasepro/types";
2
- import { getTableName } from "./relations";
3
- import { getPolicyNamesForRules } from "@rebasepro/utils";
4
-
5
- /**
6
- * Default RLS policies injected by the schema generator.
7
- *
8
- * Rebase's enforcement model is unified: authenticated (user-context) requests
9
- * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
10
- * statement — reads and writes. A collection's `securityRules` are the whole
11
- * authorization model. The server context (auth flows, migrations, raw
12
- * `rebase.sql`) runs as the owner and bypasses RLS.
13
- *
14
- * `rebase.dataAsAdmin` is **not** in that set, despite the name: it is scoped as
15
- * `{ uid: "service", roles: ["admin"] }`, so it runs as `rebase_user` like any
16
- * other caller and clears the baseline below through the *admin* arm, not the
17
- * server arm. Which is why `disableDefaultPolicies` plus a lone
18
- * `policy.serverContext()` rule locks it out too.
19
- *
20
- * Because RLS default-denies, every collection is **locked by default**: with
21
- * no rules, only the server context and admins can touch it. The generator
22
- * injects that safe baseline:
23
- *
24
- * **For every collection**
25
- * 1. A permissive **server-or-admin SELECT** grant.
26
- * 2. A permissive **server-or-admin write** grant (insert/update/delete).
27
- *
28
- * Author `securityRules` are permissive and OR together, so explicit rules only
29
- * *broaden* access from this locked baseline (e.g. "users read/write their own
30
- * rows").
31
- *
32
- * **For auth collections additionally**
33
- * 3. A permissive **self SELECT** grant (`id = rebase.uid()`), so users can read
34
- * their own row (profile, session bootstrap) without every app re-declaring
35
- * it.
36
- * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
37
- * every other policy, so a write is rejected unless the caller is an admin
38
- * (or the server context) — even if the author also wrote a permissive rule
39
- * such as "a user may edit their own row". Without this, a permissive owner
40
- * rule would let a user change their own `roles`.
41
- *
42
- * The server context is recognised as `rebase.uid() IS NULL` (`policy.serverContext()`)
43
- * — the built-in flows that run without a user (signup, migrations) set no user
44
- * GUC — which also lets the owner connection satisfy these policies even under
45
- * FORCE RLS. A *user* request never reaches that state: an anonymous one carries
46
- * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
47
- *
48
- * Opt out with `disableDefaultPolicies: true` to take full responsibility for
49
- * the collection's RLS.
50
- */
51
- // Expressed structurally (not as raw SQL) so the admin UI can evaluate it
52
- // exactly — the framework's most security-critical policies must be reflected
53
- // precisely, not left as un-evaluable raw clauses. Compiles to
54
- // `rebase.uid() IS NULL OR (string_to_array(rebase.roles(), ',') && ARRAY['admin'])`.
55
- //
56
- // `serverContext()`, emphatically not `not(authenticated())`: the server arm of
57
- // this grant must match the server context and nothing else. Anonymous visitors
58
- // are not signed in either, so a negated `authenticated()` would hand them the
59
- // server-or-admin grant on every collection's default policy.
60
- const SERVER_OR_ADMIN_EXPR: PolicyExpression = policy.or(
61
- policy.serverContext(),
62
- policy.rolesOverlap(["admin"])
63
- );
64
-
65
- /** Write operations that must be admin-gated by default on auth collections. */
66
- const DEFAULT_GUARDED_OPS: SecurityOperation[] = ["insert", "update", "delete"];
67
-
68
- /** Whether a collection is flagged as an authentication collection. */
69
- function isAuthCollection(collection: CollectionConfig): boolean {
70
- const auth = collection.auth;
71
- return auth === true || (typeof auth === "object" && (auth as AuthCollectionConfig)?.enabled === true);
72
- }
73
-
74
- /** The property marked as the row id (falls back to `id`). */
75
- function getIdPropertyName(collection: CollectionConfig): string {
76
- for (const [name, prop] of Object.entries(collection.properties ?? {})) {
77
- if (prop && typeof prop === "object" && "isId" in prop && (prop as { isId?: unknown }).isId) {
78
- return name;
79
- }
80
- }
81
- return "id";
82
- }
83
-
84
- /**
85
- * Returns the security rules that should be applied to a collection: the
86
- * author's explicit `securityRules` plus the framework defaults described in
87
- * the module doc (baseline server/admin read for all collections; self-read
88
- * and the admin write gate for auth collections).
89
- *
90
- * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
91
- */
92
- /**
93
- * The restrictive write gate for an auth collection.
94
- *
95
- * Restrictive, so it is ANDed with everything else: whatever an author's
96
- * permissive rules allow, a write to this table still has to satisfy this too.
97
- * It is the only thing standing between "users may edit their own row" and
98
- * "users may grant themselves any role".
99
- */
100
- function adminWriteGate(tableName: string): SecurityRule {
101
- return {
102
- name: `${tableName}_require_admin_write`,
103
- mode: "restrictive",
104
- operations: [...DEFAULT_GUARDED_OPS],
105
- condition: SERVER_OR_ADMIN_EXPR,
106
- check: SERVER_OR_ADMIN_EXPR
107
- };
108
- }
109
-
110
- export function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[] {
111
- const explicit = [...(collection.securityRules ?? [])];
112
-
113
- const tableName = getTableName(collection);
114
- const injected: SecurityRule[] = [];
115
-
116
- if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) {
117
- // The opt-out drops the *permissive* defaults — the ones that grant.
118
- // The restrictive admin-write gate on an auth collection is not among
119
- // them, because it is different in kind: a restrictive policy is ANDed
120
- // with every other policy and can only ever remove access, so opting
121
- // out of it cannot express anything except "let more people write".
122
- //
123
- // Dropping it did exactly that. `{ disableDefaultPolicies: true,
124
- // securityRules: [{ operation: "all", ownerField: "id" }] }` — an
125
- // ordinary "users may edit their own row" configuration — let any
126
- // signed-in user set their own `roles` to `["admin"]`, with no warning
127
- // from any boot guard, doctor check or validator.
128
- //
129
- // An author who needs a different gate can add their own restrictive
130
- // rule; they cannot end up with none by accident.
131
- return isAuthCollection(collection)
132
- ? [...explicit, adminWriteGate(tableName)]
133
- : explicit;
134
- }
135
-
136
- // Baseline read + write: the server context and admins can always operate.
137
- // RLS default-denies under the user role, so without these a rule-less
138
- // collection would be locked to everyone — including the admin studio.
139
- // Author rules are permissive and broaden access from here.
140
- injected.push({
141
- name: `${tableName}_default_admin_read`,
142
- operations: ["select"],
143
- condition: SERVER_OR_ADMIN_EXPR
144
- });
145
- injected.push({
146
- name: `${tableName}_default_admin_write`,
147
- operations: [...DEFAULT_GUARDED_OPS],
148
- condition: SERVER_OR_ADMIN_EXPR,
149
- check: SERVER_OR_ADMIN_EXPR
150
- });
151
-
152
- if (isAuthCollection(collection)) {
153
- // Self-read: a user can always read their own row.
154
- injected.push({
155
- name: `${tableName}_default_self_read`,
156
- operations: ["select"],
157
- condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
158
- });
159
-
160
- // Restrictive gate: AND'd with all other policies, so no permissive rule
161
- // (e.g. an owner "edit your own row" rule) can let a non-admin change
162
- // privileged columns like `roles`. Survives `disableDefaultPolicies` —
163
- // see the note above the opt-out.
164
- injected.push(adminWriteGate(tableName));
165
- }
166
-
167
- return [...explicit, ...injected];
168
- }
169
-
170
- /**
171
- * The framework defaults that {@link getEffectiveSecurityRules} would add to a
172
- * collection, without the author's own rules.
173
- *
174
- * These policies appear in the database under names the author never wrote, and
175
- * a permissive policy ORs with every other permissive policy — so someone
176
- * reading their `securityRules` and then the real ACL sees more access than they
177
- * declared. Dropping them by hand does nothing either: `db push` is declarative,
178
- * so the next push asserts them again. Callers use this to say, in the generated
179
- * DDL, which policies are injected and how to take them off.
180
- */
181
- export function getInjectedSecurityRules(collection: CollectionConfig): SecurityRule[] {
182
- if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) {
183
- // Not empty for an auth collection: the restrictive write gate is still
184
- // injected, and the generated DDL has to say so — a policy in the
185
- // database that the author never wrote and cannot find in this list is
186
- // exactly the surprise this function exists to prevent.
187
- return isAuthCollection(collection) ? [adminWriteGate(getTableName(collection))] : [];
188
- }
189
-
190
- const explicitCount = (collection.securityRules ?? []).length;
191
- // getEffectiveSecurityRules appends the defaults after the author's rules,
192
- // so everything past the author's count is injected.
193
- return getEffectiveSecurityRules(collection).slice(explicitCount);
194
- }
195
-
196
- /**
197
- * Every policy name `rebase db push` would write for a collection.
198
- *
199
- * This is the answer to "did the codebase produce this live policy?", and it is
200
- * more than `securityRules.map(r => r.name)` for two reasons:
201
- *
202
- * - a rule without an explicit `name` compiles to `<table>_<op>_<hash>`, one
203
- * per operation, so comparing `rule.name` to `policyname` never matches it;
204
- * - the generator also injects the safe-by-default baseline
205
- * (`<table>_default_admin_*`), which is in no collection's `securityRules`.
206
- *
207
- * Every UI that flags drift has to get both right, and each one that derived it
208
- * by hand got a different subset — which is how four policies *Rebase itself
209
- * wrote* came to be badged as hand-written drift on every table in a project,
210
- * with a button offering to import them back into the codebase that produced
211
- * them. There is one derivation now, and this is it.
212
- */
213
- export function getGeneratedPolicyNames(collection: CollectionConfig): Set<string> {
214
- return getPolicyNamesForRules(getEffectiveSecurityRules(collection), getTableName(collection));
215
- }
@@ -1,82 +0,0 @@
1
- import {
2
- CollectionConfig,
3
- FirebaseCollectionConfig,
4
- FirebaseProperties,
5
- InferEntityType,
6
- MongoDBCollectionConfig,
7
- MongoProperties,
8
- PostgresCollectionConfig,
9
- PostgresProperties,
10
- User
11
- } from "@rebasepro/types";
12
-
13
-
14
- // ── defineCollection ─────────────────────────────────────────────────────
15
- // A smarter builder that uses `const` type-parameter inference (TS 5.0+)
16
- // to capture literal property types automatically. This gives you
17
- // autocomplete on `display.title`, `sort`, `propertiesOrder`, `fixedFilter`,
18
- // callbacks, etc. — without writing `as const` or passing manual generics.
19
-
20
- /**
21
- * Define a PostgreSQL-backed collection with full type inference.
22
- *
23
- * The `const P` generic captures literal property types from your
24
- * `properties` object, which enables autocomplete on `display.title`,
25
- * `sort`, `propertiesOrder`, `fixedFilter`, and entity callbacks.
26
- *
27
- * @example
28
- * ```ts
29
- * const products = defineCollection({
30
- * name: "Products",
31
- * slug: "products",
32
- * table: "products",
33
- * properties: {
34
- * name: { name: "Name", type: "string", validation: { required: true } },
35
- * price: { name: "Price", type: "number" },
36
- * },
37
- * display: { title: "name" }, // ✅ autocomplete: "name" | "price"
38
- * sort: ["price", "asc"], // ✅ autocomplete on first element
39
- * });
40
- * ```
41
- *
42
- * @group Builder
43
- */
44
- export function defineCollection<
45
- const P extends PostgresProperties,
46
- USER extends User = User
47
- >(
48
- collection: Omit<PostgresCollectionConfig<InferEntityType<P>, USER>, "properties"> & { properties: P }
49
- ): PostgresCollectionConfig<InferEntityType<P>, USER> & { properties: P };
50
-
51
- /**
52
- * Define a Firestore-backed collection with full type inference.
53
- * @group Builder
54
- */
55
- export function defineCollection<
56
- const P extends FirebaseProperties,
57
- USER extends User = User
58
- >(
59
- collection: Omit<FirebaseCollectionConfig<InferEntityType<P>, USER>, "properties"> & { properties: P }
60
- ): FirebaseCollectionConfig<InferEntityType<P>, USER> & { properties: P };
61
-
62
- /**
63
- * Define a MongoDB-backed collection with full type inference.
64
- * @group Builder
65
- */
66
- export function defineCollection<
67
- const P extends MongoProperties,
68
- USER extends User = User
69
- >(
70
- collection: Omit<MongoDBCollectionConfig<InferEntityType<P>, USER>, "properties"> & { properties: P }
71
- ): MongoDBCollectionConfig<InferEntityType<P>, USER> & { properties: P };
72
-
73
- /**
74
- * Implementation — delegates to the correct overload at the type level.
75
- * At runtime this is a plain identity function.
76
- */
77
- export function defineCollection(
78
- collection: CollectionConfig
79
- ): CollectionConfig {
80
- return collection;
81
- }
82
-