@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.
- package/dist/db/eagerload.js +2 -1
- package/dist/engine/boot-validator/config-deps.js +13 -0
- package/dist/engine/boot-validator/pii-retention.js +39 -2
- package/dist/engine/boot-validator/screens.js +38 -6
- package/dist/engine/build-app-schema.js +3 -0
- package/dist/engine/build-config-feature-schema.js +98 -21
- package/dist/engine/config-helpers.d.ts +2 -2
- package/dist/engine/entity-handlers.js +7 -2
- package/dist/engine/extension-names.d.ts +12 -1
- package/dist/engine/extension-names.js +7 -0
- package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/extractors/round5.d.ts +1 -0
- package/dist/engine/feature-ast/extractors/round5.js +5 -0
- package/dist/engine/feature-ast/parse.js +3 -1
- package/dist/engine/feature-entity-handlers.js +22 -0
- package/dist/engine/field-access.d.ts +2 -0
- package/dist/engine/field-access.js +40 -0
- package/dist/engine/index.d.ts +4 -3
- package/dist/engine/index.js +3 -3
- package/dist/engine/role-assignment.d.ts +11 -1
- package/dist/engine/role-assignment.js +64 -3
- package/dist/engine/schema-builder.d.ts +1 -0
- package/dist/engine/schema-builder.js +11 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/files/file-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- package/dist/i18n/required-surface-keys.js +24 -3
- package/dist/i18n/settings-hub-keys.js +18 -4
- package/dist/pipeline/dispatch-batch.js +8 -3
- package/dist/pipeline/dispatch-write.js +5 -2
- package/dist/pipeline/dispatcher-utils.js +4 -1
- package/dist/pipeline/write-result-masking.d.ts +6 -0
- package/dist/pipeline/write-result-masking.js +36 -0
- package/dist/testing/shared-entities.d.ts +10 -0
- package/dist/ui-types/index.d.ts +2 -2
- package/package.json +4 -4
- 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
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -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 {
|
|
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";
|
package/dist/engine/index.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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 (
|
|
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, {
|
|
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
|
|
107
|
-
// toolbar button
|
|
108
|
-
|
|
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: "
|
|
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.
|
|
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: "
|
|
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,
|
|
63
|
+
await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, masked);
|
|
59
64
|
}
|
|
60
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|