@cosmicdrift/kumiko-framework 0.399.0 → 0.400.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/agents/api/framework-engine.md +9 -3
- package/agents/api/framework-ui-types.md +10 -5
- package/agents/api/renderer-web.md +2 -1
- package/agents/api/types.md +242 -80
- package/agents/api.md +2 -2
- package/agents/examples/derived-fields-sql.md +251 -0
- package/agents/examples/documents.md +161 -0
- package/agents/examples/inspection-checklist.md +807 -0
- package/agents/examples.md +3 -0
- package/agents/features-index.md +2 -0
- package/agents/features.md +8 -0
- package/dist/api/entity-read-access.d.ts +2 -0
- package/dist/api/entity-read-access.js +46 -2
- package/dist/api/server-boot-checks.js +2 -2
- package/dist/api/server-routes.js +3 -1
- package/dist/db/derived-field-sql.d.ts +23 -0
- package/dist/db/derived-field-sql.js +115 -0
- package/dist/db/derived-related-sql.d.ts +31 -0
- package/dist/db/derived-related-sql.js +137 -0
- package/dist/db/driver-row-coercion.d.ts +1 -0
- package/dist/db/driver-row-coercion.js +13 -0
- package/dist/db/eagerload.d.ts +3 -1
- package/dist/db/eagerload.js +4 -6
- package/dist/db/event-store-executor-context.js +1 -1
- package/dist/db/event-store-executor-read-paging.js +1 -1
- package/dist/db/event-store-executor-read-search.d.ts +3 -0
- package/dist/db/event-store-executor-read-search.js +17 -9
- package/dist/db/event-store-executor-read-sort.d.ts +9 -2
- package/dist/db/event-store-executor-read-sort.js +27 -14
- package/dist/db/event-store-executor-read-where.d.ts +0 -5
- package/dist/db/event-store-executor-read-where.js +3 -9
- package/dist/db/event-store-executor-read.js +23 -4
- package/dist/db/event-store-executor-write-steps.d.ts +6 -1
- package/dist/db/event-store-executor-write-steps.js +20 -1
- package/dist/db/event-store-executor-write.js +8 -8
- package/dist/db/money.d.ts +4 -0
- package/dist/db/money.js +34 -29
- package/dist/db/parent-ref-clause.d.ts +1 -0
- package/dist/db/parent-ref-clause.js +18 -8
- package/dist/db/read-gate-clause.d.ts +7 -0
- package/dist/db/read-gate-clause.js +188 -0
- package/dist/db/read-ownership.d.ts +9 -0
- package/dist/db/read-ownership.js +12 -0
- package/dist/engine/boot-validator/boot-check.d.ts +3 -1
- package/dist/engine/boot-validator/boot-check.js +7 -11
- package/dist/engine/boot-validator/boot-context.d.ts +26 -0
- package/dist/engine/boot-validator/boot-context.js +57 -0
- package/dist/engine/boot-validator/boot-options.d.ts +33 -0
- package/dist/engine/boot-validator/boot-options.js +1 -0
- package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
- package/dist/engine/boot-validator/boot-phases-app.js +103 -0
- package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
- package/dist/engine/boot-validator/boot-phases.js +140 -0
- package/dist/engine/boot-validator/boot-report.d.ts +6 -0
- package/dist/engine/boot-validator/boot-report.js +47 -0
- package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
- package/dist/engine/boot-validator/boot-rule.js +14 -0
- package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
- package/dist/engine/boot-validator/boot-warnings.js +20 -0
- package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
- package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
- package/dist/engine/boot-validator/entity-handler.js +6 -3
- package/dist/engine/boot-validator/entity-list-screens.js +3 -1
- package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
- package/dist/engine/boot-validator/field-conditions.js +178 -0
- package/dist/engine/boot-validator/i18n-keys.d.ts +2 -2
- package/dist/engine/boot-validator/i18n-keys.js +1 -3
- package/dist/engine/boot-validator/index.d.ts +5 -34
- package/dist/engine/boot-validator/index.js +24 -204
- package/dist/engine/boot-validator/pii-retention.js +2 -7
- package/dist/engine/boot-validator/projection-list-screens.js +19 -1
- package/dist/engine/boot-validator/read-gate.d.ts +2 -0
- package/dist/engine/boot-validator/read-gate.js +54 -0
- package/dist/engine/boot-validator/screens-forms.js +19 -6
- package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
- package/dist/engine/boot-validator/screens-lists.js +8 -0
- package/dist/engine/boot-validator/zod-shape.js +4 -2
- package/dist/engine/build-app-schema.js +11 -5
- package/dist/engine/embedded-derived.d.ts +8 -6
- package/dist/engine/embedded-derived.js +25 -7
- package/dist/engine/entity-handlers.d.ts +1 -1
- package/dist/engine/entity-handlers.js +17 -18
- package/dist/engine/entity-list-runtime-options.d.ts +2 -1
- package/dist/engine/entity-list-runtime-options.js +86 -5
- package/dist/engine/factories.d.ts +5 -5
- package/dist/engine/factories.js +3 -5
- package/dist/engine/field-access.d.ts +2 -0
- package/dist/engine/field-access.js +48 -1
- package/dist/engine/field-state.d.ts +12 -0
- package/dist/engine/field-state.js +67 -0
- package/dist/engine/handler-helpers.d.ts +3 -0
- package/dist/engine/handler-helpers.js +7 -1
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +2 -2
- package/dist/engine/ownership.d.ts +1 -1
- package/dist/engine/ownership.js +9 -5
- package/dist/engine/registry-state.d.ts +1 -0
- package/dist/engine/registry-state.js +1 -1
- package/dist/engine/schema-builder.js +3 -0
- package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
- package/dist/engine/strip-unreadable-related-lists.js +80 -0
- package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
- package/dist/files/bind-pending-file-refs.d.ts +3 -2
- package/dist/files/bind-pending-file-refs.js +11 -12
- package/dist/files/bind-referenced-files.write.js +5 -5
- package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
- package/dist/files/create-file-ref-from-bytes.js +2 -0
- package/dist/files/create-file-ref.d.ts +2 -0
- package/dist/files/create-file-ref.js +39 -22
- package/dist/files/dispose-removed-file-refs.js +5 -8
- package/dist/files/file-field-slots.d.ts +10 -0
- package/dist/files/file-field-slots.js +53 -0
- package/dist/files/file-ref-entity.d.ts +1 -1
- package/dist/files/file-ref-table.d.ts +1 -1
- package/dist/files/file-routes.js +12 -3
- package/dist/files/index.d.ts +1 -0
- package/dist/files/index.js +1 -0
- package/dist/files/load-save-file-refs.js +3 -4
- package/dist/files/record-read-check.js +2 -30
- package/dist/files/record-write-check.d.ts +2 -0
- package/dist/files/record-write-check.js +18 -3
- package/dist/files/upload-field-resolution.d.ts +2 -1
- package/dist/files/upload-field-resolution.js +33 -13
- package/dist/pipeline/dispatch-batch.js +6 -5
- package/dist/pipeline/dispatch-write.js +2 -1
- package/dist/pipeline/write-result-masking.js +2 -1
- package/dist/schema-cli/validate-command.js +46 -12
- package/dist/schema-cli.js +1 -1
- package/dist/ui-types/index.d.ts +2 -1
- package/dist/ui-types/index.js +2 -1
- package/package.json +3 -3
- package/src/changes.json +55 -0
|
@@ -3,24 +3,28 @@ import { SYSTEM_TENANT_ID, UUID_SHAPE_PATTERN } from "../engine/types/identifier
|
|
|
3
3
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
4
4
|
import { schemaTableName } from "./table-introspection.js";
|
|
5
5
|
const candidateStaticsCache = new WeakMap();
|
|
6
|
-
function candidateStaticsFor(candidateName, candidate) {
|
|
6
|
+
function candidateStaticsFor(candidateName, candidate, resolveTable) {
|
|
7
|
+
if (resolveTable !== undefined)
|
|
8
|
+
return staticsOf(resolveTable(candidateName));
|
|
7
9
|
const cached = candidateStaticsCache.get(candidate);
|
|
8
10
|
if (cached)
|
|
9
11
|
return cached;
|
|
10
|
-
const
|
|
12
|
+
const statics = staticsOf(buildEntityTable(candidateName, candidate));
|
|
13
|
+
candidateStaticsCache.set(candidate, statics);
|
|
14
|
+
return statics;
|
|
15
|
+
}
|
|
16
|
+
function staticsOf(table) {
|
|
11
17
|
const tableName = String(schemaTableName(table));
|
|
12
|
-
|
|
18
|
+
return {
|
|
13
19
|
table,
|
|
14
20
|
tableName,
|
|
15
21
|
tenantIdCol: table["tenantId"] !== undefined ? physicalColumnName(table, "tenantId") : undefined,
|
|
16
22
|
isDeletedCol: table["isDeleted"] !== undefined ? physicalColumnName(table, "isDeleted") : undefined,
|
|
17
23
|
};
|
|
18
|
-
candidateStaticsCache.set(candidate, statics);
|
|
19
|
-
return statics;
|
|
20
24
|
}
|
|
21
25
|
// Candidate order follows `option.entities` (Registry insertion order) —
|
|
22
26
|
// deterministic given a fixed feature-registration order.
|
|
23
|
-
function selectCandidates(parentRef, option, narrowTypes) {
|
|
27
|
+
function selectCandidates(parentRef, option, narrowTypes, requireReadHandler) {
|
|
24
28
|
const candidates = [];
|
|
25
29
|
for (const [name, candidate] of option.entities) {
|
|
26
30
|
if (parentRef.allowedTypes && !parentRef.allowedTypes.includes(name))
|
|
@@ -31,6 +35,11 @@ function selectCandidates(parentRef, option, narrowTypes) {
|
|
|
31
35
|
// need its own visibility resolved first, which this function doesn't do.
|
|
32
36
|
if (candidate.parentRef !== undefined)
|
|
33
37
|
continue;
|
|
38
|
+
// A readGate host would need its own gate resolved first; unreadable beats a widened read.
|
|
39
|
+
if (candidate.readGate !== undefined)
|
|
40
|
+
continue;
|
|
41
|
+
if (requireReadHandler && option.canReadEntity?.(name) !== true)
|
|
42
|
+
continue;
|
|
34
43
|
// event-store-executor-context.ts rejects non-uuid idType at executor
|
|
35
44
|
// build time, so there is no read path to check such a candidate against.
|
|
36
45
|
if (candidate.idType !== undefined && candidate.idType !== "uuid")
|
|
@@ -58,7 +67,7 @@ export function buildParentRefClause(entity, table, tableName, user, db, option,
|
|
|
58
67
|
}
|
|
59
68
|
const typeCol = physicalColumnName(table, parentRef.entityTypeField);
|
|
60
69
|
const idCol = physicalColumnName(table, parentRef.entityIdField);
|
|
61
|
-
const candidates = selectCandidates(parentRef, option, opts.narrowTypes);
|
|
70
|
+
const candidates = selectCandidates(parentRef, option, opts.narrowTypes, opts.requireReadHandler === true);
|
|
62
71
|
if (candidates.length === 0)
|
|
63
72
|
return { kind: "empty" };
|
|
64
73
|
// The uuid shape and the tenant ids are bound once and shared by every
|
|
@@ -85,6 +94,7 @@ export function buildParentRefClause(entity, table, tableName, user, db, option,
|
|
|
85
94
|
// kept, so a dropped branch reserves no index and can't shift the
|
|
86
95
|
// positional binding of the branches after it.
|
|
87
96
|
typeParamIdx: params.length + 1,
|
|
97
|
+
resolveTable: option.resolveTable,
|
|
88
98
|
});
|
|
89
99
|
if (branch === undefined)
|
|
90
100
|
continue;
|
|
@@ -99,7 +109,7 @@ export function buildParentRefClause(entity, table, tableName, user, db, option,
|
|
|
99
109
|
// read ownership can never match — an unsatisfiable arm is dropped rather than
|
|
100
110
|
// emitted, which is what keeps an unreadable host from widening the OR-chain.
|
|
101
111
|
function buildCandidateBranch(candidateName, candidate, user, ctx) {
|
|
102
|
-
const statics = candidateStaticsFor(candidateName, candidate);
|
|
112
|
+
const statics = candidateStaticsFor(candidateName, candidate, ctx.resolveTable);
|
|
103
113
|
const ownership = buildOwnershipClause(user, candidate.access?.read, statics.table, ctx.typeParamIdx + 1);
|
|
104
114
|
if (ownership.kind === "empty")
|
|
105
115
|
return undefined;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ParentVisibilityOption } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
2
|
+
import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
|
|
3
|
+
import { type OwnershipClause } from "../engine/ownership.ts";
|
|
4
|
+
import type { SessionUser } from "../engine/types/index.ts";
|
|
5
|
+
import type { Table } from "./event-store-executor-context.ts";
|
|
6
|
+
import type { TenantDb } from "./tenant-db.ts";
|
|
7
|
+
export declare function buildReadGateClause(entity: EntityDefinition, table: Table, tableName: string, user: SessionUser, db: TenantDb, option: ParentVisibilityOption | undefined): OwnershipClause;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { isFileField } from "../engine/field-helpers.js";
|
|
2
|
+
import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
|
|
3
|
+
import { SYSTEM_TENANT_ID, UUID_SHAPE_PATTERN } from "../engine/types/identifiers.js";
|
|
4
|
+
import { FILE_REF_ENTITY_NAME } from "../files/types.js";
|
|
5
|
+
import { isFrameworkSystemUser } from "./lock-guard.js";
|
|
6
|
+
import { buildParentRefClause } from "./parent-ref-clause.js";
|
|
7
|
+
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
8
|
+
import { schemaTableName } from "./table-introspection.js";
|
|
9
|
+
const PASS = { kind: "pass" };
|
|
10
|
+
const EMPTY = { kind: "empty" };
|
|
11
|
+
const LINK_ALIAS = "gate_link";
|
|
12
|
+
const FILE_ALIAS = "gate_file";
|
|
13
|
+
// Placeholders are numbered from 1 per gate fragment; combineClauses shifts them into the
|
|
14
|
+
// caller's statement, so every embedded fragment is shifted past the params collected so far.
|
|
15
|
+
class GateSql {
|
|
16
|
+
params = [];
|
|
17
|
+
bind(value) {
|
|
18
|
+
this.params.push(value);
|
|
19
|
+
return `$${this.params.length}`;
|
|
20
|
+
}
|
|
21
|
+
embed(clause) {
|
|
22
|
+
const shifted = shiftParams({ sqlText: clause.sqlText, params: clause.params }, this.params.length);
|
|
23
|
+
this.params.push(...shifted.params);
|
|
24
|
+
return shifted.sqlText;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function tableOf(option, name, entity) {
|
|
28
|
+
return option.resolveTable?.(name) ?? buildEntityTable(name, entity);
|
|
29
|
+
}
|
|
30
|
+
function tenantScopeSql(sql, db, table, alias) {
|
|
31
|
+
if (table["tenantId"] === undefined || db.mode !== "tenant")
|
|
32
|
+
return [];
|
|
33
|
+
const first = sql.bind(db.tenantId);
|
|
34
|
+
const second = sql.bind(SYSTEM_TENANT_ID);
|
|
35
|
+
return [`"${alias}"."${physicalColumnName(table, "tenantId")}" IN (${first}, ${second})`];
|
|
36
|
+
}
|
|
37
|
+
function liveRowSql(entity, table, alias) {
|
|
38
|
+
if (entity.softDelete !== true || table["isDeleted"] === undefined)
|
|
39
|
+
return [];
|
|
40
|
+
return [`"${alias}"."${physicalColumnName(table, "isDeleted")}" = FALSE`];
|
|
41
|
+
}
|
|
42
|
+
function isRoleExempt(gate, user) {
|
|
43
|
+
return user.roles.some((role) => gate.linkExemptRoles?.includes(role) === true);
|
|
44
|
+
}
|
|
45
|
+
function anyLinkClause(input, link) {
|
|
46
|
+
const { option, user, db, tableName } = input;
|
|
47
|
+
const linkEntity = option.entities.get(link.entity);
|
|
48
|
+
if (linkEntity?.parentRef === undefined)
|
|
49
|
+
return EMPTY;
|
|
50
|
+
const linkTable = tableOf(option, link.entity, linkEntity);
|
|
51
|
+
const linkTableName = String(schemaTableName(linkTable));
|
|
52
|
+
const linkOwnership = combineClauses(buildOwnershipClause(user, linkEntity.access?.read, linkTable, 1, LINK_ALIAS), buildParentRefClause(linkEntity, linkTable, LINK_ALIAS, user, db, option, {
|
|
53
|
+
includeDeleted: false,
|
|
54
|
+
requireReadHandler: true,
|
|
55
|
+
}));
|
|
56
|
+
if (linkOwnership.kind === "empty")
|
|
57
|
+
return EMPTY;
|
|
58
|
+
const sql = new GateSql();
|
|
59
|
+
const conditions = [
|
|
60
|
+
`"${LINK_ALIAS}"."${physicalColumnName(linkTable, link.field)}"::text = "${tableName}"."${physicalColumnName(input.table, "id")}"::text`,
|
|
61
|
+
...tenantScopeSql(sql, db, linkTable, LINK_ALIAS),
|
|
62
|
+
...liveRowSql(linkEntity, linkTable, LINK_ALIAS),
|
|
63
|
+
];
|
|
64
|
+
if (linkOwnership.kind === "sql")
|
|
65
|
+
conditions.push(sql.embed(linkOwnership));
|
|
66
|
+
return {
|
|
67
|
+
kind: "sql",
|
|
68
|
+
sqlText: `EXISTS (SELECT 1 FROM "${linkTableName}" AS "${LINK_ALIAS}" WHERE ${conditions.join(" AND ")})`,
|
|
69
|
+
params: sql.params,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// Every registered entity with a file field can own a file. Join rows and gated entities
|
|
73
|
+
// are left out: their readability is not resolvable without recursion.
|
|
74
|
+
function ownerCandidates(input, selfName) {
|
|
75
|
+
const { option } = input;
|
|
76
|
+
const owners = [];
|
|
77
|
+
for (const [name, entity] of option.entities) {
|
|
78
|
+
if (name === FILE_REF_ENTITY_NAME || name === selfName)
|
|
79
|
+
continue;
|
|
80
|
+
if (entity.parentRef !== undefined || entity.readGate !== undefined)
|
|
81
|
+
continue;
|
|
82
|
+
if (entity.idType !== undefined && entity.idType !== "uuid")
|
|
83
|
+
continue;
|
|
84
|
+
if (option.canReadEntity?.(name) !== true)
|
|
85
|
+
continue;
|
|
86
|
+
const fileFields = Object.entries(entity.fields)
|
|
87
|
+
.filter(([, field]) => isFileField(field))
|
|
88
|
+
.map(([fieldName]) => fieldName);
|
|
89
|
+
if (fileFields.length > 0)
|
|
90
|
+
owners.push({ name, entity, fileFields });
|
|
91
|
+
}
|
|
92
|
+
return owners;
|
|
93
|
+
}
|
|
94
|
+
function ownerBranch(input, owner, refTable, sql) {
|
|
95
|
+
const { option, user, db } = input;
|
|
96
|
+
const ownerTable = tableOf(option, owner.name, owner.entity);
|
|
97
|
+
const ownerTableName = String(schemaTableName(ownerTable));
|
|
98
|
+
const ownership = buildOwnershipClause(user, owner.entity.access?.read, ownerTable, 1, ownerTableName);
|
|
99
|
+
if (ownership.kind === "empty")
|
|
100
|
+
return undefined;
|
|
101
|
+
const readableAnywhere = [];
|
|
102
|
+
const fieldArms = [];
|
|
103
|
+
for (const field of owner.fileFields) {
|
|
104
|
+
const fieldRead = buildOwnershipClause(user, normalizeAccessEntry(owner.entity.fields[field]?.access?.read), ownerTable, 1, ownerTableName);
|
|
105
|
+
if (fieldRead.kind === "empty")
|
|
106
|
+
continue;
|
|
107
|
+
if (fieldRead.kind === "pass")
|
|
108
|
+
readableAnywhere.push(field);
|
|
109
|
+
else
|
|
110
|
+
fieldArms.push({ field, clause: fieldRead });
|
|
111
|
+
}
|
|
112
|
+
if (readableAnywhere.length === 0 && fieldArms.length === 0)
|
|
113
|
+
return undefined;
|
|
114
|
+
const typeSql = `"${FILE_ALIAS}"."${physicalColumnName(refTable, "entityType")}" = ${sql.bind(owner.name)}`;
|
|
115
|
+
const fieldCol = `"${FILE_ALIAS}"."${physicalColumnName(refTable, "fieldName")}"`;
|
|
116
|
+
const idCol = `"${FILE_ALIAS}"."${physicalColumnName(refTable, "entityId")}"`;
|
|
117
|
+
const uuidPattern = sql.bind(UUID_SHAPE_PATTERN);
|
|
118
|
+
const arms = [];
|
|
119
|
+
if (readableAnywhere.length > 0) {
|
|
120
|
+
arms.push(`${fieldCol} IN (${readableAnywhere.map((f) => sql.bind(f)).join(", ")})`);
|
|
121
|
+
}
|
|
122
|
+
for (const arm of fieldArms) {
|
|
123
|
+
if (arm.clause.kind !== "sql")
|
|
124
|
+
continue;
|
|
125
|
+
arms.push(`(${fieldCol} = ${sql.bind(arm.field)} AND ${sql.embed(arm.clause)})`);
|
|
126
|
+
}
|
|
127
|
+
const conditions = [
|
|
128
|
+
`"${ownerTableName}"."${physicalColumnName(ownerTable, "id")}" = (CASE WHEN ${idCol} ~ ${uuidPattern} THEN ${idCol} END)::uuid`,
|
|
129
|
+
...tenantScopeSql(sql, db, ownerTable, ownerTableName),
|
|
130
|
+
...liveRowSql(owner.entity, ownerTable, ownerTableName),
|
|
131
|
+
];
|
|
132
|
+
if (ownership.kind === "sql")
|
|
133
|
+
conditions.push(sql.embed(ownership));
|
|
134
|
+
conditions.push(`(${arms.join(" OR ")})`);
|
|
135
|
+
return `(${typeSql} AND EXISTS (SELECT 1 FROM "${ownerTableName}" WHERE ${conditions.join(" AND ")}))`;
|
|
136
|
+
}
|
|
137
|
+
function fileOwnerClause(input, field) {
|
|
138
|
+
const { option, db, tableName, table, entity } = input;
|
|
139
|
+
const refEntity = option.entities.get(FILE_REF_ENTITY_NAME);
|
|
140
|
+
if (refEntity === undefined)
|
|
141
|
+
return EMPTY;
|
|
142
|
+
const refTable = tableOf(option, FILE_REF_ENTITY_NAME, refEntity);
|
|
143
|
+
const refTableName = String(schemaTableName(refTable));
|
|
144
|
+
// The registry holds a spread copy of the entity, so the table name is the stable identity.
|
|
145
|
+
const selfName = [...option.entities].find(([, candidate]) => candidate.table === entity.table)?.[0];
|
|
146
|
+
const sql = new GateSql();
|
|
147
|
+
const branches = [];
|
|
148
|
+
if (selfName !== undefined) {
|
|
149
|
+
const selfType = sql.bind(selfName);
|
|
150
|
+
branches.push(`("${FILE_ALIAS}"."${physicalColumnName(refTable, "entityType")}" = ${selfType} AND "${FILE_ALIAS}"."${physicalColumnName(refTable, "entityId")}" = "${tableName}"."${physicalColumnName(table, "id")}"::text)`);
|
|
151
|
+
}
|
|
152
|
+
for (const owner of ownerCandidates(input, selfName)) {
|
|
153
|
+
const branch = ownerBranch(input, owner, refTable, sql);
|
|
154
|
+
if (branch !== undefined)
|
|
155
|
+
branches.push(branch);
|
|
156
|
+
}
|
|
157
|
+
if (branches.length === 0)
|
|
158
|
+
return EMPTY;
|
|
159
|
+
const conditions = [
|
|
160
|
+
`"${FILE_ALIAS}"."${physicalColumnName(refTable, "id")}" = "${tableName}"."${physicalColumnName(table, field)}"`,
|
|
161
|
+
...tenantScopeSql(sql, db, refTable, FILE_ALIAS),
|
|
162
|
+
...liveRowSql(refEntity, refTable, FILE_ALIAS),
|
|
163
|
+
`(${branches.join(" OR ")})`,
|
|
164
|
+
];
|
|
165
|
+
return {
|
|
166
|
+
kind: "sql",
|
|
167
|
+
sqlText: `EXISTS (SELECT 1 FROM "${refTableName}" AS "${FILE_ALIAS}" WHERE ${conditions.join(" AND ")})`,
|
|
168
|
+
params: sql.params,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// Without the registry the gate cannot be resolved; every caller but the framework system user
|
|
172
|
+
// is denied, which is what forces hand-written handlers to wire parentVisibility.
|
|
173
|
+
export function buildReadGateClause(entity, table, tableName, user, db, option) {
|
|
174
|
+
const gate = entity.readGate;
|
|
175
|
+
if (gate === undefined || isFrameworkSystemUser(user))
|
|
176
|
+
return PASS;
|
|
177
|
+
if (option === undefined)
|
|
178
|
+
return EMPTY;
|
|
179
|
+
const input = { entity, table, tableName, user, db, option };
|
|
180
|
+
let clause = PASS;
|
|
181
|
+
if (gate.anyLink !== undefined && !isRoleExempt(gate, user)) {
|
|
182
|
+
clause = combineClauses(clause, anyLinkClause(input, gate.anyLink));
|
|
183
|
+
}
|
|
184
|
+
if (gate.fileOwner !== undefined) {
|
|
185
|
+
clause = combineClauses(clause, fileOwnerClause(input, gate.fileOwner.field));
|
|
186
|
+
}
|
|
187
|
+
return clause;
|
|
188
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type OwnershipClause } from "../engine/ownership.ts";
|
|
2
|
+
import type { SessionUser } from "../engine/types/index.ts";
|
|
3
|
+
import type { ExecutorContext } from "./event-store-executor-context.ts";
|
|
4
|
+
import { type ParentVisibilityOption } from "./parent-ref-clause.ts";
|
|
5
|
+
import type { TenantDb } from "./tenant-db.ts";
|
|
6
|
+
export declare function resolveReadOwnership(ctx: Pick<ExecutorContext, "entity" | "table">, tableName: string, user: SessionUser, db: TenantDb, parentVisibility: ParentVisibilityOption | undefined, gate: {
|
|
7
|
+
readonly includeDeleted: boolean;
|
|
8
|
+
readonly narrowTypes: ReadonlySet<string> | undefined;
|
|
9
|
+
}): OwnershipClause;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { buildOwnershipClause, combineClauses } from "../engine/ownership.js";
|
|
2
|
+
import { buildParentRefClause } from "./parent-ref-clause.js";
|
|
3
|
+
import { buildReadGateClause } from "./read-gate-clause.js";
|
|
4
|
+
// H.2 — entity-level read ownership, AND-ed with the parent-ref read-gate
|
|
5
|
+
// (join-row entities derive their visibility from a host row's own read path —
|
|
6
|
+
// see db/parent-ref-clause.ts) and the declarative readGate (db/read-gate-clause.ts). `empty` means there's no row the user could ever see.
|
|
7
|
+
// Lives apart from the list WHERE builder so the reference search and sort can gate their
|
|
8
|
+
// target rows the same way without an import cycle.
|
|
9
|
+
export function resolveReadOwnership(ctx, tableName, user, db, parentVisibility, gate) {
|
|
10
|
+
const parentClause = buildParentRefClause(ctx.entity, ctx.table, tableName, user, db, parentVisibility, gate);
|
|
11
|
+
return combineClauses(combineClauses(buildOwnershipClause(user, ctx.entity.access?.read, ctx.table, 1, tableName), parentClause), buildReadGateClause(ctx.entity, ctx.table, tableName, user, db, parentVisibility));
|
|
12
|
+
}
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import type { FeatureDefinition } from "../types/index.ts";
|
|
2
|
-
|
|
2
|
+
type FeatureBootCheck = FeatureDefinition["bootChecks"][number];
|
|
3
|
+
export declare function runFeatureBootCheck(feature: FeatureDefinition, check: FeatureBootCheck, features: readonly FeatureDefinition[]): void;
|
|
4
|
+
export {};
|
|
@@ -4,17 +4,13 @@
|
|
|
4
4
|
// the full mounted-feature set and throws to fail the boot; we wrap the
|
|
5
5
|
// message with the owning feature's name so the error text points back at
|
|
6
6
|
// the feature that declared the check.
|
|
7
|
-
export function
|
|
7
|
+
export function runFeatureBootCheck(feature, check, features) {
|
|
8
8
|
const ctx = { features };
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
16
|
-
throw new Error(`[Feature ${feature.name}] r.bootCheck failed: ${message}`);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
9
|
+
try {
|
|
10
|
+
check(ctx);
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
14
|
+
throw new Error(`[Feature ${feature.name}] r.bootCheck failed: ${message}`);
|
|
19
15
|
}
|
|
20
16
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ConfigKeyDefinition } from "@cosmicdrift/kumiko-types/config";
|
|
2
|
+
import type { FeatureDefinition } from "../types/index.ts";
|
|
3
|
+
import type { ValidateBootOptions } from "./boot-options.ts";
|
|
4
|
+
import { collectKnownRoles, collectNavQns, collectWriteHandlerQns } from "./nav.ts";
|
|
5
|
+
import { collectClaimKeys } from "./ownership.ts";
|
|
6
|
+
import { buildQueryHandlerMap } from "./projection-list-screens.ts";
|
|
7
|
+
import { collectScreenQns, collectScreensByShortId } from "./screens.ts";
|
|
8
|
+
import { collectWorkspaceQns } from "./workspaces.ts";
|
|
9
|
+
export type BootContext = {
|
|
10
|
+
readonly features: readonly FeatureDefinition[];
|
|
11
|
+
readonly featureMap: ReadonlyMap<string, FeatureDefinition>;
|
|
12
|
+
readonly options: ValidateBootOptions | undefined;
|
|
13
|
+
readonly extensionProviders: ReadonlyMap<string, string>;
|
|
14
|
+
readonly allConfigKeys: ReadonlySet<string>;
|
|
15
|
+
readonly configKeyDefsByQn: ReadonlyMap<string, ConfigKeyDefinition>;
|
|
16
|
+
readonly allClaimKeys: ReturnType<typeof collectClaimKeys>;
|
|
17
|
+
readonly knownRoles: ReturnType<typeof collectKnownRoles>;
|
|
18
|
+
readonly allScreenQns: ReturnType<typeof collectScreenQns>;
|
|
19
|
+
readonly allNavQns: ReturnType<typeof collectNavQns>;
|
|
20
|
+
readonly allWorkspaceQns: ReturnType<typeof collectWorkspaceQns>;
|
|
21
|
+
readonly allWriteHandlerQns: ReturnType<typeof collectWriteHandlerQns>;
|
|
22
|
+
readonly screensByShortId: ReturnType<typeof collectScreensByShortId>;
|
|
23
|
+
readonly queryHandlerQns: ReturnType<typeof buildQueryHandlerMap>;
|
|
24
|
+
};
|
|
25
|
+
export declare function buildBootContext(features: readonly FeatureDefinition[], options: ValidateBootOptions | undefined): BootContext;
|
|
26
|
+
export declare function collectExposedApis(features: readonly FeatureDefinition[]): Map<string, string>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { QnTypes, qualifyEntityName } from "../qualified-name.js";
|
|
2
|
+
import { collectKnownRoles, collectNavQns, collectWriteHandlerQns } from "./nav.js";
|
|
3
|
+
import { collectClaimKeys } from "./ownership.js";
|
|
4
|
+
import { buildQueryHandlerMap } from "./projection-list-screens.js";
|
|
5
|
+
import { collectScreenQns, collectScreensByShortId } from "./screens.js";
|
|
6
|
+
import { collectWorkspaceQns } from "./workspaces.js";
|
|
7
|
+
export function buildBootContext(features, options) {
|
|
8
|
+
const featureMap = new Map();
|
|
9
|
+
const extensionProviders = new Map();
|
|
10
|
+
const allConfigKeys = new Set();
|
|
11
|
+
// The raw f.configKeys key is the camelCase object key; the QN is built by
|
|
12
|
+
// define-feature/registry via qualifyEntityName (kebab-cased), so configEdit
|
|
13
|
+
// screens with multi-word keys only resolve against the qualified form.
|
|
14
|
+
// allConfigKeys keeps the older `feature.key` form for validateConfigReads.
|
|
15
|
+
const configKeyDefsByQn = new Map();
|
|
16
|
+
for (const f of features) {
|
|
17
|
+
featureMap.set(f.name, f);
|
|
18
|
+
for (const extName of Object.keys(f.registrarExtensions)) {
|
|
19
|
+
extensionProviders.set(extName, f.name);
|
|
20
|
+
}
|
|
21
|
+
for (const [key, def] of Object.entries(f.configKeys)) {
|
|
22
|
+
allConfigKeys.add(`${f.name}.${key}`);
|
|
23
|
+
configKeyDefsByQn.set(qualifyEntityName(f.name, QnTypes.config, key), def);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
features,
|
|
28
|
+
featureMap,
|
|
29
|
+
options,
|
|
30
|
+
extensionProviders,
|
|
31
|
+
allConfigKeys,
|
|
32
|
+
configKeyDefsByQn,
|
|
33
|
+
allClaimKeys: collectClaimKeys(features),
|
|
34
|
+
// Corpus of "referenced in any handler access rule" (there is no runtime
|
|
35
|
+
// role registry), so typos like "Admi" fail at boot.
|
|
36
|
+
knownRoles: collectKnownRoles(features),
|
|
37
|
+
allScreenQns: collectScreenQns(features),
|
|
38
|
+
allNavQns: collectNavQns(features),
|
|
39
|
+
allWorkspaceQns: collectWorkspaceQns(features),
|
|
40
|
+
allWriteHandlerQns: collectWriteHandlerQns(features),
|
|
41
|
+
screensByShortId: collectScreensByShortId(features),
|
|
42
|
+
queryHandlerQns: buildQueryHandlerMap(features),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function collectExposedApis(features) {
|
|
46
|
+
const allExposedApis = new Map(); // apiName → providerFeature
|
|
47
|
+
for (const f of features) {
|
|
48
|
+
for (const apiName of f.exposedApis) {
|
|
49
|
+
const existing = allExposedApis.get(apiName);
|
|
50
|
+
if (existing && existing !== f.name) {
|
|
51
|
+
throw new Error(`Cross-feature API "${apiName}" exposed by both "${existing}" and "${f.name}" — API names must be globally unique.`);
|
|
52
|
+
}
|
|
53
|
+
allExposedApis.set(apiName, f.name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return allExposedApis;
|
|
57
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { EnvelopeCipher } from "../../secrets/envelope-cipher.ts";
|
|
2
|
+
export type ValidateBootOptions = {
|
|
3
|
+
/** Warn when an access role is used by exactly one handler/config-key/
|
|
4
|
+
* field across the whole boot scan — often a typo, but also the normal
|
|
5
|
+
* shape of a legitimate fine-grained role (one role scoped to one admin
|
|
6
|
+
* endpoint on purpose). Opt-in (default false): a default-on
|
|
7
|
+
* prod warning that nobody can silence per-role isn't worth the noise
|
|
8
|
+
* it generates on every boot. */
|
|
9
|
+
readonly warnOnUniqueAccessRoles?: boolean;
|
|
10
|
+
/** QNs the app's sidebar allowlist admits. When set, every declared nav
|
|
11
|
+
* whose screen isn't reachable from any allowlisted nav entry gets a
|
|
12
|
+
* boot-time warning — that screen would otherwise be silently
|
|
13
|
+
* unreachable via the sidebar. When omitted and the app has
|
|
14
|
+
* workspaces, the allowlist is derived from `r.workspace({ nav })` and
|
|
15
|
+
* `r.nav({ workspaces })` assignments instead. */
|
|
16
|
+
readonly navAllowlist?: ReadonlySet<string>;
|
|
17
|
+
/** Nav QNs deliberately left out of the sidebar allowlist because their
|
|
18
|
+
* screen is reachable another way (e.g. as a sub-page of a generated
|
|
19
|
+
* settings hub). Suppresses the navAllowlist warning for these QNs. */
|
|
20
|
+
readonly navAllowlistExempt?: ReadonlySet<string>;
|
|
21
|
+
/** Env the encrypted-field master-key probe reads. Defaults to
|
|
22
|
+
* `process.env`; runners pass their resolved env so a ciphertext-only
|
|
23
|
+
* key (already decrypted at boot) is visible to the probe. */
|
|
24
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
25
|
+
/** The cipher the runner injects after validation (built from the app's
|
|
26
|
+
* `masterKey` or the env keyring). Satisfies the availability check for
|
|
27
|
+
* `encrypted: true` fields without an env KEK. */
|
|
28
|
+
readonly entityFieldCipher?: EnvelopeCipher;
|
|
29
|
+
/** Locales every translation key must carry across all mounted features
|
|
30
|
+
* (locale packages count). Without it a key can ship in one language and
|
|
31
|
+
* silently fall back at runtime for users of the others. */
|
|
32
|
+
readonly requiredLocales?: readonly string[];
|
|
33
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { BootContext } from "./boot-context.ts";
|
|
2
|
+
import type { FeatureFlags } from "./boot-phases.ts";
|
|
3
|
+
import type { BootReport } from "./boot-report.ts";
|
|
4
|
+
export declare function runAppWidePhase(report: BootReport, ctx: BootContext): void;
|
|
5
|
+
export declare function runEnvironmentPhase(report: BootReport, ctx: BootContext, flags: FeatureFlags): void;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption.js";
|
|
2
|
+
import { FILE_STORAGE_PROVIDER_ENV } from "../extension-names.js";
|
|
3
|
+
import { createRegistry } from "../registry.js";
|
|
4
|
+
import { validateProjectionAllowlist } from "../validate-projection-allowlist.js";
|
|
5
|
+
import { runFeatureBootCheck } from "./boot-check.js";
|
|
6
|
+
import { validateCardMetaOverflow } from "./card-meta-overflow.js";
|
|
7
|
+
import { validateConfigReads } from "./config-deps.js";
|
|
8
|
+
import { validateExtensionPreSaveWiring } from "./entity-handler.js";
|
|
9
|
+
import { validateEntityListScreens } from "./entity-list-screens.js";
|
|
10
|
+
import { validateGdprStoragePersistence } from "./gdpr-storage.js";
|
|
11
|
+
import { validateGlobalTenancyEntities } from "./global-tenancy.js";
|
|
12
|
+
import { validateI18nSurfaceKeys, validateRequiredLocales, validateTranslationLocaleConsistency, } from "./i18n-keys.js";
|
|
13
|
+
import { validateNavCycles } from "./nav.js";
|
|
14
|
+
import { validateProjectionListScreens, validateRelatedListSectionQueries, } from "./projection-list-screens.js";
|
|
15
|
+
import { validateQueryOutputColumns } from "./query-output-columns.js";
|
|
16
|
+
import { validateExtensionSelectorPanels } from "./selector-owner-panels.js";
|
|
17
|
+
import { validateSubmitPrefilledHiddenFields } from "./url-prefill-fields.js";
|
|
18
|
+
import { validateDefaultWorkspaceUniqueness } from "./workspaces.js";
|
|
19
|
+
const APP_CHECKS_AFTER_I18N = [
|
|
20
|
+
{
|
|
21
|
+
check: "validateTranslationLocaleConsistency",
|
|
22
|
+
run: (c) => validateTranslationLocaleConsistency(c.features),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
check: "validateRequiredLocales",
|
|
26
|
+
run: (c) => validateRequiredLocales(c.features, c.options?.requiredLocales),
|
|
27
|
+
},
|
|
28
|
+
{ check: "validateEntityListScreens", run: (c) => validateEntityListScreens(c.features) },
|
|
29
|
+
{
|
|
30
|
+
check: "validateSubmitPrefilledHiddenFields",
|
|
31
|
+
run: (c) => validateSubmitPrefilledHiddenFields(c.features),
|
|
32
|
+
},
|
|
33
|
+
{ check: "validateCardMetaOverflow", run: (c) => validateCardMetaOverflow(c.features) },
|
|
34
|
+
{
|
|
35
|
+
check: "validateExtensionSelectorPanels",
|
|
36
|
+
run: (c) => validateExtensionSelectorPanels(c.features),
|
|
37
|
+
},
|
|
38
|
+
// Column/field checks assume every `query` string already resolves (validateQueryRefs, phase 1).
|
|
39
|
+
{ check: "validateQueryOutputColumns", run: (c) => validateQueryOutputColumns(c.features) },
|
|
40
|
+
{
|
|
41
|
+
check: "validateProjectionListScreens",
|
|
42
|
+
run: (c) => validateProjectionListScreens(c.features),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
check: "validateRelatedListSectionQueries",
|
|
46
|
+
run: (c) => validateRelatedListSectionQueries(c.features),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
check: "validateExtensionPreSaveWiring",
|
|
50
|
+
run: (c) => validateExtensionPreSaveWiring(c.features),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
check: "validateGdprStoragePersistence",
|
|
54
|
+
run: (c) => validateGdprStoragePersistence(c.features),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
check: "validateGlobalTenancyEntities",
|
|
58
|
+
run: (c) => validateGlobalTenancyEntities(c.features),
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
function runFeatureBootChecks(report, ctx) {
|
|
62
|
+
for (const feature of ctx.features) {
|
|
63
|
+
for (const check of feature.bootChecks) {
|
|
64
|
+
report.check("runFeatureBootCheck", feature.name, () => runFeatureBootCheck(feature, check, ctx.features));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function runAppWidePhase(report, ctx) {
|
|
69
|
+
const { features } = ctx;
|
|
70
|
+
report.check("validateNavCycles", undefined, () => validateNavCycles(ctx.allNavQns));
|
|
71
|
+
report.check("validateDefaultWorkspaceUniqueness", undefined, () => validateDefaultWorkspaceUniqueness(ctx.allWorkspaceQns));
|
|
72
|
+
const built = {};
|
|
73
|
+
report.check("createRegistry", undefined, () => {
|
|
74
|
+
built.registry = createRegistry(features);
|
|
75
|
+
});
|
|
76
|
+
const registry = built.registry;
|
|
77
|
+
if (registry !== undefined) {
|
|
78
|
+
report.check("validateI18nSurfaceKeys", undefined, () => validateI18nSurfaceKeys(features, registry));
|
|
79
|
+
}
|
|
80
|
+
for (const { check, run } of APP_CHECKS_AFTER_I18N) {
|
|
81
|
+
report.check(check, undefined, () => run(ctx));
|
|
82
|
+
}
|
|
83
|
+
runFeatureBootChecks(report, ctx);
|
|
84
|
+
report.check("validateProjectionAllowlist", undefined, () => validateProjectionAllowlist(features));
|
|
85
|
+
report.check("validateConfigReads", undefined, () => validateConfigReads(features, ctx.allConfigKeys));
|
|
86
|
+
}
|
|
87
|
+
// Independent of the earlier phases: whether the environment can serve the app is
|
|
88
|
+
// a separate question from whether its definitions are consistent.
|
|
89
|
+
export function runEnvironmentPhase(report, ctx, flags) {
|
|
90
|
+
const { options } = ctx;
|
|
91
|
+
if (flags.hasEncryptedFields) {
|
|
92
|
+
// Availability check, not env-presence: eagerly building the keyring
|
|
93
|
+
// catches malformed keys (wrong length, bad base64) at boot instead of
|
|
94
|
+
// on the first encrypted read in prod. An injected cipher (test seam,
|
|
95
|
+
// custom KMS provider) satisfies the requirement without env keys.
|
|
96
|
+
report.check("validateEntityFieldEncryptionAvailable", undefined, () => validateEntityFieldEncryptionAvailable(options?.env, options?.entityFieldCipher));
|
|
97
|
+
}
|
|
98
|
+
report.check("fileStorageProvider", undefined, () => {
|
|
99
|
+
if (flags.hasFileFields && !(options?.env ?? process.env)[FILE_STORAGE_PROVIDER_ENV]) {
|
|
100
|
+
throw new Error(`${FILE_STORAGE_PROVIDER_ENV} environment variable is required (file/image fields in use)`);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { BootContext } from "./boot-context.ts";
|
|
2
|
+
import type { BootReport } from "./boot-report.ts";
|
|
3
|
+
import { collectDetailForScreens } from "./detail-screens.ts";
|
|
4
|
+
export type FeatureFlags = {
|
|
5
|
+
hasEncryptedFields: boolean;
|
|
6
|
+
hasFileFields: boolean;
|
|
7
|
+
};
|
|
8
|
+
type DetailForScreens = ReturnType<typeof collectDetailForScreens>;
|
|
9
|
+
export declare function runStructurePhase(report: BootReport, ctx: BootContext): DetailForScreens;
|
|
10
|
+
export declare function runFeaturePhase(report: BootReport, ctx: BootContext, detailForScreens: DetailForScreens): FeatureFlags;
|
|
11
|
+
export {};
|