@cosmicdrift/kumiko-framework 0.398.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.md +33 -19
- package/agents/api/dev-server-testing.md +23 -0
- package/agents/api/framework-db.md +277 -0
- package/agents/api/framework-engine.md +1810 -0
- package/agents/api/framework-errors.md +119 -0
- package/agents/api/framework-event-store.md +99 -0
- package/agents/api/framework-seeding.md +26 -0
- package/agents/api/framework-ui-types.md +542 -0
- package/agents/api/renderer-web.md +1450 -0
- package/agents/api/testing-helpers.md +189 -0
- package/agents/api/testing-stack.md +144 -0
- package/agents/api/types.md +5124 -0
- package/agents/api.md +20 -0
- 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 +178 -0
- package/agents/features.md +9 -1
- 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 +5 -1
- package/dist/engine/entity-list-runtime-options.js +103 -4
- 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 -1
- package/dist/engine/index.js +2 -1
- 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/entrypoint/index.js +1 -0
- package/dist/entrypoint/internals.d.ts +2 -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 +30 -9
- 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 +4 -1
- package/dist/files/upload-field-resolution.js +57 -12
- 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 +67 -0
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import type { EntityId } from "@cosmicdrift/kumiko-types/identifiers";
|
|
2
2
|
import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
3
3
|
import type { ZodType } from "zod";
|
|
4
|
-
import type
|
|
4
|
+
import { type UnavailableSelectOption } from "../engine/screen-helpers.ts";
|
|
5
|
+
import type { FieldDefinition } from "../engine/types/fields.ts";
|
|
5
6
|
import type { UnprocessableOpts } from "../errors/classes.ts";
|
|
6
7
|
import { type WriteFailure, writeFailure } from "../errors/index.ts";
|
|
7
8
|
import type { StoredEvent } from "../event-store/index.ts";
|
|
8
9
|
import type { DbRow, DbRunner } from "./connection.ts";
|
|
9
10
|
export declare function projectEntityEvent(db: TenantDb, runner: DbRunner, event: StoredEvent): Promise<void>;
|
|
10
11
|
export declare function selectOptionNotAvailableFailure(entries: readonly UnavailableSelectOption[]): WriteFailure | undefined;
|
|
12
|
+
export declare function missingConditionalRequirementFailure(paths: readonly string[]): WriteFailure | undefined;
|
|
13
|
+
/** Value-consistency checks on the merged row (create: no `previous`): options a
|
|
14
|
+
* conditionalOptions rule no longer allows, and requiredWhen values still missing. */
|
|
15
|
+
export declare function fieldStateFailure(fields: Readonly<Record<string, FieldDefinition>>, values: Record<string, unknown>, previous?: Record<string, unknown>): WriteFailure | undefined;
|
|
11
16
|
export type PreSaveFn = (changes: Record<string, unknown>, previous: Record<string, unknown>, isNew: boolean) => Promise<Record<string, unknown>>;
|
|
12
17
|
export declare function runPreSave(preSave: PreSaveFn | undefined, changes: Record<string, unknown>, previous: Record<string, unknown>, isNew: boolean, entityName: string, action: "create" | "update", outputSchema: () => ZodType): Promise<{
|
|
13
18
|
readonly data: DbRow;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { findMissingConditionalRequirements } from "../engine/field-state.js";
|
|
2
|
+
import { findUnavailableSelectOptions, } from "../engine/screen-helpers.js";
|
|
3
|
+
import { VersionConflictError as FrameworkVersionConflict, UnprocessableError, ValidationError, writeFailure, } from "../errors/index.js";
|
|
2
4
|
import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline/projections-runner.js";
|
|
3
5
|
import { projectionRegistryOf } from "./projection-registry-binding.js";
|
|
4
6
|
import { runInSavepointIfSupported } from "./query.js";
|
|
@@ -35,6 +37,23 @@ export function selectOptionNotAvailableFailure(entries) {
|
|
|
35
37
|
},
|
|
36
38
|
}));
|
|
37
39
|
}
|
|
40
|
+
export function missingConditionalRequirementFailure(paths) {
|
|
41
|
+
if (paths.length === 0)
|
|
42
|
+
return undefined;
|
|
43
|
+
return writeFailure(new ValidationError({
|
|
44
|
+
fields: paths.map((path) => ({
|
|
45
|
+
path,
|
|
46
|
+
code: "required",
|
|
47
|
+
i18nKey: "kumiko.validation.required",
|
|
48
|
+
})),
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
/** Value-consistency checks on the merged row (create: no `previous`): options a
|
|
52
|
+
* conditionalOptions rule no longer allows, and requiredWhen values still missing. */
|
|
53
|
+
export function fieldStateFailure(fields, values, previous) {
|
|
54
|
+
return (selectOptionNotAvailableFailure(findUnavailableSelectOptions(fields, values, previous)) ??
|
|
55
|
+
missingConditionalRequirementFailure(findMissingConditionalRequirements(fields, values, previous)));
|
|
56
|
+
}
|
|
38
57
|
// preSave runs before ownership checks: authorization must evaluate the row
|
|
39
58
|
// as it will actually be persisted, including hook-derived fields. A throwing
|
|
40
59
|
// hook is an app-author bug (bad business rule, not a framework fault) — map it
|
|
@@ -3,7 +3,7 @@ import { checkWriteFieldOwnership } from "../engine/field-access.js";
|
|
|
3
3
|
import { instructionFieldNames } from "../engine/instruction-fields.js";
|
|
4
4
|
import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
|
|
5
5
|
import { buildInsertSchema, buildUpdateSchema } from "../engine/schema-builder.js";
|
|
6
|
-
import {
|
|
6
|
+
import { isRecordLocked, isUnchangedValue } from "../engine/screen-helpers.js";
|
|
7
7
|
import { IdempotentReplayError, PreconditionFailedError, RecordLockedError, } from "../errors/classes.js";
|
|
8
8
|
import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, InternalError, NotFoundError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
9
9
|
import { isSubjectErasedConflict } from "../errors/subject-erased.js";
|
|
@@ -15,7 +15,7 @@ import { applyEntityEvent } from "./apply-entity-event.js";
|
|
|
15
15
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
16
16
|
import { WritePurposes, } from "./event-store-executor.js";
|
|
17
17
|
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
18
|
-
import { optimisticLockFailure, ownershipDeniedFailure, projectEntityEvent, runPreSave,
|
|
18
|
+
import { fieldStateFailure, optimisticLockFailure, ownershipDeniedFailure, projectEntityEvent, runPreSave, } from "./event-store-executor-write-steps.js";
|
|
19
19
|
import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.js";
|
|
20
20
|
import { runInSavepointIfSupported } from "./query.js";
|
|
21
21
|
import { assertPersonalDataWrite, tableNameOf, withExecutorManagedWrites } from "./tenant-db.js";
|
|
@@ -241,9 +241,9 @@ export function createWriteVerbs(ctx) {
|
|
|
241
241
|
// Statutory retention/erasure writes may change a condition field (e.g. null a PII
|
|
242
242
|
// reference) and must not be blocked by the select rule — same exemption as the lock.
|
|
243
243
|
const isStatutorySystemWrite = options?.writePurpose !== undefined && isFrameworkSystemUser(user);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
244
|
+
if (isStatutorySystemWrite)
|
|
245
|
+
return undefined;
|
|
246
|
+
return fieldStateFailure(entity.fields, mergedNew, previous);
|
|
247
247
|
}
|
|
248
248
|
async function appendCreatedEvent(runner, user, streamTenantId, aggregateId, flatData) {
|
|
249
249
|
// 1. Append the event in the same transaction as the projection write — both must
|
|
@@ -446,9 +446,9 @@ export function createWriteVerbs(ctx) {
|
|
|
446
446
|
const ownershipDenied = createOwnershipFailure(user, streamTenantId, payloadWithoutId, data);
|
|
447
447
|
if (ownershipDenied)
|
|
448
448
|
return ownershipDenied;
|
|
449
|
-
const
|
|
450
|
-
if (
|
|
451
|
-
return
|
|
449
|
+
const invalidFieldState = fieldStateFailure(entity.fields, data);
|
|
450
|
+
if (invalidFieldState)
|
|
451
|
+
return invalidFieldState;
|
|
452
452
|
// All compound types (locatedTimestamp, money, ...) use the same pipeline: the
|
|
453
453
|
// caller sends the combined API form, the framework stores the flat DB form (see
|
|
454
454
|
// db/compound-types.ts).
|
package/dist/db/money.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
|
|
2
2
|
import { toMinorUnits } from "../engine/minor-units.ts";
|
|
3
|
+
export declare const FRAMEWORK_DEFAULT_CURRENCY: "EUR";
|
|
3
4
|
export { toMinorUnits };
|
|
4
5
|
export declare function moneyPayloadToMinorUnits(raw: unknown): number | undefined;
|
|
5
6
|
export declare function flattenMoney(payload: Record<string, unknown>, entity: EntityDefinition): Record<string, unknown>;
|
|
@@ -29,3 +30,6 @@ export type MoneyRead = {
|
|
|
29
30
|
* Pure — does not mutate its input.
|
|
30
31
|
*/
|
|
31
32
|
export declare function rehydrateMoney(row: Record<string, unknown>, entity: EntityDefinition): Record<string, unknown>;
|
|
33
|
+
/** One money value from its raw amount/currency columns; shared with SQL derived money so both
|
|
34
|
+
* produce the identical read shape. Throws on a corrupt amount. */
|
|
35
|
+
export declare function readMoney(name: string, amountRaw: unknown, currencyRaw: unknown, fallbackCurrency: string): MoneyRead;
|
package/dist/db/money.js
CHANGED
|
@@ -32,7 +32,7 @@ import { DEFAULT_CURRENCIES } from "../engine/field-helpers.js";
|
|
|
32
32
|
// amount column (legacy DB convention for money — `SUM(buying_price)` remains
|
|
33
33
|
// idiomatic). `<name>Currency` is the additional column.
|
|
34
34
|
import { MINOR_UNIT_SCALE, toMinorUnits } from "../engine/minor-units.js";
|
|
35
|
-
const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
|
|
35
|
+
export const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
|
|
36
36
|
export { toMinorUnits };
|
|
37
37
|
function toMajorUnits(amountScaled) {
|
|
38
38
|
return amountScaled / MINOR_UNIT_SCALE;
|
|
@@ -116,35 +116,40 @@ export function rehydrateMoney(row, entity) {
|
|
|
116
116
|
delete result[name];
|
|
117
117
|
continue;
|
|
118
118
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
throw new Error(`rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a safe integer — DB corruption?`);
|
|
135
|
-
}
|
|
119
|
+
result[name] = readMoney(name, amountRaw, currencyRaw, fallbackCurrency);
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
/** One money value from its raw amount/currency columns; shared with SQL derived money so both
|
|
124
|
+
* produce the identical read shape. Throws on a corrupt amount. */
|
|
125
|
+
export function readMoney(name, amountRaw, currencyRaw, fallbackCurrency) {
|
|
126
|
+
let amountScaled;
|
|
127
|
+
if (typeof amountRaw === "number") {
|
|
128
|
+
amountScaled = amountRaw;
|
|
129
|
+
}
|
|
130
|
+
else if (typeof amountRaw === "bigint") {
|
|
131
|
+
amountScaled = Number(amountRaw);
|
|
132
|
+
if (!Number.isSafeInteger(amountScaled)) {
|
|
133
|
+
throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a safe integer`);
|
|
136
134
|
}
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
}
|
|
136
|
+
else if (typeof amountRaw === "string" && amountRaw !== "") {
|
|
137
|
+
// The PG driver sometimes returns BIGINT as a string (when it exceeds the
|
|
138
|
+
// safe integer range of 2^53).
|
|
139
|
+
amountScaled = Number(amountRaw);
|
|
140
|
+
if (!Number.isSafeInteger(amountScaled)) {
|
|
141
|
+
throw new Error(`rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a safe integer — DB corruption?`);
|
|
139
142
|
}
|
|
140
|
-
const currency = typeof currencyRaw === "string" && currencyRaw !== "" ? currencyRaw : fallbackCurrency;
|
|
141
|
-
// amountMinor: deprecated alias, same value as amountScaled — see MoneyRead.
|
|
142
|
-
result[name] = {
|
|
143
|
-
amount: toMajorUnits(amountScaled),
|
|
144
|
-
currency,
|
|
145
|
-
amountScaled,
|
|
146
|
-
amountMinor: amountScaled,
|
|
147
|
-
};
|
|
148
143
|
}
|
|
149
|
-
|
|
144
|
+
else {
|
|
145
|
+
throw new Error(`rehydrateMoney: field "${name}" amount has unexpected type ${typeof amountRaw}`);
|
|
146
|
+
}
|
|
147
|
+
const currency = typeof currencyRaw === "string" && currencyRaw !== "" ? currencyRaw : fallbackCurrency;
|
|
148
|
+
// amountMinor: deprecated alias, same value as amountScaled — see MoneyRead.
|
|
149
|
+
return {
|
|
150
|
+
amount: toMajorUnits(amountScaled),
|
|
151
|
+
currency,
|
|
152
|
+
amountScaled,
|
|
153
|
+
amountMinor: amountScaled,
|
|
154
|
+
};
|
|
150
155
|
}
|
|
@@ -8,4 +8,5 @@ export type { ParentVisibilityOption } from "@cosmicdrift/kumiko-types/event-sto
|
|
|
8
8
|
export declare function buildParentRefClause(entity: EntityDefinition, table: Table, tableName: string, user: SessionUser, db: TenantDb, option: ParentVisibilityOption | undefined, opts: {
|
|
9
9
|
readonly includeDeleted?: boolean;
|
|
10
10
|
readonly narrowTypes?: ReadonlySet<string>;
|
|
11
|
+
readonly requireReadHandler?: boolean;
|
|
11
12
|
}): OwnershipClause;
|
|
@@ -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
|
+
}
|