@cosmicdrift/kumiko-framework 0.351.0 → 0.353.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/pii-leak-guard.js +3 -0
- package/dist/api/route-registrars.d.ts +2 -1
- package/dist/api/route-registrars.js +8 -0
- package/dist/api/server.js +11 -3
- package/dist/api/sse-route.js +11 -1
- package/dist/bun-db/query.js +7 -2
- package/dist/compliance/sub-processors.js +5 -0
- package/dist/crypto/pii-field-encryption.d.ts +2 -3
- package/dist/crypto/pii-field-encryption.js +2 -3
- package/dist/db/entity-table-meta.js +11 -0
- package/dist/db/event-store-executor-context.js +20 -25
- package/dist/db/migrate-generator.js +9 -1
- package/dist/db/queries/backfill-pii.d.ts +1 -0
- package/dist/db/queries/backfill-pii.js +13 -0
- package/dist/db/queries/seed-context.d.ts +17 -0
- package/dist/db/queries/seed-context.js +26 -0
- package/dist/db/queries/shadow-swap.js +5 -2
- package/dist/engine/boot-validator/access-declarations.js +16 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +30 -0
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/query-refs.js +7 -2
- package/dist/engine/boot-validator/screens.js +60 -22
- package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
- package/dist/engine/boot-validator/select-option-tone.js +4 -0
- package/dist/engine/build-app-schema.js +33 -2
- package/dist/engine/build-config-feature-schema.js +6 -0
- package/dist/engine/dedupe-features.js +17 -1
- package/dist/engine/entity-handlers.js +15 -6
- package/dist/engine/feature-builder-state.d.ts +2 -7
- package/dist/engine/feature-config-events-jobs.js +3 -0
- package/dist/engine/feature-entity-handlers.js +1 -1
- package/dist/engine/i18n-key.js +14 -3
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/pipeline.js +18 -1
- package/dist/engine/project-app-schema-for-roles.js +8 -2
- package/dist/engine/role-assignment.d.ts +2 -0
- package/dist/engine/role-assignment.js +2 -0
- package/dist/engine/schema-builder.js +8 -0
- package/dist/engine/where-rule-lint.js +6 -3
- package/dist/env/index.d.ts +4 -0
- package/dist/env/index.js +4 -0
- package/dist/es-ops/context.js +34 -2
- package/dist/es-ops/index.d.ts +1 -1
- package/dist/es-ops/types.d.ts +20 -0
- package/dist/event-store/index.d.ts +1 -0
- package/dist/event-store/index.js +1 -0
- package/dist/event-store/tenant-db-append.d.ts +8 -0
- package/dist/event-store/tenant-db-append.js +16 -0
- package/dist/files/file-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- package/dist/files/file-routes.js +3 -3
- package/dist/files/types.js +21 -1
- package/dist/observability/metrics-wiring.d.ts +1 -1
- package/dist/observability/metrics-wiring.js +2 -2
- package/dist/pipeline/dispatch-batch.js +5 -1
- package/dist/pipeline/dispatch-write.js +36 -82
- package/dist/pipeline/member-reader.js +1 -0
- package/dist/pipeline/pre-handler-gates.d.ts +9 -0
- package/dist/pipeline/pre-handler-gates.js +104 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +12 -3
- package/dist/pipeline/pre-transaction-reservations.js +34 -21
- package/dist/testing/shared-entities.d.ts +10 -0
- package/package.json +4 -4
- package/src/changes.json +57 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// into validateColumnRendererForm, splitting them would create a
|
|
5
5
|
// same-folder require cycle.
|
|
6
6
|
import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
7
|
-
import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
|
|
7
|
+
import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs, SELECT_OPTION_TONES, } from "@cosmicdrift/kumiko-types/fields";
|
|
8
8
|
import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
9
9
|
import { rowMetaFieldNames } from "../../db/table-builder.js";
|
|
10
10
|
import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
|
|
@@ -12,9 +12,10 @@ import { isEncryptedAtRest } from "../config-helpers.js";
|
|
|
12
12
|
import { parseRefTarget } from "../parse-ref-target.js";
|
|
13
13
|
import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
|
|
14
14
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js";
|
|
15
|
-
import { isExtensionEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
|
|
15
|
+
import { isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
|
|
16
16
|
import { metricField } from "../types/index.js";
|
|
17
17
|
import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
|
|
18
|
+
import { isSelectOptionTone } from "./select-option-tone.js";
|
|
18
19
|
// entityList and projectionList both allow a rowAction to double as the
|
|
19
20
|
// row-body click target (rowClick: true, fw#1708/#2164) — at most one per
|
|
20
21
|
// screen, or the renderer can't tell which one should fire.
|
|
@@ -345,19 +346,41 @@ function validateDrawerTargetAction(featureName, screenId, screenKind, actionLab
|
|
|
345
346
|
`kind:"drawer" mounts an actionForm inside a Drawer widget — point "screen" at an ` +
|
|
346
347
|
`actionForm screen, or use kind:"navigate" for a full-page target.`);
|
|
347
348
|
}
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
349
|
+
// The renderer (mergeSearchParamsIntoInitial) silently drops a prefill key
|
|
350
|
+
// when the target doesn't declare the field, marks it sensitive/password,
|
|
351
|
+
// or doesn't render it in its layout — each would only show up as an empty
|
|
352
|
+
// field at click time.
|
|
351
353
|
// skip: no params extractor — nothing to check against the target's fields.
|
|
352
354
|
if (action.params === undefined)
|
|
353
355
|
return;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
+
validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, action.screen, target, action.params);
|
|
357
|
+
}
|
|
358
|
+
function validateDrawerPrefillKeys(where, targetScreenId, target, params) {
|
|
359
|
+
const renderedFieldNames = new Set();
|
|
360
|
+
for (const section of target.layout.sections) {
|
|
361
|
+
if (!isFieldsEditSection(section))
|
|
356
362
|
continue;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
363
|
+
for (const spec of sectionFieldSpecs(section))
|
|
364
|
+
renderedFieldNames.add(normalizeEditField(spec).field);
|
|
365
|
+
}
|
|
366
|
+
for (const fieldName of rowFieldExtractorKeys(params)) {
|
|
367
|
+
if (!Object.hasOwn(target.fields, fieldName)) {
|
|
368
|
+
throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" does not declare as a ` +
|
|
369
|
+
`field — the renderer would drop it and leave the form empty. Target fields: ` +
|
|
370
|
+
`${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
|
|
371
|
+
}
|
|
372
|
+
const targetField = target.fields[fieldName];
|
|
373
|
+
const isSensitiveOrPassword = targetField !== undefined &&
|
|
374
|
+
(("sensitive" in targetField && targetField.sensitive === true) ||
|
|
375
|
+
("format" in targetField && targetField.format === "password"));
|
|
376
|
+
if (isSensitiveOrPassword) {
|
|
377
|
+
throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" marks as ` +
|
|
378
|
+
`sensitive or password — the renderer never prefills those fields and would leave the form empty.`);
|
|
379
|
+
}
|
|
380
|
+
if (!renderedFieldNames.has(fieldName)) {
|
|
381
|
+
throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" does not render in ` +
|
|
382
|
+
`its layout — the renderer only prefills fields the layout shows and would drop it.`);
|
|
383
|
+
}
|
|
361
384
|
}
|
|
362
385
|
}
|
|
363
386
|
// The target can be any screen, so this checks the resolved target's
|
|
@@ -493,7 +516,8 @@ function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fde
|
|
|
493
516
|
}
|
|
494
517
|
}
|
|
495
518
|
// Static options XOR optionsQuery on inline-form select fields, and `{ field }`
|
|
496
|
-
// payload refs must name another field of the same form
|
|
519
|
+
// payload refs must name another field of the same form (the payload also feeds
|
|
520
|
+
// optionsAvailabilityQuery). The QN existence check
|
|
497
521
|
// lives in query-refs.ts with the other query refs.
|
|
498
522
|
function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
|
|
499
523
|
if (fdef.type === "select") {
|
|
@@ -507,6 +531,12 @@ function validateFormSelectOptions(featureName, screenId, context, fieldName, fd
|
|
|
507
531
|
}
|
|
508
532
|
validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
|
|
509
533
|
}
|
|
534
|
+
else if (fdef.optionsAvailabilityQuery !== undefined) {
|
|
535
|
+
if (fdef.optionsAvailabilityQuery.length === 0) {
|
|
536
|
+
throw new Error(`${where} has an empty optionsAvailabilityQuery`);
|
|
537
|
+
}
|
|
538
|
+
validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
|
|
539
|
+
}
|
|
510
540
|
else if (fdef.optionsQueryPayload !== undefined) {
|
|
511
541
|
throw new Error(`${where} has optionsQueryPayload without optionsQuery`);
|
|
512
542
|
}
|
|
@@ -557,7 +587,7 @@ function validateFieldsXorGroups(errorPrefix, section) {
|
|
|
557
587
|
throw new Error(`${errorPrefix} section "${section.title}" declares both fields and groups — pass fields: [] ` +
|
|
558
588
|
`when using groups.`);
|
|
559
589
|
}
|
|
560
|
-
if (section.fields.length === 0 && section.groups ===
|
|
590
|
+
if (section.fields.length === 0 && (section.groups?.length ?? 0) === 0) {
|
|
561
591
|
throw new Error(`${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
|
|
562
592
|
`fields (or groups) to it.`);
|
|
563
593
|
}
|
|
@@ -1041,6 +1071,12 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1041
1071
|
}
|
|
1042
1072
|
}
|
|
1043
1073
|
}
|
|
1074
|
+
for (const [statusValue, tone] of Object.entries(screen.header?.statusTones ?? {})) {
|
|
1075
|
+
if (!isSelectOptionTone(tone)) {
|
|
1076
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) header.statusTones["${statusValue}"] ` +
|
|
1077
|
+
`is "${String(tone)}" — expected one of ${SELECT_OPTION_TONES.join(", ")}.`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1044
1080
|
if (Array.isArray(screen.header?.subtitle)) {
|
|
1045
1081
|
if (screen.header.subtitleHref !== undefined) {
|
|
1046
1082
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets header.subtitleHref ` +
|
|
@@ -1736,6 +1772,11 @@ function validateDashboardPanelVisibleWhen(context, visibleWhen) {
|
|
|
1736
1772
|
throw new Error(`${context} visibleWhen needs a non-empty query and field.`);
|
|
1737
1773
|
}
|
|
1738
1774
|
}
|
|
1775
|
+
function validateDashboardPanelSpan(featureName, screenId, panel) {
|
|
1776
|
+
if (panel.span !== undefined && panel.span !== "half" && panel.span !== "full") {
|
|
1777
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has span "${String(panel.span)}" — expected "half" or "full".`);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1739
1780
|
function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId) {
|
|
1740
1781
|
const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}"`;
|
|
1741
1782
|
if (panel.stats.length === 0) {
|
|
@@ -1744,6 +1785,7 @@ function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelI
|
|
|
1744
1785
|
if (panel.subtitle !== undefined && panel.label === undefined) {
|
|
1745
1786
|
throw new Error(`${context} sets subtitle without label — an unlabeled group renders as a KPI strip without a header.`);
|
|
1746
1787
|
}
|
|
1788
|
+
validateDashboardPanelSpan(featureName, screenId, panel);
|
|
1747
1789
|
validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
|
|
1748
1790
|
for (const stat of panel.stats) {
|
|
1749
1791
|
addPanelId(stat.id, "stat-group child");
|
|
@@ -1769,8 +1811,9 @@ function validateDashboardChartDisplay(featureName, screenId, panel) {
|
|
|
1769
1811
|
throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it.`);
|
|
1770
1812
|
}
|
|
1771
1813
|
validateStackedAreaOnlyProps(where, panel);
|
|
1772
|
-
|
|
1773
|
-
|
|
1814
|
+
const nonBooleanProp = ["brush", "legendTotals"].find((prop) => panel[prop] !== undefined && typeof panel[prop] !== "boolean");
|
|
1815
|
+
if (nonBooleanProp !== undefined) {
|
|
1816
|
+
throw new Error(`${where} has a non-boolean ${nonBooleanProp}.`);
|
|
1774
1817
|
}
|
|
1775
1818
|
for (const [key, color] of Object.entries(panel.seriesColors ?? {})) {
|
|
1776
1819
|
if (typeof color !== "string" || color.trim() === "") {
|
|
@@ -1787,7 +1830,7 @@ function validateStackedAreaOnlyProps(where, panel) {
|
|
|
1787
1830
|
// skip: stacked-area is the one chart kind that supports these props
|
|
1788
1831
|
if (panel.chart === "stacked-area")
|
|
1789
1832
|
return;
|
|
1790
|
-
const stackedAreaOnly = ["markerKinds", "legendTotals", "ranges"].filter((prop) => panel[prop] !== undefined);
|
|
1833
|
+
const stackedAreaOnly = ["markerKinds", "legendTotals", "ranges", "brush"].filter((prop) => panel[prop] !== undefined);
|
|
1791
1834
|
if (stackedAreaOnly.length > 0) {
|
|
1792
1835
|
throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it.`);
|
|
1793
1836
|
}
|
|
@@ -1856,12 +1899,7 @@ function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
|
|
|
1856
1899
|
if (!panel.query || typeof panel.query !== "string") {
|
|
1857
1900
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`);
|
|
1858
1901
|
}
|
|
1859
|
-
|
|
1860
|
-
panel.span !== undefined &&
|
|
1861
|
-
panel.span !== "half" &&
|
|
1862
|
-
panel.span !== "full") {
|
|
1863
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has span "${String(panel.span)}" — expected "half" or "full".`);
|
|
1864
|
-
}
|
|
1902
|
+
validateDashboardPanelSpan(featureName, screenId, panel);
|
|
1865
1903
|
if (panel.kind === "stat") {
|
|
1866
1904
|
if (panel.valueField.length === 0) {
|
|
1867
1905
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField.`);
|
|
@@ -268,6 +268,7 @@ export function findNonJsonSafePath(value, path) {
|
|
|
268
268
|
// one that contradicts the schema.
|
|
269
269
|
function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreenQn, encryptedConfigKeyQns) {
|
|
270
270
|
return Object.entries(screens).map(([shortId, screen]) => {
|
|
271
|
+
assertValidScreenMultilineRows(featureName, shortId, screen);
|
|
271
272
|
if (screen.type === "configEdit")
|
|
272
273
|
return projectConfigEditScreen(screen, encryptedConfigKeyQns);
|
|
273
274
|
if (screen.type === "projectionList")
|
|
@@ -330,7 +331,7 @@ function deriveProjectionListCapabilities(schema) {
|
|
|
330
331
|
function projectEntities(entities) {
|
|
331
332
|
const out = {};
|
|
332
333
|
for (const [name, entity] of Object.entries(entities)) {
|
|
333
|
-
out[name] = projectEntity(entity);
|
|
334
|
+
out[name] = projectEntity(name, entity);
|
|
334
335
|
}
|
|
335
336
|
return out;
|
|
336
337
|
}
|
|
@@ -339,9 +340,10 @@ function projectEntities(entities) {
|
|
|
339
340
|
// einzeln auszuhandeln, walken wir die Field-Map und filtern auf die
|
|
340
341
|
// Whitelist. Was nicht durchkommt: Server-only-runtime wie ZodValidate-
|
|
341
342
|
// Functions, Computed-Functions, Default-Functions.
|
|
342
|
-
function projectEntity(entity) {
|
|
343
|
+
function projectEntity(entityName, entity) {
|
|
343
344
|
const fieldsOut = {};
|
|
344
345
|
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
346
|
+
assertValidMultilineRows(`Entity "${entityName}"`, fieldName, fieldDef);
|
|
345
347
|
fieldsOut[fieldName] = projectField(fieldDef);
|
|
346
348
|
}
|
|
347
349
|
// derivedFields MÜSSEN mit ins Client-Schema (nur die Metadaten, nicht die
|
|
@@ -380,6 +382,32 @@ function projectEntity(entity) {
|
|
|
380
382
|
function projectDerivedField(derivedDef) {
|
|
381
383
|
return { valueType: derivedDef.valueType };
|
|
382
384
|
}
|
|
385
|
+
// A textarea height that is not a positive integer cannot render anywhere; fail
|
|
386
|
+
// at boot with the field name instead of letting each renderer guess a fallback.
|
|
387
|
+
function assertValidMultilineRows(owner, fieldName, fieldDef) {
|
|
388
|
+
const multiline = fieldDef.type === "text" || fieldDef.type === "longText" ? fieldDef.multiline : undefined;
|
|
389
|
+
const rows = typeof multiline === "object" ? multiline.rows : undefined;
|
|
390
|
+
// Non-number values are not JSON-safe and get dropped by the projection below.
|
|
391
|
+
if (typeof rows === "number" && (!Number.isInteger(rows) || rows < 1)) {
|
|
392
|
+
throw new Error(`${owner} field "${fieldName}": multiline.rows must be a positive integer, got ${rows}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// Screen forms carry their own inline field maps into the client schema.
|
|
396
|
+
function assertValidScreenMultilineRows(featureName, shortId, screen) {
|
|
397
|
+
const owner = `Screen "${featureName}:${shortId}"`;
|
|
398
|
+
const check = (fields) => {
|
|
399
|
+
for (const [fieldName, fieldDef] of Object.entries(fields)) {
|
|
400
|
+
assertValidMultilineRows(owner, fieldName, fieldDef);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
if (screen.type === "configEdit" || screen.type === "actionForm")
|
|
404
|
+
check(screen.fields);
|
|
405
|
+
if (screen.type === "secretMint") {
|
|
406
|
+
check(screen.fields);
|
|
407
|
+
if (screen.confirm !== undefined)
|
|
408
|
+
check(screen.confirm.fields);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
383
411
|
// Per-field whitelist. Every forwarded value must be JSON-safe (literal, or
|
|
384
412
|
// an array/plain-object built only from JSON-safe values) — even though the
|
|
385
413
|
// FieldDefinition types only type `default` as a literal, the sample-authoring
|
|
@@ -428,6 +456,9 @@ function projectField(fieldDef) {
|
|
|
428
456
|
// fw#2780: author-supplied picker source — without it the client falls back to the list handler.
|
|
429
457
|
if (typeof def["optionsQuery"] === "string")
|
|
430
458
|
out["optionsQuery"] = def["optionsQuery"];
|
|
459
|
+
// Select: tier/tenant availability of the static options, loaded by the renderer.
|
|
460
|
+
if (typeof def["optionsAvailabilityQuery"] === "string")
|
|
461
|
+
out["optionsAvailabilityQuery"] = def["optionsAvailabilityQuery"];
|
|
431
462
|
if (typeof def["multiple"] === "boolean")
|
|
432
463
|
out["multiple"] = def["multiple"];
|
|
433
464
|
// MultiSelect: display picks checkboxes vs. combobox in the renderer,
|
|
@@ -415,10 +415,16 @@ function buildSecretsEditScreen(screenId, secrets, access, declaredTranslationKe
|
|
|
415
415
|
// need a writable local type; each entry is still a fresh, never-mutated object.
|
|
416
416
|
const translations = {};
|
|
417
417
|
const sections = [];
|
|
418
|
+
const seenFieldIds = new Map();
|
|
418
419
|
for (const feature of [...new Set(secrets.map((s) => s.feature))]) {
|
|
419
420
|
const fieldIds = [];
|
|
420
421
|
for (const s of secrets.filter((v) => v.feature === feature)) {
|
|
421
422
|
const fieldId = `${s.feature}-${s.shortKey}`;
|
|
423
|
+
const prevQn = seenFieldIds.get(fieldId);
|
|
424
|
+
if (prevQn !== undefined) {
|
|
425
|
+
throw new Error(`[Settings-Hub] secrets "${prevQn}" and "${s.qn}" both resolve to field id "${fieldId}" — rename one secret or its feature.`);
|
|
426
|
+
}
|
|
427
|
+
seenFieldIds.set(fieldId, s.qn);
|
|
422
428
|
fieldIds.push(fieldId);
|
|
423
429
|
secretKeys[fieldId] = s.qn;
|
|
424
430
|
const labelKey = `config.secret.${s.feature}.${s.shortKey}.label`;
|
|
@@ -6,15 +6,31 @@ function dedupeOptionsShallowEqual(a, b) {
|
|
|
6
6
|
}
|
|
7
7
|
return true;
|
|
8
8
|
}
|
|
9
|
+
function sameKeys(a, b) {
|
|
10
|
+
const aKeys = Object.keys(a ?? {});
|
|
11
|
+
const bKeys = new Set(Object.keys(b ?? {}));
|
|
12
|
+
return aKeys.length === bKeys.size && aKeys.every((key) => bKeys.has(key));
|
|
13
|
+
}
|
|
14
|
+
// dedupeOptions is a declaration, not proof: two same-named features that both
|
|
15
|
+
// carry `{}` but register different surface must still clash, not silently drop one.
|
|
16
|
+
function sameRegisteredSurface(a, b) {
|
|
17
|
+
return (sameKeys(a.entities, b.entities) &&
|
|
18
|
+
sameKeys(a.writeHandlers, b.writeHandlers) &&
|
|
19
|
+
sameKeys(a.queryHandlers, b.queryHandlers) &&
|
|
20
|
+
sameKeys(a.streamHandlers, b.streamHandlers));
|
|
21
|
+
}
|
|
9
22
|
function isInterchangeable(existing, next) {
|
|
10
23
|
if (existing === next)
|
|
11
24
|
return true;
|
|
12
25
|
if (existing.dedupeOptions !== undefined && next.dedupeOptions !== undefined) {
|
|
13
|
-
return dedupeOptionsShallowEqual(existing.dedupeOptions, next.dedupeOptions)
|
|
26
|
+
return (dedupeOptionsShallowEqual(existing.dedupeOptions, next.dedupeOptions) &&
|
|
27
|
+
sameRegisteredSurface(existing, next));
|
|
14
28
|
}
|
|
15
29
|
return false;
|
|
16
30
|
}
|
|
17
31
|
// First occurrence wins: runtimes late-bind closure state (sessions' autoRevoke) on the kept instance.
|
|
32
|
+
// An app holding its own handle to a later, dropped instance would bind state the registry never
|
|
33
|
+
// runs — resolve the kept instance from the composed list (`features.find(name)`), not a local handle.
|
|
18
34
|
export function dedupeFeatures(features) {
|
|
19
35
|
const byName = new Map();
|
|
20
36
|
const result = [];
|
|
@@ -198,11 +198,14 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
198
198
|
// query-handler branch below: this ONE write handler reads/writes across
|
|
199
199
|
// every tenant without making the whole feature r.systemScope().
|
|
200
200
|
const crossTenantReason = resolveCrossTenantReason(name, options);
|
|
201
|
+
// create always writes into the acting user's own tenant, so its FK,
|
|
202
|
+
// unique and preSave lookups must stay tenant-filtered even when the
|
|
203
|
+
// handler declares cross-tenant access for the row-addressing verbs.
|
|
201
204
|
const dbFor = (ctx) => {
|
|
202
205
|
if (ctx.systemDb) {
|
|
203
206
|
return ctx.systemDb.acknowledgeCrossTenant(`entity convention handler for r.systemScope() feature (${name})`);
|
|
204
207
|
}
|
|
205
|
-
return crossTenantReason !== undefined
|
|
208
|
+
return crossTenantReason !== undefined && verb !== "create"
|
|
206
209
|
? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
|
|
207
210
|
: ctx.db;
|
|
208
211
|
};
|
|
@@ -539,18 +542,24 @@ export function registerEntityCrud(r, entityName, entity, options) {
|
|
|
539
542
|
};
|
|
540
543
|
const resolveWriteOpts = (verb) => {
|
|
541
544
|
const excludeFields = verb === "create" || verb === "update" ? options?.excludeFields?.[verb] : undefined;
|
|
545
|
+
const verbAgent = options?.agents?.[verb];
|
|
542
546
|
return {
|
|
543
547
|
...writeOpts,
|
|
544
548
|
access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
|
|
545
549
|
description: options?.descriptions?.[verb] ?? writeOpts?.description,
|
|
550
|
+
...(verbAgent !== undefined && { agent: verbAgent }),
|
|
546
551
|
...(excludeFields !== undefined && { excludeFields }),
|
|
547
552
|
};
|
|
548
553
|
};
|
|
549
|
-
const resolveReadOpts = (verb) =>
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
+
const resolveReadOpts = (verb) => {
|
|
555
|
+
const verbAgent = options?.agents?.[verb];
|
|
556
|
+
return {
|
|
557
|
+
...readOpts,
|
|
558
|
+
access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
|
|
559
|
+
description: options?.descriptions?.[verb] ?? readOpts?.description,
|
|
560
|
+
...(verbAgent !== undefined && { agent: verbAgent }),
|
|
561
|
+
};
|
|
562
|
+
};
|
|
554
563
|
if (verbs.create) {
|
|
555
564
|
r.writeHandler(defineEntityCreateHandler(entityName, entity, resolveWriteOpts("create")));
|
|
556
565
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { WebSocketRouteDefinition } from "@cosmicdrift/kumiko-types/websocket-route";
|
|
2
2
|
import type * as z from "zod";
|
|
3
|
-
import type { ZodType } from "zod";
|
|
4
3
|
import type { HttpRouteDefinition } from "./types/http-route.js";
|
|
5
|
-
import type { AuthClaimsFn, BootCheckFn, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventMigrationDef, ExtensionSelectorDef, FeatureMetricDef, JobDefinition, LifecycleHookFn, MultiStreamProjectionDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, SearchPayloadContributorFn, SecretKeyDefinition, SecretNamespaceDefinition, StoreTableEntry, StreamHandlerDef, TranslationKeys, TreeActionDef, UiHints, ValidationHookFn, WriteHandlerDef } from "./types/index.js";
|
|
4
|
+
import type { AuthClaimsFn, BootCheckFn, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventDef, EventMigrationDef, ExtensionSelectorDef, FeatureMetricDef, JobDefinition, LifecycleHookFn, MultiStreamProjectionDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, SearchPayloadContributorFn, SecretKeyDefinition, SecretNamespaceDefinition, StoreTableEntry, StreamHandlerDef, TranslationKeys, TreeActionDef, UiHints, ValidationHookFn, WriteHandlerDef } from "./types/index.js";
|
|
6
5
|
import type { ContentCollectionDefinition, NavDefinition } from "./types/nav.js";
|
|
7
6
|
import type { ScreenDefinition } from "./types/screen.js";
|
|
8
7
|
import type { WorkspaceDefinition } from "./types/workspace.js";
|
|
@@ -23,11 +22,7 @@ export type FeatureBuilderState = {
|
|
|
23
22
|
configKeys: Record<string, ConfigKeyDefinition>;
|
|
24
23
|
configSeeds: ConfigSeedDef[];
|
|
25
24
|
jobs: Record<string, JobDefinition>;
|
|
26
|
-
events: Record<string,
|
|
27
|
-
name: string;
|
|
28
|
-
schema: ZodType;
|
|
29
|
-
version: number;
|
|
30
|
-
}>;
|
|
25
|
+
events: Record<string, EventDef>;
|
|
31
26
|
eventMigrations: Record<string, EventMigrationDef[]>;
|
|
32
27
|
configReads: string[];
|
|
33
28
|
entityPostSave: Record<string, PhasedHook<PostSaveHookFn>[]>;
|
|
@@ -73,6 +73,9 @@ export function buildConfigEventsJobsMethods(state, name) {
|
|
|
73
73
|
if (field === normalized.ownerField) {
|
|
74
74
|
throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as the owner field — the subject id is a plaintext pseudonymous fk, the pii field is the value it owns.`);
|
|
75
75
|
}
|
|
76
|
+
if (normalized.ownerField === "id") {
|
|
77
|
+
throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" uses owner field "id" — on entities \`of: "id"\` means record-owned, but on events it would encrypt under a phantom user key that a forget never reaches. Use \`personal: "self"\` for a record-owned event field.`);
|
|
78
|
+
}
|
|
76
79
|
for (const required of [field, normalized.ownerField]) {
|
|
77
80
|
if (shape && !(required in shape)) {
|
|
78
81
|
throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`);
|
|
@@ -55,7 +55,7 @@ function agentSlots(source) {
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
function requireInlineHandlerArgs(kind, schema, handler, options) {
|
|
58
|
-
if (!schema || !handler || !options) {
|
|
58
|
+
if (!schema || !handler || !options?.access) {
|
|
59
59
|
throw new Error(`${kind} inline form requires schema + handler + options.access`);
|
|
60
60
|
}
|
|
61
61
|
return { schema, handler, options };
|
package/dist/engine/i18n-key.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
// Dot-form labels are indistinguishable from literal display text ("actions.open"),
|
|
2
2
|
// so authors mark the i18n ones explicitly; the registry is what boot validation reads.
|
|
3
|
-
|
|
3
|
+
// Marking is by string value and therefore global across features. The registry sits on
|
|
4
|
+
// globalThis so a second installed copy of the framework still reads the marks the
|
|
5
|
+
// other copy wrote; otherwise they are silently ignored and #2313 reopens.
|
|
6
|
+
const REGISTRY_KEY = Symbol.for("kumiko.i18n.explicitDotFormKeys");
|
|
7
|
+
function explicitDotFormKeys() {
|
|
8
|
+
const existing = Reflect.get(globalThis, REGISTRY_KEY);
|
|
9
|
+
if (existing instanceof Set)
|
|
10
|
+
return existing;
|
|
11
|
+
const created = new Set();
|
|
12
|
+
Reflect.set(globalThis, REGISTRY_KEY, created);
|
|
13
|
+
return created;
|
|
14
|
+
}
|
|
4
15
|
export function i18nKey(value) {
|
|
5
|
-
explicitDotFormKeys.add(value);
|
|
16
|
+
explicitDotFormKeys().add(value);
|
|
6
17
|
return value;
|
|
7
18
|
}
|
|
8
19
|
export function isExplicitDotFormKey(value) {
|
|
9
|
-
return explicitDotFormKeys.has(value);
|
|
20
|
+
return explicitDotFormKeys().has(value);
|
|
10
21
|
}
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -59,7 +59,7 @@ export { createRegistry } from "./registry.js";
|
|
|
59
59
|
export type { ClampInfo, ResolveOptions } from "./resolve-config-or-param.js";
|
|
60
60
|
export { resolveConfigOrParam } from "./resolve-config-or-param.js";
|
|
61
61
|
export type { AssignableAppRoles, AssignableFromRole } from "./role-assignment.js";
|
|
62
|
-
export { assignableAppRolesFromUsages, assignableAppRolesOf, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
|
|
62
|
+
export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
|
|
63
63
|
export { runsInLane } from "./run-in.js";
|
|
64
64
|
export type { StepListOutcome } from "./run-pipeline.js";
|
|
65
65
|
export { runPipeline, runStepList } from "./run-pipeline.js";
|
package/dist/engine/index.js
CHANGED
|
@@ -55,7 +55,7 @@ export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
|
|
|
55
55
|
export { readClaim } from "./read-claim.js";
|
|
56
56
|
export { createRegistry } from "./registry.js";
|
|
57
57
|
export { resolveConfigOrParam } from "./resolve-config-or-param.js";
|
|
58
|
-
export { assignableAppRolesFromUsages, assignableAppRolesOf, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
|
|
58
|
+
export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
|
|
59
59
|
export { runsInLane } from "./run-in.js";
|
|
60
60
|
export { runPipeline, runStepList } from "./run-pipeline.js";
|
|
61
61
|
export { buildInsertSchema, buildUpdateSchema, fieldToZod, SYSTEM_ONLY_JSON_SCHEMA_KEY, } from "./schema-builder.js";
|
package/dist/engine/pipeline.js
CHANGED
|
@@ -79,9 +79,26 @@ export function stepsPipeline(closure) {
|
|
|
79
79
|
// carried in (D4, workflow-resume-loop.md), never an unchecked external
|
|
80
80
|
// value.
|
|
81
81
|
export function buildPipelineSteps(pipelineDef, event) {
|
|
82
|
-
|
|
82
|
+
const steps = pipelineDef.build({
|
|
83
83
|
event,
|
|
84
84
|
r: stepBuilder,
|
|
85
85
|
awaits: (pipelineDef.awaits ?? {}),
|
|
86
86
|
});
|
|
87
|
+
assertUniqueWaitForEventTypes(steps);
|
|
88
|
+
return steps;
|
|
89
|
+
}
|
|
90
|
+
// waitForEvent's result key is the awaited event type, so a second wait on the
|
|
91
|
+
// same type would overwrite the first one's resumed payload.
|
|
92
|
+
function assertUniqueWaitForEventTypes(steps) {
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
for (const step of steps) {
|
|
95
|
+
if (step.kind !== "workflow.waitForEvent")
|
|
96
|
+
continue;
|
|
97
|
+
const { event } = step.args; // @cast-boundary engine-payload
|
|
98
|
+
if (seen.has(event)) {
|
|
99
|
+
throw new Error(`r.step.waitForEvent: event "${String(event)}" is awaited more than once in one workflow — ` +
|
|
100
|
+
"each awaits entry can back only one waitForEvent step.");
|
|
101
|
+
}
|
|
102
|
+
seen.add(event);
|
|
103
|
+
}
|
|
87
104
|
}
|
|
@@ -530,20 +530,26 @@ function projectActionFormScreen(screen, feature, indices, keptScreenQns) {
|
|
|
530
530
|
};
|
|
531
531
|
}
|
|
532
532
|
function projectSecretMintScreen(screen, feature, indices, keptScreenQns) {
|
|
533
|
-
const { redirect, cancelTarget, layout, listScreenId, ...rest } = screen;
|
|
533
|
+
const { redirect, cancelTarget, layout, listScreenId, confirm, ...rest } = screen;
|
|
534
534
|
const nextRedirect = projectRedirect(feature.featureName, redirect, keptScreenQns);
|
|
535
535
|
const nextCancelTarget = projectCancelTarget(feature.featureName, cancelTarget, keptScreenQns);
|
|
536
536
|
const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
|
|
537
537
|
const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
|
|
538
|
+
const nextConfirmLayout = confirm === undefined
|
|
539
|
+
? undefined
|
|
540
|
+
: projectEditLayout(confirm.layout, feature, indices, keptScreenQns);
|
|
538
541
|
const unchanged = nextRedirect === redirect &&
|
|
539
542
|
nextCancelTarget === cancelTarget &&
|
|
540
543
|
nextLayout === layout &&
|
|
541
|
-
nextListScreenId === listScreenId
|
|
544
|
+
nextListScreenId === listScreenId &&
|
|
545
|
+
nextConfirmLayout === confirm?.layout;
|
|
542
546
|
if (unchanged)
|
|
543
547
|
return screen;
|
|
544
548
|
return {
|
|
545
549
|
...rest,
|
|
546
550
|
layout: nextLayout,
|
|
551
|
+
...(confirm !== undefined &&
|
|
552
|
+
nextConfirmLayout !== undefined && { confirm: { ...confirm, layout: nextConfirmLayout } }),
|
|
547
553
|
...(nextRedirect !== undefined && { redirect: nextRedirect }),
|
|
548
554
|
...(nextCancelTarget !== undefined && { cancelTarget: nextCancelTarget }),
|
|
549
555
|
...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Registry } from "./types/feature.js";
|
|
2
|
+
/** Every membership role the engine ranks, excluding the internal `system` actor. */
|
|
3
|
+
export declare const BUILT_IN_MEMBERSHIP_ROLES: readonly string[];
|
|
2
4
|
declare const ASSIGNABLE_FROM_ROLE_LIST: readonly ["User", "Member", "Editor", "Admin", "TenantAdmin", "SystemAdmin"];
|
|
3
5
|
export type AssignableFromRole = (typeof ASSIGNABLE_FROM_ROLE_LIST)[number];
|
|
4
6
|
/** App role → lowest built-in role allowed to assign it. */
|
|
@@ -16,6 +16,8 @@ const ROLE_RANKS = new Map([
|
|
|
16
16
|
["SystemAdmin", 4],
|
|
17
17
|
["system", 5],
|
|
18
18
|
]);
|
|
19
|
+
/** Every membership role the engine ranks, excluding the internal `system` actor. */
|
|
20
|
+
export const BUILT_IN_MEMBERSHIP_ROLES = [...ROLE_RANKS.keys()].filter((role) => role !== "system");
|
|
19
21
|
const ASSIGNABLE_FROM_ROLE_LIST = [
|
|
20
22
|
"User",
|
|
21
23
|
"Member",
|
|
@@ -87,6 +87,14 @@ export function fieldToZod(field, currencies, opts = {}) {
|
|
|
87
87
|
let schema = z.string();
|
|
88
88
|
if (field.maxLength)
|
|
89
89
|
schema = schema.max(field.maxLength);
|
|
90
|
+
if (field.minLength) {
|
|
91
|
+
// "" must stay valid for optional fields and writeOnly updates (""
|
|
92
|
+
// = unchanged); `required` rejects it separately via min(1) below.
|
|
93
|
+
const minLength = field.minLength;
|
|
94
|
+
schema = schema.refine((value) => value.length === 0 || value.length >= minLength, {
|
|
95
|
+
message: `at least ${minLength} characters`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
90
98
|
if (field.writeOnly === true) {
|
|
91
99
|
// Update: "" = unchanged (dropped before the executor), null = clear,
|
|
92
100
|
// unless required. Create: "" would leave a required field unset.
|
|
@@ -17,9 +17,9 @@ const SELF_COMPARISON_RE = new RegExp(`(${QUALIFIED_SRC})\\s*(?<![!<>=])=(?!=)\\
|
|
|
17
17
|
const QUALIFIED_PARSE_RE = new RegExp(`^(${IDENT_SRC})(?:\\.(${IDENT_SRC}))?$`);
|
|
18
18
|
// Resolve the SQL column names a table exposes, so the lint can tell a
|
|
19
19
|
// real column reference apart from a table alias / function name / SQL
|
|
20
|
-
// keyword. Returns an empty set when nothing is derivable;
|
|
21
|
-
//
|
|
22
|
-
//
|
|
20
|
+
// keyword. Returns an empty set when nothing is derivable; callers that need
|
|
21
|
+
// the column-aware check must treat that as "unknown" (see
|
|
22
|
+
// assertQualifiedWhereFragment, which fails closed on it).
|
|
23
23
|
export function tableColumnSqlNames(table) {
|
|
24
24
|
if (table === null || typeof table !== "object")
|
|
25
25
|
return new Set();
|
|
@@ -182,6 +182,9 @@ export function assertQualifiedWhereFragment(sqlText, columnSqlNames, scope) {
|
|
|
182
182
|
// the fail-open shape this lint targets is impossible here.
|
|
183
183
|
if (!/\bSELECT\b/i.test(cleaned))
|
|
184
184
|
return;
|
|
185
|
+
if (columnSqlNames.size === 0) {
|
|
186
|
+
throw new Error(`[Kumiko Ownership] ${scope}: cannot verify where-rule SQL containing a subquery — the table's column set is unknown, so unqualified column references cannot be detected.`);
|
|
187
|
+
}
|
|
185
188
|
const unqualified = findUnqualifiedColumnReference(cleaned, columnSqlNames);
|
|
186
189
|
if (unqualified !== null) {
|
|
187
190
|
throw new Error(`[Kumiko Ownership] ${scope}: where-rule SQL references column "${unqualified}" unqualified inside a subquery. Postgres binds an unqualified name to the innermost table first, which silently turns the ownership predicate into a tautology (fail-open). Qualify it with the outer table, e.g. \`\${ctx.tableName}.${unqualified}\`.`);
|
package/dist/env/index.d.ts
CHANGED
|
@@ -88,6 +88,10 @@ export type ParseEnvOptions = {
|
|
|
88
88
|
/** When set, error suggestions include `pulumi config set <prefix>...`. */
|
|
89
89
|
readonly pulumiPrefix?: string;
|
|
90
90
|
};
|
|
91
|
+
/** Validates `env` against `schema` and throws KumikoBootError on any issue.
|
|
92
|
+
* A `kms: true` field delivered only as `<NAME>_CIPHERTEXT` passes validation but
|
|
93
|
+
* is `undefined` in the result despite its `string` type — it is only set once
|
|
94
|
+
* the boot-time decrypt (resolvePlatformKeks) has run. */
|
|
91
95
|
export declare function parseEnv<S extends z.ZodObject<z.ZodRawShape>>(schema: S, env: Record<string, string | undefined>, options?: ParseEnvOptions): z.infer<S>;
|
|
92
96
|
/** Dry-Run-Gegenstück zu parseEnv (KUMIKO_DRY_RUN_ENV-Pfad): parst jedes
|
|
93
97
|
* Feld einzeln und liefert ein ehrliches Partial — vorhandene Werte kommen
|
package/dist/env/index.js
CHANGED
|
@@ -259,6 +259,10 @@ function safeParseReportingThrows(schema, input) {
|
|
|
259
259
|
]);
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
|
+
/** Validates `env` against `schema` and throws KumikoBootError on any issue.
|
|
263
|
+
* A `kms: true` field delivered only as `<NAME>_CIPHERTEXT` passes validation but
|
|
264
|
+
* is `undefined` in the result despite its `string` type — it is only set once
|
|
265
|
+
* the boot-time decrypt (resolvePlatformKeks) has run. */
|
|
262
266
|
export function parseEnv(schema, env, options = {}) {
|
|
263
267
|
// Filter undefined values to keep Zod's required-vs-invalid signal clean.
|
|
264
268
|
// (process.env returns string|undefined; passing undefined would parse
|