@cosmicdrift/kumiko-framework 0.256.0 → 0.258.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/package.json +3 -3
- package/src/db/__tests__/parent-ref-clause.test.ts +213 -0
- package/src/db/event-store-executor-read.ts +97 -19
- package/src/db/parent-ref-clause.ts +182 -0
- package/src/db/table-builder.ts +7 -0
- package/src/engine/__tests__/boot-validator.test.ts +228 -0
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +149 -0
- package/src/engine/boot-validator/action-wiring.ts +7 -7
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/parent-ref.ts +96 -0
- package/src/engine/boot-validator/projection-list-screens.ts +36 -23
- package/src/engine/boot-validator/query-output-columns.ts +2 -1
- package/src/engine/boot-validator/query-refs.ts +2 -1
- package/src/engine/boot-validator/screens.ts +322 -131
- package/src/engine/define-handler.ts +6 -0
- package/src/engine/entity-handlers.ts +6 -1
- package/src/engine/factories.ts +2 -0
- package/src/engine/feature-entity-handlers.ts +5 -0
- package/src/engine/index.ts +6 -0
- package/src/engine/ownership.ts +15 -0
- package/src/engine/pattern-library/mixed-schemas.ts +1 -0
- package/src/engine/screen-helpers.ts +25 -0
- package/src/engine/types/index.ts +5 -0
- package/src/i18n/required-surface-keys.ts +48 -0
- package/src/testing/e2e-generator.ts +2 -0
- package/src/ui-types/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.258.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.258.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.258.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createEntity, createTextField } from "../../engine/factories";
|
|
3
|
+
import { combineClauses, from } from "../../engine/ownership";
|
|
4
|
+
import type { EntityDefinition, SessionUser } from "../../engine/types";
|
|
5
|
+
import { buildParentRefClause } from "../parent-ref-clause";
|
|
6
|
+
import { buildEntityTable } from "../table-builder";
|
|
7
|
+
import type { TenantDb } from "../tenant-db";
|
|
8
|
+
|
|
9
|
+
const TENANT = "11111111-1111-4111-8111-111111111111";
|
|
10
|
+
|
|
11
|
+
const user: SessionUser = {
|
|
12
|
+
id: "22222222-2222-4222-8222-222222222222",
|
|
13
|
+
tenantId: TENANT,
|
|
14
|
+
roles: ["TenantMember"],
|
|
15
|
+
claims: {},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Only `mode` and `tenantId` are read by the clause builder.
|
|
19
|
+
const tenantDb = { mode: "tenant", tenantId: TENANT } as unknown as TenantDb;
|
|
20
|
+
const systemDb = { mode: "system", tenantId: TENANT } as unknown as TenantDb;
|
|
21
|
+
|
|
22
|
+
const textField = () =>
|
|
23
|
+
createTextField({
|
|
24
|
+
required: true,
|
|
25
|
+
maxLength: 64,
|
|
26
|
+
personal: false,
|
|
27
|
+
reason: "technical_reference",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const joinEntity = createEntity({
|
|
31
|
+
table: "read_test_joins",
|
|
32
|
+
fields: { entityType: textField(), entityId: textField() },
|
|
33
|
+
parentRef: { entityTypeField: "entityType", entityIdField: "entityId" },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function hostEntity(opts?: {
|
|
37
|
+
readonly access?: EntityDefinition["access"];
|
|
38
|
+
readonly softDelete?: boolean;
|
|
39
|
+
readonly idType?: "serial" | "uuid";
|
|
40
|
+
readonly parentRef?: EntityDefinition["parentRef"];
|
|
41
|
+
}) {
|
|
42
|
+
return createEntity({
|
|
43
|
+
table: "read_test_hosts",
|
|
44
|
+
fields: { ownerId: textField(), entityType: textField(), entityId: textField() },
|
|
45
|
+
...opts,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const joinTable = buildEntityTable("test-join", joinEntity);
|
|
50
|
+
|
|
51
|
+
function build(
|
|
52
|
+
entities: ReadonlyMap<string, EntityDefinition> | undefined,
|
|
53
|
+
opts: { includeDeleted?: boolean; narrowTypes?: ReadonlySet<string>; db?: TenantDb } = {},
|
|
54
|
+
) {
|
|
55
|
+
return buildParentRefClause(
|
|
56
|
+
joinEntity,
|
|
57
|
+
joinTable,
|
|
58
|
+
"read_test_joins",
|
|
59
|
+
user,
|
|
60
|
+
opts.db ?? tenantDb,
|
|
61
|
+
entities === undefined ? undefined : { entities },
|
|
62
|
+
{
|
|
63
|
+
...(opts.includeDeleted !== undefined && { includeDeleted: opts.includeDeleted }),
|
|
64
|
+
...(opts.narrowTypes !== undefined && { narrowTypes: opts.narrowTypes }),
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("buildParentRefClause", () => {
|
|
70
|
+
test("an entity without parentRef is not gated", () => {
|
|
71
|
+
const plain = createEntity({ table: "read_plain", fields: { a: textField() } });
|
|
72
|
+
const clause = buildParentRefClause(
|
|
73
|
+
plain,
|
|
74
|
+
buildEntityTable("plain", plain),
|
|
75
|
+
"read_plain",
|
|
76
|
+
user,
|
|
77
|
+
tenantDb,
|
|
78
|
+
{ entities: new Map([["host", hostEntity()]]) },
|
|
79
|
+
{},
|
|
80
|
+
);
|
|
81
|
+
expect(clause.kind).toBe("pass");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a raw executor call without the registry option stays ungated", () => {
|
|
85
|
+
expect(build(undefined).kind).toBe("pass");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("no candidate at all denies every row", () => {
|
|
89
|
+
expect(build(new Map()).kind).toBe("empty");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a candidate whose read-ownership can never match is dropped, denying when it was the only one", () => {
|
|
93
|
+
// The role has a rule, but the column it names is not on the host — the
|
|
94
|
+
// ownership clause collapses to `empty`, so the branch cannot be satisfied.
|
|
95
|
+
const host = hostEntity({ access: { read: { TenantMember: from("user:id", "notAColumn") } } });
|
|
96
|
+
expect(build(new Map([["host", host]])).kind).toBe("empty");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a serial-id candidate is excluded — it has no event-store read path", () => {
|
|
100
|
+
const host = hostEntity({ idType: "serial" });
|
|
101
|
+
expect(build(new Map([["host", host]])).kind).toBe("empty");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a candidate that is itself a join row is excluded — no recursive gating", () => {
|
|
105
|
+
const host = hostEntity({
|
|
106
|
+
parentRef: { entityTypeField: "entityType", entityIdField: "entityId" },
|
|
107
|
+
});
|
|
108
|
+
expect(build(new Map([["host", host]])).kind).toBe("empty");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("narrowTypes restricts the OR-chain to the named hosts", () => {
|
|
112
|
+
const entities = new Map([
|
|
113
|
+
["host", hostEntity()],
|
|
114
|
+
["other", hostEntity()],
|
|
115
|
+
["third", hostEntity()],
|
|
116
|
+
]);
|
|
117
|
+
const all = build(entities);
|
|
118
|
+
const narrowed = build(entities, { narrowTypes: new Set(["other"]) });
|
|
119
|
+
if (all.kind !== "sql" || narrowed.kind !== "sql") throw new Error("expected sql clauses");
|
|
120
|
+
expect(all.sqlText.split(" OR ")).toHaveLength(3);
|
|
121
|
+
expect(narrowed.sqlText.split(" OR ")).toHaveLength(1);
|
|
122
|
+
expect(narrowed.params).toContain("other");
|
|
123
|
+
expect(narrowed.params).not.toContain("host");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("the host's soft-delete filter applies by default and lifts under includeDeleted", () => {
|
|
127
|
+
const entities = new Map([["host", hostEntity({ softDelete: true })]]);
|
|
128
|
+
const gated = build(entities);
|
|
129
|
+
const trash = build(entities, { includeDeleted: true });
|
|
130
|
+
if (gated.kind !== "sql" || trash.kind !== "sql") throw new Error("expected sql clauses");
|
|
131
|
+
expect(gated.sqlText).toContain('"is_deleted" = FALSE');
|
|
132
|
+
expect(trash.sqlText).not.toContain("is_deleted");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("the host id is matched through a shape guard, so a foreign id form cannot raise 22P02", () => {
|
|
136
|
+
const clause = build(new Map([["host", hostEntity()]]));
|
|
137
|
+
if (clause.kind !== "sql") throw new Error("expected sql clause");
|
|
138
|
+
expect(clause.sqlText).toContain("CASE WHEN");
|
|
139
|
+
expect(clause.sqlText).toContain("::uuid");
|
|
140
|
+
// The shape itself is bound, never inlined — a literal carrying `$` would
|
|
141
|
+
// be rewritten by shiftParams when the clause is combined.
|
|
142
|
+
expect(clause.params[0]).toBe("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
|
|
143
|
+
expect(clause.sqlText).not.toContain("[0-9a-f]");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("the tenant predicate is bound once and reused across branches", () => {
|
|
147
|
+
const entities = new Map([
|
|
148
|
+
["host", hostEntity()],
|
|
149
|
+
["other", hostEntity()],
|
|
150
|
+
]);
|
|
151
|
+
const clause = build(entities);
|
|
152
|
+
if (clause.kind !== "sql") throw new Error("expected sql clause");
|
|
153
|
+
expect(clause.params.filter((p) => p === TENANT)).toHaveLength(1);
|
|
154
|
+
// uuid shape + 2 tenant params + one host-name param per branch.
|
|
155
|
+
expect(clause.params).toHaveLength(5);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("a system-mode db drops the tenant predicate, mirroring list()", () => {
|
|
159
|
+
const clause = build(new Map([["host", hostEntity()]]), { db: systemDb });
|
|
160
|
+
if (clause.kind !== "sql") throw new Error("expected sql clause");
|
|
161
|
+
expect(clause.sqlText).not.toContain("tenant_id");
|
|
162
|
+
expect(clause.params).not.toContain(TENANT);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a dropped branch reserves no placeholder — every $N still binds its own value", () => {
|
|
166
|
+
// First candidate collapses to `empty` (column missing on the host), so its
|
|
167
|
+
// tentatively-reserved indices must not leave a gap for the second.
|
|
168
|
+
const entities = new Map([
|
|
169
|
+
[
|
|
170
|
+
"dropped",
|
|
171
|
+
hostEntity({ access: { read: { TenantMember: from("user:id", "notAColumn") } } }),
|
|
172
|
+
],
|
|
173
|
+
["kept", hostEntity({ access: { read: { TenantMember: from("user:id", "ownerId") } } })],
|
|
174
|
+
]);
|
|
175
|
+
const clause = build(entities);
|
|
176
|
+
if (clause.kind !== "sql") throw new Error("expected sql clause");
|
|
177
|
+
|
|
178
|
+
const indices = [...clause.sqlText.matchAll(/\$(\d+)/g)].map((m) => Number(m[1]));
|
|
179
|
+
expect(Math.max(...indices)).toBeLessThanOrEqual(clause.params.length);
|
|
180
|
+
expect(Math.min(...indices)).toBeGreaterThanOrEqual(1);
|
|
181
|
+
|
|
182
|
+
// The kept branch's host-name param must sit at the placeholder the SQL
|
|
183
|
+
// actually names for it, not one slot over.
|
|
184
|
+
const keptSlot = clause.params.indexOf("kept") + 1;
|
|
185
|
+
expect(keptSlot).toBeGreaterThan(0);
|
|
186
|
+
expect(clause.sqlText).toContain(`"entity_type" = $${keptSlot}`);
|
|
187
|
+
expect(clause.params).toContain(user.id);
|
|
188
|
+
expect(clause.params).not.toContain("dropped");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("combineClauses", () => {
|
|
193
|
+
const sqlA = { kind: "sql", sqlText: '"a" = $1', params: ["av"] } as const;
|
|
194
|
+
const sqlB = { kind: "sql", sqlText: '"b" = $1 OR "c" = $2', params: ["bv", "cv"] } as const;
|
|
195
|
+
|
|
196
|
+
test("empty on either side denies", () => {
|
|
197
|
+
expect(combineClauses({ kind: "empty" }, sqlA).kind).toBe("empty");
|
|
198
|
+
expect(combineClauses(sqlA, { kind: "empty" }).kind).toBe("empty");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("pass is neutral", () => {
|
|
202
|
+
expect(combineClauses({ kind: "pass" }, sqlA)).toEqual(sqlA);
|
|
203
|
+
expect(combineClauses(sqlA, { kind: "pass" })).toEqual(sqlA);
|
|
204
|
+
expect(combineClauses({ kind: "pass" }, { kind: "pass" }).kind).toBe("pass");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("the second clause's placeholders are shifted past the first's params", () => {
|
|
208
|
+
const combined = combineClauses(sqlA, sqlB);
|
|
209
|
+
if (combined.kind !== "sql") throw new Error("expected sql clause");
|
|
210
|
+
expect(combined.sqlText).toBe('("a" = $1 AND "b" = $2 OR "c" = $3)');
|
|
211
|
+
expect(combined.params).toEqual(["av", "bv", "cv"]);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -3,7 +3,12 @@ import { collectPiiSubjectFields, computeBlindIndex, configuredBlindIndexKey } f
|
|
|
3
3
|
import { escapeLikePattern } from "../crypto/ciphertext-pattern";
|
|
4
4
|
import { executeRawQueryRead } from "../db/queries/raw-sql";
|
|
5
5
|
import { coerceRow, extractTableInfo } from "../db/query";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildOwnershipClause,
|
|
8
|
+
combineClauses,
|
|
9
|
+
normalizeAccessEntry,
|
|
10
|
+
shiftParams,
|
|
11
|
+
} from "../engine/ownership";
|
|
7
12
|
import type { EntityDefinition, EntityId } from "../engine/types";
|
|
8
13
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
9
14
|
import { UnprocessableError } from "../errors";
|
|
@@ -14,7 +19,8 @@ import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor";
|
|
|
14
19
|
import { collectEncryptedFieldNames } from "./entity-field-encryption";
|
|
15
20
|
import type { EventStoreExecutor } from "./event-store-executor";
|
|
16
21
|
import { buildFilterWhere, type ExecutorContext, type Table } from "./event-store-executor-context";
|
|
17
|
-
import {
|
|
22
|
+
import { buildParentRefClause } from "./parent-ref-clause";
|
|
23
|
+
import { buildEntityTable, physicalColumnName } from "./table-builder";
|
|
18
24
|
import type { TenantDb, TenantDbMode } from "./tenant-db";
|
|
19
25
|
|
|
20
26
|
// The two read verbs (list/detail) of the event-store-executor. Split out
|
|
@@ -204,16 +210,51 @@ function applyScreenFilter(
|
|
|
204
210
|
}
|
|
205
211
|
}
|
|
206
212
|
|
|
213
|
+
// A `filter`/`filters` entry that already pins the parentRef's entityType
|
|
214
|
+
// field is a real subset of the parent-ref clause's own OR-branches — the
|
|
215
|
+
// client-supplied filter can't widen the result, only narrow which branches
|
|
216
|
+
// the SQL even needs to consider. Skipped for multiSelect fields, where `eq`
|
|
217
|
+
// means containment (see applyMultiSelectFilter) rather than equality.
|
|
218
|
+
function deriveParentNarrowTypes(
|
|
219
|
+
entity: EntityDefinition,
|
|
220
|
+
payload: { readonly filter?: ListFilter; readonly filters?: readonly ListFilter[] },
|
|
221
|
+
): ReadonlySet<string> | undefined {
|
|
222
|
+
const parentRef = entity.parentRef;
|
|
223
|
+
if (parentRef === undefined) return undefined;
|
|
224
|
+
if (entity.fields[parentRef.entityTypeField]?.type === "multiSelect") return undefined;
|
|
225
|
+
|
|
226
|
+
const typeFilters = [
|
|
227
|
+
...(payload.filter ? [payload.filter] : []),
|
|
228
|
+
...(payload.filters ?? []),
|
|
229
|
+
].filter((f) => f.field === parentRef.entityTypeField);
|
|
230
|
+
|
|
231
|
+
let narrowed: Set<string> | undefined;
|
|
232
|
+
for (const f of typeFilters) {
|
|
233
|
+
let values: readonly string[] | undefined;
|
|
234
|
+
if (f.op === "eq" && typeof f.value === "string") {
|
|
235
|
+
values = [f.value];
|
|
236
|
+
} else if (
|
|
237
|
+
f.op === "in" &&
|
|
238
|
+
Array.isArray(f.value) &&
|
|
239
|
+
f.value.every((v) => typeof v === "string")
|
|
240
|
+
) {
|
|
241
|
+
values = f.value;
|
|
242
|
+
}
|
|
243
|
+
if (values === undefined) continue;
|
|
244
|
+
narrowed =
|
|
245
|
+
narrowed === undefined
|
|
246
|
+
? new Set(values)
|
|
247
|
+
: new Set([...narrowed].filter((v) => values.includes(v)));
|
|
248
|
+
}
|
|
249
|
+
return narrowed;
|
|
250
|
+
}
|
|
251
|
+
|
|
207
252
|
// fw#2660 — above this many target-row matches, drop the reference clause
|
|
208
253
|
// entirely instead of truncating: an arbitrary 200-row slice of "which
|
|
209
254
|
// targets match" would silently hide rows a full-cardinality match would
|
|
210
255
|
// have found. Native text search on the entity's own fields still applies.
|
|
211
256
|
const MAX_REFERENCE_SEARCH_IDS = 200;
|
|
212
257
|
|
|
213
|
-
function physicalColumnName(table: Table, field: string): string {
|
|
214
|
-
return (table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
258
|
type ReferenceSearchDescriptor = {
|
|
218
259
|
readonly ownColumn: string;
|
|
219
260
|
readonly targetEntityName: string;
|
|
@@ -384,11 +425,27 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
384
425
|
async list(payload, user, db, runtimeOptions) {
|
|
385
426
|
const { limit, offset } = resolveListPagination(payload);
|
|
386
427
|
const totalCount = payload.totalCount === true;
|
|
428
|
+
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
387
429
|
|
|
388
|
-
// H.2 — entity-level read ownership
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
430
|
+
// H.2 — entity-level read ownership, AND-ed with the parent-ref
|
|
431
|
+
// read-gate (join-row entities derive their visibility from a host
|
|
432
|
+
// row's own read path — see db/parent-ref-clause.ts). Decide before
|
|
433
|
+
// touching search or the DB: `empty` means there's no row the user
|
|
434
|
+
// could ever see, so skip both paths and return an empty page.
|
|
435
|
+
const narrowTypes = deriveParentNarrowTypes(entity, payload);
|
|
436
|
+
const parentClause = buildParentRefClause(
|
|
437
|
+
entity,
|
|
438
|
+
table,
|
|
439
|
+
tableName,
|
|
440
|
+
user,
|
|
441
|
+
db,
|
|
442
|
+
runtimeOptions?.parentVisibility,
|
|
443
|
+
{ includeDeleted: runtimeOptions?.includeDeleted === true, narrowTypes },
|
|
444
|
+
);
|
|
445
|
+
const ownership = combineClauses(
|
|
446
|
+
buildOwnershipClause(user, entity.access?.read, table),
|
|
447
|
+
parentClause,
|
|
448
|
+
);
|
|
392
449
|
if (ownership.kind === "empty") {
|
|
393
450
|
return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
|
|
394
451
|
}
|
|
@@ -401,7 +458,6 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
401
458
|
// Build the WHERE clause as raw SQL — ownership produces a
|
|
402
459
|
// parameterised fragment that we splice in alongside simple WhereObject
|
|
403
460
|
// conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
|
|
404
|
-
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
405
461
|
const whereSql: string[] = [];
|
|
406
462
|
const params: unknown[] = [];
|
|
407
463
|
const physicalCol = (field: string): string => physicalColumnName(table, field);
|
|
@@ -493,6 +549,10 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
493
549
|
// tenant filter that follows.
|
|
494
550
|
whereSql.push(`(${orParts.join(" OR ")})`);
|
|
495
551
|
}
|
|
552
|
+
// The total-count fast-path below is only valid while this is still
|
|
553
|
+
// true after every later clause (tenant, soft-delete, cursor,
|
|
554
|
+
// ownership, parent-ref, filters) — see where it's checked.
|
|
555
|
+
const whereSqlLenAfterSearch = whereSql.length;
|
|
496
556
|
|
|
497
557
|
const sortCandidate =
|
|
498
558
|
payload.sort && table[payload.sort] && fieldReadClause(payload.sort).kind === "pass"
|
|
@@ -664,11 +724,13 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
664
724
|
// Postgres cost is O(table-scan) without a filter, with a filter as
|
|
665
725
|
// expensive as the corresponding WHERE — cheap enough on indexed columns.
|
|
666
726
|
// On the search path, `total = filterIds.length` needs no extra query —
|
|
667
|
-
// unless a reference match contributed additional rows (fw#2660),
|
|
668
|
-
//
|
|
727
|
+
// unless a reference match contributed additional rows (fw#2660), or the
|
|
728
|
+
// WHERE grew past the search block (ownership, parent-ref, filters,
|
|
729
|
+
// soft-delete, cursor all splice in afterwards), in which case the raw
|
|
730
|
+
// filterIds length alone would undercount.
|
|
669
731
|
let total: number | undefined;
|
|
670
732
|
if (totalCount) {
|
|
671
|
-
if (filterIds && !referenceClauseActive) {
|
|
733
|
+
if (filterIds && !referenceClauseActive && whereSql.length === whereSqlLenAfterSearch) {
|
|
672
734
|
total = filterIds.length;
|
|
673
735
|
} else {
|
|
674
736
|
const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
|
|
@@ -684,11 +746,25 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
684
746
|
return { rows, nextCursor, ...(total !== undefined && { total }) };
|
|
685
747
|
},
|
|
686
748
|
|
|
687
|
-
async detail(payload, user, db) {
|
|
688
|
-
|
|
749
|
+
async detail(payload, user, db, options) {
|
|
750
|
+
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
751
|
+
// H.2 — ownership check, AND-ed with the parent-ref read-gate (see
|
|
752
|
+
// db/parent-ref-clause.ts). `empty` → the user can never see this row
|
|
689
753
|
// regardless of its id. Return null (same shape as "not found", so a
|
|
690
754
|
// probing attacker can't distinguish "no access" from "doesn't exist").
|
|
691
|
-
const
|
|
755
|
+
const parentClause = buildParentRefClause(
|
|
756
|
+
entity,
|
|
757
|
+
table,
|
|
758
|
+
tableName,
|
|
759
|
+
user,
|
|
760
|
+
db,
|
|
761
|
+
options?.parentVisibility,
|
|
762
|
+
{ includeDeleted: false, narrowTypes: undefined },
|
|
763
|
+
);
|
|
764
|
+
const ownership = combineClauses(
|
|
765
|
+
buildOwnershipClause(user, entity.access?.read, table),
|
|
766
|
+
parentClause,
|
|
767
|
+
);
|
|
692
768
|
if (ownership.kind === "empty") return null;
|
|
693
769
|
|
|
694
770
|
const idWhere = idFilter(payload.id);
|
|
@@ -714,8 +790,10 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
714
790
|
const cached = await entityCache.get(user.tenantId, entityName, payload.id);
|
|
715
791
|
if (cached) {
|
|
716
792
|
if (ownership.kind === "sql") {
|
|
717
|
-
// Re-check ownership predicate
|
|
718
|
-
// is keyed only by tenant + id, not by
|
|
793
|
+
// Re-check ownership predicate (incl. the parent-ref gate) against
|
|
794
|
+
// the live row — the cache is keyed only by tenant + id, not by
|
|
795
|
+
// role, so a cache hit can't bypass either gate. Deliberate price:
|
|
796
|
+
// one extra roundtrip per cache hit once either gate produces SQL.
|
|
719
797
|
const checkRows = await loadWithOwnership(db, idWhere, ownership);
|
|
720
798
|
if (checkRows.length === 0) return null;
|
|
721
799
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { ParentVisibilityOption } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
2
|
+
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
3
|
+
import { buildOwnershipClause, type OwnershipClause } from "../engine/ownership";
|
|
4
|
+
import type { EntityDefinition, SessionUser } from "../engine/types";
|
|
5
|
+
import { SYSTEM_TENANT_ID, UUID_SHAPE_PATTERN } from "../engine/types/identifiers";
|
|
6
|
+
import type { Table } from "./event-store-executor-context";
|
|
7
|
+
import { buildEntityTable, physicalColumnName } from "./table-builder";
|
|
8
|
+
import type { TenantDb } from "./tenant-db";
|
|
9
|
+
|
|
10
|
+
export type { ParentVisibilityOption } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
11
|
+
|
|
12
|
+
// Static, per-candidate SQL fragments (table name + physical column names) —
|
|
13
|
+
// derived from the entity definition alone, so they're the same on every
|
|
14
|
+
// request. buildEntityTable is too expensive to redo per request per
|
|
15
|
+
// candidate, hence the cache keyed by the entity object itself.
|
|
16
|
+
type CandidateStatics = {
|
|
17
|
+
readonly table: Table;
|
|
18
|
+
readonly tableName: string;
|
|
19
|
+
readonly tenantIdCol: string | undefined;
|
|
20
|
+
readonly isDeletedCol: string | undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const candidateStaticsCache = new WeakMap<EntityDefinition, CandidateStatics>();
|
|
24
|
+
|
|
25
|
+
function candidateStaticsFor(candidateName: string, candidate: EntityDefinition): CandidateStatics {
|
|
26
|
+
const cached = candidateStaticsCache.get(candidate);
|
|
27
|
+
if (cached) return cached;
|
|
28
|
+
const table = buildEntityTable(candidateName, candidate);
|
|
29
|
+
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
30
|
+
const statics: CandidateStatics = {
|
|
31
|
+
table,
|
|
32
|
+
tableName,
|
|
33
|
+
tenantIdCol:
|
|
34
|
+
table["tenantId"] !== undefined ? physicalColumnName(table, "tenantId") : undefined,
|
|
35
|
+
isDeletedCol:
|
|
36
|
+
table["isDeleted"] !== undefined ? physicalColumnName(table, "isDeleted") : undefined,
|
|
37
|
+
};
|
|
38
|
+
candidateStaticsCache.set(candidate, statics);
|
|
39
|
+
return statics;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Candidate order follows `option.entities` (Registry insertion order) —
|
|
43
|
+
// deterministic given a fixed feature-registration order.
|
|
44
|
+
function selectCandidates(
|
|
45
|
+
parentRef: NonNullable<EntityDefinition["parentRef"]>,
|
|
46
|
+
option: ParentVisibilityOption,
|
|
47
|
+
narrowTypes: ReadonlySet<string> | undefined,
|
|
48
|
+
): ReadonlyArray<readonly [string, EntityDefinition]> {
|
|
49
|
+
const candidates: Array<readonly [string, EntityDefinition]> = [];
|
|
50
|
+
for (const [name, candidate] of option.entities) {
|
|
51
|
+
if (parentRef.allowedTypes && !parentRef.allowedTypes.includes(name)) continue;
|
|
52
|
+
if (narrowTypes && !narrowTypes.has(name)) continue;
|
|
53
|
+
// No recursive gating — a host that is itself a join-row entity would
|
|
54
|
+
// need its own visibility resolved first, which this function doesn't do.
|
|
55
|
+
if (candidate.parentRef !== undefined) continue;
|
|
56
|
+
// event-store-executor-context.ts rejects non-uuid idType at executor
|
|
57
|
+
// build time, so there is no read path to check such a candidate against.
|
|
58
|
+
if (candidate.idType !== undefined && candidate.idType !== "uuid") continue;
|
|
59
|
+
candidates.push([name, candidate]);
|
|
60
|
+
}
|
|
61
|
+
return candidates;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildParentRefClause(
|
|
65
|
+
entity: EntityDefinition,
|
|
66
|
+
table: Table,
|
|
67
|
+
tableName: string,
|
|
68
|
+
user: SessionUser,
|
|
69
|
+
db: TenantDb,
|
|
70
|
+
option: ParentVisibilityOption | undefined,
|
|
71
|
+
opts: {
|
|
72
|
+
readonly includeDeleted?: boolean;
|
|
73
|
+
readonly narrowTypes?: ReadonlySet<string>;
|
|
74
|
+
},
|
|
75
|
+
): OwnershipClause {
|
|
76
|
+
const parentRef = entity.parentRef;
|
|
77
|
+
if (parentRef === undefined) return { kind: "pass" };
|
|
78
|
+
// The raw executor stays ungated when no registry is available — the
|
|
79
|
+
// fail-closed guarantee for client-reachable paths comes from the boot
|
|
80
|
+
// guard (validateParentRefs), not from throwing here. Throwing would 500
|
|
81
|
+
// every existing custom handler and framework-internal detail() call that
|
|
82
|
+
// doesn't pass parentVisibility.
|
|
83
|
+
if (option === undefined) return { kind: "pass" };
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
entity.fields[parentRef.entityTypeField] === undefined ||
|
|
87
|
+
entity.fields[parentRef.entityIdField] === undefined
|
|
88
|
+
) {
|
|
89
|
+
// Misconfigured parentRef — fail closed here; the boot guard catches
|
|
90
|
+
// this earlier for any entity the registry actually validates.
|
|
91
|
+
return { kind: "empty" };
|
|
92
|
+
}
|
|
93
|
+
const typeCol = physicalColumnName(table, parentRef.entityTypeField);
|
|
94
|
+
const idCol = physicalColumnName(table, parentRef.entityIdField);
|
|
95
|
+
|
|
96
|
+
const candidates = selectCandidates(parentRef, option, opts.narrowTypes);
|
|
97
|
+
if (candidates.length === 0) return { kind: "empty" };
|
|
98
|
+
|
|
99
|
+
// The uuid shape and the tenant ids are bound once and shared by every
|
|
100
|
+
// branch. The shape is a parameter rather than a literal because shiftParams
|
|
101
|
+
// rewrites `$<digits>` anywhere in the text, including inside a string.
|
|
102
|
+
const params: unknown[] = [UUID_SHAPE_PATTERN];
|
|
103
|
+
const uuidIdx = params.length;
|
|
104
|
+
let tenantIdx: readonly [number, number] | undefined;
|
|
105
|
+
if (db.mode === "tenant") {
|
|
106
|
+
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
107
|
+
tenantIdx = [params.length - 1, params.length];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const branches: string[] = [];
|
|
111
|
+
for (const [candidateName, candidate] of candidates) {
|
|
112
|
+
const branch = buildCandidateBranch(candidateName, candidate, user, {
|
|
113
|
+
joinTableName: tableName,
|
|
114
|
+
typeCol,
|
|
115
|
+
idCol,
|
|
116
|
+
uuidIdx,
|
|
117
|
+
tenantIdx,
|
|
118
|
+
includeDeleted: opts.includeDeleted === true,
|
|
119
|
+
// Ownership params are laid out right after the branch's own host-type
|
|
120
|
+
// placeholder. Nothing reaches the shared `params` until the branch is
|
|
121
|
+
// kept, so a dropped branch reserves no index and can't shift the
|
|
122
|
+
// positional binding of the branches after it.
|
|
123
|
+
typeParamIdx: params.length + 1,
|
|
124
|
+
});
|
|
125
|
+
if (branch === undefined) continue;
|
|
126
|
+
params.push(candidateName, ...branch.params);
|
|
127
|
+
branches.push(branch.sqlText);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (branches.length === 0) return { kind: "empty" };
|
|
131
|
+
return { kind: "sql", sqlText: `(${branches.join(" OR ")})`, params };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type BranchContext = {
|
|
135
|
+
readonly joinTableName: string;
|
|
136
|
+
readonly typeCol: string;
|
|
137
|
+
readonly idCol: string;
|
|
138
|
+
readonly uuidIdx: number;
|
|
139
|
+
readonly tenantIdx: readonly [number, number] | undefined;
|
|
140
|
+
readonly includeDeleted: boolean;
|
|
141
|
+
readonly typeParamIdx: number;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// One `entity_type = $n AND EXISTS (...)` arm, or undefined when the host's own
|
|
145
|
+
// read ownership can never match — an unsatisfiable arm is dropped rather than
|
|
146
|
+
// emitted, which is what keeps an unreadable host from widening the OR-chain.
|
|
147
|
+
function buildCandidateBranch(
|
|
148
|
+
candidateName: string,
|
|
149
|
+
candidate: EntityDefinition,
|
|
150
|
+
user: SessionUser,
|
|
151
|
+
ctx: BranchContext,
|
|
152
|
+
): { readonly sqlText: string; readonly params: readonly unknown[] } | undefined {
|
|
153
|
+
const statics = candidateStaticsFor(candidateName, candidate);
|
|
154
|
+
const ownership = buildOwnershipClause(
|
|
155
|
+
user,
|
|
156
|
+
candidate.access?.read,
|
|
157
|
+
statics.table,
|
|
158
|
+
ctx.typeParamIdx + 1,
|
|
159
|
+
);
|
|
160
|
+
if (ownership.kind === "empty") return undefined;
|
|
161
|
+
|
|
162
|
+
const hostId = `"${ctx.joinTableName}"."${ctx.idCol}"`;
|
|
163
|
+
// CASE, not a bare cast: an id shaped for another host entity would abort the
|
|
164
|
+
// whole statement with 22P02, and AND does not guarantee evaluation order.
|
|
165
|
+
const conditions: string[] = [
|
|
166
|
+
`"${statics.tableName}"."id" = (CASE WHEN ${hostId} ~ $${ctx.uuidIdx} THEN ${hostId} END)::uuid`,
|
|
167
|
+
];
|
|
168
|
+
if (statics.tenantIdCol !== undefined && ctx.tenantIdx !== undefined) {
|
|
169
|
+
conditions.push(
|
|
170
|
+
`"${statics.tableName}"."${statics.tenantIdCol}" IN ($${ctx.tenantIdx[0]}, $${ctx.tenantIdx[1]})`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (candidate.softDelete === true && statics.isDeletedCol !== undefined && !ctx.includeDeleted) {
|
|
174
|
+
conditions.push(`"${statics.tableName}"."${statics.isDeletedCol}" = FALSE`);
|
|
175
|
+
}
|
|
176
|
+
if (ownership.kind === "sql") conditions.push(ownership.sqlText);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
sqlText: `("${ctx.joinTableName}"."${ctx.typeCol}" = $${ctx.typeParamIdx} AND EXISTS (SELECT 1 FROM "${statics.tableName}" WHERE ${conditions.join(" AND ")}))`,
|
|
180
|
+
params: ownership.kind === "sql" ? ownership.params : [],
|
|
181
|
+
};
|
|
182
|
+
}
|
package/src/db/table-builder.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from "../engine/types";
|
|
8
8
|
import { assertUnreachable } from "../utils";
|
|
9
9
|
import { toSnakeCase } from "../utils/case";
|
|
10
|
+
import type { Table } from "./event-store-executor-context";
|
|
10
11
|
|
|
11
12
|
export { toSnakeCase } from "../utils/case";
|
|
12
13
|
|
|
@@ -35,6 +36,12 @@ import {
|
|
|
35
36
|
uuid,
|
|
36
37
|
} from "./dialect";
|
|
37
38
|
|
|
39
|
+
// Shared by the CRUD executor and the parent-ref read-gate — a field's
|
|
40
|
+
// declared column name if it has one, else its snake_case default.
|
|
41
|
+
export function physicalColumnName(table: Table, field: string): string {
|
|
42
|
+
return (table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field);
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
// Local AnyPgColumn alias — kept for legacy field-definition callers that
|
|
39
46
|
// still import this name as a type. ColumnHandle from the native dialect
|
|
40
47
|
// matches the same role (snake_case name + sql type accessor).
|