@cosmicdrift/kumiko-framework 0.340.0 → 0.341.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/index.d.ts +2 -1
- package/dist/api/index.js +1 -1
- package/dist/api/route-registrars.d.ts +4 -2
- package/dist/api/route-registrars.js +9 -1
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +43 -5
- package/dist/db/queries/shadow-swap.d.ts +2 -1
- package/dist/db/queries/shadow-swap.js +70 -7
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +48 -0
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/feature-ast/extractors/handlers.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/handlers.js +11 -1
- package/dist/engine/feature-ast/extractors/hooks.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/hooks.js +14 -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/patch.js +1 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +20 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +10 -0
- package/dist/engine/feature-ast/patterns.d.ts +2 -1
- package/dist/engine/feature-ast/render.js +3 -0
- package/dist/engine/feature-entity-handlers.d.ts +2 -1
- package/dist/engine/feature-entity-handlers.js +4 -0
- package/dist/engine/feature-ui-extensions.js +9 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/membership-roles.js +5 -1
- package/dist/engine/pattern-library/mixed-schemas.js +5 -0
- package/dist/engine/registry-state.js +38 -5
- package/dist/engine/registry-validate.js +2 -0
- package/dist/engine/tier-resolver-extension.d.ts +2 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/entrypoint/index.js +25 -6
- package/dist/errors/classes.d.ts +5 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/files/file-routes.js +7 -2
- package/dist/files/index.d.ts +1 -0
- package/dist/files/index.js +1 -0
- package/dist/files/provider-resolver.d.ts +2 -0
- package/dist/files/provider-resolver.js +16 -1
- package/dist/files/resolve-content-type.d.ts +11 -0
- package/dist/files/resolve-content-type.js +71 -0
- package/dist/files/types.d.ts +1 -1
- package/dist/files/types.js +5 -1
- package/dist/jobs/job-runner.js +1 -1
- package/dist/pipeline/dispatch-shared.d.ts +4 -1
- package/dist/pipeline/dispatch-shared.js +70 -9
- package/dist/pipeline/dispatch-write.js +35 -10
- package/dist/pipeline/dispatcher.d.ts +2 -0
- package/dist/pipeline/dispatcher.js +17 -1
- package/dist/pipeline/projection-rebuild.js +1 -1
- package/dist/rate-limit/bucket.d.ts +4 -0
- package/dist/rate-limit/bucket.js +25 -0
- package/dist/rate-limit/index.d.ts +2 -2
- package/dist/rate-limit/index.js +2 -2
- package/dist/rate-limit/middleware.d.ts +9 -0
- package/dist/rate-limit/middleware.js +33 -2
- package/dist/rate-limit/resolver.js +2 -1
- package/dist/redis/cache-sync-bus.d.ts +24 -0
- package/dist/redis/cache-sync-bus.js +152 -0
- package/dist/redis/cache-sync-topics.d.ts +17 -0
- package/dist/redis/cache-sync-topics.js +22 -0
- package/dist/redis/index.d.ts +4 -0
- package/dist/redis/index.js +2 -0
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +28 -0
- package/dist/stack/redis.d.ts +6 -1
- package/dist/stack/redis.js +7 -2
- package/dist/stack/test-stack.d.ts +15 -0
- package/dist/stack/test-stack.js +21 -1
- package/package.json +4 -4
- package/src/changes.json +47 -0
|
@@ -3,7 +3,7 @@ import type { ZodType } from "zod";
|
|
|
3
3
|
import { type QueryHandlerDefinition, type StreamHandlerDefinition, type WriteHandlerDefinition } from "./define-handler.js";
|
|
4
4
|
import type { RegisterEntityCrudOptions } from "./entity-handlers.js";
|
|
5
5
|
import type { FeatureBuilderState } from "./feature-builder-state.js";
|
|
6
|
-
import type { AccessRule, AgentHandlerHints, EntityDefinition, EntityRef, EscapeHatchDeclaration, HandlerRef, NameOrRef, QueryHandlerFn, RateLimitDeclaration, RelationDefinition, StreamHandlerFn, WriteHandlerFn } from "./types/index.js";
|
|
6
|
+
import type { AccessRule, AgentHandlerHints, EntityDefinition, EntityRef, EscapeHatchDeclaration, HandlerRef, NameOrRef, PayloadRateLimitOption, QueryHandlerFn, RateLimitDeclaration, RelationDefinition, StreamHandlerFn, WriteHandlerFn } from "./types/index.js";
|
|
7
7
|
export declare function buildEntityHandlerMethods<TName extends string>(state: FeatureBuilderState, name: TName): {
|
|
8
8
|
entity(nameOrDefinition: string | ({
|
|
9
9
|
readonly name: string;
|
|
@@ -17,6 +17,7 @@ export declare function buildEntityHandlerMethods<TName extends string>(state: F
|
|
|
17
17
|
writeHandler<TName_1 extends string, TSchema extends ZodType>(nameOrDef: string | WriteHandlerDefinition<TName_1, TSchema>, schema?: TSchema, handler?: WriteHandlerFn<z.infer<TSchema>>, options?: {
|
|
18
18
|
access: AccessRule;
|
|
19
19
|
rateLimit?: RateLimitDeclaration;
|
|
20
|
+
additionalRateLimits?: readonly PayloadRateLimitOption[];
|
|
20
21
|
description?: string;
|
|
21
22
|
agent?: AgentHandlerHints;
|
|
22
23
|
escapeHatch?: EscapeHatchDeclaration;
|
|
@@ -105,6 +105,7 @@ export function buildEntityHandlerMethods(state, name) {
|
|
|
105
105
|
...agentSlots(def),
|
|
106
106
|
...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
|
|
107
107
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
108
|
+
...(def.additionalRateLimits && { additionalRateLimits: def.additionalRateLimits }),
|
|
108
109
|
...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
|
|
109
110
|
...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
|
|
110
111
|
// Forward the pipeline-build closure so boot-validators and
|
|
@@ -129,6 +130,9 @@ export function buildEntityHandlerMethods(state, name) {
|
|
|
129
130
|
access: inline.options.access,
|
|
130
131
|
...agentSlots(inline.options),
|
|
131
132
|
...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
|
|
133
|
+
...(inline.options.additionalRateLimits && {
|
|
134
|
+
additionalRateLimits: inline.options.additionalRateLimits,
|
|
135
|
+
}),
|
|
132
136
|
...(inline.options.escapeHatch && { escapeHatch: inline.options.escapeHatch }),
|
|
133
137
|
};
|
|
134
138
|
tryMapEntity(state, name, nameOrDef);
|
|
@@ -421,6 +421,15 @@ export function buildUiExtensionsMethods(state, name) {
|
|
|
421
421
|
throw new Error(`[Feature ${name}] httpRoute "${definition.method} ${definition.path}" must declare ` +
|
|
422
422
|
`anonymous: true | false — true mounts it public, false behind the session auth chain.`);
|
|
423
423
|
}
|
|
424
|
+
const routeLimit = definition.rateLimit;
|
|
425
|
+
if (routeLimit !== undefined &&
|
|
426
|
+
(!Number.isInteger(routeLimit.limit) ||
|
|
427
|
+
routeLimit.limit < 1 ||
|
|
428
|
+
!Number.isInteger(routeLimit.windowSeconds) ||
|
|
429
|
+
routeLimit.windowSeconds < 1)) {
|
|
430
|
+
throw new Error(`[Feature ${name}] httpRoute "${definition.method} ${definition.path}" rateLimit needs ` +
|
|
431
|
+
`positive integer limit and windowSeconds.`);
|
|
432
|
+
}
|
|
424
433
|
const key = `${definition.method} ${definition.path}`;
|
|
425
434
|
if (state.httpRoutes[key]) {
|
|
426
435
|
throw new Error(`[Feature ${name}] HTTP-Route "${key}" already registered. ` +
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -74,7 +74,7 @@ export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
|
|
|
74
74
|
export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
|
|
75
75
|
export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
|
|
76
76
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
|
77
|
-
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, 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, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, 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, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, 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, 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";
|
|
77
|
+
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, 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, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, 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, 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";
|
|
78
78
|
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases, isAgentVisibleScreen, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
|
|
79
79
|
export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
|
|
80
80
|
export { runValidation } from "./validation.js";
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// matching strip (anonymous/all only) to globalRoles, where SystemAdmin
|
|
11
11
|
// legitimately lives.
|
|
12
12
|
import { access } from "./config-helpers.js";
|
|
13
|
+
import { SYSTEM_ROLE } from "./system-user.js";
|
|
13
14
|
export const FORBIDDEN_MEMBERSHIP_ROLES = new Set([
|
|
14
15
|
...access.privileged, // system, SystemAdmin
|
|
15
16
|
...access.all, // all
|
|
@@ -26,10 +27,13 @@ export function findForbiddenMembershipRole(roles) {
|
|
|
26
27
|
export function stripForbiddenMembershipRoles(roles) {
|
|
27
28
|
return roles.filter((role) => !isForbiddenMembershipRole(role));
|
|
28
29
|
}
|
|
29
|
-
// "anonymous"/"all" are never legitimate global roles;
|
|
30
|
+
// "anonymous"/"all" are never legitimate global roles; SystemAdmin stays.
|
|
31
|
+
// "system" is the in-process identity (system-identity-switch treats any user
|
|
32
|
+
// carrying it as System) and must never end up in a session.
|
|
30
33
|
const NON_MINTABLE_GLOBAL_ROLES = new Set([
|
|
31
34
|
...access.all,
|
|
32
35
|
...access.anonymous,
|
|
36
|
+
SYSTEM_ROLE,
|
|
33
37
|
]);
|
|
34
38
|
function stripNonMintableGlobalRoles(roles) {
|
|
35
39
|
return roles.filter((role) => !NON_MINTABLE_GLOBAL_ROLES.has(role));
|
|
@@ -78,6 +78,11 @@ export const writeHandlerSchema = {
|
|
|
78
78
|
label: { en: "Rate limit", de: "Rate-Limit" },
|
|
79
79
|
input: "json-readonly",
|
|
80
80
|
},
|
|
81
|
+
{
|
|
82
|
+
path: "additionalRateLimits",
|
|
83
|
+
label: { en: "Per-payload rate limits", de: "Payload-Rate-Limits" },
|
|
84
|
+
input: "json-readonly",
|
|
85
|
+
},
|
|
81
86
|
{
|
|
82
87
|
path: "unsafeSkipTransitionGuard",
|
|
83
88
|
label: { en: "Skip transition guard", de: "Übergangs-Guard überspringen" },
|
|
@@ -43,7 +43,6 @@ export function buildImplicitProjection(featureName, entityName, entity, qualify
|
|
|
43
43
|
// r.extendEntityProjection: merge extension applies into the rebuild
|
|
44
44
|
// replay. Collisions with lifecycle applies (or another extension) are
|
|
45
45
|
// authoring bugs — fail at boot, not by silently overwriting a handler.
|
|
46
|
-
const extraSources = [];
|
|
47
46
|
for (const ext of extensions) {
|
|
48
47
|
for (const [eventType, fn] of Object.entries(ext.apply)) {
|
|
49
48
|
if (apply[eventType]) {
|
|
@@ -52,20 +51,54 @@ export function buildImplicitProjection(featureName, entityName, entity, qualify
|
|
|
52
51
|
}
|
|
53
52
|
apply[eventType] = fn;
|
|
54
53
|
}
|
|
55
|
-
for (const s of ext.sources ?? []) {
|
|
56
|
-
if (s !== entityName && !extraSources.includes(s))
|
|
57
|
-
extraSources.push(s);
|
|
58
|
-
}
|
|
59
54
|
}
|
|
55
|
+
const { extraSources, extraSourceRowIds } = collectExtensionSources(name, entityName, extensions);
|
|
60
56
|
return {
|
|
61
57
|
name,
|
|
62
58
|
source: entityName,
|
|
63
59
|
...(extraSources.length > 0 && { extraSources }),
|
|
60
|
+
...(Object.keys(extraSourceRowIds).length > 0 && { extraSourceRowIds }),
|
|
64
61
|
table: drizzleTable,
|
|
65
62
|
apply,
|
|
66
63
|
isImplicit: true,
|
|
67
64
|
};
|
|
68
65
|
}
|
|
66
|
+
function assertRowIdOfSources(projectionName, entityName, ext) {
|
|
67
|
+
// skip: extension maps no rowIdOf, nothing to validate
|
|
68
|
+
if (ext.rowIdOf === undefined)
|
|
69
|
+
return;
|
|
70
|
+
const sources = ext.sources ?? [];
|
|
71
|
+
if (sources.length === 0) {
|
|
72
|
+
throw new Error(`Implicit projection "${projectionName}": extendEntityProjection declares rowIdOf without ` +
|
|
73
|
+
`\`sources\` — rowIdOf maps events of the declared sources to row ids.`);
|
|
74
|
+
}
|
|
75
|
+
if (sources.includes(entityName)) {
|
|
76
|
+
throw new Error(`Implicit projection "${projectionName}": extendEntityProjection rowIdOf cannot apply to the ` +
|
|
77
|
+
`entity's own stream "${entityName}" — host-stream rows are keyed by the aggregate id.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function collectExtensionSources(projectionName, entityName, extensions) {
|
|
81
|
+
const extraSources = [];
|
|
82
|
+
const extraSourceRowIds = {};
|
|
83
|
+
const rowIdDeclaredBy = new Map();
|
|
84
|
+
for (const ext of extensions) {
|
|
85
|
+
for (const s of ext.sources ?? []) {
|
|
86
|
+
if (s !== entityName && !extraSources.includes(s))
|
|
87
|
+
extraSources.push(s);
|
|
88
|
+
}
|
|
89
|
+
assertRowIdOfSources(projectionName, entityName, ext);
|
|
90
|
+
for (const s of ext.sources ?? []) {
|
|
91
|
+
if (rowIdDeclaredBy.has(s) && rowIdDeclaredBy.get(s) !== ext.rowIdOf) {
|
|
92
|
+
throw new Error(`Implicit projection "${projectionName}": source "${s}" is declared by several extensions with ` +
|
|
93
|
+
`different or missing rowIdOf — the row id for its events is ambiguous.`);
|
|
94
|
+
}
|
|
95
|
+
rowIdDeclaredBy.set(s, ext.rowIdOf);
|
|
96
|
+
if (ext.rowIdOf !== undefined)
|
|
97
|
+
extraSourceRowIds[s] = ext.rowIdOf;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { extraSources, extraSourceRowIds };
|
|
101
|
+
}
|
|
69
102
|
// Validates a r.entity backing table is a superset of the entity's field-
|
|
70
103
|
// derived columns, then hands it back as the projection table. The cast is a
|
|
71
104
|
// system-boundary reconstitution: the table is stored as `unknown` on
|
|
@@ -655,6 +655,8 @@ export function computeHasRateLimitedHandler(state) {
|
|
|
655
655
|
for (const h of state.writeHandlerMap.values()) {
|
|
656
656
|
if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit))
|
|
657
657
|
return true;
|
|
658
|
+
if (h.additionalRateLimits !== undefined)
|
|
659
|
+
return true;
|
|
658
660
|
}
|
|
659
661
|
for (const h of state.queryHandlerMap.values()) {
|
|
660
662
|
if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
|
|
1
2
|
import type { DbConnection } from "../db/connection.js";
|
|
2
3
|
import type { RegistrarExtensionRegistration } from "./types/config.js";
|
|
3
4
|
import type { FeatureDefinition, Registry } from "./types/feature.js";
|
|
@@ -44,6 +45,7 @@ export type TierResolverPlugin = {
|
|
|
44
45
|
readonly build: (deps: {
|
|
45
46
|
readonly db: DbConnection;
|
|
46
47
|
readonly registry: Registry;
|
|
48
|
+
readonly cacheSync?: CacheSyncBus;
|
|
47
49
|
}) => Promise<EffectiveFeaturesResolver>;
|
|
48
50
|
};
|
|
49
51
|
/**
|
|
@@ -7,7 +7,7 @@ export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsM
|
|
|
7
7
|
export type { BootCheckContext, BootCheckFn, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretOptions, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
|
|
8
8
|
export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
|
|
9
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
|
-
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";
|
|
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, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadRateLimitOption, 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";
|
|
13
13
|
export type { HttpRouteDefinition, HttpRouteHandler, HttpRouteHandlerDeps, HttpRouteMethod, } from "@cosmicdrift/kumiko-types/http-route";
|
|
@@ -15,7 +15,7 @@ export type { EntityId, TenantId } from "@cosmicdrift/kumiko-types/identifiers";
|
|
|
15
15
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-types/identifiers";
|
|
16
16
|
export type { ContentCollectionDefinition, NavDefinition, NavIconKey, } from "@cosmicdrift/kumiko-types/nav";
|
|
17
17
|
export type { FromRule, FromRuleKind, OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule, SqlFragment, WhereRule, WhereRuleContext, } from "@cosmicdrift/kumiko-types/ownership";
|
|
18
|
-
export type { EntityProjectionExtension, MspErrorMode, MspErrorPolicy, MultiStreamApplyFn, MultiStreamProjectionDefinition, ProjectionDefinition, ProjectionTable, SingleStreamApplyFn, } from "@cosmicdrift/kumiko-types/projection";
|
|
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
20
|
export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, 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";
|
|
21
21
|
export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-types/screen";
|
package/dist/entrypoint/index.js
CHANGED
|
@@ -45,6 +45,7 @@ import { createJobRunner } from "../jobs/job-runner.js";
|
|
|
45
45
|
import { createLifecycle } from "../lifecycle/index.js";
|
|
46
46
|
import { createNoopProvider } from "../observability/index.js";
|
|
47
47
|
import { dispatcherToWriteRef } from "../pipeline/dispatcher.js";
|
|
48
|
+
import { resolveCacheSyncBus } from "../redis/cache-sync-bus.js";
|
|
48
49
|
// --- Shared builders ------------------------------------------------------
|
|
49
50
|
//
|
|
50
51
|
// Three factories, three near-identical buildServer() + createJobRunner()
|
|
@@ -71,6 +72,24 @@ function mergeDispatcherOptions(caller, jobRunner) {
|
|
|
71
72
|
function resolveObservability(observability) {
|
|
72
73
|
return observability ?? createNoopProvider();
|
|
73
74
|
}
|
|
75
|
+
// One bus per process, decided before the file-provider resolver is built so
|
|
76
|
+
// the resolver subscribes to it; buildServer then receives it explicitly and
|
|
77
|
+
// does not create a second one.
|
|
78
|
+
function resolveEntrypointCacheSync(options, lifecycle) {
|
|
79
|
+
const { bus, owned } = resolveCacheSyncBus(options.dispatcherOptions?.cacheSync);
|
|
80
|
+
if (owned) {
|
|
81
|
+
lifecycle.registerShutdownHook("cacheSync", async () => {
|
|
82
|
+
await owned.close();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
options: {
|
|
87
|
+
...options,
|
|
88
|
+
dispatcherOptions: { ...options.dispatcherOptions, cacheSync: bus ?? null },
|
|
89
|
+
},
|
|
90
|
+
context: withFileProviderResolver(options.registry, options.context, bus),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
74
93
|
function contextWithObservability(context, observability, effectiveFeatures) {
|
|
75
94
|
return {
|
|
76
95
|
...context,
|
|
@@ -177,7 +196,7 @@ function requireDispatcher(server, mode) {
|
|
|
177
196
|
export function createApiEntrypoint(options) {
|
|
178
197
|
const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
|
|
179
198
|
const observability = resolveObservability(options.observability);
|
|
180
|
-
const context =
|
|
199
|
+
const { options: busOptions, context } = resolveEntrypointCacheSync(options, lifecycle);
|
|
181
200
|
// Boot-validation (Welle 2.6.c) — fail loud before traffic arrives:
|
|
182
201
|
// (a) Any jobs declared + no jobs-block → command-dispatcher would
|
|
183
202
|
// silently drop every event-triggered enqueue. Fix: add jobs:
|
|
@@ -211,7 +230,7 @@ export function createApiEntrypoint(options) {
|
|
|
211
230
|
// processLane is "both": worker-lane MSPs must run here or they'd never
|
|
212
231
|
// apply anywhere.
|
|
213
232
|
const { runLocal: runLocalDispatcher, ...dispatcherTunables } = options.eventDispatcher ?? {};
|
|
214
|
-
const server = buildApiServer({ ...
|
|
233
|
+
const server = buildApiServer({ ...busOptions, context }, lifecycle, runLocalDispatcher ? dispatcherTunables : { disabled: true }, apiJobRunner, runLocalDispatcher ? "both" : "api");
|
|
215
234
|
apiJobRunner?.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
|
|
216
235
|
return {
|
|
217
236
|
app: server.app,
|
|
@@ -243,9 +262,9 @@ export function createApiEntrypoint(options) {
|
|
|
243
262
|
export function createWorkerEntrypoint(options) {
|
|
244
263
|
const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
|
|
245
264
|
const observability = resolveObservability(options.observability);
|
|
246
|
-
const context =
|
|
265
|
+
const { options: busOptions, context } = resolveEntrypointCacheSync(options, lifecycle);
|
|
247
266
|
const jobRunner = buildJobRunnerWithHook(options.registry, contextWithObservability(context, observability, options.dispatcherOptions?.effectiveFeatures), options, "worker", lifecycle, "jobRunner");
|
|
248
|
-
const server = buildWorkerServer({ ...
|
|
267
|
+
const server = buildWorkerServer({ ...busOptions, context }, lifecycle, jobRunner);
|
|
249
268
|
jobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
|
|
250
269
|
const eventDispatcher = requireDispatcher(server, "worker");
|
|
251
270
|
return {
|
|
@@ -268,7 +287,7 @@ export function createWorkerEntrypoint(options) {
|
|
|
268
287
|
export function createAllInOneEntrypoint(options) {
|
|
269
288
|
const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
|
|
270
289
|
const observability = resolveObservability(options.observability);
|
|
271
|
-
const context =
|
|
290
|
+
const { options: busOptions, context } = resolveEntrypointCacheSync(options, lifecycle);
|
|
272
291
|
const jobRunnerContext = contextWithObservability(context, observability, options.dispatcherOptions?.effectiveFeatures);
|
|
273
292
|
// All-in-one consumes BOTH lanes: two runners, each with a BullMQ worker
|
|
274
293
|
// for its own lane's queue. Both runners hold queue-clients for both
|
|
@@ -286,7 +305,7 @@ export function createAllInOneEntrypoint(options) {
|
|
|
286
305
|
// every MSP (api-only, worker-only, both) must run here. `runLocal` is
|
|
287
306
|
// the API-mode flag — all-in-one is always local, strip it.
|
|
288
307
|
const { runLocal: _runLocal, ...allInOneDispatcherTunables } = options.eventDispatcher ?? {};
|
|
289
|
-
const server = buildApiServer({ ...
|
|
308
|
+
const server = buildApiServer({ ...busOptions, context }, lifecycle, allInOneDispatcherTunables, workerJobRunner, "both");
|
|
290
309
|
workerJobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
|
|
291
310
|
apiJobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
|
|
292
311
|
const eventDispatcher = requireDispatcher(server, "all-in-one");
|
package/dist/errors/classes.d.ts
CHANGED
|
@@ -112,3 +112,8 @@ export declare class RateLimitError extends KumikoError {
|
|
|
112
112
|
readonly details: RateLimitDetails;
|
|
113
113
|
constructor(details: RateLimitDetails, opts?: Pick<ErrorOpts, "i18nKey" | "cause">);
|
|
114
114
|
}
|
|
115
|
+
export declare class RateLimitUnavailableError extends KumikoError {
|
|
116
|
+
readonly code = "rate_limit_unavailable";
|
|
117
|
+
readonly httpStatus = 503;
|
|
118
|
+
constructor(opts?: Pick<ErrorOpts, "cause">);
|
|
119
|
+
}
|
package/dist/errors/classes.js
CHANGED
|
@@ -203,3 +203,16 @@ export class RateLimitError extends KumikoError {
|
|
|
203
203
|
this.details = details;
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
|
+
// The rate-limit backend (Redis) failed, so no decision could be made. The
|
|
207
|
+
// request is refused (fail-closed) with 503 instead of leaking a generic 500.
|
|
208
|
+
export class RateLimitUnavailableError extends KumikoError {
|
|
209
|
+
code = "rate_limit_unavailable";
|
|
210
|
+
httpStatus = 503;
|
|
211
|
+
constructor(opts) {
|
|
212
|
+
super({
|
|
213
|
+
message: "rate limiter unavailable",
|
|
214
|
+
i18nKey: "errors.rate_limit_unavailable",
|
|
215
|
+
...(opts?.cause && { cause: opts.cause }),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
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, NotFoundError, PreconditionFailedError, RateLimitError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
2
|
+
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
3
3
|
export type { ErrorCtorInput, ErrorOpts } from "./kumiko-error.js";
|
|
4
4
|
export { isKumikoError, KumikoError } from "./kumiko-error.js";
|
|
5
5
|
export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
|
package/dist/errors/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
1
|
+
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
2
2
|
export { isKumikoError, KumikoError } from "./kumiko-error.js";
|
|
3
3
|
export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
|
|
4
4
|
export { AgentReasons, FrameworkReasons } from "./reasons.js";
|
|
@@ -13,6 +13,7 @@ import { buildContentDispositionHeader } from "./content-disposition.js";
|
|
|
13
13
|
import { createFileContext } from "./file-handle.js";
|
|
14
14
|
import { fileRefEntity } from "./file-ref-entity.js";
|
|
15
15
|
import { fileRefsTable } from "./file-ref-table.js";
|
|
16
|
+
import { resolveContentType } from "./resolve-content-type.js";
|
|
16
17
|
import { buildStorageKey, parseMaxSize, resolveServedContentType, resolveUploadMimeType, validateFile, validateFileContent, } from "./types.js";
|
|
17
18
|
const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
|
|
18
19
|
// 15 minutes — long enough for a download to start, short enough that a
|
|
@@ -195,10 +196,14 @@ export function createFileRoutes(options) {
|
|
|
195
196
|
if (mimeTypeResolution.kind === "rejected") {
|
|
196
197
|
return c.json({ error: mimeTypeResolution.error }, 400);
|
|
197
198
|
}
|
|
198
|
-
mimeType =
|
|
199
|
+
mimeType = resolveContentType({
|
|
200
|
+
declared: mimeTypeResolution.mimeType,
|
|
201
|
+
bytes: data,
|
|
202
|
+
filename: file.name,
|
|
203
|
+
});
|
|
199
204
|
}
|
|
200
205
|
const fileRefId = generateId();
|
|
201
|
-
const storageKey = buildStorageKey(user.tenantId, entityType ?? "unattached", entityId
|
|
206
|
+
const storageKey = buildStorageKey(user.tenantId, entityType ?? "unattached", entityId, fieldName ?? "file", file.name, generateId(), contentValidation.kind === "normalized" ? contentValidation.extension : undefined);
|
|
202
207
|
// Write binary FIRST (outside the tx — network/disk I/O doesn't belong
|
|
203
208
|
// inside a PG connection's tx window). On DB-tx rollback below the bytes
|
|
204
209
|
// are orphaned in the provider; cleanup-jobs sweep those later. Losing a
|
package/dist/files/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export { createInMemoryFileProvider } from "./in-memory-provider.js";
|
|
|
10
10
|
export { createLocalProvider } from "./local-provider.js";
|
|
11
11
|
export type { FileProviderContext, FileProviderPlugin, FileProviderResolver, FileProviderResolverDeps, } from "./provider-resolver.js";
|
|
12
12
|
export { createFileProviderForTenant, isFileProviderPlugin, makeFileProviderResolver, } from "./provider-resolver.js";
|
|
13
|
+
export { resolveContentType } from "./resolve-content-type.js";
|
|
13
14
|
export { fileRefStorageDelta, filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.js";
|
|
14
15
|
export type { FileContentValidationResult, FileMetadata, FileStorageProvider, FileValidationOptions, SignedUrlOptions, UploadMimeTypeResolution, WriteStreamOptions, } from "./types.js";
|
|
15
16
|
export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, parseMaxSize, resolveUploadMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, validateFileContent, } from "./types.js";
|
package/dist/files/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { createFileRoutes, readFilesRouteOptions, resolveMaxUploadBodyBytes, } f
|
|
|
8
8
|
export { createInMemoryFileProvider } from "./in-memory-provider.js";
|
|
9
9
|
export { createLocalProvider } from "./local-provider.js";
|
|
10
10
|
export { createFileProviderForTenant, isFileProviderPlugin, makeFileProviderResolver, } from "./provider-resolver.js";
|
|
11
|
+
export { resolveContentType } from "./resolve-content-type.js";
|
|
11
12
|
export { fileRefStorageDelta, filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.js";
|
|
12
13
|
export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, parseMaxSize, resolveUploadMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, validateFileContent, } from "./types.js";
|
|
13
14
|
export { createZipStream } from "./zip-stream.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
|
|
1
2
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
|
|
2
3
|
import type { DbConnection } from "../db/connection.js";
|
|
3
4
|
import type { TenantDb } from "../db/tenant-db.js";
|
|
@@ -54,5 +55,6 @@ export type FileProviderResolverDeps = {
|
|
|
54
55
|
readonly _configAccessorFactory?: ConfigAccessorFactory;
|
|
55
56
|
readonly secrets?: SecretsContext;
|
|
56
57
|
readonly db?: DbConnection | TenantDb;
|
|
58
|
+
readonly cacheSync?: CacheSyncBus;
|
|
57
59
|
};
|
|
58
60
|
export declare function makeFileProviderResolver(deps: FileProviderResolverDeps): FileProviderResolver;
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// (moved here from there) so existing imports keep working.
|
|
14
14
|
import { EXT_FILE_PROVIDER, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT_SENTINEL, } from "../engine/extension-names.js";
|
|
15
15
|
import { SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
16
|
+
import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
|
|
17
|
+
import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-sync-topics.js";
|
|
16
18
|
const FEATURE_NAME = "file-foundation";
|
|
17
19
|
// extension-usage `options` is engine-payload (unknown) — structurally validate
|
|
18
20
|
// instead of casting blind.
|
|
@@ -83,6 +85,16 @@ export function makeFileProviderResolver(deps) {
|
|
|
83
85
|
// promise is evicted so a transient failure (or a secret genuinely not
|
|
84
86
|
// configured yet) doesn't permanently poison this tenant's entry.
|
|
85
87
|
const cache = new Map();
|
|
88
|
+
deps.cacheSync?.subscribe(CACHE_SYNC_TOPICS.tenantConfig, (message) => {
|
|
89
|
+
// skip: malformed message from another process
|
|
90
|
+
if (!isTenantConfigSyncMessage(message))
|
|
91
|
+
return;
|
|
92
|
+
if ("scope" in message || message.tenantId === SYSTEM_TENANT_ID)
|
|
93
|
+
cache.clear();
|
|
94
|
+
else
|
|
95
|
+
cache.delete(message.tenantId);
|
|
96
|
+
});
|
|
97
|
+
deps.cacheSync?.onResync(() => cache.clear());
|
|
86
98
|
return (tenantId) => {
|
|
87
99
|
const cached = cache.get(tenantId);
|
|
88
100
|
if (cached)
|
|
@@ -99,7 +111,10 @@ export function makeFileProviderResolver(deps) {
|
|
|
99
111
|
});
|
|
100
112
|
return createFileProviderForTenant({ config, registry: deps.registry, secrets: deps.secrets, _userId: SYSTEM_USER_ID }, tenantId);
|
|
101
113
|
})();
|
|
102
|
-
built.catch(() =>
|
|
114
|
+
built.catch(() => {
|
|
115
|
+
if (cache.get(tenantId) === built)
|
|
116
|
+
cache.delete(tenantId);
|
|
117
|
+
});
|
|
103
118
|
cache.set(tenantId, built);
|
|
104
119
|
return built;
|
|
105
120
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content type for a file whose sender may not have declared a useful one
|
|
3
|
+
* (mail attachments commonly arrive as application/octet-stream). A concrete
|
|
4
|
+
* declared type is returned untouched; a generic/missing one is resolved from
|
|
5
|
+
* the bytes first and the file extension only as fallback.
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveContentType(args: {
|
|
8
|
+
readonly declared: string | undefined;
|
|
9
|
+
readonly bytes: Uint8Array;
|
|
10
|
+
readonly filename: string | undefined;
|
|
11
|
+
}): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { normalizeMimeType, sniffMimeType } from "./types.js";
|
|
2
|
+
const OCTET_STREAM = "application/octet-stream";
|
|
3
|
+
const GENERIC_MIME_TYPES = new Set(["", OCTET_STREAM, "binary/octet-stream"]);
|
|
4
|
+
// Signatures that identify exactly one type. sniffMimeType also matches OLE and
|
|
5
|
+
// ZIP, which it maps to msword/docx — wrong for .xls/.xlsx/.zip, so they are
|
|
6
|
+
// handled separately below.
|
|
7
|
+
const UNAMBIGUOUS_SNIFFED_TYPES = new Set([
|
|
8
|
+
"image/png",
|
|
9
|
+
"image/jpeg",
|
|
10
|
+
"image/gif",
|
|
11
|
+
"image/webp",
|
|
12
|
+
"application/pdf",
|
|
13
|
+
]);
|
|
14
|
+
const ZIP_SIGNATURE = [0x50, 0x4b, 0x03, 0x04];
|
|
15
|
+
const XML_PROBE_BYTES = 512;
|
|
16
|
+
const EXTENSION_FALLBACK = {
|
|
17
|
+
xml: "application/xml",
|
|
18
|
+
pdf: "application/pdf",
|
|
19
|
+
png: "image/png",
|
|
20
|
+
jpg: "image/jpeg",
|
|
21
|
+
jpeg: "image/jpeg",
|
|
22
|
+
gif: "image/gif",
|
|
23
|
+
webp: "image/webp",
|
|
24
|
+
zip: "application/zip",
|
|
25
|
+
json: "application/json",
|
|
26
|
+
csv: "text/csv",
|
|
27
|
+
txt: "text/plain",
|
|
28
|
+
};
|
|
29
|
+
function hasPrefix(bytes, prefix) {
|
|
30
|
+
return prefix.every((byte, i) => bytes[i] === byte);
|
|
31
|
+
}
|
|
32
|
+
// Textual start only: a NUL in the probe means binary (or UTF-16, which is
|
|
33
|
+
// deliberately not detected). HTML also starts with `<` and must not be
|
|
34
|
+
// relabelled as XML.
|
|
35
|
+
function looksLikeXml(bytes) {
|
|
36
|
+
const probe = bytes.subarray(0, XML_PROBE_BYTES);
|
|
37
|
+
if (probe.includes(0))
|
|
38
|
+
return false;
|
|
39
|
+
const text = new TextDecoder().decode(probe).replace(/^/, "").trimStart();
|
|
40
|
+
if (text.startsWith("<?xml"))
|
|
41
|
+
return true;
|
|
42
|
+
if (!/^<[A-Za-z_]/.test(text))
|
|
43
|
+
return false;
|
|
44
|
+
return !/^<(html|head|body|script)\b/i.test(text);
|
|
45
|
+
}
|
|
46
|
+
function extensionOf(filename) {
|
|
47
|
+
const dot = filename.lastIndexOf(".");
|
|
48
|
+
return dot === -1 ? "" : filename.slice(dot + 1).toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Content type for a file whose sender may not have declared a useful one
|
|
52
|
+
* (mail attachments commonly arrive as application/octet-stream). A concrete
|
|
53
|
+
* declared type is returned untouched; a generic/missing one is resolved from
|
|
54
|
+
* the bytes first and the file extension only as fallback.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveContentType(args) {
|
|
57
|
+
const declared = args.declared ?? "";
|
|
58
|
+
if (!GENERIC_MIME_TYPES.has(normalizeMimeType(declared)))
|
|
59
|
+
return declared;
|
|
60
|
+
const sniffed = sniffMimeType(args.bytes);
|
|
61
|
+
if (sniffed !== null && UNAMBIGUOUS_SNIFFED_TYPES.has(sniffed))
|
|
62
|
+
return sniffed;
|
|
63
|
+
if (looksLikeXml(args.bytes))
|
|
64
|
+
return "application/xml";
|
|
65
|
+
const fromExtension = EXTENSION_FALLBACK[extensionOf(args.filename ?? "")];
|
|
66
|
+
if (fromExtension !== undefined)
|
|
67
|
+
return fromExtension;
|
|
68
|
+
if (hasPrefix(args.bytes, ZIP_SIGNATURE))
|
|
69
|
+
return "application/zip";
|
|
70
|
+
return OCTET_STREAM;
|
|
71
|
+
}
|
package/dist/files/types.d.ts
CHANGED
|
@@ -47,7 +47,7 @@ export type UploadMimeTypeResolution = {
|
|
|
47
47
|
*/
|
|
48
48
|
export declare function resolveUploadMimeType(fileName: string, declaredMimeType: string, content: Uint8Array): UploadMimeTypeResolution;
|
|
49
49
|
export declare function assertSafeStorageKey(key: string): void;
|
|
50
|
-
export declare function buildStorageKey(tenantId: TenantId, entityType: string, entityId: number | string, fieldName: string, fileName: string, uniqueId: string, extensionOverride?: string): string;
|
|
50
|
+
export declare function buildStorageKey(tenantId: TenantId, entityType: string, entityId: number | string | undefined, fieldName: string, fileName: string, uniqueId: string, extensionOverride?: string): string;
|
|
51
51
|
/** Fixed leading segment so one S3 lifecycle rule (Prefix: "exports/") can expire export bundles for every tenant without matching a buildStorageKey() upload. */
|
|
52
52
|
export declare function tenantExportPrefix(tenantId: TenantId): string;
|
|
53
53
|
/**
|
package/dist/files/types.js
CHANGED
|
@@ -261,6 +261,9 @@ export function assertSafeStorageKey(key) {
|
|
|
261
261
|
throw new Error(`storage key contains a path-traversal segment: "${key}"`);
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
|
+
// Object stores (S3/MinIO) reject keys with an empty path segment, so an
|
|
265
|
+
// upload without an owning entity needs a placeholder id segment.
|
|
266
|
+
const NO_ENTITY_ID_SEGMENT = "none";
|
|
264
267
|
export function buildStorageKey(tenantId, entityType, entityId, fieldName, fileName, uniqueId,
|
|
265
268
|
// Set when validateFileContent normalized the upload to a different real
|
|
266
269
|
// type — the storage key then reflects the sniffed type, not the user's
|
|
@@ -268,7 +271,8 @@ export function buildStorageKey(tenantId, entityType, entityId, fieldName, fileN
|
|
|
268
271
|
extensionOverride) {
|
|
269
272
|
const rawExt = extensionOverride ?? fileName.split(".").pop() ?? "";
|
|
270
273
|
const ext = /^[A-Za-z0-9]+$/.test(rawExt) ? rawExt.toLowerCase() : "bin";
|
|
271
|
-
|
|
274
|
+
const entitySegment = entityId === undefined || entityId === "" ? NO_ENTITY_ID_SEGMENT : entityId;
|
|
275
|
+
return `${tenantId}/${entityType}/${entitySegment}/${fieldName}/${uniqueId}.${ext}`;
|
|
272
276
|
}
|
|
273
277
|
const EXPORTS_PREFIX_SEGMENT = "exports";
|
|
274
278
|
/** Fixed leading segment so one S3 lifecycle rule (Prefix: "exports/") can expire export bundles for every tenant without matching a buildStorageKey() upload. */
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -677,7 +677,7 @@ export function createJobRunner(options) {
|
|
|
677
677
|
// must resolve for jobs the same way they do for write-handlers, or an
|
|
678
678
|
// app-author job calling ctx.notify(...)/ctx.config(...) hits a TypeError
|
|
679
679
|
// at runtime the write-handler path never would (framework#1532).
|
|
680
|
-
const notify = context._notifyFactory?.(jobSystemUser, tenantId);
|
|
680
|
+
const notify = context._notifyFactory?.(jobSystemUser, tenantId, selfRunner);
|
|
681
681
|
const configDb = context.db; // @cast-boundary db-operator
|
|
682
682
|
// Shared by the config accessor and ctx.derivatives below — both need the
|
|
683
683
|
// same tenant-scoped db, and building it twice would let the two calls
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import type { CacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
|
|
1
2
|
import type { SseBroker } from "../api/sse-broker.js";
|
|
2
3
|
import type { DbConnection, DbTx } from "../db/connection.js";
|
|
3
4
|
import type { buildEntityTable } from "../db/table-builder.js";
|
|
4
5
|
import type { defineTransitions } from "../engine/state-machine.js";
|
|
5
6
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension.js";
|
|
6
7
|
import type { TenantId } from "../engine/types/identifiers.js";
|
|
7
|
-
import type { AppContext, HandlerContext, JobRunnerRef, RateLimitDeclaration, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
|
|
8
|
+
import type { AppContext, HandlerContext, JobRunnerRef, PayloadRateLimitOption, RateLimitDeclaration, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
|
|
8
9
|
import { type WriteErrorInfo } from "../errors/index.js";
|
|
9
10
|
import { type EscapeHatchReportWindow, type getFallbackMeter, getFallbackTracer } from "../observability/index.js";
|
|
10
11
|
import { type AfterCommitHook } from "./dispatcher-utils.js";
|
|
@@ -40,6 +41,7 @@ export type DispatchContext = {
|
|
|
40
41
|
tableCache: Map<string, ReturnType<typeof buildEntityTable>>;
|
|
41
42
|
transitionCache: Map<string, ReturnType<typeof defineTransitions>>;
|
|
42
43
|
tenantTimezoneCache: TenantTimezoneCache;
|
|
44
|
+
cacheSync: CacheSyncBus | undefined;
|
|
43
45
|
escapeHatchReportWindow: EscapeHatchReportWindow;
|
|
44
46
|
tracer: ReturnType<typeof getFallbackTracer>;
|
|
45
47
|
meter: ReturnType<typeof getFallbackMeter>;
|
|
@@ -54,4 +56,5 @@ export declare function runStreamInstrumented<T>(ctx: DispatchContext, type: str
|
|
|
54
56
|
export declare function checkFeatureEnabled(ctx: DispatchContext, qualifiedHandler: string, tenantId: TenantId): Promise<import("../errors/index.js").FeatureDisabledError | undefined>;
|
|
55
57
|
export declare function ensureFeatureEnabled(ctx: DispatchContext, qualifiedHandler: string, tenantId: TenantId): Promise<void>;
|
|
56
58
|
export declare function enforceRateLimit(ctx: DispatchContext, rateLimit: RateLimitDeclaration | undefined, handlerName: string, user: SessionUser, isSystemScope: boolean): Promise<void>;
|
|
59
|
+
export declare function enforcePayloadRateLimits(ctx: DispatchContext, limits: readonly PayloadRateLimitOption[] | undefined, handlerName: string, user: SessionUser, data: unknown): Promise<void>;
|
|
57
60
|
export declare function resolveAuthClaimsFn(ctx: DispatchContext, user: SessionUser): Promise<Record<string, unknown>>;
|