@cosmicdrift/kumiko-framework 0.360.0 → 0.361.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/server.js +11 -1
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/owner-transition-rekey.d.ts +2 -0
- package/dist/crypto/owner-transition-rekey.js +30 -0
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +5 -0
- package/dist/crypto/subject-resolver.d.ts +2 -0
- package/dist/crypto/subject-resolver.js +14 -1
- package/dist/db/apply-entity-event.js +0 -5
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-field-encryption.js +3 -2
- package/dist/db/entity-table-meta.js +3 -3
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +9 -2
- package/dist/db/event-store-executor-write.js +31 -5
- package/dist/db/event-store-executor.d.ts +2 -1
- package/dist/db/event-store-executor.js +1 -0
- package/dist/db/index.d.ts +3 -3
- package/dist/db/index.js +2 -2
- package/dist/db/migrate-generator.d.ts +1 -0
- package/dist/db/migrate-generator.js +12 -0
- package/dist/db/queries/backfill-pii.d.ts +2 -0
- package/dist/db/queries/backfill-pii.js +120 -15
- package/dist/db/table-builder.d.ts +3 -1
- package/dist/db/table-builder.js +3 -6
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +38 -1
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/query-output-columns.js +3 -0
- package/dist/engine/boot-validator/screens.d.ts +1 -1
- package/dist/engine/boot-validator/screens.js +89 -14
- package/dist/engine/define-handler.js +1 -3
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/schema-builder.js +2 -2
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +9 -0
- package/dist/engine/soft-delete-cleanup.js +12 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/errors/classes.d.ts +7 -0
- package/dist/errors/classes.js +12 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +8 -0
- package/dist/files/assert-file-refs-belong-to-tenant.js +50 -0
- package/dist/files/bind-pending-file-refs.d.ts +1 -0
- package/dist/files/bind-pending-file-refs.js +13 -13
- package/dist/files/dispose-removed-file-refs.d.ts +8 -0
- package/dist/files/dispose-removed-file-refs.js +51 -0
- package/dist/files/file-ref-entity.d.ts +1 -1
- package/dist/files/file-ref-entity.js +1 -1
- package/dist/files/file-routes.d.ts +2 -0
- package/dist/files/file-routes.js +28 -4
- package/dist/files/purge-expired-file-ref-bytes.d.ts +14 -0
- package/dist/files/purge-expired-file-ref-bytes.js +34 -0
- package/dist/files/record-write-check.d.ts +21 -0
- package/dist/files/record-write-check.js +64 -0
- package/dist/files/types.d.ts +1 -0
- package/dist/files/types.js +3 -0
- package/dist/i18n/required-surface-keys.js +10 -0
- package/dist/migrations/pii-event-backfill.d.ts +1 -0
- package/dist/migrations/pii-event-backfill.js +16 -2
- package/dist/pipeline/cascade-handler.js +17 -2
- package/dist/pipeline/dispatch-write.js +6 -6
- package/dist/schema-cli.js +12 -6
- package/dist/search/reindex-entity.js +3 -4
- package/dist/stack/test-stack.d.ts +3 -1
- package/dist/stack/test-stack.js +1 -0
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/package.json +4 -4
- package/src/changes.json +90 -0
package/dist/engine/index.d.ts
CHANGED
|
@@ -80,7 +80,7 @@ export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUse
|
|
|
80
80
|
export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
|
|
81
81
|
export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTransferAdjacency, type TransferAdjacency, type TransferEdge, type TransferEdgeLink, } from "./transfer-adjacency.js";
|
|
82
82
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
|
83
|
-
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TenantWriteTarget, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
|
|
83
|
+
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EmbeddedListEditSpec, EntityDefinition, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TenantWriteTarget, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
|
|
84
84
|
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
|
|
85
85
|
export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
|
|
86
86
|
export { runValidation } from "./validation.js";
|
|
@@ -409,8 +409,8 @@ export function buildInsertSchema(entity, currencies = [...DEFAULT_CURRENCIES],
|
|
|
409
409
|
// Field types whose column is nullable and whose projection write accepts a
|
|
410
410
|
// plain `null`. Excluded: select/writeOnly text (fieldToZod is already
|
|
411
411
|
// nullable there), and types where null would not clear cleanly — NOT NULL
|
|
412
|
-
// jsonb columns (multiSelect, embedded, jsonb, multi reference
|
|
413
|
-
//
|
|
412
|
+
// jsonb columns (multiSelect, embedded, jsonb, multi reference, files/images),
|
|
413
|
+
// locatedTimestamp (split into _utc/_tz
|
|
414
414
|
// columns, a bare null targets a column that does not exist) and money (the
|
|
415
415
|
// currency column would keep its stale value).
|
|
416
416
|
const NULL_CLEARABLE_FIELD_TYPES = new Set([
|
|
@@ -25,6 +25,7 @@ export declare function normalizeListColumn(c: ListColumnSpec): Exclude<ListColu
|
|
|
25
25
|
* (form-condition closures) reuse it; three hand-rolled copies had
|
|
26
26
|
* already drifted in shape. */
|
|
27
27
|
export declare function evalFieldCondition(cond: FieldCondition, values: Record<string, unknown>): boolean;
|
|
28
|
+
export declare function isRecordLocked(lockedWhen: FieldCondition | undefined, row: Readonly<Record<string, unknown>>): boolean;
|
|
28
29
|
type SelectOptionRules = Pick<SelectFieldDef, "options" | "conditionalOptions">;
|
|
29
30
|
export type UnavailableSelectOption = {
|
|
30
31
|
readonly field: string;
|
|
@@ -110,6 +110,15 @@ export function evalFieldCondition(cond, values) {
|
|
|
110
110
|
return !cond.notIn.includes(val);
|
|
111
111
|
return val !== cond.ne;
|
|
112
112
|
}
|
|
113
|
+
// A row that lacks the condition's field counts as locked: an unreadable lock state
|
|
114
|
+
// must not open the record.
|
|
115
|
+
export function isRecordLocked(lockedWhen, row) {
|
|
116
|
+
if (lockedWhen === undefined)
|
|
117
|
+
return false;
|
|
118
|
+
if (typeof lockedWhen === "object" && !(lockedWhen.field in row))
|
|
119
|
+
return true;
|
|
120
|
+
return evalFieldCondition(lockedWhen, { ...row });
|
|
121
|
+
}
|
|
113
122
|
/** Options of a select field that are offered for the given row values, in declaration order. */
|
|
114
123
|
export function availableSelectOptions(field, values) {
|
|
115
124
|
const rules = field.conditionalOptions;
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// table growth; irreversible event-log purging is data-retention's job
|
|
12
12
|
// (pruneEvents), a separate, consumer-lag-guarded path.
|
|
13
13
|
import { asEntityTableMeta, deleteMany } from "../db/query.js";
|
|
14
|
+
import { purgeExpiredFileRefsWithBytes } from "../files/purge-expired-file-ref-bytes.js";
|
|
15
|
+
import { FILE_REF_ENTITY_NAME } from "../files/types.js";
|
|
14
16
|
import { SYSTEM_USER_ID } from "./system-user.js";
|
|
15
17
|
// qualifyEntityName convention (feature:type:kebab-name) with a reserved
|
|
16
18
|
// "soft-delete" owner — no real feature owns these; the framework synthesizes
|
|
@@ -77,9 +79,18 @@ export const softDeleteCleanupJob = async (_payload, ctx) => {
|
|
|
77
79
|
if (isSystemScopedSoftDeleteTable(proj.table, entity))
|
|
78
80
|
continue;
|
|
79
81
|
const where = { isDeleted: true, deletedAt: { lt: cutoff }, tenantId };
|
|
80
|
-
await
|
|
82
|
+
await purgeExpiredRows(db, proj.source, proj.table, where, ctx);
|
|
81
83
|
}
|
|
82
84
|
};
|
|
85
|
+
async function purgeExpiredRows(db, source, table, where, ctx) {
|
|
86
|
+
if (source === FILE_REF_ENTITY_NAME) {
|
|
87
|
+
// The row is the only record of the storageKey, so its bytes go first.
|
|
88
|
+
await purgeExpiredFileRefsWithBytes({ db, table, where, files: ctx.files, log: ctx.log });
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
await deleteMany(db, table, where);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
83
94
|
// System-global soft-deleted entities (tenancy "global" or no tenantId column) can't take a
|
|
84
95
|
// per-tenant grace period — there's no "this tenant's view" of a row that
|
|
85
96
|
// isn't scoped to any tenant. Runs once (not perTenant) with a fixed grace
|
|
@@ -17,7 +17,7 @@ export type { ContentCollectionDefinition, NavDefinition, NavIconKey, } from "@c
|
|
|
17
17
|
export type { FromRule, FromRuleKind, OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule, SqlFragment, WhereRule, WhereRuleContext, } from "@cosmicdrift/kumiko-types/ownership";
|
|
18
18
|
export type { EntityProjectionExtension, MspErrorMode, MspErrorPolicy, MultiStreamApplyFn, MultiStreamProjectionDefinition, ProjectionDefinition, ProjectionRowIdOf, ProjectionTable, SingleStreamApplyFn, } from "@cosmicdrift/kumiko-types/projection";
|
|
19
19
|
export type { BelongsToRelation, EntityRelations, HasManyRelation, ManyToManyRelation, RelationDefinition, } from "@cosmicdrift/kumiko-types/relations";
|
|
20
|
-
export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelGate, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RecordHeaderSubtitlePart, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
20
|
+
export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelGate, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RecordHeaderSubtitlePart, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
21
21
|
export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
22
22
|
export type { TargetRef } from "@cosmicdrift/kumiko-types/target-ref";
|
|
23
23
|
export type { Subscribe, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, } from "@cosmicdrift/kumiko-types/tree-node";
|
package/dist/errors/classes.d.ts
CHANGED
|
@@ -60,6 +60,13 @@ export declare class PreconditionFailedError extends ConflictError {
|
|
|
60
60
|
readonly code: string;
|
|
61
61
|
constructor(details: PreconditionFailedDetails, opts?: Pick<ErrorOpts, "i18nKey" | "cause">);
|
|
62
62
|
}
|
|
63
|
+
export declare class RecordLockedError extends ConflictError {
|
|
64
|
+
readonly code: string;
|
|
65
|
+
constructor(details: {
|
|
66
|
+
readonly entityName: string;
|
|
67
|
+
readonly entityId: number | string;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
63
70
|
export declare class IdempotentReplayError extends ConflictError {
|
|
64
71
|
readonly code: string;
|
|
65
72
|
constructor(details: {
|
package/dist/errors/classes.js
CHANGED
|
@@ -119,6 +119,18 @@ export class PreconditionFailedError extends ConflictError {
|
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
+
// The entity's `lockedWhen` condition holds for the stored row: no write handler
|
|
123
|
+
// may change or delete it. Same 409 family as the other state conflicts.
|
|
124
|
+
export class RecordLockedError extends ConflictError {
|
|
125
|
+
code = "record_locked";
|
|
126
|
+
constructor(details) {
|
|
127
|
+
super({
|
|
128
|
+
message: `${details.entityName} ${details.entityId} is locked and cannot be changed`,
|
|
129
|
+
i18nKey: "errors.recordLocked",
|
|
130
|
+
details,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
122
134
|
// The caller retried a write with an idempotencyKey it had already used —
|
|
123
135
|
// the event-store's partial unique index on metadata.idempotencyKey caught
|
|
124
136
|
// the duplicate. Distinct from unique_violation/version_conflict: this is
|
package/dist/errors/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { FeatureDisabledDetails, FieldIssue, NotFoundDetails, PreconditionFailedDetails, RateLimitDetails, UnconfiguredDetails, UniqueViolationDetails, UnprocessableOpts, ValidationDetails, VersionConflictDetails, } from "./classes.js";
|
|
2
|
-
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
2
|
+
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, RecordLockedError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
3
3
|
export type { ErrorDocsConfig } from "./docs-url.js";
|
|
4
4
|
export { resolveErrorDocsUrl } from "./docs-url.js";
|
|
5
5
|
export type { ErrorCtorInput, ErrorOpts } from "./kumiko-error.js";
|
package/dist/errors/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
1
|
+
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, RecordLockedError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
2
2
|
export { resolveErrorDocsUrl } from "./docs-url.js";
|
|
3
3
|
export { isKumikoError, KumikoError } from "./kumiko-error.js";
|
|
4
4
|
export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
+
import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
|
|
3
|
+
export declare function assertFileRefsBelongToTenant(args: {
|
|
4
|
+
readonly registry: Registry;
|
|
5
|
+
readonly result: SaveContext;
|
|
6
|
+
readonly user: SessionUser;
|
|
7
|
+
readonly db: TenantDb;
|
|
8
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { isFileField, isUuid, } from "../engine/types/index.js";
|
|
2
|
+
import { ValidationError } from "../errors/index.js";
|
|
3
|
+
import { fileRefIdsOf } from "./bind-pending-file-refs.js";
|
|
4
|
+
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
5
|
+
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
6
|
+
// A file field only stores an id, and the file routes authorize by tenant at
|
|
7
|
+
// read time: without this check a writer could name another tenant's fileRef
|
|
8
|
+
// and make the record point at it. Only newly written values are checked (all
|
|
9
|
+
// of a create, `changes` of an update), so existing data is left alone. The
|
|
10
|
+
// error is the same for "missing" and "belongs to another tenant" so the id
|
|
11
|
+
// cannot be probed across tenants.
|
|
12
|
+
export async function assertFileRefsBelongToTenant(args) {
|
|
13
|
+
const { registry, result, user, db } = args;
|
|
14
|
+
// skip: saves of unregistered aggregates (custom executors) declare no file fields
|
|
15
|
+
if (result.entityName === undefined)
|
|
16
|
+
return;
|
|
17
|
+
const entity = registry.getEntity(result.entityName);
|
|
18
|
+
// skip: an aggregate without an entity definition has no file fields
|
|
19
|
+
if (entity === undefined)
|
|
20
|
+
return;
|
|
21
|
+
// skip: without the files feature the file_refs table is not part of the schema
|
|
22
|
+
if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
|
|
23
|
+
return;
|
|
24
|
+
const executor = getFileRefExecutor();
|
|
25
|
+
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
26
|
+
if (!isFileField(fieldDef))
|
|
27
|
+
continue;
|
|
28
|
+
for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
|
|
29
|
+
if (await isFileRefOfTenant(executor, fileRefId, user, db))
|
|
30
|
+
continue;
|
|
31
|
+
throw new ValidationError({
|
|
32
|
+
fields: [
|
|
33
|
+
{
|
|
34
|
+
path: fieldName,
|
|
35
|
+
code: "invalid_element",
|
|
36
|
+
i18nKey: "errors.validation.invalid_element",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function isFileRefOfTenant(executor, fileRefId, user, db) {
|
|
44
|
+
if (!isUuid(fileRefId))
|
|
45
|
+
return false;
|
|
46
|
+
// detail() widens its tenant filter to (self, SYSTEM); only the writer's own
|
|
47
|
+
// tenant counts here, same as GET /files/:id/meta.
|
|
48
|
+
const row = await executor.detail({ id: fileRefId }, user, db);
|
|
49
|
+
return row !== null && row["tenantId"] === user.tenantId;
|
|
50
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
2
|
import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
|
|
3
|
+
export declare function fileRefIdsOf(value: unknown): readonly string[];
|
|
3
4
|
export declare function bindPendingFileRefs(args: {
|
|
4
5
|
readonly registry: Registry;
|
|
5
6
|
readonly result: SaveContext;
|
|
@@ -2,7 +2,7 @@ import { isFileField, isUuid, } from "../engine/types/index.js";
|
|
|
2
2
|
import { reraiseAsKumikoError } from "../errors/index.js";
|
|
3
3
|
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
4
4
|
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
5
|
-
function fileRefIdsOf(value) {
|
|
5
|
+
export function fileRefIdsOf(value) {
|
|
6
6
|
if (typeof value === "string")
|
|
7
7
|
return isUuid(value) ? [value] : [];
|
|
8
8
|
if (!Array.isArray(value))
|
|
@@ -15,16 +15,17 @@ function isPendingUploadFor(row, entityName, fieldName, user) {
|
|
|
15
15
|
row["fieldName"] === fieldName &&
|
|
16
16
|
row["insertedById"] === user.id);
|
|
17
17
|
}
|
|
18
|
-
// A
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// access work for them. Only the
|
|
22
|
-
//
|
|
23
|
-
// must not pull that file into a
|
|
18
|
+
// A form (or a server handler via createFileRefFromBytes) uploads before the
|
|
19
|
+
// record references the file, so its fileRefs carry entityType + fieldName but
|
|
20
|
+
// no entityId. Binding them in the save's own transaction makes record-bound
|
|
21
|
+
// variants (qr overlay) and record-read file access work for them. Only the
|
|
22
|
+
// caller's own pending uploads for exactly this entity field are bound: naming
|
|
23
|
+
// someone else's upload id in a save payload must not pull that file into a
|
|
24
|
+
// record the caller controls.
|
|
24
25
|
export async function bindPendingFileRefs(args) {
|
|
25
26
|
const { registry, result, user, db } = args;
|
|
26
|
-
// skip:
|
|
27
|
-
if (
|
|
27
|
+
// skip: saves of unnamed aggregates declare no file fields
|
|
28
|
+
if (result.entityName === undefined)
|
|
28
29
|
return;
|
|
29
30
|
const entityName = result.entityName;
|
|
30
31
|
const entity = registry.getEntity(entityName);
|
|
@@ -38,10 +39,9 @@ export async function bindPendingFileRefs(args) {
|
|
|
38
39
|
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
39
40
|
if (!isFileField(fieldDef))
|
|
40
41
|
continue;
|
|
41
|
-
//
|
|
42
|
-
// the
|
|
43
|
-
const
|
|
44
|
-
for (const fileRefId of fileRefIdsOf(namedIds)) {
|
|
42
|
+
// changes, not data: an update's row carries every stored file id, including
|
|
43
|
+
// ones bound earlier; only the ids this save names can be pending uploads.
|
|
44
|
+
for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
|
|
45
45
|
const row = await executor.detail({ id: fileRefId }, user, db);
|
|
46
46
|
if (row === null || !isPendingUploadFor(row, entityName, fieldName, user))
|
|
47
47
|
continue;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
+
import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
|
|
3
|
+
export declare function disposeRemovedFileRefs(args: {
|
|
4
|
+
readonly registry: Registry;
|
|
5
|
+
readonly result: SaveContext;
|
|
6
|
+
readonly user: SessionUser;
|
|
7
|
+
readonly db: TenantDb;
|
|
8
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { isFileField, } from "../engine/types/index.js";
|
|
2
|
+
import { reraiseAsKumikoError } from "../errors/index.js";
|
|
3
|
+
import { fileRefIdsOf } from "./bind-pending-file-refs.js";
|
|
4
|
+
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
5
|
+
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
6
|
+
function isBoundTo(row, entityName, entityId, fieldName) {
|
|
7
|
+
return (row["entityType"] === entityName &&
|
|
8
|
+
row["entityId"] === entityId &&
|
|
9
|
+
row["fieldName"] === fieldName);
|
|
10
|
+
}
|
|
11
|
+
// An update that drops or replaces a file id leaves the fileRef readable to
|
|
12
|
+
// everyone who may read the record. Deleting it goes through the same executor
|
|
13
|
+
// path as DELETE /files/:id (soft delete, bytes kept until retention), so
|
|
14
|
+
// record readers get 404 afterwards. Only fileRefs bound to exactly this
|
|
15
|
+
// record and field are touched: an id that is pending, shared or bound
|
|
16
|
+
// elsewhere is not this save's to dispose. The authority is the caller's write
|
|
17
|
+
// right on the record (the fileRef entity declares no write access of its own).
|
|
18
|
+
export async function disposeRemovedFileRefs(args) {
|
|
19
|
+
const { registry, result, user, db } = args;
|
|
20
|
+
// skip: a create has no earlier files that could be removed
|
|
21
|
+
if (result.isNew)
|
|
22
|
+
return;
|
|
23
|
+
// skip: saves of unnamed aggregates declare no file fields
|
|
24
|
+
if (result.entityName === undefined)
|
|
25
|
+
return;
|
|
26
|
+
const entityName = result.entityName;
|
|
27
|
+
const entity = registry.getEntity(entityName);
|
|
28
|
+
// skip: saves of unregistered aggregates (custom executors) declare no file fields
|
|
29
|
+
if (entity === undefined)
|
|
30
|
+
return;
|
|
31
|
+
// skip: without the files feature the file_refs table is not part of the schema
|
|
32
|
+
if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
|
|
33
|
+
return;
|
|
34
|
+
const entityId = String(result.id);
|
|
35
|
+
const executor = getFileRefExecutor();
|
|
36
|
+
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
37
|
+
if (!isFileField(fieldDef) || !(fieldName in result.changes))
|
|
38
|
+
continue;
|
|
39
|
+
const kept = new Set(fileRefIdsOf(result.data[fieldName]));
|
|
40
|
+
for (const fileRefId of fileRefIdsOf(result.previous[fieldName])) {
|
|
41
|
+
if (kept.has(fileRefId))
|
|
42
|
+
continue;
|
|
43
|
+
const row = await executor.detail({ id: fileRefId }, user, db);
|
|
44
|
+
if (row === null || !isBoundTo(row, entityName, entityId, fieldName))
|
|
45
|
+
continue;
|
|
46
|
+
const removed = await executor.delete({ id: fileRefId }, user, db);
|
|
47
|
+
if (!removed.isSuccess)
|
|
48
|
+
throw reraiseAsKumikoError(removed.error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -17,7 +17,7 @@ import { createBigIntField, createEntity, createTextField } from "../engine/inde
|
|
|
17
17
|
// reference: "Marc-Lebenslauf.pdf", "Krankheitsattest-Mai.pdf"). The other
|
|
18
18
|
// fields (storageKey, mimeType, entityType, entityId, fieldName) are
|
|
19
19
|
// technical references or system metadata, not subject content.
|
|
20
|
-
export
|
|
20
|
+
export { FILE_REF_ENTITY_NAME } from "./types.js";
|
|
21
21
|
export const fileRefEntity = createEntity({
|
|
22
22
|
table: "file_refs",
|
|
23
23
|
softDelete: true,
|
|
@@ -3,6 +3,7 @@ import type { DbConnection } from "../db/connection.js";
|
|
|
3
3
|
import { type Registry, type SessionUser, type TenantId } from "../engine/types/index.js";
|
|
4
4
|
import type { FileProviderResolver } from "./provider-resolver.js";
|
|
5
5
|
import type { RecordReadCheck } from "./record-read-check.js";
|
|
6
|
+
import { type RecordWriteCheck } from "./record-write-check.js";
|
|
6
7
|
export type FileAccessDecision = "allow" | "deny";
|
|
7
8
|
export type FileRef = {
|
|
8
9
|
id: string;
|
|
@@ -29,6 +30,7 @@ export type FileRoutesOptions = {
|
|
|
29
30
|
readonly privilegedRoles?: readonly string[];
|
|
30
31
|
readonly accessGuard?: FileAccessGuard;
|
|
31
32
|
readonly canReadRecord?: RecordReadCheck;
|
|
33
|
+
readonly canWriteRecord?: RecordWriteCheck;
|
|
32
34
|
};
|
|
33
35
|
export declare function resolveMaxUploadBodyBytes(options: Pick<FileRoutesOptions, "registry" | "maxUploadSize">): number;
|
|
34
36
|
export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize">;
|
|
@@ -12,6 +12,7 @@ import { DEFAULT_MAX_UPLOAD_SIZE, getFileRefExecutor, storeFileAsFileRef, } from
|
|
|
12
12
|
import { createFileContext } from "./file-handle.js";
|
|
13
13
|
import { fileRefEntity } from "./file-ref-entity.js";
|
|
14
14
|
import { fileRefsTable } from "./file-ref-table.js";
|
|
15
|
+
import { RecordWriteDecisions } from "./record-write-check.js";
|
|
15
16
|
import { neutralFileNameForMimeType } from "./resolve-content-type.js";
|
|
16
17
|
import { parseMaxSize, resolveServedContentType } from "./types.js";
|
|
17
18
|
const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
|
|
@@ -22,16 +23,17 @@ const SIGNED_URL_DEFAULT_EXPIRY_SECONDS = 15 * 60;
|
|
|
22
23
|
// Default guard: on attached files, allow the uploader or a privileged role,
|
|
23
24
|
// and for reads also everyone who may read the record. Delete stays with the
|
|
24
25
|
// uploader and privileged roles: reading a record is no right to remove its
|
|
25
|
-
// files.
|
|
26
|
-
//
|
|
26
|
+
// files. Pending uploads (entityType set, record not yet created) are the
|
|
27
|
+
// uploader's own until bound; so are uploads without an entityType, which no
|
|
28
|
+
// record can ever claim.
|
|
27
29
|
function createDefaultGuard(privilegedRoles, canReadRecord) {
|
|
28
30
|
return async ({ fileRef, user, operation }) => {
|
|
29
|
-
if (fileRef.entityType === null || fileRef.entityId === null)
|
|
30
|
-
return "allow";
|
|
31
31
|
if (fileRef.insertedById === user.id)
|
|
32
32
|
return "allow";
|
|
33
33
|
if (privilegedRoles.some((role) => user.roles.includes(role)))
|
|
34
34
|
return "allow";
|
|
35
|
+
if (fileRef.entityType === null || fileRef.entityId === null)
|
|
36
|
+
return "deny";
|
|
35
37
|
if (operation !== "read" || canReadRecord === undefined)
|
|
36
38
|
return "deny";
|
|
37
39
|
const recordReadable = await canReadRecord({
|
|
@@ -135,6 +137,28 @@ export function createFileRoutes(options) {
|
|
|
135
137
|
// store it in the text entityId column.
|
|
136
138
|
const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
|
|
137
139
|
const fieldName = typeof body["fieldName"] === "string" ? body["fieldName"] : undefined;
|
|
140
|
+
if (entityId !== undefined) {
|
|
141
|
+
if (entityType === undefined) {
|
|
142
|
+
return c.json({ error: "missing_entity_type: entityId requires entityType" }, 400);
|
|
143
|
+
}
|
|
144
|
+
// Before any bytes are written: a rejected attach must leave no file behind. An
|
|
145
|
+
// unregistered entityType attaches to nothing; storeFileAsFileRef rejects it with 400.
|
|
146
|
+
const decision = options.registry !== undefined && options.registry.getEntity(entityType) === undefined
|
|
147
|
+
? RecordWriteDecisions.writable
|
|
148
|
+
: options.canWriteRecord
|
|
149
|
+
? await options.canWriteRecord({ entityType, entityId, fieldName, user })
|
|
150
|
+
: RecordWriteDecisions.forbidden;
|
|
151
|
+
switch (decision) {
|
|
152
|
+
case RecordWriteDecisions.writable:
|
|
153
|
+
break;
|
|
154
|
+
case RecordWriteDecisions.notFound:
|
|
155
|
+
return c.json({ error: "not_found" }, 404);
|
|
156
|
+
case RecordWriteDecisions.forbidden:
|
|
157
|
+
return c.json({ error: "forbidden: no write access to this record" }, 403);
|
|
158
|
+
case RecordWriteDecisions.locked:
|
|
159
|
+
return c.json({ error: "record_locked: the record is locked" }, 409);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
138
162
|
const data = new Uint8Array(await file.arrayBuffer());
|
|
139
163
|
const storageProvider = await options.resolveProvider(user.tenantId);
|
|
140
164
|
const outcome = await storeFileAsFileRef({
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FileContext } from "@cosmicdrift/kumiko-types/file-handle-types";
|
|
2
|
+
import type { Logger } from "@cosmicdrift/kumiko-types/logger";
|
|
3
|
+
import { deleteMany, type WhereObject } from "../db/query.js";
|
|
4
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
5
|
+
export declare const EXPIRED_FILE_REF_PURGE_BATCH = 500;
|
|
6
|
+
type PurgeLog = Pick<Logger, "warn"> | undefined;
|
|
7
|
+
export declare function purgeExpiredFileRefsWithBytes(args: {
|
|
8
|
+
readonly db: TenantDb;
|
|
9
|
+
readonly table: Parameters<typeof deleteMany>[1];
|
|
10
|
+
readonly where: WhereObject;
|
|
11
|
+
readonly files: FileContext | undefined;
|
|
12
|
+
readonly log: PurgeLog;
|
|
13
|
+
}): Promise<void>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { deleteMany, selectMany } from "../db/query.js";
|
|
2
|
+
import { deleteStoredFileAndDerivatives } from "../derivatives/delete-stored-file.js";
|
|
3
|
+
export const EXPIRED_FILE_REF_PURGE_BATCH = 500;
|
|
4
|
+
// Deletes the bytes (original + derivatives) of expired soft-deleted fileRef rows, then the
|
|
5
|
+
// rows. Bytes first and fail-closed: once the row is gone nobody knows the storageKey, so a
|
|
6
|
+
// row whose bytes could not be removed stays and the next run retries it.
|
|
7
|
+
export async function purgeExpiredFileRefsWithBytes(args) {
|
|
8
|
+
const { db, table, where, files, log } = args;
|
|
9
|
+
if (!files) {
|
|
10
|
+
log?.warn("[soft-delete-cleanup] no file storage available, expired fileRef rows kept");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const rows = await selectMany(db, table, where, {
|
|
14
|
+
orderBy: [{ col: "deletedAt", direction: "asc" }],
|
|
15
|
+
limit: EXPIRED_FILE_REF_PURGE_BATCH,
|
|
16
|
+
});
|
|
17
|
+
const purgedIds = [];
|
|
18
|
+
const store = { list: files.list, delete: (key) => files.ref(key).delete() };
|
|
19
|
+
for (const row of rows) {
|
|
20
|
+
try {
|
|
21
|
+
const failedKeys = await deleteStoredFileAndDerivatives(row.storageKey, store, "soft-delete-cleanup:fileRef");
|
|
22
|
+
if (failedKeys.length === 0) {
|
|
23
|
+
purgedIds.push(row.id);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (purgedIds.length > 0)
|
|
33
|
+
await deleteMany(db, table, { ...where, id: { in: purgedIds } });
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
+
import type { Registry, SessionUser, TenantId } from "../engine/types/index.js";
|
|
3
|
+
import type { RecordReadCheck } from "./record-read-check.js";
|
|
4
|
+
export declare const RecordWriteDecisions: {
|
|
5
|
+
readonly writable: "writable";
|
|
6
|
+
readonly notFound: "not_found";
|
|
7
|
+
readonly forbidden: "forbidden";
|
|
8
|
+
readonly locked: "locked";
|
|
9
|
+
};
|
|
10
|
+
export type RecordWriteDecision = (typeof RecordWriteDecisions)[keyof typeof RecordWriteDecisions];
|
|
11
|
+
export type RecordWriteCheck = (args: {
|
|
12
|
+
readonly entityType: string;
|
|
13
|
+
readonly entityId: string;
|
|
14
|
+
readonly fieldName: string | undefined;
|
|
15
|
+
readonly user: SessionUser;
|
|
16
|
+
}) => Promise<RecordWriteDecision>;
|
|
17
|
+
export declare function createRecordWriteCheck(args: {
|
|
18
|
+
readonly registry: Registry;
|
|
19
|
+
readonly canReadRecord: RecordReadCheck;
|
|
20
|
+
readonly tenantDbFor: (tenantId: TenantId) => TenantDb;
|
|
21
|
+
}): RecordWriteCheck;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { hasAccess } from "../engine/access.js";
|
|
2
|
+
import { normalizeAccessEntry, userCanWriteFieldRow } from "../engine/ownership.js";
|
|
3
|
+
import { toKebab } from "../engine/qualified-name.js";
|
|
4
|
+
import { isRecordLocked } from "../engine/screen-helpers.js";
|
|
5
|
+
export const RecordWriteDecisions = {
|
|
6
|
+
writable: "writable",
|
|
7
|
+
notFound: "not_found",
|
|
8
|
+
forbidden: "forbidden",
|
|
9
|
+
locked: "locked",
|
|
10
|
+
};
|
|
11
|
+
function isEntityUpdateWrite(qn, entityName) {
|
|
12
|
+
return qn.endsWith(`:write:${toKebab(entityName)}:update`);
|
|
13
|
+
}
|
|
14
|
+
// "May this user attach a file to the record?" asks what saving the record would ask: the
|
|
15
|
+
// entity's update handler, the row-level and field-level write rules against the stored row,
|
|
16
|
+
// and the lock. Not-readable, foreign-tenant and missing records answer alike (not_found) so
|
|
17
|
+
// the upload never confirms what exists.
|
|
18
|
+
export function createRecordWriteCheck(args) {
|
|
19
|
+
const { registry, canReadRecord, tenantDbFor } = args;
|
|
20
|
+
function findUpdateHandlerAccess(entityType) {
|
|
21
|
+
for (const [qn, handler] of registry.getAllWriteHandlers()) {
|
|
22
|
+
if (registry.getHandlerEntity(qn) === entityType && isEntityUpdateWrite(qn, entityType)) {
|
|
23
|
+
return handler.access;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
function findEntityTable(entityType) {
|
|
29
|
+
for (const projection of registry.getAllProjections().values()) {
|
|
30
|
+
if (projection.isImplicit === true && projection.source === entityType) {
|
|
31
|
+
return projection.table;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return async ({ entityType, entityId, fieldName, user }) => {
|
|
37
|
+
if (!(await canReadRecord({ entityType, entityId, user }))) {
|
|
38
|
+
return RecordWriteDecisions.notFound;
|
|
39
|
+
}
|
|
40
|
+
const entity = registry.getEntity(entityType);
|
|
41
|
+
const table = findEntityTable(entityType);
|
|
42
|
+
if (!entity || !table)
|
|
43
|
+
return RecordWriteDecisions.notFound;
|
|
44
|
+
// The stored row, not the detail output: the detail query can hide ownership fields.
|
|
45
|
+
const row = await tenantDbFor(user.tenantId).fetchOne(table, {
|
|
46
|
+
id: entityId,
|
|
47
|
+
...(entity.softDelete === true && { isDeleted: false }),
|
|
48
|
+
});
|
|
49
|
+
if (!row)
|
|
50
|
+
return RecordWriteDecisions.notFound;
|
|
51
|
+
if (!hasAccess(user, findUpdateHandlerAccess(entityType)))
|
|
52
|
+
return RecordWriteDecisions.forbidden;
|
|
53
|
+
if (!userCanWriteFieldRow(user, entity.access?.write, row, row)) {
|
|
54
|
+
return RecordWriteDecisions.forbidden;
|
|
55
|
+
}
|
|
56
|
+
const fieldWriteRules = fieldName === undefined ? undefined : entity.fields[fieldName]?.access?.write;
|
|
57
|
+
if (!userCanWriteFieldRow(user, normalizeAccessEntry(fieldWriteRules), row, row)) {
|
|
58
|
+
return RecordWriteDecisions.forbidden;
|
|
59
|
+
}
|
|
60
|
+
if (isRecordLocked(entity.lockedWhen, row))
|
|
61
|
+
return RecordWriteDecisions.locked;
|
|
62
|
+
return RecordWriteDecisions.writable;
|
|
63
|
+
};
|
|
64
|
+
}
|
package/dist/files/types.d.ts
CHANGED
package/dist/files/types.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Lives here (not in file-ref-entity.ts) so engine code can name the entity without importing
|
|
2
|
+
// a module that calls createEntity at load time.
|
|
3
|
+
export const FILE_REF_ENTITY_NAME = "fileRef";
|
|
1
4
|
export function parseMaxSize(maxSize) {
|
|
2
5
|
const match = maxSize.match(/^(\d+)(kb|mb|gb)$/i);
|
|
3
6
|
if (!match)
|
|
@@ -162,6 +162,12 @@ function pushRelatedListSectionKeys(out, section) {
|
|
|
162
162
|
for (const label of Object.values(section.groupBy?.labels ?? {}))
|
|
163
163
|
pushKey(out, label);
|
|
164
164
|
}
|
|
165
|
+
function pushListEmptyStateKeys(out, emptyState) {
|
|
166
|
+
pushKey(out, emptyState?.title);
|
|
167
|
+
pushKey(out, emptyState?.description);
|
|
168
|
+
if (emptyState?.action !== undefined)
|
|
169
|
+
pushRowActionKeys(out, emptyState.action);
|
|
170
|
+
}
|
|
165
171
|
function pushExpandableRowKeys(out, section) {
|
|
166
172
|
pushRelatedListSectionKeys(out, section);
|
|
167
173
|
for (const action of section.actions ?? [])
|
|
@@ -192,6 +198,8 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
192
198
|
}
|
|
193
199
|
pushKey(out, list.createLabel);
|
|
194
200
|
pushKey(out, list.searchPlaceholder);
|
|
201
|
+
pushKey(out, list.description);
|
|
202
|
+
pushListEmptyStateKeys(out, list.emptyState);
|
|
195
203
|
for (const action of list.rowActions ?? [])
|
|
196
204
|
pushRowActionKeys(out, action);
|
|
197
205
|
for (const action of list.toolbarActions ?? [])
|
|
@@ -210,6 +218,8 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
210
218
|
}
|
|
211
219
|
pushKey(out, list.createLabel);
|
|
212
220
|
pushKey(out, list.searchPlaceholder);
|
|
221
|
+
pushKey(out, list.description);
|
|
222
|
+
pushListEmptyStateKeys(out, list.emptyState);
|
|
213
223
|
for (const action of list.rowActions ?? [])
|
|
214
224
|
pushRowActionKeys(out, action);
|
|
215
225
|
for (const action of list.toolbarActions ?? [])
|