@cosmicdrift/kumiko-framework 0.332.0 → 0.334.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.
Files changed (38) hide show
  1. package/dist/db/eagerload.js +2 -1
  2. package/dist/engine/boot-validator/config-deps.js +13 -0
  3. package/dist/engine/boot-validator/pii-retention.js +39 -2
  4. package/dist/engine/boot-validator/screens.js +38 -6
  5. package/dist/engine/build-app-schema.js +3 -0
  6. package/dist/engine/build-config-feature-schema.js +98 -21
  7. package/dist/engine/config-helpers.d.ts +2 -2
  8. package/dist/engine/entity-handlers.js +7 -2
  9. package/dist/engine/extension-names.d.ts +12 -1
  10. package/dist/engine/extension-names.js +7 -0
  11. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  12. package/dist/engine/feature-ast/extractors/index.js +1 -1
  13. package/dist/engine/feature-ast/extractors/round5.d.ts +1 -0
  14. package/dist/engine/feature-ast/extractors/round5.js +5 -0
  15. package/dist/engine/feature-ast/parse.js +3 -1
  16. package/dist/engine/feature-entity-handlers.js +22 -0
  17. package/dist/engine/field-access.d.ts +2 -0
  18. package/dist/engine/field-access.js +40 -0
  19. package/dist/engine/index.d.ts +4 -3
  20. package/dist/engine/index.js +3 -3
  21. package/dist/engine/role-assignment.d.ts +11 -1
  22. package/dist/engine/role-assignment.js +64 -3
  23. package/dist/engine/schema-builder.d.ts +1 -0
  24. package/dist/engine/schema-builder.js +11 -1
  25. package/dist/engine/types/index.d.ts +1 -1
  26. package/dist/files/file-ref-entity.d.ts +6 -0
  27. package/dist/files/file-ref-table.d.ts +6 -0
  28. package/dist/i18n/required-surface-keys.js +24 -3
  29. package/dist/i18n/settings-hub-keys.js +18 -4
  30. package/dist/pipeline/dispatch-batch.js +8 -3
  31. package/dist/pipeline/dispatch-write.js +5 -2
  32. package/dist/pipeline/dispatcher-utils.js +4 -1
  33. package/dist/pipeline/write-result-masking.d.ts +6 -0
  34. package/dist/pipeline/write-result-masking.js +36 -0
  35. package/dist/testing/shared-entities.d.ts +10 -0
  36. package/dist/ui-types/index.d.ts +2 -2
  37. package/package.json +4 -4
  38. package/src/changes.json +12 -0
@@ -1,4 +1,6 @@
1
1
  import type { EntityDefinition, SessionUser } from "./types/index.js";
2
+ export declare function maskWriteOnlyFields<T extends Readonly<Record<string, unknown>>>(entity: EntityDefinition, row: T): T;
3
+ export declare function dropEmptyWriteOnlyValues<T extends Readonly<Record<string, unknown>>>(entity: EntityDefinition, values: T): T;
2
4
  export declare function filterReadFields(entity: EntityDefinition, data: Readonly<Record<string, unknown>>, user: SessionUser): Record<string, unknown>;
3
5
  export declare function checkWriteFieldRoles(entity: EntityDefinition, changes: Readonly<Record<string, unknown>>, user: SessionUser): string | null;
4
6
  export declare function checkWriteFieldOwnership(entity: EntityDefinition, submittedChanges: Readonly<Record<string, unknown>>, user: SessionUser, oldRow?: Readonly<Record<string, unknown>>, rowContext?: Readonly<Record<string, unknown>>): string | null;
@@ -1,4 +1,40 @@
1
1
  import { normalizeAccessEntry, userCanReadFieldRow, userCanWriteFieldRow } from "./ownership.js";
2
+ function writeOnlyFieldNames(entity) {
3
+ const names = [];
4
+ for (const [name, field] of Object.entries(entity.fields)) {
5
+ if (field.type === "text" && field.writeOnly === true)
6
+ names.push(name);
7
+ }
8
+ return names;
9
+ }
10
+ function maskWriteOnlyValue(value) {
11
+ return value === null || value === undefined || value === "" ? null : true;
12
+ }
13
+ // writeOnly text fields never leave the server as plaintext: set -> true,
14
+ // empty -> null. Only keys present in the row are touched (a partial `changes`
15
+ // stays partial). Returns the same object when the entity has no such field.
16
+ export function maskWriteOnlyFields(entity, row) {
17
+ const names = writeOnlyFieldNames(entity);
18
+ if (names.length === 0)
19
+ return row;
20
+ const masked = { ...row };
21
+ for (const name of names) {
22
+ if (name in masked)
23
+ masked[name] = maskWriteOnlyValue(masked[name]);
24
+ }
25
+ return masked; // @cast-boundary engine-payload
26
+ }
27
+ // Write payloads: "" on a writeOnly field means "unchanged" (an untouched
28
+ // masked input submits it), so the key is dropped before the executor sees it.
29
+ export function dropEmptyWriteOnlyValues(entity, values) {
30
+ const names = writeOnlyFieldNames(entity).filter((name) => values[name] === "");
31
+ if (names.length === 0)
32
+ return values;
33
+ const kept = { ...values };
34
+ for (const name of names)
35
+ delete kept[name];
36
+ return kept; // @cast-boundary engine-payload
37
+ }
2
38
  // Field-level read filtering. Returns a copy of `data` with fields stripped
