@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0
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/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-helpers.d.ts +56 -0
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1720 -946
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
- package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +60 -6
- package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +24 -0
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +3 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +139 -2
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +8 -7
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +136 -11
- package/src/auth/ensure-tables.ts +212 -91
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +124 -11
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +299 -168
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +129 -25
- package/src/history/ensure-history-table.ts +9 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +149 -72
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +366 -30
- package/src/schema/generate-drizzle-schema-logic.ts +146 -66
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +277 -10
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/generated-schema-staleness.ts +14 -7
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +150 -7
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +243 -22
- package/src/services/PersistService.ts +68 -42
- package/src/services/RelationService.ts +37 -696
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +3 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +347 -86
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +433 -35
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
- package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
- package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-CU6WZGYV.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
|
@@ -34,7 +34,9 @@ export function buildCollectionRegistry(schema: RegistrySchema): PostgresCollect
|
|
|
34
34
|
|
|
35
35
|
if (schema.collections) {
|
|
36
36
|
registry.registerMultiple(schema.collections);
|
|
37
|
-
|
|
37
|
+
// `Auto-discovered collections` already reports the count and the
|
|
38
|
+
// directory they came from; this is the same fact with the names.
|
|
39
|
+
logger.debug(
|
|
38
40
|
`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: ` +
|
|
39
41
|
`[${registry.getCollections().map(c => c.slug).join(", ")}]`
|
|
40
42
|
);
|
package/src/data-transformer.ts
CHANGED
|
@@ -2,12 +2,12 @@ import { eq, SQL } from "drizzle-orm";
|
|
|
2
2
|
import { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
3
3
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
4
|
import { CollectionConfig, Properties, Property, ResolvedRelation, RelationProperty, Vector, BinaryProperty, hasForeignKeyOnTarget, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, type ResolvedVia } from "@rebasepro/types";
|
|
5
|
-
import { getTableName, resolveCollectionRelations, findRelation, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
|
|
5
|
+
import { getTableName, resolveCollectionRelations, findRelation, fieldKeyForColumn, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
|
|
6
6
|
import { isPrototypePollutingKey } from "@rebasepro/utils";
|
|
7
7
|
import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
|
|
8
8
|
import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
|
|
9
9
|
import { getPrimaryKeys, buildCompositeId } from "./services/collection-helpers";
|
|
10
|
-
import { logger } from "@rebasepro/server";
|
|
10
|
+
import { ApiError, logger } from "@rebasepro/server";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Data transformation utilities for converting between frontend and database formats.
|
|
@@ -126,16 +126,30 @@ joinPathRelationUpdates: [] };
|
|
|
126
126
|
newTargetId: string | number | null;
|
|
127
127
|
}> = [];
|
|
128
128
|
|
|
129
|
-
// Pre-calculate all local keys used as foreign keys
|
|
129
|
+
// Pre-calculate all local keys used as foreign keys.
|
|
130
|
+
//
|
|
131
|
+
// Keyed by the *wire* name: the payload arrives from a client, which knows
|
|
132
|
+
// the field as `authorId`, never as the `author_id` column behind it.
|
|
130
133
|
const foreignKeys = new Set<string>();
|
|
131
134
|
Object.values(resolvedRelations).forEach(relation => {
|
|
132
|
-
if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
|
|
135
|
+
if (relation.kind === "belongsTo") foreignKeys.add(fieldKeyForColumn(collection, relation.localKey));
|
|
133
136
|
});
|
|
134
137
|
|
|
135
138
|
for (const [key, value] of Object.entries(row)) {
|
|
136
139
|
// Same reasoning as `sanitizeAndConvertDates`: these keys come from the
|
|
137
140
|
// request body, and no column answers to them.
|
|
138
141
|
if (isPrototypePollutingKey(key)) continue;
|
|
142
|
+
|
|
143
|
+
// `{ subtitle: undefined }` means "I have no value for this", not "set
|
|
144
|
+
// this column to NULL" — it is what spreading an optional field
|
|
145
|
+
// produces, and what Drizzle itself skips. The key used to survive with
|
|
146
|
+
// `undefined` and `sanitizeAndConvertDates` then turned it into `null`,
|
|
147
|
+
// so `update(id, { title, subtitle: payload.subtitle })` wiped
|
|
148
|
+
// `subtitle` whenever the payload happened not to carry one. Unreachable
|
|
149
|
+
// over HTTP (JSON has no `undefined`); the in-process data API passes
|
|
150
|
+
// the caller's object straight through, so it is reachable there.
|
|
151
|
+
if (value === undefined) continue;
|
|
152
|
+
|
|
139
153
|
const property = properties[key as keyof M] as Property;
|
|
140
154
|
|
|
141
155
|
// Coerce empty strings to null for any field that acts as a foreign key
|
|
@@ -153,15 +167,17 @@ joinPathRelationUpdates: [] };
|
|
|
153
167
|
if (relation) {
|
|
154
168
|
if (relation.kind === "belongsTo") {
|
|
155
169
|
// Owning relation: Map relation object to FK column on current table
|
|
156
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
170
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
157
171
|
if (serializedValue !== undefined) {
|
|
158
|
-
|
|
172
|
+
// The Drizzle key, not the column: Drizzle maps it back
|
|
173
|
+
// to `author_id` when it builds the statement.
|
|
174
|
+
result[fieldKeyForColumn(collection, relation.localKey)] = serializedValue;
|
|
159
175
|
}
|
|
160
176
|
// Don't add the original relation property to the result
|
|
161
177
|
continue;
|
|
162
178
|
} else if (hasForeignKeyOnTarget(relation)) {
|
|
163
179
|
// Inverse relation: Need to update the target table's FK
|
|
164
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
180
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
165
181
|
inverseRelationUpdates.push({
|
|
166
182
|
relationKey: key,
|
|
167
183
|
relation,
|
|
@@ -175,7 +191,7 @@ joinPathRelationUpdates: [] };
|
|
|
175
191
|
// There used to be two arms here — "owning" and "inverse"
|
|
176
192
|
// joinPath — but a join chain has no owning side, so they
|
|
177
193
|
// only ever differed by which list they pushed to.
|
|
178
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
194
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
179
195
|
if (relation.cardinality === "one") {
|
|
180
196
|
// Ordering matters: PersistService applies these BEFORE
|
|
181
197
|
// the main UPDATE, so the mapping reads the pre-update
|
|
@@ -197,7 +213,7 @@ joinPathRelationUpdates: [] };
|
|
|
197
213
|
}
|
|
198
214
|
}
|
|
199
215
|
|
|
200
|
-
result[key] = serializePropertyToServer(effectiveValue, property);
|
|
216
|
+
result[key] = serializePropertyToServer(effectiveValue, property, key);
|
|
201
217
|
}
|
|
202
218
|
|
|
203
219
|
return {
|
|
@@ -208,19 +224,41 @@ joinPathRelationUpdates: [] };
|
|
|
208
224
|
}
|
|
209
225
|
|
|
210
226
|
/**
|
|
211
|
-
*
|
|
227
|
+
* How to name a rejected value in an error, without quoting it back.
|
|
228
|
+
*
|
|
229
|
+
* The value may be anything the caller sent, including a secret in the wrong
|
|
230
|
+
* field, so the message describes its shape rather than echoing it — an echoed
|
|
231
|
+
* value ends up in logs and in error-reporting services.
|
|
212
232
|
*/
|
|
213
|
-
|
|
233
|
+
function describeValue(value: unknown): string {
|
|
234
|
+
if (value === null) return "null";
|
|
235
|
+
if (Array.isArray(value)) return "an array";
|
|
236
|
+
if (value instanceof Date) return "a date";
|
|
237
|
+
const type = typeof value;
|
|
238
|
+
return type === "object" ? "an object" : `a ${type}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Serialize a single property value for database storage.
|
|
243
|
+
*
|
|
244
|
+
* `propertyKey` is only ever used to phrase errors and warnings. Without it the
|
|
245
|
+
* one trace a bad value left was `Expected array value for array property, got
|
|
246
|
+
* string` — no collection, no property, no value, which in the log of a
|
|
247
|
+
* thousand-row import names nothing at all.
|
|
248
|
+
*/
|
|
249
|
+
export function serializePropertyToServer(value: unknown, property: Property, propertyKey?: string): unknown {
|
|
214
250
|
if (value === null || value === undefined) {
|
|
215
251
|
return value;
|
|
216
252
|
}
|
|
217
253
|
|
|
254
|
+
const fieldLabel = propertyKey ? `'${propertyKey}'` : `a '${property.type}' field`;
|
|
255
|
+
|
|
218
256
|
const propertyType = property.type;
|
|
219
257
|
|
|
220
258
|
switch (propertyType) {
|
|
221
259
|
case "relation":
|
|
222
260
|
if (Array.isArray(value)) {
|
|
223
|
-
return value.map(v => serializePropertyToServer(v, property));
|
|
261
|
+
return value.map(v => serializePropertyToServer(v, property, propertyKey));
|
|
224
262
|
} else if (typeof value === "object" && value !== null && "id" in value) {
|
|
225
263
|
return (value as Record<string, unknown>).id;
|
|
226
264
|
}
|
|
@@ -230,7 +268,7 @@ export function serializePropertyToServer(value: unknown, property: Property): u
|
|
|
230
268
|
case "array":
|
|
231
269
|
if (Array.isArray(value)) {
|
|
232
270
|
if (property.of) {
|
|
233
|
-
return value.map(item => serializePropertyToServer(item, property.of as Property));
|
|
271
|
+
return value.map(item => serializePropertyToServer(item, property.of as Property, propertyKey));
|
|
234
272
|
} else if (property.oneOf) {
|
|
235
273
|
const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
|
|
236
274
|
const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
|
|
@@ -243,15 +281,50 @@ export function serializePropertyToServer(value: unknown, property: Property): u
|
|
|
243
281
|
if (!type || !childProperty) return e;
|
|
244
282
|
return {
|
|
245
283
|
[typeField]: type,
|
|
246
|
-
[valueField]: serializePropertyToServer(rec[valueField], childProperty)
|
|
284
|
+
[valueField]: serializePropertyToServer(rec[valueField], childProperty, propertyKey)
|
|
247
285
|
};
|
|
248
286
|
});
|
|
249
287
|
}
|
|
250
288
|
return value;
|
|
251
289
|
}
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
290
|
+
// A non-array value used to become `[]` here — the caller's value
|
|
291
|
+
// destroyed, answered 200/201, and the row reading back as an empty
|
|
292
|
+
// list on every later fetch. `POST /posts {"tags":"news"}` from a
|
|
293
|
+
// client that sent a single tag as a scalar, or a CSV import that
|
|
294
|
+
// did not split a column, was unrecoverable data loss on a `text[]`
|
|
295
|
+
// column. The stated reason for coercing — "avoid .map() crashes
|
|
296
|
+
// downstream" — is better served by refusing the value at the
|
|
297
|
+
// boundary, which is what a 400 does.
|
|
298
|
+
//
|
|
299
|
+
// The read-side twin (`parsePropertyFromServer`) still coerces, and
|
|
300
|
+
// should: a row already in the database is not this caller's fault.
|
|
301
|
+
throw ApiError.badRequest(
|
|
302
|
+
`${fieldLabel} expects an array, but received ${describeValue(value)}.`,
|
|
303
|
+
"VALIDATION_INVALID_VALUE"
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
case "geopoint": {
|
|
307
|
+
// Stored as `jsonb`, in the `{ latitude, longitude }` shape the
|
|
308
|
+
// OpenAPI schema and the generated TS type both promise. Nothing
|
|
309
|
+
// used to handle `geopoint` on either side, which is why the type
|
|
310
|
+
// was documented, code-generated, admin-editable — and dropped.
|
|
311
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
312
|
+
throw ApiError.badRequest(
|
|
313
|
+
`${fieldLabel} expects a geopoint object with \`latitude\` and \`longitude\`, ` +
|
|
314
|
+
`but received ${describeValue(value)}.`,
|
|
315
|
+
"VALIDATION_INVALID_VALUE"
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const point = value as Record<string, unknown>;
|
|
319
|
+
if (typeof point.latitude !== "number" || typeof point.longitude !== "number") {
|
|
320
|
+
throw ApiError.badRequest(
|
|
321
|
+
`${fieldLabel} expects a geopoint object with numeric \`latitude\` and \`longitude\`.`,
|
|
322
|
+
"VALIDATION_INVALID_VALUE"
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
return { latitude: point.latitude,
|
|
326
|
+
longitude: point.longitude };
|
|
327
|
+
}
|
|
255
328
|
|
|
256
329
|
|
|
257
330
|
case "map":
|
|
@@ -260,7 +333,7 @@ export function serializePropertyToServer(value: unknown, property: Property): u
|
|
|
260
333
|
for (const [subKey, subValue] of Object.entries(value)) {
|
|
261
334
|
const subProperty = (property.properties as Properties)[subKey];
|
|
262
335
|
if (subProperty) {
|
|
263
|
-
result[subKey] = serializePropertyToServer(subValue, subProperty);
|
|
336
|
+
result[subKey] = serializePropertyToServer(subValue, subProperty, propertyKey ? `${propertyKey}.${subKey}` : subKey);
|
|
264
337
|
} else {
|
|
265
338
|
result[subKey] = subValue;
|
|
266
339
|
}
|
|
@@ -321,9 +394,12 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
321
394
|
// Find the normalized relation for this property
|
|
322
395
|
const relation = findRelation(resolvedRelations, propKey);
|
|
323
396
|
if (relation) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
397
|
+
const localField = relation.kind === "belongsTo"
|
|
398
|
+
? fieldKeyForColumn(collection, relation.localKey)
|
|
399
|
+
: "";
|
|
400
|
+
if (relation.kind === "belongsTo" && localField in data) {
|
|
401
|
+
// Owning relation: FK is in current table, under its wire name
|
|
402
|
+
const fkValue = data[localField as keyof M];
|
|
327
403
|
if (fkValue !== null && fkValue !== undefined) {
|
|
328
404
|
try {
|
|
329
405
|
const targetCollection = relation.target();
|
|
@@ -347,7 +423,8 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
347
423
|
: buildCompositeId(data, pks);
|
|
348
424
|
|
|
349
425
|
if (targetTable && currentId !== undefined && currentId !== null && currentId !== "") {
|
|
350
|
-
const
|
|
426
|
+
const fkFieldKey = fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget);
|
|
427
|
+
const foreignKeyColumn = targetTable[fkFieldKey as keyof typeof targetTable] as AnyPgColumn;
|
|
351
428
|
if (foreignKeyColumn) {
|
|
352
429
|
// Query the target table to find row that references this row
|
|
353
430
|
const relatedRows = await db
|
|
@@ -626,6 +703,13 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
626
703
|
}
|
|
627
704
|
return value;
|
|
628
705
|
|
|
706
|
+
case "geopoint":
|
|
707
|
+
// A `jsonb` column, so node-postgres hands back the object already.
|
|
708
|
+
// Named explicitly rather than left to `default:`, which would run
|
|
709
|
+
// it past the Buffer probe — and because a type with a write
|
|
710
|
+
// serializer and no read counterpart is how this one went missing.
|
|
711
|
+
return value;
|
|
712
|
+
|
|
629
713
|
case "vector": {
|
|
630
714
|
let nums: number[] = [];
|
|
631
715
|
if (typeof value === "string") {
|
|
@@ -685,6 +769,15 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
685
769
|
* from the result (used by `normalizeDbValues` where
|
|
686
770
|
* Drizzle's relational API already hydrates them).
|
|
687
771
|
*/
|
|
772
|
+
/**
|
|
773
|
+
* Keys a query computes and attaches to a row, rather than reads from a column.
|
|
774
|
+
*
|
|
775
|
+
* An explicit set rather than a `_` prefix rule: a user's column may perfectly
|
|
776
|
+
* well be called `_internal`, and passing it through here would put a value on
|
|
777
|
+
* the row that no property describes and nothing downstream knows how to type.
|
|
778
|
+
*/
|
|
779
|
+
const QUERY_METADATA_KEYS = new Set(["_score", "_distance", "_matches"]);
|
|
780
|
+
|
|
688
781
|
function normalizeScalarValues<M extends Record<string, unknown>>(
|
|
689
782
|
data: M,
|
|
690
783
|
properties: Properties,
|
|
@@ -697,9 +790,11 @@ function normalizeScalarValues<M extends Record<string, unknown>>(
|
|
|
697
790
|
// Identify FK columns used only for relations and not exposed as properties
|
|
698
791
|
const internalFKColumns = new Set<string>();
|
|
699
792
|
Object.values(resolvedRelations).forEach(relation => {
|
|
700
|
-
if (relation.kind
|
|
701
|
-
|
|
702
|
-
|
|
793
|
+
if (relation.kind !== "belongsTo") return;
|
|
794
|
+
// The key a row carries this foreign key under, which is the wire name
|
|
795
|
+
// — `authorId`, not the `author_id` column.
|
|
796
|
+
const localField = fieldKeyForColumn(collection, relation.localKey);
|
|
797
|
+
if (!properties[localField]) internalFKColumns.add(localField);
|
|
703
798
|
});
|
|
704
799
|
|
|
705
800
|
for (const [key, value] of Object.entries(data)) {
|
|
@@ -709,6 +804,15 @@ function normalizeScalarValues<M extends Record<string, unknown>>(
|
|
|
709
804
|
continue;
|
|
710
805
|
}
|
|
711
806
|
|
|
807
|
+
// Query-computed metadata, which by definition is not a column and so
|
|
808
|
+
// has no property to be found. Dropped here until now — which meant
|
|
809
|
+
// `_distance` was computed for every vector search on this path and
|
|
810
|
+
// then discarded before the caller ever saw it.
|
|
811
|
+
if (QUERY_METADATA_KEYS.has(key)) {
|
|
812
|
+
result[key] = value;
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
|
|
712
816
|
const property = properties[key as keyof M] as Property;
|
|
713
817
|
if (!property) continue; // Skip DB columns not defined in properties
|
|
714
818
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
2
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { logger } from "@rebasepro/server";
|
|
4
|
+
import { revokeInternalTableSql } from "@rebasepro/common";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Auto-create the row history table if it doesn't exist.
|
|
@@ -8,7 +9,7 @@ import { logger } from "@rebasepro/server";
|
|
|
8
9
|
* pattern as `ensureAuthTablesExist`.
|
|
9
10
|
*/
|
|
10
11
|
export async function ensureHistoryTableExists(db: NodePgDatabase): Promise<void> {
|
|
11
|
-
logger.
|
|
12
|
+
logger.debug("🔍 Checking row history table...");
|
|
12
13
|
|
|
13
14
|
try {
|
|
14
15
|
// Create the rebase schema (idempotent — may already exist from auth init)
|
|
@@ -38,7 +39,13 @@ export async function ensureHistoryTableExists(db: NodePgDatabase): Promise<void
|
|
|
38
39
|
ON rebase.entity_history(table_name, entity_id, updated_at DESC)
|
|
39
40
|
`);
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
// Every previous value of every audited row, in one table with no RLS
|
|
43
|
+
// and no tenant scoping — so a readable copy defeats the row policies on
|
|
44
|
+
// the tables it shadows. The driver's schema-wide grant reaches it
|
|
45
|
+
// (created here, after that grant ran), so take it back.
|
|
46
|
+
await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
|
|
47
|
+
|
|
48
|
+
logger.debug("✅ Entity history table ready");
|
|
42
49
|
} catch (error) {
|
|
43
50
|
logger.error("❌ Failed to create row history table", { error: error });
|
|
44
51
|
logger.warn("⚠️ Continuing without creating history table.");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pgSchema, pgTable, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
|
|
1
|
+
import { pgSchema, pgTable, uuid, timestamp, boolean, jsonb, text, unique, index, integer, bigint } from "drizzle-orm/pg-core";
|
|
2
2
|
import { relations } from "drizzle-orm";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -87,6 +87,13 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
87
87
|
* that rotates immediately after it.
|
|
88
88
|
*/
|
|
89
89
|
sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
|
|
90
|
+
/**
|
|
91
|
+
* The assurance level the sign-in was established at — `aal2` only
|
|
92
|
+
* where a second factor was actually presented. Carried across
|
|
93
|
+
* rotations, because refresh is not a new authentication and has
|
|
94
|
+
* nothing else to read the level from.
|
|
95
|
+
*/
|
|
96
|
+
aal: text("aal"),
|
|
90
97
|
userAgent: text("user_agent"),
|
|
91
98
|
ipAddress: text("ip_address"),
|
|
92
99
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
@@ -140,6 +147,13 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
140
147
|
secretEncrypted: text("secret_encrypted").notNull(),
|
|
141
148
|
friendlyName: text("friendly_name"),
|
|
142
149
|
verified: boolean("verified").default(false).notNull(),
|
|
150
|
+
/**
|
|
151
|
+
* The highest TOTP time step ever accepted for this factor. RFC 6238
|
|
152
|
+
* §5.2 forbids accepting an OTP twice, and the ±1 step window that
|
|
153
|
+
* exists for clock drift is also a 90-second replay window: without
|
|
154
|
+
* this, one observed code buys a fresh session for a minute and a half.
|
|
155
|
+
*/
|
|
156
|
+
lastUsedCounter: bigint("last_used_counter", { mode: "number" }),
|
|
143
157
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
144
158
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
145
159
|
});
|
|
@@ -153,6 +167,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
153
167
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
154
168
|
verifiedAt: timestamp("verified_at"),
|
|
155
169
|
ipAddress: text("ip_address"),
|
|
170
|
+
/** Failed guesses recorded against this challenge; bounded by the route. */
|
|
171
|
+
attempts: integer("attempts").default(0).notNull(),
|
|
156
172
|
expiresAt: timestamp("expires_at").notNull()
|
|
157
173
|
});
|
|
158
174
|
|
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
/**
|
|
52
|
+
* A column auth owns.
|
|
53
|
+
*
|
|
54
|
+
* Structured rather than one SQL string because the same facts are needed in
|
|
55
|
+
* three grammars — a `CREATE TABLE` column list, an `ADD COLUMN IF NOT EXISTS`,
|
|
56
|
+
* and a pair of `ALTER COLUMN … SET DEFAULT` / `SET NOT NULL` reconciles for a
|
|
57
|
+
* table that already exists with the wrong shape. Parsing a string back apart
|
|
58
|
+
* for the third of those is how the copies drifted in the first place.
|
|
59
|
+
*/
|
|
60
|
+
export interface AuthUsersColumn {
|
|
61
|
+
/** Physical column name. */
|
|
62
|
+
column: string;
|
|
63
|
+
/** Postgres type. */
|
|
64
|
+
type: string;
|
|
65
|
+
/** Default expression, verbatim, or absent for no default. */
|
|
66
|
+
default?: string;
|
|
67
|
+
/** Whether the column is NOT NULL. */
|
|
68
|
+
notNull?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
|
|
73
|
+
* — it synthesizes `anon_<32 hex>@anonymous.local` rather than inserting NULL.
|
|
74
|
+
* The 320-char bound (RFC 5321) is a CHECK rather than a `VARCHAR(n)`, added
|
|
75
|
+
* separately by `ensureAuthTablesExist` so it can be `NOT VALID` on an adopted
|
|
76
|
+
* table that already holds a longer row.
|
|
77
|
+
*/
|
|
78
|
+
export const AUTH_USERS_COLUMNS: readonly AuthUsersColumn[] = [
|
|
79
|
+
{ column: "email", type: "TEXT", notNull: true },
|
|
80
|
+
{ column: "display_name", type: "TEXT" },
|
|
81
|
+
{ column: "photo_url", type: "TEXT" },
|
|
82
|
+
{ column: "roles", type: "TEXT[]", default: "'{}'", notNull: true },
|
|
83
|
+
{ column: "password_hash", type: "TEXT" },
|
|
84
|
+
{ column: "email_verified", type: "BOOLEAN", default: "FALSE", notNull: true },
|
|
85
|
+
{ column: "email_verification_token", type: "TEXT" },
|
|
86
|
+
{ column: "email_verification_sent_at", type: "TIMESTAMP WITH TIME ZONE" },
|
|
87
|
+
{ column: "is_anonymous", type: "BOOLEAN", default: "FALSE", notNull: true },
|
|
88
|
+
{ column: "metadata", type: "JSONB", default: "'{}'", notNull: true },
|
|
89
|
+
{ column: "tokens_valid_after", type: "TIMESTAMP WITH TIME ZONE" },
|
|
90
|
+
{ column: "created_at", type: "TIMESTAMP WITH TIME ZONE", default: "NOW()", notNull: true },
|
|
91
|
+
{ column: "updated_at", type: "TIMESTAMP WITH TIME ZONE", default: "NOW()", notNull: true }
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const BY_COLUMN = new Map(AUTH_USERS_COLUMNS.map(c => [c.column, c]));
|
|
95
|
+
|
|
96
|
+
/** Type + inline constraints, as they appear after the column name. */
|
|
97
|
+
export function authUsersColumnSql(spec: AuthUsersColumn): string {
|
|
98
|
+
return [
|
|
99
|
+
spec.type,
|
|
100
|
+
spec.default !== undefined ? `DEFAULT ${spec.default}` : "",
|
|
101
|
+
spec.notNull ? "NOT NULL" : ""
|
|
102
|
+
].filter(Boolean).join(" ");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The auth-owned definition for a physical column name, or `undefined` when
|
|
107
|
+
* auth does not own it.
|
|
108
|
+
*
|
|
109
|
+
* Callers pass the RESOLVED column name (after `columnName` mapping), because
|
|
110
|
+
* that is the only name the three creators agree on: the scaffold's users
|
|
111
|
+
* collection spells the property `displayName` and the column `display_name`.
|
|
112
|
+
*/
|
|
113
|
+
export function authUsersColumnDefinition(column: string): string | undefined {
|
|
114
|
+
const spec = BY_COLUMN.get(column);
|
|
115
|
+
return spec ? authUsersColumnSql(spec) : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether a collection is an auth collection, i.e. whether the definitions in
|
|
120
|
+
* this module apply to its table at all.
|
|
121
|
+
*
|
|
122
|
+
* Duplicated in shape from `@rebasepro/common`'s policy defaults on purpose:
|
|
123
|
+
* that one takes a `CollectionConfig`, this one is called from DDL code paths
|
|
124
|
+
* that hold looser objects, and both spellings must accept `auth: true` as well
|
|
125
|
+
* as `auth: { enabled: true }`.
|
|
126
|
+
*/
|
|
127
|
+
export function isAuthCollection(collection: unknown): boolean {
|
|
128
|
+
const auth = (collection as { auth?: unknown } | undefined)?.auth;
|
|
129
|
+
if (auth === true) return true;
|
|
130
|
+
return typeof auth === "object" && auth !== null && (auth as { enabled?: unknown }).enabled === true;
|
|
131
|
+
}
|
package/src/schema/doctor-cli.ts
CHANGED
|
@@ -5,62 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import path from "path";
|
|
7
7
|
import chalk from "chalk";
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import { checkPolicyDrift, formatPolicyDrift, hasDrift } from "../security/policy-drift";
|
|
11
|
-
import { validatePolicyPgRoles, warnOnAnonymousGrants } from "../security/rls-enforcement";
|
|
12
|
-
import { logger } from "@rebasepro/server";
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The RLS half of the doctor: policies actually deployed vs the ones the
|
|
16
|
-
* collections describe, plus policy roles this server could never satisfy.
|
|
17
|
-
*
|
|
18
|
-
* Returns true when something is wrong, so callers can set the exit code.
|
|
19
|
-
*/
|
|
20
|
-
async function runPolicyChecks(collectionsPath: string, databaseUrl?: string): Promise<boolean> {
|
|
21
|
-
if (!databaseUrl) {
|
|
22
|
-
logger.warn(chalk.yellow(" ⚠ No DATABASE_URL — skipping RLS policy checks"));
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
let problems = false;
|
|
27
|
-
const { Pool } = await import("pg");
|
|
28
|
-
const pool = new Pool({ connectionString: databaseUrl });
|
|
29
|
-
try {
|
|
30
|
-
const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
|
|
31
|
-
const runSql = async (text: string) => (await pool.query(text)).rows as Record<string, unknown>[];
|
|
32
|
-
|
|
33
|
-
// A policy naming a role the server never runs as filters every row, so
|
|
34
|
-
// the collection reads as empty. Report it without booting a server.
|
|
35
|
-
try {
|
|
36
|
-
await validatePolicyPgRoles(runSql, collections as never);
|
|
37
|
-
logger.info(chalk.green(" ✓ Policy roles are usable by this server"));
|
|
38
|
-
} catch (err) {
|
|
39
|
-
problems = true;
|
|
40
|
-
logger.info("");
|
|
41
|
-
logger.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// A rule that reads as a lockdown but is true for every caller compiles
|
|
45
|
-
// to a grant. Report it without booting a server.
|
|
46
|
-
warnOnAnonymousGrants(collections as never);
|
|
47
|
-
|
|
48
|
-
const drift = await checkPolicyDrift(pool as never, collections);
|
|
49
|
-
logger.info("");
|
|
50
|
-
if (hasDrift(drift)) {
|
|
51
|
-
problems = true;
|
|
52
|
-
logger.info(chalk.yellow(" RLS policies: database does not match your collections"));
|
|
53
|
-
logger.info(formatPolicyDrift(drift));
|
|
54
|
-
} else {
|
|
55
|
-
logger.info(chalk.green(" ✓ RLS policies match your collections"));
|
|
56
|
-
}
|
|
57
|
-
} catch (err) {
|
|
58
|
-
logger.warn(chalk.yellow(" ⚠ Could not check RLS policies"), { error: err });
|
|
59
|
-
} finally {
|
|
60
|
-
await pool.end();
|
|
61
|
-
}
|
|
62
|
-
return problems;
|
|
63
|
-
}
|
|
8
|
+
import { runDoctor } from "./doctor";
|
|
9
|
+
import { exitCodeForPolicyGate, runPolicyChecks } from "./doctor-policy-checks";
|
|
64
10
|
|
|
65
11
|
async function main() {
|
|
66
12
|
const collectionsArg = process.argv.find((a) => a.startsWith("--collections="));
|
|
@@ -80,9 +26,9 @@ async function main() {
|
|
|
80
26
|
const dotenv = await import("dotenv");
|
|
81
27
|
const envPath = process.env.DOTENV_CONFIG_PATH;
|
|
82
28
|
if (envPath) {
|
|
83
|
-
dotenv.config({ path: envPath });
|
|
29
|
+
dotenv.config({ path: envPath, quiet: true });
|
|
84
30
|
} else {
|
|
85
|
-
dotenv.config();
|
|
31
|
+
dotenv.config({ quiet: true });
|
|
86
32
|
}
|
|
87
33
|
} catch {
|
|
88
34
|
// dotenv may not be installed
|
|
@@ -91,9 +37,9 @@ async function main() {
|
|
|
91
37
|
const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
|
|
92
38
|
|
|
93
39
|
if (policiesOnly) {
|
|
94
|
-
// Non-zero so this can gate CI — the whole point of the flag
|
|
95
|
-
|
|
96
|
-
|
|
40
|
+
// Non-zero so this can gate CI — the whole point of the flag, and a
|
|
41
|
+
// check that could not run has not passed. See exitCodeForPolicyGate.
|
|
42
|
+
process.exit(exitCodeForPolicyGate(await runPolicyChecks(collectionsPath, databaseUrl)));
|
|
97
43
|
}
|
|
98
44
|
|
|
99
45
|
const report = await runDoctor({
|
|
@@ -103,15 +49,18 @@ async function main() {
|
|
|
103
49
|
databaseUrl: databaseUrl ?? undefined
|
|
104
50
|
});
|
|
105
51
|
|
|
106
|
-
const
|
|
52
|
+
const policyStatus = await runPolicyChecks(collectionsPath, databaseUrl);
|
|
107
53
|
|
|
108
|
-
// Exit
|
|
109
|
-
|
|
54
|
+
// Exit non-zero if there are errors. A policy run that could not happen is
|
|
55
|
+
// reported loudly above but does not fail the interactive command — the
|
|
56
|
+
// same treatment the skipped database phase gets. `--policies` is the gate,
|
|
57
|
+
// and that one fails closed.
|
|
58
|
+
if (report.summary.errors > 0 || policyStatus === "problems") {
|
|
110
59
|
process.exit(1);
|
|
111
60
|
}
|
|
112
61
|
}
|
|
113
62
|
|
|
114
63
|
main().catch((err) => {
|
|
115
|
-
|
|
64
|
+
console.error(chalk.red(" ✗ Doctor failed"), err instanceof Error ? (err.stack ?? err.message) : String(err));
|
|
116
65
|
process.exit(1);
|
|
117
66
|
});
|