@rebasepro/server-postgres 0.9.1-canary.1d2d8b5 → 0.9.1-canary.58368ce
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 +25 -2
- package/dist/collections/buildRegistry.d.ts +27 -0
- package/dist/data-transformer.d.ts +9 -2
- package/dist/index.es.js +942 -373
- package/dist/index.es.js.map +1 -1
- package/dist/services/FetchService.d.ts +21 -7
- package/dist/services/PersistService.d.ts +9 -1
- package/dist/services/RelationService.d.ts +0 -1
- package/dist/services/collection-helpers.d.ts +52 -14
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +7 -0
- package/package.json +6 -7
- package/src/PostgresBackendDriver.ts +109 -8
- package/src/PostgresBootstrapper.ts +11 -23
- package/src/collections/buildRegistry.ts +59 -0
- package/src/data-transformer.ts +11 -9
- package/src/schema/generate-drizzle-schema-logic.ts +24 -29
- package/src/schema/generate-postgres-ddl-logic.ts +76 -28
- package/src/services/BranchService.ts +42 -10
- package/src/services/FetchService.ts +84 -155
- package/src/services/PersistService.ts +53 -7
- package/src/services/RelationService.ts +10 -11
- package/src/services/collection-helpers.ts +129 -44
- package/src/services/dataService.ts +3 -2
- package/src/services/index.ts +1 -0
- package/src/services/realtimeService.ts +38 -19
- package/src/websocket.ts +4 -1
- package/dist/schema/auth-default-policies.d.ts +0 -10
- package/src/schema/auth-default-policies.ts +0 -132
|
@@ -2,6 +2,15 @@ import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
|
2
2
|
import { CollectionConfig, Property } from "@rebasepro/types";
|
|
3
3
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
4
4
|
import { getTableName } from "@rebasepro/common";
|
|
5
|
+
import { logger } from "@rebasepro/server";
|
|
6
|
+
|
|
7
|
+
// Row identity is derived on both sides of the wire — the driver parses an
|
|
8
|
+
// incoming address into key columns, the admin derives one from a served row —
|
|
9
|
+
// so the implementation lives in `common` and both agree by construction.
|
|
10
|
+
export { buildCompositeId, parseIdValues, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
|
|
11
|
+
export type { PrimaryKeyInfo } from "@rebasepro/common";
|
|
12
|
+
import { buildCompositeId, COMPOSITE_ID_SEPARATOR, getDeclaredPrimaryKeys } from "@rebasepro/common";
|
|
13
|
+
import type { PrimaryKeyInfo } from "@rebasepro/common";
|
|
5
14
|
|
|
6
15
|
/**
|
|
7
16
|
* Shared helper functions for row operations.
|
|
@@ -49,7 +58,7 @@ export function getTableForCollection(collection: CollectionConfig, registry: Po
|
|
|
49
58
|
return table;
|
|
50
59
|
}
|
|
51
60
|
|
|
52
|
-
export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry):
|
|
61
|
+
export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): PrimaryKeyInfo[] {
|
|
53
62
|
const table = getTableForCollection(collection, registry);
|
|
54
63
|
|
|
55
64
|
// Fallback to explicitly defined isId properties
|
|
@@ -68,7 +77,7 @@ export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresC
|
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
// Otherwise infer from Drizzle schema
|
|
71
|
-
const keys:
|
|
80
|
+
const keys: PrimaryKeyInfo[] = [];
|
|
72
81
|
for (const [key, colRaw] of Object.entries(table)) {
|
|
73
82
|
const col = colRaw as AnyPgColumn;
|
|
74
83
|
if (col && typeof col === "object" && "primary" in col && col.primary) {
|
|
@@ -96,56 +105,132 @@ isUUID });
|
|
|
96
105
|
return keys;
|
|
97
106
|
}
|
|
98
107
|
|
|
99
|
-
|
|
100
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Collections whose key the *browser* cannot resolve, and what it will do
|
|
110
|
+
* instead.
|
|
111
|
+
*
|
|
112
|
+
* The two sides resolve keys from different evidence. This driver reads, in
|
|
113
|
+
* order: properties marked `isId`, the primary keys of the Drizzle schema, then
|
|
114
|
+
* a column literally named `id`. The admin shares the `CollectionConfig` — it
|
|
115
|
+
* compiles the same collection files into its bundle — but never the Drizzle
|
|
116
|
+
* schema, so the middle tier is invisible to it.
|
|
117
|
+
*
|
|
118
|
+
* Nothing can normalize this at runtime: the server does not serve the admin
|
|
119
|
+
* its collections, so a key resolved here cannot be handed over there. The
|
|
120
|
+
* config files are the only thing both sides read, so the fix is an edit to
|
|
121
|
+
* them, and the most this can do is say exactly which edit.
|
|
122
|
+
*
|
|
123
|
+
* Two shapes, and the second is the dangerous one:
|
|
124
|
+
*
|
|
125
|
+
* - No `isId`, no `id` property → the admin resolves no address, warns in the
|
|
126
|
+
* console, and rows cannot be opened or linked.
|
|
127
|
+
* - No `isId`, but an `id` property that is *not* the key → the admin addresses
|
|
128
|
+
* rows by `id` while this driver reads the address as the real key. Nothing
|
|
129
|
+
* errors: the addresses look right and route wrong.
|
|
130
|
+
*/
|
|
131
|
+
export function findUnresolvableKeyCollections(
|
|
132
|
+
collections: CollectionConfig[],
|
|
133
|
+
registry: PostgresCollectionRegistry
|
|
134
|
+
): { collection: CollectionConfig; keys: PrimaryKeyInfo[]; shadowedByIdProperty: boolean }[] {
|
|
135
|
+
const findings: { collection: CollectionConfig; keys: PrimaryKeyInfo[]; shadowedByIdProperty: boolean }[] = [];
|
|
136
|
+
|
|
137
|
+
for (const collection of collections) {
|
|
138
|
+
// Declared `isId` is the tier both sides share: if it is there, they agree.
|
|
139
|
+
if (getDeclaredPrimaryKeys(collection).length > 0) continue;
|
|
140
|
+
|
|
141
|
+
let keys: PrimaryKeyInfo[];
|
|
142
|
+
try {
|
|
143
|
+
keys = getPrimaryKeys(collection, registry);
|
|
144
|
+
} catch {
|
|
145
|
+
// No registered table — nothing resolved here either, so there is no
|
|
146
|
+
// disagreement to report.
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (keys.length === 0) continue;
|
|
101
150
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
151
|
+
// A single key named `id` is the last tier on both sides: they agree
|
|
152
|
+
// without anything being declared.
|
|
153
|
+
if (keys.length === 1 && keys[0].fieldName === "id") continue;
|
|
105
154
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
throw new Error(`Invalid numeric ID: ${idValue}`);
|
|
112
|
-
}
|
|
113
|
-
result[pk.fieldName] = parsed;
|
|
114
|
-
} else {
|
|
115
|
-
result[pk.fieldName] = String(idValue);
|
|
116
|
-
}
|
|
117
|
-
return result;
|
|
155
|
+
findings.push({
|
|
156
|
+
collection,
|
|
157
|
+
keys,
|
|
158
|
+
shadowedByIdProperty: Boolean(collection.properties?.id)
|
|
159
|
+
});
|
|
118
160
|
}
|
|
119
161
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (parts.length !== primaryKeys.length) {
|
|
123
|
-
throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
|
|
124
|
-
}
|
|
162
|
+
return findings;
|
|
163
|
+
}
|
|
125
164
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Report the collections from {@link findUnresolvableKeyCollections} at boot,
|
|
167
|
+
* with the edit that fixes each one.
|
|
168
|
+
*
|
|
169
|
+
* Grouped by failure, not by collection: the shadowed case is a routing bug and
|
|
170
|
+
* the silent case is a missing feature, and they deserve different urgency.
|
|
171
|
+
*/
|
|
172
|
+
export function warnOnKeysTheAdminCannotResolve(
|
|
173
|
+
collections: CollectionConfig[],
|
|
174
|
+
registry: PostgresCollectionRegistry
|
|
175
|
+
): void {
|
|
176
|
+
const findings = findUnresolvableKeyCollections(collections, registry);
|
|
177
|
+
if (findings.length === 0) return;
|
|
178
|
+
|
|
179
|
+
const edit = (f: { collection: CollectionConfig; keys: PrimaryKeyInfo[] }) =>
|
|
180
|
+
`${f.collection.slug}: mark ${f.keys.map(k => `\`${k.fieldName}\``).join(" and ")} with ` +
|
|
181
|
+
`\`isId: ${f.keys[0].isUUID ? "\"uuid\"" : f.keys[0].type === "number" ? "\"increment\"" : "true"}\``;
|
|
182
|
+
|
|
183
|
+
const shadowed = findings.filter(f => f.shadowedByIdProperty);
|
|
184
|
+
const silent = findings.filter(f => !f.shadowedByIdProperty);
|
|
185
|
+
|
|
186
|
+
if (shadowed.length > 0) {
|
|
187
|
+
logger.warn(
|
|
188
|
+
`⚠️ These collections declare no \`isId\`, and their key is only in the drizzle schema — but they ` +
|
|
189
|
+
`do have a property called \`id\`. The admin has no way to know \`id\` is not the key, so it will ` +
|
|
190
|
+
`address rows by it while this server reads the address as the real key: the links look right and ` +
|
|
191
|
+
`route wrong. Nothing will error.\n\n` +
|
|
192
|
+
shadowed.map(f => ` • ${edit(f)}`).join("\n") + "\n"
|
|
193
|
+
);
|
|
138
194
|
}
|
|
139
195
|
|
|
140
|
-
|
|
196
|
+
if (silent.length > 0) {
|
|
197
|
+
logger.warn(
|
|
198
|
+
`⚠️ These collections declare no \`isId\`, and their key is only in the drizzle schema, which the ` +
|
|
199
|
+
`admin never sees. It will resolve no address for their rows, so detail links, caching and ` +
|
|
200
|
+
`relations will not work for them:\n\n` +
|
|
201
|
+
silent.map(f => ` • ${edit(f)}`).join("\n") + "\n"
|
|
202
|
+
);
|
|
203
|
+
}
|
|
141
204
|
}
|
|
142
205
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
206
|
+
/**
|
|
207
|
+
* The address of a row: derived from the collection's primary keys, because a
|
|
208
|
+
* row does not carry one — it is exactly its columns.
|
|
209
|
+
*
|
|
210
|
+
* Falls back to a literal `id` column, which covers the two cases where the
|
|
211
|
+
* keys cannot be resolved: a row that reached us from somewhere other than the
|
|
212
|
+
* postgres driver, and a collection whose table the registry cannot look up
|
|
213
|
+
* (`getPrimaryKeys` throws for an unregistered table rather than returning
|
|
214
|
+
* nothing). Returns `""` when there is no key and no `id` — callers decide what
|
|
215
|
+
* that means, since "unaddressable" is a different answer in a notification
|
|
216
|
+
* (broadcast a wildcard) than in a save (fail).
|
|
217
|
+
*/
|
|
218
|
+
export function deriveRowAddress(
|
|
219
|
+
row: Record<string, unknown>,
|
|
220
|
+
collection: CollectionConfig,
|
|
221
|
+
registry: PostgresCollectionRegistry
|
|
222
|
+
): string {
|
|
223
|
+
try {
|
|
224
|
+
const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
|
|
225
|
+
// An all-empty composite is indistinguishable from a missing key, and
|
|
226
|
+
// `buildCompositeId` returns "" for no keys at all.
|
|
227
|
+
if (composite && composite.split(COMPOSITE_ID_SEPARATOR).some(part => part !== "")) {
|
|
228
|
+
return composite;
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
/* unresolvable table or keys — fall through to the literal column */
|
|
149
232
|
}
|
|
150
|
-
|
|
233
|
+
if (row.id !== undefined && row.id !== null) return String(row.id);
|
|
234
|
+
return "";
|
|
151
235
|
}
|
|
236
|
+
|
|
@@ -154,9 +154,10 @@ export class DataService implements DataRepository {
|
|
|
154
154
|
collectionPath: string,
|
|
155
155
|
values: Partial<M>,
|
|
156
156
|
id?: string | number,
|
|
157
|
-
databaseId?: string
|
|
157
|
+
databaseId?: string,
|
|
158
|
+
options?: { upsert?: boolean }
|
|
158
159
|
): Promise<Record<string, unknown>> {
|
|
159
|
-
return this.persistService.save<M>(collectionPath, values, id, databaseId);
|
|
160
|
+
return this.persistService.save<M>(collectionPath, values, id, databaseId, options);
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
/**
|
package/src/services/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { applyAuthContext } from "../security/rls-enforcement";
|
|
|
14
14
|
import { logger } from "@rebasepro/server";
|
|
15
15
|
import { sanitizeErrorForClient } from "../utils/pg-error-utils";
|
|
16
16
|
import { CdcListener, type CdcChangeEvent } from "./cdc/CdcListener";
|
|
17
|
-
import { getPrimaryKeys,
|
|
17
|
+
import { deriveRowAddress, getPrimaryKeys, type PrimaryKeyInfo } from "./collection-helpers";
|
|
18
18
|
|
|
19
19
|
/** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
|
|
20
20
|
const PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
@@ -390,7 +390,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
390
390
|
authContext
|
|
391
391
|
);
|
|
392
392
|
|
|
393
|
-
this.sendCollectionUpdate(clientId, subscriptionId, rows);
|
|
393
|
+
this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
|
|
394
394
|
|
|
395
395
|
} catch (error) {
|
|
396
396
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
@@ -554,7 +554,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
554
554
|
// Phase 1: Send instant row-level patch (no DB query)
|
|
555
555
|
// This gives immediate cross-tab feedback
|
|
556
556
|
if (!row || !(row as Record<string, unknown>)?._rebase_invalidated) {
|
|
557
|
-
this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
|
|
557
|
+
this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
|
|
558
558
|
}
|
|
559
559
|
|
|
560
560
|
// Phase 2: Schedule a deferred full refetch for correctness
|
|
@@ -609,7 +609,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
609
609
|
if (!this._subscriptions.has(subscriptionId)) return;
|
|
610
610
|
try {
|
|
611
611
|
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
|
|
612
|
-
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
|
|
612
|
+
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
|
|
613
613
|
} catch (error) {
|
|
614
614
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
615
615
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -910,11 +910,12 @@ roles: activeAuth.roles },
|
|
|
910
910
|
return await this.dataService.fetchOne(notifyPath, id);
|
|
911
911
|
}
|
|
912
912
|
|
|
913
|
-
private sendCollectionUpdate(clientId: string, subscriptionId: string, rows: Record<string, unknown>[]) {
|
|
913
|
+
private sendCollectionUpdate(clientId: string, subscriptionId: string, rows: Record<string, unknown>[], path: string) {
|
|
914
914
|
const message: CollectionUpdateMessage = {
|
|
915
915
|
type: "collection_update",
|
|
916
916
|
subscriptionId,
|
|
917
|
-
rows: rows
|
|
917
|
+
rows: rows,
|
|
918
|
+
pks: this.primaryKeysForPath(path)
|
|
918
919
|
};
|
|
919
920
|
this.sendMessage(clientId, message);
|
|
920
921
|
}
|
|
@@ -931,17 +932,43 @@ roles: activeAuth.roles },
|
|
|
931
932
|
/**
|
|
932
933
|
* Send a lightweight row-level patch to a collection subscriber.
|
|
933
934
|
* The client can merge this into its cached data for instant feedback.
|
|
935
|
+
*
|
|
936
|
+
* The key columns ride along: the patch names a row by address, and the
|
|
937
|
+
* client has to find that row among the ones it cached — which carry
|
|
938
|
+
* columns and no address. The SDK holds no collection config to derive one
|
|
939
|
+
* from, so this is the only place the mapping can come from.
|
|
934
940
|
*/
|
|
935
|
-
private sendCollectionPatch(
|
|
941
|
+
private sendCollectionPatch(
|
|
942
|
+
clientId: string,
|
|
943
|
+
subscriptionId: string,
|
|
944
|
+
id: string,
|
|
945
|
+
row: Record<string, unknown> | null,
|
|
946
|
+
notifyPath: string
|
|
947
|
+
) {
|
|
936
948
|
const message: CollectionPatchMessage = {
|
|
937
949
|
type: "collection_patch",
|
|
938
950
|
subscriptionId,
|
|
939
951
|
id,
|
|
940
|
-
row: row
|
|
952
|
+
row: row,
|
|
953
|
+
pks: this.primaryKeysForPath(notifyPath)
|
|
941
954
|
};
|
|
942
955
|
this.sendMessage(clientId, message);
|
|
943
956
|
}
|
|
944
957
|
|
|
958
|
+
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
959
|
+
private primaryKeysForPath(path: string): PrimaryKeyInfo[] | undefined {
|
|
960
|
+
try {
|
|
961
|
+
const collection = this.registry.getCollectionByPath(path);
|
|
962
|
+
if (!collection) return undefined;
|
|
963
|
+
const keys = getPrimaryKeys(collection, this.registry);
|
|
964
|
+
return keys.length > 0 ? keys : undefined;
|
|
965
|
+
} catch {
|
|
966
|
+
// Unregistered table, or a relation path with no collection of its
|
|
967
|
+
// own: the client keeps its pre-existing `id` behaviour.
|
|
968
|
+
return undefined;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
945
972
|
private sendError(clientId: string, error: string, subscriptionId?: string, code?: string) {
|
|
946
973
|
const message = {
|
|
947
974
|
type: "error" as const,
|
|
@@ -1294,17 +1321,9 @@ lastSeen: Date.now() });
|
|
|
1294
1321
|
|
|
1295
1322
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
1296
1323
|
private extractIdFromCdcRow(collection: CollectionConfig, row: Record<string, unknown>): string {
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
if (composite && composite !== ":::") return composite;
|
|
1301
|
-
} catch {
|
|
1302
|
-
/* fall through to id-field / wildcard */
|
|
1303
|
-
}
|
|
1304
|
-
// Fallbacks: a plain `id` column (common), else a collection-level
|
|
1305
|
-
// invalidation ("*") — single-row subs won't match but collection subs refetch.
|
|
1306
|
-
if (row.id !== undefined && row.id !== null) return String(row.id);
|
|
1307
|
-
return "*";
|
|
1324
|
+
// Unaddressable falls back to a collection-level invalidation: single-row
|
|
1325
|
+
// subs won't match, but collection subs still refetch.
|
|
1326
|
+
return deriveRowAddress(row, collection, this.registry) || "*";
|
|
1308
1327
|
}
|
|
1309
1328
|
|
|
1310
1329
|
// ── App/CDC de-duplication ──
|
package/src/websocket.ts
CHANGED
|
@@ -620,9 +620,12 @@ roles: ["anon"] };
|
|
|
620
620
|
if (error instanceof Error) {
|
|
621
621
|
logger.error("Stack trace", { detail: error.stack });
|
|
622
622
|
}
|
|
623
|
+
// Unwrap the cause chain: a Drizzle failure reports itself as
|
|
624
|
+
// "Failed query: <sql> params:", which tells the user nothing and
|
|
625
|
+
// echoes the statement back at them. The reason is in the cause.
|
|
623
626
|
const errorMessage = process.env.NODE_ENV === "production"
|
|
624
627
|
? "An unexpected error occurred"
|
|
625
|
-
: (error
|
|
628
|
+
: extractErrorMessage(error);
|
|
626
629
|
const errorResponse = {
|
|
627
630
|
type: "ERROR",
|
|
628
631
|
requestId,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { CollectionConfig, SecurityRule } from "@rebasepro/types";
|
|
2
|
-
/**
|
|
3
|
-
* Returns the security rules that should be applied to a collection: the
|
|
4
|
-
* author's explicit `securityRules` plus the framework defaults described in
|
|
5
|
-
* the module doc (baseline server/admin read for all collections; self-read
|
|
6
|
-
* and the admin write gate for auth collections).
|
|
7
|
-
*
|
|
8
|
-
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
9
|
-
*/
|
|
10
|
-
export declare function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[];
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import { CollectionConfig, SecurityRule, SecurityOperation, AuthCollectionConfig, PolicyExpression, isPostgresCollectionConfig, policy } from "@rebasepro/types";
|
|
2
|
-
import { getTableName } from "@rebasepro/common";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Default RLS policies injected by the schema generator.
|
|
6
|
-
*
|
|
7
|
-
* Rebase's enforcement model is unified: authenticated (user-context) requests
|
|
8
|
-
* run under the restricted `rebase_user` role, so Postgres RLS binds *every*
|
|
9
|
-
* statement — reads and writes. A collection's `securityRules` are the whole
|
|
10
|
-
* authorization model. The server context (auth flows, migrations,
|
|
11
|
-
* `dataAsAdmin`) runs as the owner and bypasses RLS.
|
|
12
|
-
*
|
|
13
|
-
* Because RLS default-denies, every collection is **locked by default**: with
|
|
14
|
-
* no rules, only the server context and admins can touch it. The generator
|
|
15
|
-
* injects that safe baseline:
|
|
16
|
-
*
|
|
17
|
-
* **For every collection**
|
|
18
|
-
* 1. A permissive **server-or-admin SELECT** grant.
|
|
19
|
-
* 2. A permissive **server-or-admin write** grant (insert/update/delete).
|
|
20
|
-
*
|
|
21
|
-
* Author `securityRules` are permissive and OR together, so explicit rules only
|
|
22
|
-
* *broaden* access from this locked baseline (e.g. "users read/write their own
|
|
23
|
-
* rows").
|
|
24
|
-
*
|
|
25
|
-
* **For auth collections additionally**
|
|
26
|
-
* 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
|
|
27
|
-
* their own row (profile, session bootstrap) without every app re-declaring
|
|
28
|
-
* it.
|
|
29
|
-
* 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
|
|
30
|
-
* every other policy, so a write is rejected unless the caller is an admin
|
|
31
|
-
* (or the server context) — even if the author also wrote a permissive rule
|
|
32
|
-
* such as "a user may edit their own row". Without this, a permissive owner
|
|
33
|
-
* rule would let a user change their own `roles`.
|
|
34
|
-
*
|
|
35
|
-
* The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
|
|
36
|
-
* — the built-in flows that run without a user (signup, migrations) set no user
|
|
37
|
-
* GUC — which also lets the owner connection satisfy these policies even under
|
|
38
|
-
* FORCE RLS. A *user* request never reaches that state: an anonymous one carries
|
|
39
|
-
* `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
|
|
40
|
-
*
|
|
41
|
-
* Opt out with `disableDefaultPolicies: true` to take full responsibility for
|
|
42
|
-
* the collection's RLS.
|
|
43
|
-
*/
|
|
44
|
-
// Expressed structurally (not as raw SQL) so the admin UI can evaluate it
|
|
45
|
-
// exactly — the framework's most security-critical policies must be reflected
|
|
46
|
-
// precisely, not left as un-evaluable raw clauses. Compiles to
|
|
47
|
-
// `auth.uid() IS NULL OR (string_to_array(auth.roles(), ',') && ARRAY['admin'])`.
|
|
48
|
-
//
|
|
49
|
-
// `serverContext()`, emphatically not `not(authenticated())`: the server arm of
|
|
50
|
-
// this grant must match the server context and nothing else. Anonymous visitors
|
|
51
|
-
// are not signed in either, so a negated `authenticated()` would hand them the
|
|
52
|
-
// server-or-admin grant on every collection's default policy.
|
|
53
|
-
const SERVER_OR_ADMIN_EXPR: PolicyExpression = policy.or(
|
|
54
|
-
policy.serverContext(),
|
|
55
|
-
policy.rolesOverlap(["admin"])
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
/** Write operations that must be admin-gated by default on auth collections. */
|
|
59
|
-
const DEFAULT_GUARDED_OPS: SecurityOperation[] = ["insert", "update", "delete"];
|
|
60
|
-
|
|
61
|
-
/** Whether a collection is flagged as an authentication collection. */
|
|
62
|
-
function isAuthCollection(collection: CollectionConfig): boolean {
|
|
63
|
-
const auth = collection.auth;
|
|
64
|
-
return auth === true || (typeof auth === "object" && (auth as AuthCollectionConfig)?.enabled === true);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** The property marked as the row id (falls back to `id`). */
|
|
68
|
-
function getIdPropertyName(collection: CollectionConfig): string {
|
|
69
|
-
for (const [name, prop] of Object.entries(collection.properties ?? {})) {
|
|
70
|
-
if (prop && typeof prop === "object" && "isId" in prop && (prop as { isId?: unknown }).isId) {
|
|
71
|
-
return name;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return "id";
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Returns the security rules that should be applied to a collection: the
|
|
79
|
-
* author's explicit `securityRules` plus the framework defaults described in
|
|
80
|
-
* the module doc (baseline server/admin read for all collections; self-read
|
|
81
|
-
* and the admin write gate for auth collections).
|
|
82
|
-
*
|
|
83
|
-
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
84
|
-
*/
|
|
85
|
-
export function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[] {
|
|
86
|
-
const explicit = [...((isPostgresCollectionConfig(collection) ? collection.securityRules : undefined) ?? [])];
|
|
87
|
-
|
|
88
|
-
if (collection.disableDefaultPolicies) {
|
|
89
|
-
return explicit;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const tableName = getTableName(collection);
|
|
93
|
-
const injected: SecurityRule[] = [];
|
|
94
|
-
|
|
95
|
-
// Baseline read + write: the server context and admins can always operate.
|
|
96
|
-
// RLS default-denies under the user role, so without these a rule-less
|
|
97
|
-
// collection would be locked to everyone — including the admin studio.
|
|
98
|
-
// Author rules are permissive and broaden access from here.
|
|
99
|
-
injected.push({
|
|
100
|
-
name: `${tableName}_default_admin_read`,
|
|
101
|
-
operations: ["select"],
|
|
102
|
-
condition: SERVER_OR_ADMIN_EXPR
|
|
103
|
-
});
|
|
104
|
-
injected.push({
|
|
105
|
-
name: `${tableName}_default_admin_write`,
|
|
106
|
-
operations: [...DEFAULT_GUARDED_OPS],
|
|
107
|
-
condition: SERVER_OR_ADMIN_EXPR,
|
|
108
|
-
check: SERVER_OR_ADMIN_EXPR
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
if (isAuthCollection(collection)) {
|
|
112
|
-
// Self-read: a user can always read their own row.
|
|
113
|
-
injected.push({
|
|
114
|
-
name: `${tableName}_default_self_read`,
|
|
115
|
-
operations: ["select"],
|
|
116
|
-
condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// Restrictive gate: AND'd with all other policies, so no permissive rule
|
|
120
|
-
// (e.g. an owner "edit your own row" rule) can let a non-admin change
|
|
121
|
-
// privileged columns like `roles`.
|
|
122
|
-
injected.push({
|
|
123
|
-
name: `${tableName}_require_admin_write`,
|
|
124
|
-
mode: "restrictive",
|
|
125
|
-
operations: [...DEFAULT_GUARDED_OPS],
|
|
126
|
-
condition: SERVER_OR_ADMIN_EXPR,
|
|
127
|
-
check: SERVER_OR_ADMIN_EXPR
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return [...explicit, ...injected];
|
|
132
|
-
}
|