@rebasepro/common 0.6.1 → 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/dist/collections/CollectionRegistry.d.ts +30 -2
- package/dist/collections/default-collections.d.ts +255 -2
- package/dist/data/buildRoutedRebaseData.d.ts +53 -0
- package/dist/data/filter-dialect.d.ts +61 -0
- package/dist/data/query_builder.d.ts +4 -4
- package/dist/data/resolveDataSource.d.ts +43 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.es.js +777 -178
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +793 -176
- package/dist/index.umd.js.map +1 -1
- package/dist/table-classification.d.ts +47 -0
- package/dist/util/builders.d.ts +48 -1
- package/dist/util/callbacks.d.ts +6 -1
- package/dist/util/index.d.ts +1 -0
- package/dist/util/permissions.d.ts +26 -2
- package/dist/util/policy/evaluatePolicy.d.ts +31 -0
- package/dist/util/policy/index.d.ts +3 -0
- package/dist/util/policy/policyToPostgres.d.ts +10 -0
- package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
- package/dist/util/policy/sqlToPolicy.d.ts +20 -0
- package/dist/util/storage.d.ts +26 -1
- package/package.json +3 -3
- package/src/collections/CollectionRegistry.ts +80 -16
- package/src/collections/default-collections.ts +4 -4
- package/src/data/buildRebaseData.ts +9 -120
- package/src/data/buildRoutedRebaseData.ts +97 -0
- package/src/data/filter-dialect.ts +318 -0
- package/src/data/query_builder.ts +10 -10
- package/src/data/resolveDataSource.ts +79 -0
- package/src/index.ts +4 -1
- package/src/table-classification.ts +109 -0
- package/src/util/builders.ts +78 -1
- package/src/util/callbacks.ts +8 -1
- package/src/util/index.ts +1 -0
- package/src/util/permissions.test.ts +5 -3
- package/src/util/permissions.ts +85 -158
- package/src/util/policy/evaluatePolicy.ts +146 -0
- package/src/util/policy/index.ts +3 -0
- package/src/util/policy/policyToPostgres.ts +85 -0
- package/src/util/policy/securityRuleToConditions.ts +67 -0
- package/src/util/policy/sqlToPolicy.ts +88 -0
- package/src/util/references.ts +1 -1
- package/src/util/relations.ts +8 -9
- package/src/util/resolutions.ts +6 -6
- package/src/util/storage.ts +34 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Entity, PolicyCompareOperator, PolicyExpression, PolicyOperand } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Result of evaluating a policy client-side. `"unknown"` means the expression
|
|
5
|
+
* could not be decided without more information — either a raw-SQL escape-hatch
|
|
6
|
+
* node (which the client deliberately never guesses) or a row-column reference
|
|
7
|
+
* with no entity in hand (e.g. list-level gating). Callers decide how to resolve
|
|
8
|
+
* `"unknown"`: fail-closed for an enforcement decision, optimistic for pure
|
|
9
|
+
* visibility gating.
|
|
10
|
+
*/
|
|
11
|
+
export type TriState = boolean | "unknown";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Context for {@link evaluatePolicy}: the acting user (or none) and the row
|
|
15
|
+
* being evaluated (or none, for collection-level gating).
|
|
16
|
+
*/
|
|
17
|
+
export interface PolicyEvalContext {
|
|
18
|
+
/** The current user's id, or null/undefined when unauthenticated. */
|
|
19
|
+
uid?: string | null;
|
|
20
|
+
/** The current user's application roles. */
|
|
21
|
+
roles?: string[];
|
|
22
|
+
/** The row being evaluated, or null when no specific row is available. */
|
|
23
|
+
entity: Entity | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Evaluates a {@link PolicyExpression} against a user + row, using three-valued
|
|
28
|
+
* (Kleene) logic so that `"unknown"` sub-results propagate soundly.
|
|
29
|
+
*
|
|
30
|
+
* This is the JavaScript twin of {@link policyToPostgres}: both derive from the
|
|
31
|
+
* same expression, so the admin UI matches database enforcement by construction
|
|
32
|
+
* for every non-raw rule.
|
|
33
|
+
*/
|
|
34
|
+
export function evaluatePolicy(expr: PolicyExpression, ctx: PolicyEvalContext): TriState {
|
|
35
|
+
switch (expr.kind) {
|
|
36
|
+
case "true":
|
|
37
|
+
return true;
|
|
38
|
+
case "false":
|
|
39
|
+
return false;
|
|
40
|
+
case "and":
|
|
41
|
+
return kleeneAnd(expr.operands.map(o => evaluatePolicy(o, ctx)));
|
|
42
|
+
case "or":
|
|
43
|
+
return kleeneOr(expr.operands.map(o => evaluatePolicy(o, ctx)));
|
|
44
|
+
case "not":
|
|
45
|
+
return kleeneNot(evaluatePolicy(expr.operand, ctx));
|
|
46
|
+
case "compare":
|
|
47
|
+
return evaluateCompare(expr.op, expr.left, expr.right, ctx);
|
|
48
|
+
case "rolesOverlap": {
|
|
49
|
+
const userRoles = ctx.roles ?? [];
|
|
50
|
+
return expr.roles.some(r => r === "public" || userRoles.includes(r));
|
|
51
|
+
}
|
|
52
|
+
case "rolesContain": {
|
|
53
|
+
const userRoles = ctx.roles ?? [];
|
|
54
|
+
return expr.roles.every(r => r === "public" || userRoles.includes(r));
|
|
55
|
+
}
|
|
56
|
+
case "authenticated":
|
|
57
|
+
return ctx.uid != null;
|
|
58
|
+
case "raw":
|
|
59
|
+
// Arbitrary SQL cannot be evaluated client-side — never guess.
|
|
60
|
+
return "unknown";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Three-valued logic ───────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function kleeneAnd(values: TriState[]): TriState {
|
|
67
|
+
if (values.some(v => v === false)) return false;
|
|
68
|
+
if (values.some(v => v === "unknown")) return "unknown";
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function kleeneOr(values: TriState[]): TriState {
|
|
73
|
+
if (values.some(v => v === true)) return true;
|
|
74
|
+
if (values.some(v => v === "unknown")) return "unknown";
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function kleeneNot(value: TriState): TriState {
|
|
79
|
+
if (value === "unknown") return "unknown";
|
|
80
|
+
return !value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Comparison ───────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
type ResolvedOperand = { known: false } | { known: true; value: unknown };
|
|
86
|
+
|
|
87
|
+
function resolveOperand(operand: PolicyOperand, ctx: PolicyEvalContext): ResolvedOperand {
|
|
88
|
+
switch (operand.kind) {
|
|
89
|
+
case "literal":
|
|
90
|
+
return { known: true, value: operand.value };
|
|
91
|
+
case "authUid":
|
|
92
|
+
return { known: true, value: ctx.uid ?? null };
|
|
93
|
+
case "authRoles":
|
|
94
|
+
return { known: true, value: ctx.roles ?? [] };
|
|
95
|
+
case "field":
|
|
96
|
+
// Can't resolve a row column without the row.
|
|
97
|
+
if (!ctx.entity) return { known: false };
|
|
98
|
+
return { known: true, value: ctx.entity.values[operand.name] };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function evaluateCompare(
|
|
103
|
+
op: PolicyCompareOperator,
|
|
104
|
+
left: PolicyOperand,
|
|
105
|
+
right: PolicyOperand,
|
|
106
|
+
ctx: PolicyEvalContext
|
|
107
|
+
): TriState {
|
|
108
|
+
const l = resolveOperand(left, ctx);
|
|
109
|
+
const r = resolveOperand(right, ctx);
|
|
110
|
+
if (!l.known || !r.known) return "unknown";
|
|
111
|
+
|
|
112
|
+
const a = l.value;
|
|
113
|
+
const b = r.value;
|
|
114
|
+
|
|
115
|
+
if (a === null || b === null) {
|
|
116
|
+
if (op === "eq") return false;
|
|
117
|
+
if (op === "neq") return true;
|
|
118
|
+
return "unknown";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (op === "eq") return a === b;
|
|
122
|
+
if (op === "neq") return a !== b;
|
|
123
|
+
|
|
124
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
125
|
+
if (op === "lt") return a < b;
|
|
126
|
+
if (op === "lte") return a <= b;
|
|
127
|
+
if (op === "gt") return a > b;
|
|
128
|
+
if (op === "gte") return a >= b;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
132
|
+
if (op === "lt") return a < b;
|
|
133
|
+
if (op === "lte") return a <= b;
|
|
134
|
+
if (op === "gt") return a > b;
|
|
135
|
+
if (op === "gte") return a >= b;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (typeof a === "bigint" && typeof b === "bigint") {
|
|
139
|
+
if (op === "lt") return a < b;
|
|
140
|
+
if (op === "lte") return a <= b;
|
|
141
|
+
if (op === "gt") return a > b;
|
|
142
|
+
if (op === "gte") return a >= b;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return "unknown";
|
|
146
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { EntityCollection, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property } from "@rebasepro/types";
|
|
2
|
+
import { toSnakeCase } from "@rebasepro/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compiles a {@link PolicyExpression} to a PostgreSQL boolean SQL string,
|
|
6
|
+
* suitable for a `USING (...)` / `WITH CHECK (...)` clause.
|
|
7
|
+
*
|
|
8
|
+
* This is one of the two consumers of the shared policy model (the other being
|
|
9
|
+
* {@link evaluatePolicy}); the Postgres schema generators call it so that DDL
|
|
10
|
+
* and the admin UI derive from the exact same expression.
|
|
11
|
+
*/
|
|
12
|
+
export function policyToPostgres(expr: PolicyExpression, collection?: EntityCollection): string {
|
|
13
|
+
switch (expr.kind) {
|
|
14
|
+
case "true":
|
|
15
|
+
return "true";
|
|
16
|
+
case "false":
|
|
17
|
+
return "false";
|
|
18
|
+
case "and":
|
|
19
|
+
return expr.operands.length === 0
|
|
20
|
+
? "true"
|
|
21
|
+
: expr.operands.map(o => `(${policyToPostgres(o, collection)})`).join(" AND ");
|
|
22
|
+
case "or":
|
|
23
|
+
return expr.operands.length === 0
|
|
24
|
+
? "false"
|
|
25
|
+
: expr.operands.map(o => `(${policyToPostgres(o, collection)})`).join(" OR ");
|
|
26
|
+
case "not":
|
|
27
|
+
// Render the common `auth.uid() IS NULL` (unauthenticated) form directly.
|
|
28
|
+
if (expr.operand.kind === "authenticated") return "auth.uid() IS NULL";
|
|
29
|
+
return `NOT (${policyToPostgres(expr.operand, collection)})`;
|
|
30
|
+
case "compare":
|
|
31
|
+
return `${operandToSql(expr.left, collection)} ${COMPARE_SQL[expr.op]} ${operandToSql(expr.right, collection)}`;
|
|
32
|
+
case "rolesOverlap":
|
|
33
|
+
return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
|
|
34
|
+
case "rolesContain":
|
|
35
|
+
return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
|
|
36
|
+
case "authenticated":
|
|
37
|
+
return "auth.uid() IS NOT NULL";
|
|
38
|
+
case "raw":
|
|
39
|
+
// Full-power escape hatch: `{column}` references resolve to the bare
|
|
40
|
+
// column name (matching the previous raw-SQL behavior).
|
|
41
|
+
return expr.sql.replace(/\{(\w+)\}/g, (_, col) => col);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const COMPARE_SQL: Record<PolicyCompareOperator, string> = {
|
|
46
|
+
eq: "=",
|
|
47
|
+
neq: "!=",
|
|
48
|
+
lt: "<",
|
|
49
|
+
lte: "<=",
|
|
50
|
+
gt: ">",
|
|
51
|
+
gte: ">="
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function operandToSql(operand: PolicyOperand, collection?: EntityCollection): string {
|
|
55
|
+
switch (operand.kind) {
|
|
56
|
+
case "field":
|
|
57
|
+
return resolveColumnName(operand.name, collection);
|
|
58
|
+
case "literal":
|
|
59
|
+
return quoteLiteral(operand.value);
|
|
60
|
+
case "authUid":
|
|
61
|
+
return "auth.uid()";
|
|
62
|
+
case "authRoles":
|
|
63
|
+
return "string_to_array(auth.roles(), ',')";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveColumnName(propName: string, collection?: EntityCollection): string {
|
|
68
|
+
const prop = collection?.properties?.[propName] as Property | undefined;
|
|
69
|
+
if (prop && "columnName" in prop && typeof (prop as { columnName?: unknown }).columnName === "string") {
|
|
70
|
+
return (prop as { columnName: string }).columnName;
|
|
71
|
+
}
|
|
72
|
+
return toSnakeCase(propName);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function quoteLiteral(value: string | number | boolean | null): string {
|
|
76
|
+
if (value === null) return "NULL";
|
|
77
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
78
|
+
if (typeof value === "number") return String(value);
|
|
79
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output. */
|
|
83
|
+
function rolesArraySql(roles: string[]): string {
|
|
84
|
+
return `ARRAY[${[...roles].sort().map(r => `'${r}'`).join(",")}]`;
|
|
85
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { PolicyExpression, SecurityRule, policy } from "@rebasepro/types";
|
|
2
|
+
import { sqlToPolicy } from "./sqlToPolicy";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The normalized `USING` / `WITH CHECK` conditions for a single security rule,
|
|
6
|
+
* expressed in the engine-agnostic {@link PolicyExpression} model.
|
|
7
|
+
*
|
|
8
|
+
* A `null` clause means "this rule contributes no condition for that clause";
|
|
9
|
+
* consumers apply the default (Postgres denies with `false`).
|
|
10
|
+
*/
|
|
11
|
+
export interface RuleConditions {
|
|
12
|
+
usingExpr: PolicyExpression | null;
|
|
13
|
+
withCheckExpr: PolicyExpression | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Desugars a {@link SecurityRule} — its `access`/`ownerField`/`roles` shortcuts,
|
|
18
|
+
* structured `condition`/`check`, and raw `using`/`withCheck` — into a single
|
|
19
|
+
* normalized {@link PolicyExpression} pair.
|
|
20
|
+
*
|
|
21
|
+
* **This is the linchpin against drift:** both the Postgres DDL generators and
|
|
22
|
+
* the client-side evaluator consume this one function, so there is exactly one
|
|
23
|
+
* definition of what a rule means. In particular, application `roles` are folded
|
|
24
|
+
* into the expression here (AND'd with the base condition, matching how Postgres
|
|
25
|
+
* generates the clause) rather than being handled separately by each consumer.
|
|
26
|
+
*/
|
|
27
|
+
export function securityRuleToConditions(rule: SecurityRule): RuleConditions {
|
|
28
|
+
return {
|
|
29
|
+
usingExpr: withRoles(baseUsing(rule), rule),
|
|
30
|
+
withCheckExpr: withRoles(baseWithCheck(rule), rule)
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function baseUsing(rule: SecurityRule): PolicyExpression | null {
|
|
35
|
+
if (rule.condition) return rule.condition;
|
|
36
|
+
if (rule.using != null) return sqlToPolicy(rule.using);
|
|
37
|
+
if (rule.access === "public") return policy.true();
|
|
38
|
+
if (rule.ownerField) return policy.compare(policy.field(rule.ownerField), "eq", policy.authUid());
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function baseWithCheck(rule: SecurityRule): PolicyExpression | null {
|
|
43
|
+
if (rule.check) return rule.check;
|
|
44
|
+
if (rule.withCheck != null) return sqlToPolicy(rule.withCheck);
|
|
45
|
+
// No explicit WITH CHECK → fall back to the USING condition, matching
|
|
46
|
+
// PostgreSQL's own default behavior.
|
|
47
|
+
return baseUsing(rule);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* AND the base condition with an application-role check, or produce a roles-only
|
|
52
|
+
* condition when there is no base. Mirrors the Postgres generator so that a
|
|
53
|
+
* role-scoped restrictive rule denies exactly the same set of users on both
|
|
54
|
+
* sides.
|
|
55
|
+
*/
|
|
56
|
+
function withRoles(base: PolicyExpression | null, rule: SecurityRule): PolicyExpression | null {
|
|
57
|
+
if (!rule.roles || rule.roles.length === 0) return base;
|
|
58
|
+
const rolesExpr = policy.rolesOverlap(rule.roles);
|
|
59
|
+
if (rule.mode === "restrictive") {
|
|
60
|
+
// Restrictive rule: applies ONLY if user has the roles.
|
|
61
|
+
// If user DOES NOT have the roles, they are NOT restricted (passes).
|
|
62
|
+
// If user HAS the roles, they must pass the base condition.
|
|
63
|
+
// Logical equivalent: NOT(roles) OR base
|
|
64
|
+
return base ? policy.or(policy.not(rolesExpr), base) : policy.not(rolesExpr);
|
|
65
|
+
}
|
|
66
|
+
return base ? policy.and(base, rolesExpr) : rolesExpr;
|
|
67
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { PolicyExpression, policy } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A tiny, regex-based SQL "parser" for security rules.
|
|
5
|
+
*
|
|
6
|
+
* This is NOT a full SQL parser. It is designed to handle the subset of SQL
|
|
7
|
+
* commonly used in `USING` and `WITH CHECK` clauses, enough to drive the
|
|
8
|
+
* optimistic client-side UI decision.
|
|
9
|
+
*
|
|
10
|
+
* It handles:
|
|
11
|
+
* - `field = 'literal'`
|
|
12
|
+
* - `field != 'literal'`
|
|
13
|
+
* - `field = current_setting('app.user_id')`
|
|
14
|
+
* - `A AND B`
|
|
15
|
+
* - `true`
|
|
16
|
+
* - `IN (...)` (as optimistic true)
|
|
17
|
+
*
|
|
18
|
+
* For anything it doesn't understand, it returns a `raw` expression, which
|
|
19
|
+
* the evaluator treats as "unknown" (and usually optimistic true).
|
|
20
|
+
*/
|
|
21
|
+
export function sqlToPolicy(sql: string): PolicyExpression {
|
|
22
|
+
const trimmed = sql.trim();
|
|
23
|
+
|
|
24
|
+
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
25
|
+
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
26
|
+
|
|
27
|
+
// Handle roles overlap (&&)
|
|
28
|
+
// Matches: string_to_array(auth.roles(), ',') && ARRAY['admin', 'editor']
|
|
29
|
+
const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
|
|
30
|
+
if (overlapMatch) {
|
|
31
|
+
const roles = overlapMatch[1].split(",").map(s => s.trim().replace(/^'|'$/g, ""));
|
|
32
|
+
return policy.rolesOverlap(roles);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Handle roles containment (@>)
|
|
36
|
+
// Matches: string_to_array(auth.roles(), ',') @> ARRAY['admin']
|
|
37
|
+
const containMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
|
|
38
|
+
if (containMatch) {
|
|
39
|
+
const roles = containMatch[1].split(",").map(s => s.trim().replace(/^'|'$/g, ""));
|
|
40
|
+
return policy.rolesContain(roles);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Handle OR
|
|
44
|
+
if (trimmed.toUpperCase().includes(" OR ")) {
|
|
45
|
+
const parts = trimmed.split(/ OR /i);
|
|
46
|
+
return policy.or(...parts.map(sqlToPolicy));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Handle AND (very basic split, doesn't handle nested parens properly)
|
|
50
|
+
if (trimmed.toUpperCase().includes(" AND ")) {
|
|
51
|
+
const parts = trimmed.split(/ AND /i);
|
|
52
|
+
return policy.and(...parts.map(sqlToPolicy));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Handle = and !=
|
|
56
|
+
const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
|
|
57
|
+
if (match) {
|
|
58
|
+
const [, leftStr, op, rightStr] = match;
|
|
59
|
+
const left = parseOperand(leftStr.trim());
|
|
60
|
+
const right = parseOperand(rightStr.trim());
|
|
61
|
+
if (left && right) {
|
|
62
|
+
return policy.compare(left, op === "=" ? "eq" : "neq", right);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Fallback to raw
|
|
67
|
+
return policy.raw(sql);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseOperand(str: string) {
|
|
71
|
+
// current_setting('app.user_id') or auth.uid()
|
|
72
|
+
if (/current_setting\s*\(\s*'app\.user_id'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) {
|
|
73
|
+
return policy.authUid();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Literal string: 'value'
|
|
77
|
+
const stringMatch = str.match(/^'(.+)'$/);
|
|
78
|
+
if (stringMatch) {
|
|
79
|
+
return policy.literal(stringMatch[1]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Bare field name
|
|
83
|
+
if (/^\w+$/.test(str)) {
|
|
84
|
+
return policy.field(str);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
package/src/util/references.ts
CHANGED
|
@@ -26,7 +26,7 @@ export function getEntityImagePreviewPropertyKey<M extends Record<string, unknow
|
|
|
26
26
|
// and arrays of URL properties with image preview type
|
|
27
27
|
for (const key in collection.properties) {
|
|
28
28
|
const property = collection.properties[key];
|
|
29
|
-
if (property.type === "array" && property.of && !Array.isArray(property.of) && property.of.type === "string" && property.of.url === "image") {
|
|
29
|
+
if (property.type === "array" && property.of && !Array.isArray(property.of) && property.of.type === "string" && property.of.ui?.url === "image") {
|
|
30
30
|
return key;
|
|
31
31
|
}
|
|
32
32
|
}
|
package/src/util/relations.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EntityCollection, getDataSourceCapabilities, Property, Relation, RelationProperty } from "@rebasepro/types";
|
|
2
2
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
3
3
|
import { generateForeignKeyName } from "@rebasepro/utils";
|
|
4
4
|
|
|
@@ -89,7 +89,7 @@ name: evaluated } as EntityCollection;
|
|
|
89
89
|
|
|
90
90
|
try {
|
|
91
91
|
// Look for an owning relation on the target that points back to this collection
|
|
92
|
-
const targetRelations = getDataSourceCapabilities(targetCollection.
|
|
92
|
+
const targetRelations = getDataSourceCapabilities(targetCollection.engine).supportsRelations ? (targetCollection.relations || []) : [];
|
|
93
93
|
for (const targetRel of targetRelations) {
|
|
94
94
|
if (targetRel.direction === "owning" &&
|
|
95
95
|
targetRel.cardinality === "one" &&
|
|
@@ -135,7 +135,7 @@ name: evaluated } as EntityCollection;
|
|
|
135
135
|
// `cardinality: "many" + direction: "owning"` is sufficient to identify owning M2M.
|
|
136
136
|
|
|
137
137
|
// 1. Check the explicit relations[] array
|
|
138
|
-
const targetRelations = getDataSourceCapabilities(targetCollection.
|
|
138
|
+
const targetRelations = getDataSourceCapabilities(targetCollection.engine).supportsRelations ? (targetCollection.relations || []) : [];
|
|
139
139
|
for (const targetRel of targetRelations) {
|
|
140
140
|
if (targetRel.cardinality === "many" &&
|
|
141
141
|
(targetRel.direction === "owning" || !targetRel.direction) &&
|
|
@@ -206,8 +206,7 @@ export function resolveCollectionRelations(
|
|
|
206
206
|
const cached = _resolvedRelationsCache.get(collection);
|
|
207
207
|
if (cached) return cached;
|
|
208
208
|
|
|
209
|
-
if (!getDataSourceCapabilities(collection.
|
|
210
|
-
const relCollection = collection as CollectionWithRelations;
|
|
209
|
+
if (!getDataSourceCapabilities(collection.engine).supportsRelations) return {};
|
|
211
210
|
const relations: Record<string, Relation> = {};
|
|
212
211
|
|
|
213
212
|
// Track which explicit relationName values have been registered so that
|
|
@@ -217,8 +216,8 @@ export function resolveCollectionRelations(
|
|
|
217
216
|
|
|
218
217
|
// 1. Process explicit relations from the `relations` field.
|
|
219
218
|
// Each relation is stored once under its canonical relationName key.
|
|
220
|
-
if (
|
|
221
|
-
|
|
219
|
+
if (collection.relations) {
|
|
220
|
+
collection.relations.forEach((relation: Relation) => {
|
|
222
221
|
try {
|
|
223
222
|
const normalizedRelation = sanitizeRelation(relation, collection);
|
|
224
223
|
const relationKey = normalizedRelation.relationName;
|
|
@@ -306,8 +305,8 @@ export function resolvePropertyRelation({
|
|
|
306
305
|
}
|
|
307
306
|
|
|
308
307
|
export function getTableName(collection: EntityCollection): string {
|
|
309
|
-
if (getDataSourceCapabilities(collection.
|
|
310
|
-
return
|
|
308
|
+
if (getDataSourceCapabilities(collection.engine).supportsRelations) {
|
|
309
|
+
return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
|
|
311
310
|
}
|
|
312
311
|
return toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
|
|
313
312
|
}
|
package/src/util/resolutions.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ArrayProperty,
|
|
3
3
|
AuthController,
|
|
4
|
-
CollectionWithRelations,
|
|
5
|
-
CollectionWithSubcollections,
|
|
6
4
|
EntityCollection,
|
|
7
5
|
EnumValueConfig,
|
|
8
6
|
EnumValues,
|
|
@@ -12,7 +10,8 @@ import {
|
|
|
12
10
|
Relation,
|
|
13
11
|
RelationProperty,
|
|
14
12
|
StringProperty,
|
|
15
|
-
getDataSourceCapabilities
|
|
13
|
+
getDataSourceCapabilities,
|
|
14
|
+
getDeclaredSubcollections
|
|
16
15
|
} from "@rebasepro/types";
|
|
17
16
|
|
|
18
17
|
type PropertyConfig = { property: unknown; [key: string]: unknown };
|
|
@@ -347,11 +346,12 @@ export function getSubcollections<M extends Record<string, unknown> = Record<str
|
|
|
347
346
|
return collection.childCollections() ?? [];
|
|
348
347
|
}
|
|
349
348
|
|
|
350
|
-
|
|
351
|
-
|
|
349
|
+
const declaredSubcollections = getDeclaredSubcollections(collection);
|
|
350
|
+
if (getDataSourceCapabilities(collection.engine).supportsSubcollections && declaredSubcollections) {
|
|
351
|
+
return declaredSubcollections() ?? [];
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
-
if (getDataSourceCapabilities(collection.
|
|
354
|
+
if (getDataSourceCapabilities(collection.engine).supportsRelations) {
|
|
355
355
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
356
356
|
const manyRelations = Object.values(resolvedRelations).filter((r: Relation) => r.cardinality === "many");
|
|
357
357
|
|
package/src/util/storage.ts
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
|
-
import { ArrayProperty, EntityValues, StorageConfig, StringProperty, UploadedFileContext } from "@rebasepro/types";
|
|
1
|
+
import { ArrayProperty, EntityValues, StorageConfig, StorageSource, StorageSourceRegistry, StringProperty, UploadedFileContext } from "@rebasepro/types";
|
|
2
2
|
import { randomString } from "@rebasepro/utils";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the {@link StorageSource} to use for a property, given the key
|
|
6
|
+
* referenced by `StorageConfig.storageSource`.
|
|
7
|
+
*
|
|
8
|
+
* Resolution priority:
|
|
9
|
+
* 1. No `sourceKey` → the default source (backward compatible).
|
|
10
|
+
* 2. An explicit {@link StorageSourceRegistry} (e.g. `client.storageRegistry`).
|
|
11
|
+
* 3. A `sources` lookup map (e.g. the `StorageSourcesContext`).
|
|
12
|
+
* 4. Fall back to the default source.
|
|
13
|
+
*
|
|
14
|
+
* Shared by the upload hook, the markdown editor, and the read-only previews
|
|
15
|
+
* so the resolution logic lives in one place.
|
|
16
|
+
*
|
|
17
|
+
* @group Storage
|
|
18
|
+
*/
|
|
19
|
+
export function resolveStorageSource(params: {
|
|
20
|
+
/** Key from `StorageConfig.storageSource`. */
|
|
21
|
+
sourceKey?: string | null;
|
|
22
|
+
/** Built sources keyed by storage-source key (e.g. from context). */
|
|
23
|
+
sources?: Record<string, StorageSource>;
|
|
24
|
+
/** Optional explicit registry — takes precedence over `sources`. */
|
|
25
|
+
registry?: StorageSourceRegistry;
|
|
26
|
+
/** Default source, used when no key is set or the key cannot be resolved. */
|
|
27
|
+
defaultSource: StorageSource;
|
|
28
|
+
}): StorageSource {
|
|
29
|
+
const { sourceKey, sources, registry, defaultSource } = params;
|
|
30
|
+
if (!sourceKey) return defaultSource;
|
|
31
|
+
if (registry) return registry.getOrDefault(sourceKey);
|
|
32
|
+
const fromSources = sources?.[sourceKey];
|
|
33
|
+
if (fromSources) return fromSources;
|
|
34
|
+
return defaultSource;
|
|
35
|
+
}
|
|
36
|
+
|
|
4
37
|
interface ResolveFilenameStringParams<M extends Record<string, unknown>> {
|
|
5
38
|
input: string | ((context: UploadedFileContext) => (Promise<string> | string));
|
|
6
39
|
storage: StorageConfig;
|