@rebasepro/server-postgresql 0.7.0 → 0.8.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/build-errors.txt +37 -0
- package/dist/PostgresAdapter.d.ts +6 -0
- package/dist/PostgresBackendDriver.d.ts +118 -0
- package/dist/PostgresBootstrapper.d.ts +46 -0
- package/dist/auth/ensure-tables.d.ts +10 -0
- package/dist/auth/services.d.ts +251 -0
- package/dist/cli-errors.d.ts +29 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/cli.d.ts +1 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
- package/dist/connection.d.ts +65 -0
- package/dist/data-transformer.d.ts +55 -0
- package/dist/databasePoolManager.d.ts +20 -0
- package/dist/history/HistoryService.d.ts +71 -0
- package/dist/history/ensure-history-table.d.ts +7 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.es.js +13560 -0
- package/dist/index.es.js.map +1 -0
- package/dist/interfaces.d.ts +18 -0
- package/dist/schema/auth-default-policies.d.ts +12 -0
- package/dist/schema/auth-schema.d.ts +2376 -0
- package/dist/schema/doctor-cli.d.ts +2 -0
- package/dist/schema/doctor.d.ts +52 -0
- package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/introspect-db-inference.d.ts +5 -0
- package/dist/schema/introspect-db-logic.d.ts +118 -0
- package/dist/schema/introspect-db.d.ts +1 -0
- package/dist/schema/test-schema.d.ts +24 -0
- package/dist/services/BranchService.d.ts +47 -0
- package/dist/services/EntityFetchService.d.ts +214 -0
- package/dist/services/EntityPersistService.d.ts +40 -0
- package/dist/services/RelationService.d.ts +98 -0
- package/dist/services/entity-helpers.d.ts +38 -0
- package/dist/services/entityService.d.ts +110 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/realtimeService.d.ts +220 -0
- package/dist/types.d.ts +3 -0
- package/dist/utils/drizzle-conditions.d.ts +138 -0
- package/dist/utils/pg-array-null-patch.d.ts +16 -0
- package/dist/utils/pg-error-utils.d.ts +65 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/dist/websocket.d.ts +11 -0
- package/package.json +17 -17
- package/src/PostgresBackendDriver.ts +135 -24
- package/src/cli.ts +73 -1
- package/src/collections/PostgresCollectionRegistry.ts +6 -6
- package/src/data-transformer.ts +2 -2
- package/src/schema/auth-default-policies.ts +13 -6
- package/src/schema/generate-drizzle-schema-logic.ts +16 -79
- package/src/schema/generate-postgres-ddl-logic.ts +14 -51
- package/src/services/realtimeService.ts +26 -2
- package/test/entity-callbacks-redaction.test.ts +86 -0
- package/test/postgresDataDriver.test.ts +2 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EntityCollection, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollection, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
|
|
2
2
|
import { getPrimaryKeys } from "../services/entity-helpers";
|
|
3
|
-
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
3
|
+
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
|
|
4
4
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
5
5
|
import { createHash } from "crypto";
|
|
6
6
|
import { logger } from "@rebasepro/server-core";
|
|
@@ -308,62 +308,9 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
|
|
|
308
308
|
};
|
|
309
309
|
|
|
310
310
|
/**
|
|
311
|
-
*
|
|
312
|
-
* The result is wrapped in a Drizzle sql`` template literal.
|
|
311
|
+
* Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
|
|
313
312
|
*/
|
|
314
|
-
const
|
|
315
|
-
// Replace {column_name} with column_name directly (so Drizzle-kit can parse it as a static string)
|
|
316
|
-
const resolved = expression.replace(/\{(\w+)\}/g, (_, col) => col);
|
|
317
|
-
return `sql\`${resolved}\``;
|
|
318
|
-
};
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Wraps a SQL clause with a role check using AND.
|
|
322
|
-
* Generates: `(<clause>) AND (string_to_array(auth.roles(), ',') && ARRAY['<role1>','<role2>'])`
|
|
323
|
-
*/
|
|
324
|
-
const wrapWithRoleCheck = (clause: string, roles: string[]): string => {
|
|
325
|
-
const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
|
|
326
|
-
const roleCondition = `string_to_array(auth.roles(), ',') && ${rolesArrayString}`;
|
|
327
|
-
return `sql\`(${unwrapSql(clause)}) AND (${roleCondition})\``;
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Extracts the inner expression from a `sql\`...\`` wrapper.
|
|
332
|
-
*/
|
|
333
|
-
const unwrapSql = (sqlExpr: string): string => {
|
|
334
|
-
const match = sqlExpr.match(/^sql`(.*)`$/s);
|
|
335
|
-
return match ? match[1] : sqlExpr;
|
|
336
|
-
};
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Builds the USING clause for a policy based on shortcuts or raw SQL.
|
|
340
|
-
*/
|
|
341
|
-
const buildUsingClause = (rule: SecurityRule, collection: EntityCollection): string | null => {
|
|
342
|
-
if (rule.using) {
|
|
343
|
-
return resolveRawSql(rule.using);
|
|
344
|
-
}
|
|
345
|
-
if (rule.access === "public") {
|
|
346
|
-
return "sql`true`";
|
|
347
|
-
}
|
|
348
|
-
if (rule.ownerField) {
|
|
349
|
-
const prop = collection.properties?.[rule.ownerField];
|
|
350
|
-
const colName = resolveColumnName(rule.ownerField, prop);
|
|
351
|
-
return `sql\`${colName} = auth.uid()\``;
|
|
352
|
-
}
|
|
353
|
-
return null;
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Builds the WITH CHECK clause for a policy based on shortcuts or raw SQL.
|
|
358
|
-
* Falls back to the USING clause if not explicitly provided.
|
|
359
|
-
*/
|
|
360
|
-
const buildWithCheckClause = (rule: SecurityRule, collection: EntityCollection): string | null => {
|
|
361
|
-
if (rule.withCheck) {
|
|
362
|
-
return resolveRawSql(rule.withCheck);
|
|
363
|
-
}
|
|
364
|
-
// For insert/update/all, fall back to using clause if withCheck not specified
|
|
365
|
-
return buildUsingClause(rule, collection);
|
|
366
|
-
};
|
|
313
|
+
const wrapSql = (clause: string): string => `sql\`${clause}\``;
|
|
367
314
|
|
|
368
315
|
/**
|
|
369
316
|
* Generates a deterministic hash based on the rule configuration.
|
|
@@ -378,7 +325,9 @@ const getPolicyNameHash = (rule: SecurityRule): string => {
|
|
|
378
325
|
rol: rule.roles?.slice().sort(),
|
|
379
326
|
pg: rule.pgRoles?.slice().sort(),
|
|
380
327
|
u: rule.using,
|
|
381
|
-
w: rule.withCheck
|
|
328
|
+
w: rule.withCheck,
|
|
329
|
+
c: rule.condition,
|
|
330
|
+
ch: rule.check
|
|
382
331
|
});
|
|
383
332
|
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
384
333
|
};
|
|
@@ -417,7 +366,6 @@ const generatePolicyCode = (collection: EntityCollection, rule: SecurityRule, in
|
|
|
417
366
|
*/
|
|
418
367
|
const generateSinglePolicyCode = (collection: EntityCollection, rule: SecurityRule, operation: SecurityOperation, policyName: string): string => {
|
|
419
368
|
const mode = rule.mode ?? "permissive";
|
|
420
|
-
const roles = rule.roles ? [...rule.roles].sort() : undefined;
|
|
421
369
|
|
|
422
370
|
// Determine which clauses this operation needs:
|
|
423
371
|
// SELECT, DELETE → USING only
|
|
@@ -426,26 +374,13 @@ const generateSinglePolicyCode = (collection: EntityCollection, rule: SecurityRu
|
|
|
426
374
|
const needsUsing = operation !== "insert";
|
|
427
375
|
const needsWithCheck = operation !== "select" && operation !== "delete";
|
|
428
376
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
usingClause = wrapWithRoleCheck(usingClause, roles);
|
|
437
|
-
} else if (needsUsing) {
|
|
438
|
-
// Roles-only rule (e.g. { operation: "select", roles: ["admin"] })
|
|
439
|
-
const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
|
|
440
|
-
usingClause = `sql\`string_to_array(auth.roles(), ',') && ${rolesArrayString}\``;
|
|
441
|
-
}
|
|
442
|
-
if (withCheckClause) {
|
|
443
|
-
withCheckClause = wrapWithRoleCheck(withCheckClause, roles);
|
|
444
|
-
} else if (needsWithCheck) {
|
|
445
|
-
const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
|
|
446
|
-
withCheckClause = `sql\`string_to_array(auth.roles(), ',') && ${rolesArrayString}\``;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
377
|
+
// Desugar the rule (access / ownerField / roles / structured condition / raw
|
|
378
|
+
// SQL) into the shared PolicyExpression model, then compile to SQL — the same
|
|
379
|
+
// normalization the DDL generator and the client-side evaluator use.
|
|
380
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
381
|
+
|
|
382
|
+
let usingClause = needsUsing && usingExpr ? wrapSql(policyToPostgres(usingExpr, collection)) : null;
|
|
383
|
+
let withCheckClause = needsWithCheck && withCheckExpr ? wrapSql(policyToPostgres(withCheckExpr, collection)) : null;
|
|
449
384
|
|
|
450
385
|
// Fallback: if we still have no clauses, deny all (safety net)
|
|
451
386
|
if (!usingClause && needsUsing) {
|
|
@@ -617,7 +552,9 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
|
|
|
617
552
|
const enumVarName = getEnumVarName(collectionPath, propName);
|
|
618
553
|
const enumDbName = `${collectionPath}_${resolveColumnName(propName, prop)}`;
|
|
619
554
|
const values = Array.isArray(prop.enum)
|
|
620
|
-
? prop.enum.map(v
|
|
555
|
+
? (prop.enum as (string | number | { id: string | number })[]).map((v: string | number | { id: string | number }) =>
|
|
556
|
+
String(typeof v === "object" && v !== null && "id" in v ? v.id : v)
|
|
557
|
+
)
|
|
621
558
|
: Object.keys(prop.enum);
|
|
622
559
|
if (values.length > 0) {
|
|
623
560
|
schemaContent += `export const ${enumVarName} = pgEnum(\"${enumDbName}\", [${values.map(v => `'${v}'`).join(", ")}]);\n`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EntityCollection, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollection, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
|
|
2
|
-
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
2
|
+
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
|
|
3
3
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
4
4
|
import { createHash } from "crypto";
|
|
5
5
|
import { getEffectiveSecurityRules } from "./auth-default-policies";
|
|
@@ -44,36 +44,6 @@ const isIdProperty = (propName: string, prop: Property, collection: EntityCollec
|
|
|
44
44
|
return !hasExplicitId && propName === "id";
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
const resolveRawSql = (expression: string): string => {
|
|
48
|
-
return expression.replace(/\{(\w+)\}/g, (_, col) => col);
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const unwrapSql = (sqlExpr: string): string => {
|
|
52
|
-
return sqlExpr;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const buildUsingClause = (rule: SecurityRule, collection: EntityCollection): string | null => {
|
|
56
|
-
if (rule.using) {
|
|
57
|
-
return resolveRawSql(rule.using);
|
|
58
|
-
}
|
|
59
|
-
if (rule.access === "public") {
|
|
60
|
-
return "true";
|
|
61
|
-
}
|
|
62
|
-
if (rule.ownerField) {
|
|
63
|
-
const prop = collection.properties?.[rule.ownerField];
|
|
64
|
-
const colName = resolveColumnName(rule.ownerField, prop);
|
|
65
|
-
return `${colName} = auth.uid()`;
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const buildWithCheckClause = (rule: SecurityRule, collection: EntityCollection): string | null => {
|
|
71
|
-
if (rule.withCheck) {
|
|
72
|
-
return resolveRawSql(rule.withCheck);
|
|
73
|
-
}
|
|
74
|
-
return buildUsingClause(rule, collection);
|
|
75
|
-
};
|
|
76
|
-
|
|
77
47
|
const getPolicyNameHash = (rule: SecurityRule): string => {
|
|
78
48
|
const data = JSON.stringify({
|
|
79
49
|
a: rule.access,
|
|
@@ -84,7 +54,9 @@ const getPolicyNameHash = (rule: SecurityRule): string => {
|
|
|
84
54
|
rol: rule.roles?.slice().sort(),
|
|
85
55
|
pg: rule.pgRoles?.slice().sort(),
|
|
86
56
|
u: rule.using,
|
|
87
|
-
w: rule.withCheck
|
|
57
|
+
w: rule.withCheck,
|
|
58
|
+
c: rule.condition,
|
|
59
|
+
ch: rule.check
|
|
88
60
|
});
|
|
89
61
|
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
90
62
|
};
|
|
@@ -111,29 +83,18 @@ const generateSinglePolicyDdl = (collection: EntityCollection, rule: SecurityRul
|
|
|
111
83
|
const tableName = getTableName(collection);
|
|
112
84
|
const mode = (rule.mode ?? "permissive").toUpperCase();
|
|
113
85
|
const operationUpper = operation.toUpperCase();
|
|
114
|
-
const roles = rule.roles ? [...rule.roles].sort() : undefined;
|
|
115
86
|
const pgRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
|
|
116
87
|
|
|
117
88
|
const needsUsing = operation !== "insert";
|
|
118
89
|
const needsWithCheck = operation !== "select" && operation !== "delete";
|
|
119
90
|
|
|
120
|
-
|
|
121
|
-
|
|
91
|
+
// Desugar the rule (access / ownerField / roles / structured condition / raw
|
|
92
|
+
// SQL) into the shared PolicyExpression model, then compile to SQL. This is
|
|
93
|
+
// the same normalization the client-side evaluator uses, so DDL and UI agree.
|
|
94
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
122
95
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const roleCondition = `string_to_array(auth.roles(), ',') && ${rolesArrayString}`;
|
|
126
|
-
if (usingClause) {
|
|
127
|
-
usingClause = `(${usingClause}) AND (${roleCondition})`;
|
|
128
|
-
} else if (needsUsing) {
|
|
129
|
-
usingClause = roleCondition;
|
|
130
|
-
}
|
|
131
|
-
if (withCheckClause) {
|
|
132
|
-
withCheckClause = `(${withCheckClause}) AND (${roleCondition})`;
|
|
133
|
-
} else if (needsWithCheck) {
|
|
134
|
-
withCheckClause = roleCondition;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
96
|
+
let usingClause = needsUsing && usingExpr ? policyToPostgres(usingExpr, collection) : null;
|
|
97
|
+
let withCheckClause = needsWithCheck && withCheckExpr ? policyToPostgres(withCheckExpr, collection) : null;
|
|
137
98
|
|
|
138
99
|
if (!usingClause && needsUsing) {
|
|
139
100
|
usingClause = "false";
|
|
@@ -273,7 +234,9 @@ export const generatePostgresDdl = async (
|
|
|
273
234
|
if (("enum" in prop) && (prop.type === "string" || prop.type === "number") && prop.enum) {
|
|
274
235
|
const enumDbName = `${collectionTable}_${resolveColumnName(propName, prop)}`;
|
|
275
236
|
const values = Array.isArray(prop.enum)
|
|
276
|
-
? prop.enum.map(v
|
|
237
|
+
? (prop.enum as (string | number | { id: string | number })[]).map((v: string | number | { id: string | number }) =>
|
|
238
|
+
String(typeof v === "object" && v !== null && "id" in v ? v.id : v)
|
|
239
|
+
)
|
|
277
240
|
: Object.keys(prop.enum);
|
|
278
241
|
if (values.length > 0) {
|
|
279
242
|
ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(v => `'${v}'`).join(", ")});\n`;
|
|
@@ -421,7 +384,7 @@ export const generatePostgresDdl = async (
|
|
|
421
384
|
colDef += " PRIMARY KEY";
|
|
422
385
|
}
|
|
423
386
|
|
|
424
|
-
if ("isId" in prop && prop.isId !== "manual" && prop.isId !== true) {
|
|
387
|
+
if ("isId" in prop && prop.isId !== "manual" && prop.isId !== true && prop.isId !== "increment") {
|
|
425
388
|
if (prop.isId === "uuid") {
|
|
426
389
|
colDef += " DEFAULT gen_random_uuid()";
|
|
427
390
|
} else if (prop.isId === "cuid") {
|
|
@@ -651,9 +651,10 @@ roles: activeAuth.roles })}, true)`);
|
|
|
651
651
|
...registryCollection } as EntityCollection : registryCollection as EntityCollection;
|
|
652
652
|
|
|
653
653
|
const callbacks = resolvedCollection?.callbacks;
|
|
654
|
+
const globalCallbacks = this.registry?.getGlobalCallbacks();
|
|
654
655
|
const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
|
|
655
656
|
|
|
656
|
-
if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
657
|
+
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
657
658
|
const contextForCallback = {
|
|
658
659
|
user: { uid: activeAuth.userId,
|
|
659
660
|
roles: activeAuth.roles },
|
|
@@ -663,6 +664,16 @@ roles: activeAuth.roles },
|
|
|
663
664
|
|
|
664
665
|
return await Promise.all(fetchedEntities.map(async (entity) => {
|
|
665
666
|
let processedEntity = entity;
|
|
667
|
+
// 1. Global callbacks first
|
|
668
|
+
if (globalCallbacks?.afterRead) {
|
|
669
|
+
processedEntity = await globalCallbacks.afterRead({
|
|
670
|
+
collection: resolvedCollection,
|
|
671
|
+
path: notifyPath,
|
|
672
|
+
entity: processedEntity,
|
|
673
|
+
context: contextForCallback
|
|
674
|
+
}) ?? processedEntity;
|
|
675
|
+
}
|
|
676
|
+
// 2. Collection callbacks second
|
|
666
677
|
if (callbacks?.afterRead) {
|
|
667
678
|
processedEntity = await callbacks.afterRead({
|
|
668
679
|
collection: resolvedCollection,
|
|
@@ -671,6 +682,7 @@ roles: activeAuth.roles },
|
|
|
671
682
|
context: contextForCallback
|
|
672
683
|
}) ?? processedEntity;
|
|
673
684
|
}
|
|
685
|
+
// 3. Property callbacks third
|
|
674
686
|
if (propertyCallbacks?.afterRead) {
|
|
675
687
|
processedEntity = await propertyCallbacks.afterRead({
|
|
676
688
|
collection: resolvedCollection,
|
|
@@ -797,9 +809,10 @@ roles: activeAuth.roles })}, true)`);
|
|
|
797
809
|
...registryCollection } as EntityCollection : registryCollection as EntityCollection;
|
|
798
810
|
|
|
799
811
|
const callbacks = resolvedCollection?.callbacks;
|
|
812
|
+
const globalCallbacks = this.registry?.getGlobalCallbacks();
|
|
800
813
|
const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
|
|
801
814
|
|
|
802
|
-
if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
815
|
+
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
803
816
|
const contextForCallback = {
|
|
804
817
|
user: { uid: activeAuth.userId,
|
|
805
818
|
roles: activeAuth.roles },
|
|
@@ -807,6 +820,16 @@ roles: activeAuth.roles },
|
|
|
807
820
|
data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
|
|
808
821
|
} as unknown as RebaseCallContext;
|
|
809
822
|
|
|
823
|
+
// 1. Global callbacks first
|
|
824
|
+
if (globalCallbacks?.afterRead) {
|
|
825
|
+
processedEntity = await globalCallbacks.afterRead({
|
|
826
|
+
collection: resolvedCollection,
|
|
827
|
+
path: notifyPath,
|
|
828
|
+
entity: processedEntity,
|
|
829
|
+
context: contextForCallback
|
|
830
|
+
}) ?? processedEntity;
|
|
831
|
+
}
|
|
832
|
+
// 2. Collection callbacks second
|
|
810
833
|
if (callbacks?.afterRead) {
|
|
811
834
|
processedEntity = await callbacks.afterRead({
|
|
812
835
|
collection: resolvedCollection,
|
|
@@ -815,6 +838,7 @@ roles: activeAuth.roles },
|
|
|
815
838
|
context: contextForCallback
|
|
816
839
|
}) ?? processedEntity;
|
|
817
840
|
}
|
|
841
|
+
// 3. Property callbacks third
|
|
818
842
|
if (propertyCallbacks?.afterRead) {
|
|
819
843
|
processedEntity = await propertyCallbacks.afterRead({
|
|
820
844
|
collection: resolvedCollection,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from "@jest/globals";
|
|
2
|
+
import type { Entity, EntityAfterReadProps, EntityCallbacks } from "@rebasepro/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression guard for the PII-redaction pattern.
|
|
6
|
+
*
|
|
7
|
+
* PII masking is defined in per-collection {@link EntityCallbacks.afterRead},
|
|
8
|
+
* which runs inside the DataDriver on every read path — REST, server-side
|
|
9
|
+
* `rebase.data`, and realtime.
|
|
10
|
+
*
|
|
11
|
+
* This suite proves the redaction contract:
|
|
12
|
+
* 1. the `afterRead` masker redacts values (and does not mutate the source), and
|
|
13
|
+
* 2. it is assignable to `EntityCallbacks.afterRead`, i.e. it is a valid
|
|
14
|
+
* driver/realtime callback.
|
|
15
|
+
*
|
|
16
|
+
* That the driver invokes `afterRead` on `fetchCollection` (REST list /
|
|
17
|
+
* `rebase.data.find`) and `fetchEntity` (REST get / `rebase.data.findById`) is
|
|
18
|
+
* already covered by the "storageSource in Callbacks" suite in
|
|
19
|
+
* `postgresDataDriver.test.ts`; the realtime refetch invokes the identical
|
|
20
|
+
* callback in `realtimeService.ts` (`fetchCollectionWithAuth` /
|
|
21
|
+
* `fetchEntityWithAuth`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
interface CustomerValues extends Record<string, unknown> {
|
|
25
|
+
email: string;
|
|
26
|
+
first_name: string;
|
|
27
|
+
phone: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const maskEmail = (email: string): string => {
|
|
31
|
+
const [local, domain] = email.split("@");
|
|
32
|
+
return local && domain ? `${local[0]}***@${domain}` : email;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A representative per-collection redactor. Typed to require only `entity`
|
|
37
|
+
* (a supertype of the full props), so it is assignable to
|
|
38
|
+
* `EntityCallbacks.afterRead` yet callable in isolation without constructing a
|
|
39
|
+
* full `RebaseCallContext`.
|
|
40
|
+
*/
|
|
41
|
+
const redactCustomer = (
|
|
42
|
+
{ entity }: Pick<EntityAfterReadProps<CustomerValues>, "entity">
|
|
43
|
+
): Entity<CustomerValues> => ({
|
|
44
|
+
...entity,
|
|
45
|
+
values: {
|
|
46
|
+
...entity.values,
|
|
47
|
+
email: maskEmail(entity.values.email),
|
|
48
|
+
phone: "***"
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Compile-time proof: the redactor is a valid EntityCallbacks.afterRead, so the
|
|
53
|
+
// driver and realtime service accept and invoke it on every read path.
|
|
54
|
+
const customerCallbacks: EntityCallbacks<CustomerValues> = { afterRead: redactCustomer };
|
|
55
|
+
void customerCallbacks;
|
|
56
|
+
|
|
57
|
+
describe("EntityCallbacks.afterRead PII redaction contract", () => {
|
|
58
|
+
|
|
59
|
+
const customer: Entity<CustomerValues> = {
|
|
60
|
+
id: "c1",
|
|
61
|
+
path: "customers",
|
|
62
|
+
values: { email: "jane.doe@acme.com", first_name: "Jane", phone: "+15551234567" }
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
it("masks redacted fields in the returned entity", () => {
|
|
66
|
+
const result = redactCustomer({ entity: customer });
|
|
67
|
+
|
|
68
|
+
expect(result.values.email).toBe("j***@acme.com");
|
|
69
|
+
expect(result.values.phone).toBe("***");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("leaves non-redacted fields untouched", () => {
|
|
73
|
+
const result = redactCustomer({ entity: customer });
|
|
74
|
+
|
|
75
|
+
expect(result.values.first_name).toBe("Jane");
|
|
76
|
+
expect(result.id).toBe("c1");
|
|
77
|
+
expect(result.path).toBe("customers");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does not mutate the source entity", () => {
|
|
81
|
+
redactCustomer({ entity: customer });
|
|
82
|
+
|
|
83
|
+
expect(customer.values.email).toBe("jane.doe@acme.com");
|
|
84
|
+
expect(customer.values.phone).toBe("+15551234567");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -39,7 +39,8 @@ describe("PostgresBackendDriver", () => {
|
|
|
39
39
|
getCollectionByPath: jest.fn().mockReturnValue({ slug: "test_coll",
|
|
40
40
|
properties: {} }),
|
|
41
41
|
getCollections: jest.fn().mockReturnValue([]),
|
|
42
|
-
getTable: jest.fn().mockReturnValue({})
|
|
42
|
+
getTable: jest.fn().mockReturnValue({}),
|
|
43
|
+
getGlobalCallbacks: jest.fn().mockReturnValue(undefined)
|
|
43
44
|
} as any;
|
|
44
45
|
delegate = new PostgresBackendDriver(mockDb, mockRealtimeService, mockRegistry);
|
|
45
46
|
});
|