3
39
  // if the user's roles don't grant read access OR the ownership-rule for the
4
40
  // matching role doesn't accept this concrete row. Fields without access
@@ -22,6 +58,10 @@ export function filterReadFields(entity, data, user) {
22
58
  if (!userCanReadFieldRow(user, accessMap, data)) {
23
59
  continue; // entire field stripped
24
60
  }
61
+ if (field.type === "text" && field.writeOnly === true) {
62
+ result[key] = maskWriteOnlyValue(value);
63
+ continue;
64
+ }
25
65
  // For embedded fields: filter sub-fields with access restrictions.
26
66
  // A list-embedded value is an array of rows — each row is filtered on its
27
67
  // own, so a sub-field rule can reference the row it sits in. Filtering the
@@ -28,7 +28,7 @@ export { declareEscapeHatch } from "./escape-hatch-declaration.js";
28
28
  export type { EmitCtx } from "./event-helpers.js";
29
29
  export { emitEvent, typedPayload } from "./event-helpers.js";
30
30
  export type { KumikoExtensionName, TenantResourceExtensionName } from "./extension-names.js";
31
- export { EXT_DERIVATIVE_OVERLAY_RESOLVER, EXT_DERIVATIVE_PUBLIC_PREDICATE, EXT_DERIVATIVE_RENDERER, EXT_EXTERNAL_RESOURCE, EXT_FILE_PROVIDER, EXT_INFRA_RESOURCE, EXT_PRINCIPAL_STATUS, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, EXT_TENANT_LIFECYCLE_STATUS, EXT_USER_DATA, EXT_USER_DATA_ORDER, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT_SENTINEL, FILE_STORAGE_PROVIDER_ENV, TENANT_MEMBERSHIPS_QUERY, } from "./extension-names.js";
31
+ export { EXT_ASSIGNABLE_ROLE, EXT_DERIVATIVE_OVERLAY_RESOLVER, EXT_DERIVATIVE_PUBLIC_PREDICATE, EXT_DERIVATIVE_RENDERER, EXT_EXTERNAL_RESOURCE, EXT_FILE_PROVIDER, EXT_INFRA_RESOURCE, EXT_PRINCIPAL_STATUS, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, EXT_TENANT_LIFECYCLE_STATUS, EXT_USER_DATA, EXT_USER_DATA_ORDER, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT_SENTINEL, FILE_STORAGE_PROVIDER_ENV, TENANT_MEMBERSHIPS_QUERY, } from "./extension-names.js";
32
32
  export { EXTENSION_SELECTOR_HINT_KEY, extensionSelectorTargets, SELECTED_EXTENSIONS_QUERY, selectablePluginIds, } from "./extension-selector-plugins.js";
33
33
  export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage.js";
34
34
  export type { StorageProviderDestroyTenantHook, StorageProviderExtensionHooks, StorageProviderHookCtx, } from "./extensions/storage-provider.js";
@@ -40,7 +40,7 @@ export type { AddEntityArgs, AddHookArgs, AddRelationArgs, AddWriteHandlerArgs,
40
40
  export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, groupByCategory, PATTERN_LIBRARY, parseFeatureFile, parsePatternChanges, parseSourceFile, removePattern, renderFeatureFile, renderPattern, replacePattern, updatePattern, VERSION_HEADER, } from "./feature-ast/index.js";
41
41
  export { type ChangelogEntry, type ChangelogType, compareVersions, type FeatureChangelog, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.js";
42
42
  export { type BuildManifestOptions, buildManifestFromRegistry, type FeatureManifest, type ManifestConfigKey, type ManifestExtension, type ManifestFeature, type ManifestSecret, serializeManifest, } from "./feature-manifest.js";
43
- export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, } from "./field-access.js";
43
+ export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
44
44
  export { resolveName, withResponseData } from "./handler-helpers.js";
45
45
  export { i18nKey } from "./i18n-key.js";
46
46
  export { buildSessionRoles, findForbiddenMembershipRole, isForbiddenMembershipRole, stripForbiddenMembershipRoles, } from "./membership-roles.js";
@@ -57,7 +57,8 @@ export { readClaim } from "./read-claim.js";
57
57
  export { createRegistry } from "./registry.js";
58
58
  export type { ClampInfo, ResolveOptions } from "./resolve-config-or-param.js";
59
59
  export { resolveConfigOrParam } from "./resolve-config-or-param.js";
60
- export { findForbiddenRoleAssignment } from "./role-assignment.js";
60
+ export type { AssignableAppRoles, AssignableFromRole } from "./role-assignment.js";
61
+ export { assignableAppRolesFromUsages, findForbiddenRoleAssignment, isAssignableByRole, } from "./role-assignment.js";
61
62
  export { runsInLane } from "./run-in.js";
62
63
  export type { StepListOutcome } from "./run-pipeline.js";
63
64
  export { runPipeline, runStepList } from "./run-pipeline.js";
@@ -26,7 +26,7 @@ export { createEntityExecutor, defineEntityCreateHandler, defineEntityDeleteHand
26
26
  defineEntityQueryHandler, defineEntityRestoreHandler, defineEntityUpdateHandler, defineEntityWriteHandler, defineProjectionQueryHandler, entityListSchema, MAX_LIST_LIMIT, registerEntityCrud, } from "./entity-handlers.js";
27
27
  export { declareEscapeHatch } from "./escape-hatch-declaration.js";
28
28
  export { emitEvent, typedPayload } from "./event-helpers.js";
29
- export { EXT_DERIVATIVE_OVERLAY_RESOLVER, EXT_DERIVATIVE_PUBLIC_PREDICATE, EXT_DERIVATIVE_RENDERER, EXT_EXTERNAL_RESOURCE, EXT_FILE_PROVIDER, EXT_INFRA_RESOURCE, EXT_PRINCIPAL_STATUS, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, EXT_TENANT_LIFECYCLE_STATUS, EXT_USER_DATA, EXT_USER_DATA_ORDER, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT_SENTINEL, FILE_STORAGE_PROVIDER_ENV, TENANT_MEMBERSHIPS_QUERY, } from "./extension-names.js";
29
+ export { EXT_ASSIGNABLE_ROLE, EXT_DERIVATIVE_OVERLAY_RESOLVER, EXT_DERIVATIVE_PUBLIC_PREDICATE, EXT_DERIVATIVE_RENDERER, EXT_EXTERNAL_RESOURCE, EXT_FILE_PROVIDER, EXT_INFRA_RESOURCE, EXT_PRINCIPAL_STATUS, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, EXT_TENANT_LIFECYCLE_STATUS, EXT_USER_DATA, EXT_USER_DATA_ORDER, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT_SENTINEL, FILE_STORAGE_PROVIDER_ENV, TENANT_MEMBERSHIPS_QUERY, } from "./extension-names.js";
30
30
  export { EXTENSION_SELECTOR_HINT_KEY, extensionSelectorTargets, SELECTED_EXTENSIONS_QUERY, selectablePluginIds, } from "./extension-selector-plugins.js";
31
31
  export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage.js";
32
32
  export { isTenantDataExtensionHooks, } from "./extensions/tenant-data.js";
@@ -35,7 +35,7 @@ export { createBigIntField, createBooleanField, createDateField, createDecimalFi
35
35
  export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, groupByCategory, PATTERN_LIBRARY, parseFeatureFile, parsePatternChanges, parseSourceFile, removePattern, renderFeatureFile, renderPattern, replacePattern, updatePattern, VERSION_HEADER, } from "./feature-ast/index.js";
36
36
  export { compareVersions, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.js";
37
37
  export { buildManifestFromRegistry, serializeManifest, } from "./feature-manifest.js";
38
- export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, } from "./field-access.js";
38
+ export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
39
39
  export { resolveName, withResponseData } from "./handler-helpers.js";
40
40
  export { i18nKey } from "./i18n-key.js";
41
41
  // findForbiddenMembershipRole/isForbiddenMembershipRole/
@@ -54,7 +54,7 @@ export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
54
54
  export { readClaim } from "./read-claim.js";
55
55
  export { createRegistry } from "./registry.js";
56
56
  export { resolveConfigOrParam } from "./resolve-config-or-param.js";
57
- export { findForbiddenRoleAssignment } from "./role-assignment.js";
57
+ export { assignableAppRolesFromUsages, findForbiddenRoleAssignment, isAssignableByRole, } from "./role-assignment.js";
58
58
  export { runsInLane } from "./run-in.js";
59
59
  export { runPipeline, runStepList } from "./run-pipeline.js";
60
60
  export { buildInsertSchema, buildUpdateSchema, fieldToZod } from "./schema-builder.js";
@@ -1 +1,11 @@
1
- export declare function findForbiddenRoleAssignment(actorRoles: readonly string[], assignedRoles: readonly string[], targetCurrentRoles: readonly string[]): string | undefined;
1
+ export type AssignableFromRole = "User" | "Member" | "Editor" | "Admin" | "TenantAdmin" | "SystemAdmin";
2
+ /** App role → lowest built-in role allowed to assign it. */
3
+ export type AssignableAppRoles = ReadonlyMap<string, AssignableFromRole>;
4
+ export declare const DEFAULT_APP_ROLE_ASSIGNABLE_FROM: AssignableFromRole;
5
+ export declare function assignableAppRolesFromUsages(usages: readonly {
6
+ entityName: string;
7
+ options?: Record<string, unknown> | undefined;
8
+ }[]): AssignableAppRoles;
9
+ /** True when `actorRole` ranks at least as high as the role an app role is assignable from. */
10
+ export declare function isAssignableByRole(assignableFrom: AssignableFromRole, actorRole: AssignableFromRole): boolean;
11
+ export declare function findForbiddenRoleAssignment(actorRoles: readonly string[], assignedRoles: readonly string[], targetCurrentRoles: readonly string[], assignableAppRoles?: AssignableAppRoles): string | undefined;
@@ -1,8 +1,13 @@
1
+ import { ROLES } from "../auth/roles.js";
2
+ import { isForbiddenMembershipRole } from "./membership-roles.js";
3
+ const PLATFORM_ROLE_NAMES = new Set(Object.values(ROLES));
1
4
  // Prototype-free rank table — Object literals leak Object.prototype
2
5
  // (`constructor`, `toString`, …) into `ROLE_RANKS[role]` lookups and turn
3
6
  // Math.max into NaN, which fails every `rank > actorRank` check open.
4
7
  const ROLE_RANKS = new Map([
5
8
  ["User", 0],
9
+ // Default seed role — unranked it would fail closed and never be assignable.
10
+ ["Member", 0],
6
11
  // Matches DEFAULT_INVITE_ROLE_OPTIONS — must stay ranked or invite UI fails closed.
7
12
  ["Editor", 1],
8
13
  ["Admin", 2],
@@ -10,7 +15,55 @@ const ROLE_RANKS = new Map([
10
15
  ["SystemAdmin", 4],
11
16
  ["system", 5],
12
17
  ]);
13
- // Unknown roles: +∞ on the assigned path (cannot grant what we don't know),
18
+ // App roles are ordinary, non-privileged tiers: whoever may manage members
19
+ // (access.admin) may grant them; an app raises the bar via assignableFrom.
20
+ export const DEFAULT_APP_ROLE_ASSIGNABLE_FROM = "Admin";
21
+ const NO_ASSIGNABLE_APP_ROLES = new Map();
22
+ const ASSIGNABLE_FROM_ROLES = new Set([
23
+ "User",
24
+ "Member",
25
+ "Editor",
26
+ "Admin",
27
+ "TenantAdmin",
28
+ "SystemAdmin",
29
+ ]);
30
+ function isAssignableFromRole(value) {
31
+ return typeof value === "string" && ASSIGNABLE_FROM_ROLES.has(value);
32
+ }
33
+ export function assignableAppRolesFromUsages(usages) {
34
+ const result = new Map();
35
+ for (const usage of usages) {
36
+ const role = usage.entityName;
37
+ if (role === "") {
38
+ throw new Error("assignableRole: role name must not be empty");
39
+ }
40
+ // Platform roles (TenantOwner, DataProtectionOfficer) gate handlers that
41
+ // TenantAdmin/Admin cannot reach — declaring one would let an Admin
42
+ // self-grant it, so they stay out of reach of app declarations.
43
+ if (ROLE_RANKS.has(role) || PLATFORM_ROLE_NAMES.has(role)) {
44
+ throw new Error(`assignableRole: "${role}" is a built-in role and cannot be redeclared`);
45
+ }
46
+ if (isForbiddenMembershipRole(role)) {
47
+ throw new Error(`assignableRole: "${role}" is a reserved role and cannot be a membership role`);
48
+ }
49
+ const declaredAssignableFrom = usage.options?.["assignableFrom"];
50
+ if (declaredAssignableFrom !== undefined && !isAssignableFromRole(declaredAssignableFrom)) {
51
+ throw new Error(`assignableRole: "${role}" has invalid assignableFrom, expected one of ${[...ASSIGNABLE_FROM_ROLES].join(", ")}`);
52
+ }
53
+ const assignableFrom = declaredAssignableFrom ?? DEFAULT_APP_ROLE_ASSIGNABLE_FROM;
54
+ const existing = result.get(role);
55
+ if (existing !== undefined && existing !== assignableFrom) {
56
+ throw new Error(`assignableRole: "${role}" is declared with conflicting assignableFrom (${existing} vs ${assignableFrom})`);
57
+ }
58
+ result.set(role, assignableFrom);
59
+ }
60
+ return result;
61
+ }
62
+ /** True when `actorRole` ranks at least as high as the role an app role is assignable from. */
63
+ export function isAssignableByRole(assignableFrom, actorRole) {
64
+ return getRoleRank(assignableFrom) <= getRoleRank(actorRole);
65
+ }
66
+ // Unknown roles: +∞ on the assigned path (cannot grant what we don't know, unless declared),
14
67
  // -1 on the actor path (cannot elevate via an unrecognized self-role).
15
68
  function roleRankOr(role, unknownRank) {
16
69
  return ROLE_RANKS.get(role) ?? unknownRank;
@@ -27,15 +80,23 @@ function maxRoleRank(roles) {
27
80
  function knownRoleRank(role) {
28
81
  return ROLE_RANKS.get(role);
29
82
  }
83
+ // Built-ins win: a declaration can never re-tier a built-in role.
84
+ function requiredAssignRank(role, assignableAppRoles) {
85
+ const builtIn = ROLE_RANKS.get(role);
86
+ if (builtIn !== undefined)
87
+ return builtIn;
88
+ const assignableFrom = assignableAppRoles.get(role);
89
+ return assignableFrom === undefined ? Number.POSITIVE_INFINITY : getRoleRank(assignableFrom);
90
+ }
30
91
  export function findForbiddenRoleAssignment(actorRoles, assignedRoles,
31
92
  // Required so callers cannot accidentally disable the downgrade/
32
93
  // takeover guard by omitting the third argument (new users: pass []).
33
- targetCurrentRoles) {
94
+ targetCurrentRoles, assignableAppRoles = NO_ASSIGNABLE_APP_ROLES) {
34
95
  const actorRank = maxRoleRank(actorRoles);
35
96
  // Assign path: fail-closed on unknown / above-actor roles — except unranked
36
97
  // app roles the target already holds (round-trip restore after strip).
37
98
  const forbiddenAssigned = assignedRoles.find((role) => {
38
- if (getRoleRank(role) <= actorRank)
99
+ if (requiredAssignRank(role, assignableAppRoles) <= actorRank)
39
100
  return false;
40
101
  if (knownRoleRank(role) === undefined && targetCurrentRoles.includes(role))
41
102
  return false;
@@ -3,6 +3,7 @@ import type { EntityDefinition, FieldDefinition } from "./types/index.js";
3
3
  export declare function isRepresentableAtScale(n: number, scale: number): boolean;
4
4
  export declare function fieldToZod(field: FieldDefinition, currencies: readonly string[], opts?: {
5
5
  readonly applyDefaults?: boolean;
6
+ readonly isUpdate?: boolean;
6
7
  }): z.ZodTypeAny;
7
8
  export declare function buildInsertSchema(entity: EntityDefinition, currencies?: readonly string[], excludedFields?: readonly string[], options?: {
8
9
  readonly allowCallerId?: boolean;
@@ -87,6 +87,13 @@ 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.writeOnly === true) {
91
+ // Update: "" = unchanged (dropped before the executor), null = clear,
92
+ // unless required. Create: "" would leave a required field unset.
93
+ if (opts.isUpdate !== true)
94
+ return field.required ? schema.min(1) : schema;
95
+ return field.required ? schema : schema.nullable();
96
+ }
90
97
  if (field.format === "email")
91
98
  schema = schema.email();
92
99
  if (field.format === "url")
@@ -399,7 +406,10 @@ export function buildUpdateSchema(entity, currencies = [...DEFAULT_CURRENCIES],
399
406
  // fieldToZod still knows the default for its "" → default mapping
400
407
  // (e.g. select) — applyDefaults: false only suppresses the schema-level
401
408
  // `.default(...)` fallback for a genuinely omitted key.
402
- shape[name] = fieldToZod(field, currencies, { applyDefaults: false }).optional();
409
+ shape[name] = fieldToZod(field, currencies, {
410
+ applyDefaults: false,
411
+ isUpdate: true,
412
+ }).optional();
403
413
  }
404
414
  return applyTotalsMatchRefinements(entity, z.object(shape));
405
415
  }
@@ -6,7 +6,7 @@ export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap,
6
6
  export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsMap, } from "@cosmicdrift/kumiko-types/extension-options-map";
7
7
  export type { BootCheckContext, BootCheckFn, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretOptions, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
8
8
  export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
9
- export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
9
+ export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
10
10
  export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
11
11
  export { isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
12
12
  export type { DeleteContext, EntityHookMap, HookMap, HookOwner, HookPhase, LifecycleHookFn, LifecycleResult, OwnedFn, PhasedHook, PostDeleteBatchHookFn, PostDeleteHookFn, PostQueryHookFn, PostSaveBatchHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, SaveContext, SearchPayloadContributorFn, ValidationError, ValidationHookFn, } from "@cosmicdrift/kumiko-types/hooks";
@@ -9,6 +9,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
9
9
  readonly filterable?: boolean;
10
10
  readonly encrypted?: boolean;
11
11
  readonly sensitive?: boolean;
12
+ readonly writeOnly?: boolean;
12
13
  readonly readAsInstruction?: boolean;
13
14
  readonly format?: "email" | "url" | "phone" | "password";
14
15
  readonly default?: string;
@@ -29,6 +30,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
29
30
  readonly filterable?: boolean;
30
31
  readonly encrypted?: boolean;
31
32
  readonly sensitive?: boolean;
33
+ readonly writeOnly?: boolean;
32
34
  readonly readAsInstruction?: boolean;
33
35
  readonly format?: "email" | "url" | "phone" | "password";
34
36
  readonly default?: string;
@@ -49,6 +51,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
49
51
  readonly filterable?: boolean;
50
52
  readonly encrypted?: boolean;
51
53
  readonly sensitive?: boolean;
54
+ readonly writeOnly?: boolean;
52
55
  readonly readAsInstruction?: boolean;
53
56
  readonly format?: "email" | "url" | "phone" | "password";
54
57
  readonly default?: string;
@@ -81,6 +84,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
81
84
  readonly filterable?: boolean;
82
85
  readonly encrypted?: boolean;
83
86
  readonly sensitive?: boolean;
87
+ readonly writeOnly?: boolean;
84
88
  readonly readAsInstruction?: boolean;
85
89
  readonly format?: "email" | "url" | "phone" | "password";
86
90
  readonly default?: string;
@@ -101,6 +105,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
101
105
  readonly filterable?: boolean;
102
106
  readonly encrypted?: boolean;
103
107
  readonly sensitive?: boolean;
108
+ readonly writeOnly?: boolean;
104
109
  readonly readAsInstruction?: boolean;
105
110
  readonly format?: "email" | "url" | "phone" | "password";
106
111
  readonly default?: string;
@@ -121,6 +126,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
121
126
  readonly filterable?: boolean;
122
127
  readonly encrypted?: boolean;
123
128
  readonly sensitive?: boolean;
129
+ readonly writeOnly?: boolean;
124
130
  readonly readAsInstruction?: boolean;
125
131
  readonly format?: "email" | "url" | "phone" | "password";
126
132
  readonly default?: string;
@@ -9,6 +9,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
9
9
  readonly filterable?: boolean;
10
10
  readonly encrypted?: boolean;
11
11
  readonly sensitive?: boolean;
12
+ readonly writeOnly?: boolean;
12
13
  readonly readAsInstruction?: boolean;
13
14
  readonly format?: "email" | "url" | "phone" | "password";
14
15
  readonly default?: string;
@@ -29,6 +30,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
29
30
  readonly filterable?: boolean;
30
31
  readonly encrypted?: boolean;
31
32
  readonly sensitive?: boolean;
33
+ readonly writeOnly?: boolean;
32
34
  readonly readAsInstruction?: boolean;
33
35
  readonly format?: "email" | "url" | "phone" | "password";
34
36
  readonly default?: string;
@@ -49,6 +51,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
49
51
  readonly filterable?: boolean;
50
52
  readonly encrypted?: boolean;
51
53
  readonly sensitive?: boolean;
54
+ readonly writeOnly?: boolean;
52
55
  readonly readAsInstruction?: boolean;
53
56
  readonly format?: "email" | "url" | "phone" | "password";
54
57
  readonly default?: string;
@@ -81,6 +84,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
81
84
  readonly filterable?: boolean;
82
85
  readonly encrypted?: boolean;
83
86
  readonly sensitive?: boolean;
87
+ readonly writeOnly?: boolean;
84
88
  readonly readAsInstruction?: boolean;
85
89
  readonly format?: "email" | "url" | "phone" | "password";
86
90
  readonly default?: string;
@@ -101,6 +105,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
101
105
  readonly filterable?: boolean;
102
106
  readonly encrypted?: boolean;
103
107
  readonly sensitive?: boolean;
108
+ readonly writeOnly?: boolean;
104
109
  readonly readAsInstruction?: boolean;
105
110
  readonly format?: "email" | "url" | "phone" | "password";
106
111
  readonly default?: string;
@@ -121,6 +126,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
121
126
  readonly filterable?: boolean;
122
127
  readonly encrypted?: boolean;
123
128
  readonly sensitive?: boolean;
129
+ readonly writeOnly?: boolean;
124
130
  readonly readAsInstruction?: boolean;
125
131
  readonly format?: "email" | "url" | "phone" | "password";
126
132
  readonly default?: string;
@@ -42,6 +42,7 @@ function editFieldName(f) {
42
42
  // Group titles are translated exactly like the section title (computeEditViewModel).
43
43
  function pushSectionTitles(out, section, treatAsKey = false) {
44
44
  pushKey(out, section.title, treatAsKey);
45
+ pushKey(out, section.subtitle, treatAsKey);
45
46
  for (const group of section.groups ?? [])
46
47
  pushKey(out, group.title, treatAsKey);
47
48
  }
@@ -51,6 +52,10 @@ function pushRowActionKeys(out, action) {
51
52
  pushKey(out, action.confirm);
52
53
  pushKey(out, action.confirmLabel);
53
54
  }
55
+ if (action.kind === "drawer") {
56
+ pushKey(out, action.title);
57
+ pushKey(out, action.subtitle);
58
+ }
54
59
  }
55
60
  function pushDashboardScreenKeys(out, dashboard) {
56
61
  for (const panel of dashboard.panels)
@@ -103,9 +108,12 @@ function pushToolbarActionKeys(out, action) {
103
108
  pushKey(out, action.confirm);
104
109
  pushKey(out, action.confirmLabel);
105
110
  }
106
- // drawer-kind carries no confirm/confirmLabel — action.label (the
107
- // toolbar button AND the Drawer title, see ToolbarDrawerHost) is
108
- // already pushed above.
111
+ // drawer-kind carries no confirm/confirmLabel — action.label is the
112
+ // toolbar button and the Drawer title unless `title` overrides it.
113
+ if (action.kind === "drawer") {
114
+ pushKey(out, action.title);
115
+ pushKey(out, action.subtitle);
116
+ }
109
117
  }
110
118
  function pushRelatedListSectionKeys(out, section) {
111
119
  pushKey(out, section.title);
@@ -180,6 +188,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
180
188
  case "entityEdit": {
181
189
  const edit = screen;
182
190
  pushKey(out, edit.submitLabel);
191
+ pushKey(out, edit.titleTemplate);
183
192
  for (const section of edit.layout.sections) {
184
193
  if (isExtensionEditSection(section)) {
185
194
  pushKey(out, section.title);
@@ -204,6 +213,8 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
204
213
  pushKey(out, form.submitLabel);
205
214
  pushKey(out, form.summary?.title);
206
215
  pushKey(out, form.summary?.subtitle);
216
+ for (const action of form.footerActions ?? [])
217
+ pushKey(out, action.label);
207
218
  for (const fieldName of Object.keys(form.fields)) {
208
219
  const override = form.fieldLabels?.[fieldName];
209
220
  if (override !== undefined)
@@ -282,6 +293,10 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
282
293
  case "configEdit": {
283
294
  const config = screen;
284
295
  pushKey(out, config.submitLabel);
296
+ pushKey(out, config.description, treatDotFormAsKey);
297
+ for (const key of Object.values(config.fieldDescriptions ?? {})) {
298
+ pushKey(out, key, treatDotFormAsKey);
299
+ }
285
300
  for (const fieldName of Object.keys(config.fields)) {
286
301
  const override = config.fieldLabels?.[fieldName];
287
302
  if (override !== undefined)
@@ -297,6 +312,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
297
312
  if (section.kind === "relatedList")
298
313
  continue; // rejected at boot, unreachable here
299
314
  pushSectionTitles(out, section, treatDotFormAsKey);
315
+ pushKey(out, section.description, treatDotFormAsKey);
300
316
  for (const f of sectionFieldSpecs(section)) {
301
317
  const fieldName = editFieldName(f);
302
318
  const override = config.fieldLabels?.[fieldName];
@@ -328,6 +344,11 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
328
344
  }
329
345
  if (section.kind === "relatedList") {
330
346
  pushRelatedListSectionKeys(out, section);
347
+ if (section.groupBy !== undefined) {
348
+ pushKey(out, section.groupBy.label);
349
+ for (const label of Object.values(section.groupBy.labels ?? {}))
350
+ pushKey(out, label);
351
+ }
331
352
  continue;
332
353
  }
333
354
  if (isWriteFormEditSection(section)) {
@@ -16,14 +16,28 @@ export const SETTINGS_HUB_I18N = {
16
16
  "config.settings.tenant": { en: "Tenant" },
17
17
  "config.settings.user": { en: "Personal" },
18
18
  "config.settings.extensionSelectorHint": {
19
- en: "Save the provider selection to show its settings below.",
19
+ en: "Choose a provider and save. Its settings appear below.",
20
20
  },
21
+ "config.settings.provider": { en: "Provider" },
22
+ "config.settings.saveProvider": { en: "Save provider" },
23
+ "config.settings.audience.system": {
24
+ en: "Applies to every tenant unless a tenant sets its own value.",
25
+ },
26
+ "config.settings.audience.tenant": {
27
+ en: "Applies to everyone in this tenant. Platform defaults are shown where nothing is set here.",
28
+ },
29
+ "config.settings.audience.user": { en: "Only applies to you." },
21
30
  "config.secrets.title": { en: "Secrets" },
22
31
  "config.secrets.notSet": { en: "Not set" },
23
32
  "config.secrets.set": { en: "Set" },
24
- "config.secrets.required": { en: "Required" },
33
+ "config.secrets.saved": { en: "Saved" },
34
+ "config.secrets.section": { en: "Secrets" },
35
+ "config.secrets.description": {
36
+ en: "Stored encrypted. Saved values are never shown again.",
37
+ },
38
+ "config.secrets.stored": { en: "Stored: {preview}" },
25
39
  "config.secrets.placeholder": { en: "Enter a value" },
26
- "config.secrets.replacePlaceholder": { en: "Enter a new value to replace" },
27
- "config.secrets.delete": { en: "Delete" },
40
+ "config.secrets.replacePlaceholder": { en: "Enter a new value to replace it" },
41
+ "config.secrets.delete": { en: "Remove" },
28
42
  "screen:secrets.title": { en: "Secrets" },
29
43
  };
@@ -7,6 +7,7 @@ import { resolveDbSource } from "./dispatch-shared.js";
7
7
  import { executeNestedWrite } from "./dispatch-write.js";
8
8
  import { BatchRollback, isLifecycleResult, wrapToKumiko, } from "./dispatcher-utils.js";
9
9
  import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin.js";
10
+ import { maskBatchResultForClient } from "./write-result-masking.js";
10
11
  // afterCommit hooks fire in flushAfterCommit, outside the command's scope.
11
12
  function rewrapHooksWithOrigin(afterCommitHooks, fromIndex, origin) {
12
13
  for (let i = fromIndex; i < afterCommitHooks.length; i++) {
@@ -51,20 +52,24 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
51
52
  idempotencyToken = checked.token;
52
53
  }
53
54
  }
55
+ // Hooks already ran on the plaintext results; what is returned and cached
56
+ // for retries must not carry writeOnly values.
57
+ const maskForClient = (result) => maskBatchResultForClient(ctx.registry, commands, result);
54
58
  // Cache the result under requestId so retries get the same answer. Only a
55
59
  // provably rolled-back 5xx releases the lock instead (releaseOrFinalize).
56
60
  const finalize = async (result) => {
61
+ const masked = maskForClient(result);
57
62
  if (requestId && idempotency && idempotencyToken) {
58
- await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, result);
63
+ await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, masked);
59
64
  }
60
- return result;
65
+ return masked;
61
66
  };
62
67
  // Never for the no-tx fallback: without a rollback, a re-run would repeat
63
68
  // the side effects of the commands that already ran.
64
69
  const releaseOrFinalize = async (result, isRetryableRollback) => {
65
70
  if (isRetryableRollback && requestId && idempotency && idempotencyToken) {
66
71
  await idempotency.release(user.tenantId, user.id, requestId, idempotencyToken);
67
- return result;
72
+ return maskForClient(result);
68
73
  }
69
74
  return finalize(result);
70
75
  };
@@ -5,7 +5,7 @@ import { createTenantDb, hasTenantColumn } from "../db/tenant-db.js";
5
5
  import { tenantDbRunner } from "../db/tenant-db-runner.js";
6
6
  import { hasAccess } from "../engine/access.js";
7
7
  import { ConfigScopes } from "../engine/constants.js";
8
- import { checkWriteFieldOwnership, checkWriteFieldRoles } from "../engine/field-access.js";
8
+ import { checkWriteFieldOwnership, checkWriteFieldRoles, maskWriteOnlyFields, } from "../engine/field-access.js";
9
9
  import { defineTransitions, guardTransition } from "../engine/state-machine.js";
10
10
  import { HookPhases } from "../engine/types/index.js";
11
11
  import { runValidation } from "../engine/validation.js";
@@ -262,7 +262,10 @@ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, a
262
262
  }
263
263
  const subWrapper = subResult.data; // @cast-boundary engine-payload
264
264
  const subRow = (subWrapper["data"] ?? subWrapper); // @cast-boundary engine-payload
265
- subRows.push(subRow);
265
+ const subEntityName = registry.getHandlerEntity(spec.subType);
266
+ const subEntity = subEntityName ? registry.getEntity(subEntityName) : undefined;
267
+ // parentRow is returned to the client; the child's own SaveContext keeps the plaintext.
268
+ subRows.push(subEntity ? maskWriteOnlyFields(subEntity, subRow) : subRow);
266
269
  }
267
270
  parentRow[spec.key] = subRows;
268
271
  }
@@ -4,8 +4,11 @@ import { VersionConflictError as EventStoreVersionConflictError } from "../event
4
4
  export function isFailedWriteResult(result) {
5
5
  return (!!result && typeof result === "object" && "isSuccess" in result && result.isSuccess === false);
6
6
  }
7
+ const LIFECYCLE_KINDS = ["save", "delete"];
7
8
  export function isLifecycleResult(data) {
8
- return !!data && typeof data === "object" && "kind" in data;
9
+ if (!data || typeof data !== "object" || !("kind" in data))
10
+ return false;
11
+ return LIFECYCLE_KINDS.some((kind) => kind === data.kind);
9
12
  }
10
13
  export function isWriteResultShape(result) {
11
14
  return (!!result &&
@@ -0,0 +1,6 @@
1
+ import type { WriteResult } from "../engine/types/index.js";
2
+ import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shared.js";
3
+ type EntityLookup = Pick<DispatchContext["registry"], "getHandlerEntity" | "getEntity">;
4
+ export declare function maskWriteResultForClient(registry: EntityLookup, handlerType: string, result: WriteResult): WriteResult;
5
+ export declare function maskBatchResultForClient(registry: EntityLookup, commands: readonly BatchCommand[], batch: BatchResult): BatchResult;
6
+ export {};
@@ -0,0 +1,36 @@
1
+ import { maskWriteOnlyFields } from "../engine/field-access.js";
2
+ import { isLifecycleResult } from "./dispatcher-utils.js";
3
+ function maskLifecycleOrRow(entity, data) {
4
+ if (typeof data !== "object" || data === null || Array.isArray(data))
5
+ return data;
6
+ if (!isLifecycleResult(data))
7
+ return maskWriteOnlyFields(entity, data); // @cast-boundary engine-payload
8
+ if (data.kind === "save") {
9
+ return {
10
+ ...data,
11
+ data: maskWriteOnlyFields(entity, data.data),
12
+ changes: maskWriteOnlyFields(entity, data.changes),
13
+ previous: maskWriteOnlyFields(entity, data.previous),
14
+ };
15
+ }
16
+ return { ...data, data: maskWriteOnlyFields(entity, data.data) };
17
+ }
18
+ // Copy-on-mask: the original results still feed postSaveBatch hooks, which
19
+ // need the plaintext. Only what leaves the dispatcher is masked.
20
+ export function maskWriteResultForClient(registry, handlerType, result) {
21
+ if (!result.isSuccess)
22
+ return result;
23
+ const entityName = registry.getHandlerEntity(handlerType);
24
+ const entity = entityName ? registry.getEntity(entityName) : undefined;
25
+ if (!entity)
26
+ return result;
27
+ const data = maskLifecycleOrRow(entity, result.data);
28
+ return data === result.data ? result : { ...result, data };
29
+ }
30
+ export function maskBatchResultForClient(registry, commands, batch) {
31
+ const results = batch.results.map((result, index) => {
32
+ const command = commands[index];
33
+ return command ? maskWriteResultForClient(registry, command.type, result) : result;
34
+ });
35
+ return { ...batch, results };
36
+ }