@cosmicdrift/kumiko-framework 0.355.0 → 0.356.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.
@@ -25,6 +25,7 @@ export type ExecutorContext = {
25
25
  readonly entityCache?: EntityCache;
26
26
  readonly searchAdapter?: SearchAdapter;
27
27
  readonly softDelete: boolean;
28
+ readonly streamTenantCreateFailure: (db: TenantDb, payload: Record<string, unknown>, requestedStreamTenantId: TenantId | undefined) => WriteFailure | undefined;
28
29
  readonly streamTenantFor: (user: SessionUser, requestedStreamTenantId?: TenantId) => TenantId;
29
30
  readonly streamTenantOverrideFailure: (db: TenantDb, row: Record<string, unknown>, requestedStreamTenantId: TenantId | undefined) => WriteFailure | undefined;
30
31
  readonly idFilter: (id: EntityId) => WhereObject;
@@ -118,14 +118,15 @@ export function buildExecutorContext(table, entity, options) {
118
118
  // there. Everything else stays on the caller's tenant (byte-identical to the
119
119
  // old hardcoded user.tenantId). Single source of truth for the stream key.
120
120
  const streamTenantFor = (user, requestedStreamTenantId) => entity.systemStream ? SYSTEM_TENANT_ID : (requestedStreamTenantId ?? user.tenantId);
121
- // Only a system-mode db (r.systemScope() / acknowledged cross-tenant) may address another
122
- // tenant's stream, and only the tenant of the row it just loaded.
121
+ // Only a system-mode db (r.systemScope() / acknowledged cross-tenant) or a tenant-mode db bound
122
+ // to exactly that stream tenant (ctx.forTenant) may address another tenant's stream, and only
123
+ // the tenant of the row it just loaded.
123
124
  const streamTenantOverrideFailure = (db, row, requestedStreamTenantId) => {
124
125
  if (requestedStreamTenantId === undefined)
125
126
  return undefined;
126
- if (db.mode !== "system") {
127
+ if (db.mode !== "system" && db.tenantId !== requestedStreamTenantId) {
127
128
  return writeFailure(new AccessDeniedError({
128
- message: `${entityName}: streamTenantId requires a system-mode db`,
129
+ message: `${entityName}: streamTenantId requires a system-mode db or a db bound to that tenant`,
129
130
  }));
130
131
  }
131
132
  if (row["tenantId"] !== requestedStreamTenantId) {
@@ -135,6 +136,8 @@ export function buildExecutorContext(table, entity, options) {
135
136
  }
136
137
  return undefined;
137
138
  };
139
+ // Create has no loaded row: the payload's own tenantId (if any) must agree with the target stream.
140
+ const streamTenantCreateFailure = (db, payload, requestedStreamTenantId) => streamTenantOverrideFailure(db, { tenantId: payload["tenantId"] ?? requestedStreamTenantId }, requestedStreamTenantId);
138
141
  // idType default (undefined) is now "uuid" — the ES-pivot made UUID the
139
142
  // only valid aggregate-id type. Explicit `idType: "serial"` is the only
140
143
  // shape that's incompatible with the event-store and still rejected.
@@ -416,6 +419,7 @@ export function buildExecutorContext(table, entity, options) {
416
419
  softDelete,
417
420
  streamTenantFor,
418
421
  streamTenantOverrideFailure,
422
+ streamTenantCreateFailure,
419
423
  idFilter,
420
424
  loadById,
421
425
  assertStreamWritable,
@@ -118,7 +118,7 @@ async function runPreSave(preSave, changes, previous, isNew, entityName, action,
118
118
  }
119
119
  }
120
120
  export function createWriteVerbs(ctx) {
121
- const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
121
+ const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, streamTenantCreateFailure, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
122
122
  const entityInstructionFieldNames = instructionFieldNames(entity);
123
123
  const eventVersion = entity.eventVersion ?? 1;
124
124
  let insertSchema;
@@ -163,6 +163,10 @@ export function createWriteVerbs(ctx) {
163
163
  "SYSTEM_TENANT_ID or omitted.",
164
164
  });
165
165
  }
166
+ const streamTenantId = options?.streamTenantId;
167
+ const streamTenantMismatch = streamTenantCreateFailure(db, payload, streamTenantId);
168
+ if (streamTenantMismatch)
169
+ return streamTenantMismatch;
166
170
  const runner = tenantDbRunner(db);
167
171
  // Respect an explicit id in the payload (seed pattern, SCIM import). Without
168
172
  // one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
@@ -179,7 +183,7 @@ export function createWriteVerbs(ctx) {
179
183
  assertInstructionFieldWriteAllowed(entityName, "create", Object.keys(data), entityInstructionFieldNames);
180
184
  // H.2 — entity-level write-ownership on create. No oldRow exists, so
181
185
  // only the new row is checked. No Straddle concern for creates.
182
- if (!userCanCreateFieldRow(user, entity.access?.write, data)) {
186
+ if (!userCanCreateFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, data)) {
183
187
  return writeFailure(new UnprocessableError("ownership_denied", {
184
188
  i18nKey: "errors.ownershipDenied",
185
189
  details: { scope: "entity", entityName, action: "create", userId: user.id },
@@ -195,7 +199,7 @@ export function createWriteVerbs(ctx) {
195
199
  // field is still evaluated against the full post-hook row (`data`) —
196
200
  // an ownership rule can reference a column only a hook populates
197
201
  // (kumiko-framework#1672).
198
- const fieldDeniedCreate = checkWriteFieldOwnership(entity, applyDefaults(payloadWithoutId), user, undefined, data);
202
+ const fieldDeniedCreate = checkWriteFieldOwnership(entity, applyDefaults(payloadWithoutId), ownershipSubject(user, streamTenantId), undefined, data);
199
203
  if (fieldDeniedCreate) {
200
204
  return writeFailure(new UnprocessableError("ownership_denied", {
201
205
  i18nKey: "errors.ownershipDenied",
@@ -219,6 +223,7 @@ export function createWriteVerbs(ctx) {
219
223
  const flatCreateData = flattenCompoundTypes(data, entity);
220
224
  const flatData = await encryptForStorage(flatCreateData, user, {
221
225
  subjectSource: { ...flatCreateData, id: aggregateId },
226
+ keyTenantId: streamTenantId,
222
227
  });
223
228
  // 1. Append event (same TX as the projection write — both must succeed
224
229
  // or both roll back; the dispatcher wraps both in one transaction).
@@ -246,7 +251,7 @@ export function createWriteVerbs(ctx) {
246
251
  event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
247
252
  aggregateId,
248
253
  aggregateType: entityName,
249
- tenantId: streamTenantFor(user),
254
+ tenantId: streamTenantFor(user, streamTenantId),
250
255
  expectedVersion: 0,
251
256
  type: entityEventName(entityName, "created"),
252
257
  eventVersion,
@@ -258,7 +263,7 @@ export function createWriteVerbs(ctx) {
258
263
  if (e instanceof EventStoreVersionConflict) {
259
264
  let currentVersion = -1;
260
265
  try {
261
- currentVersion = await getStreamVersion(runner, aggregateId, streamTenantFor(user));
266
+ currentVersion = await getStreamVersion(runner, aggregateId, streamTenantFor(user, streamTenantId));
262
267
  }
263
268
  catch {
264
269
  // Lookup failure — keep the sentinel.
@@ -308,7 +313,7 @@ export function createWriteVerbs(ctx) {
308
313
  // Compound-Types in einem Pass.
309
314
  const projection = await decryptForRead(rehydrateCompoundTypes(row, entity));
310
315
  if (entityCache && entityName) {
311
- await entityCache.del(user.tenantId, entityName, aggregateId);
316
+ await entityCache.del(streamTenantId ?? user.tenantId, entityName, aggregateId);
312
317
  }
313
318
  // The echoed event is the one projections see (and the dispatcher later
314
319
  // skips), so build it before projecting.
@@ -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, 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";
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";
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";
@@ -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, EntityEventMigration, 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, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, 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";
10
+ export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, 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, TenantWriteTarget, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
11
11
  export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, 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";
@@ -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, 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, 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";
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";
@@ -869,7 +869,6 @@ export function createJobRunner(options) {
869
869
  }
870
870
  return dispatchWriteRef.queryAs(user, qn, payload, jobOrigin);
871
871
  };
872
- // Only writeAs is gated for jobs; queryAs stays ungated (fw#2859).
873
872
  const gatedIdentitySwitch = createGatedIdentitySwitch(`job "${jobName}"`, jobSystemUser, isSystemJob || identitySwitchHatch !== undefined, {
874
873
  queryAs: ungatedQueryAs,
875
874
  writeAs: async (user, qn, payload) => {
@@ -909,7 +908,7 @@ export function createJobRunner(options) {
909
908
  return dispatchWriteRef.write(jobSystemUser, qn, payload, jobOrigin);
910
909
  },
911
910
  writeAs: gatedIdentitySwitch.writeAs,
912
- queryAs: ungatedQueryAs,
911
+ queryAs: gatedIdentitySwitch.queryAs,
913
912
  queryAsMember: (userId, qn, payload) => {
914
913
  if (!dispatchWriteRef) {
915
914
  throw new Error("JobContext.queryAsMember called before dispatcher attached — call attachDispatcher() first");
@@ -3,9 +3,10 @@ import { requestContext, runWithOrigin } from "../api/request-context.js";
3
3
  import { runInSavepoint, selectMany } from "../db/query.js";
4
4
  import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, runInOwnTransaction, } from "../db/tenant-db.js";
5
5
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
6
+ import { mayOverrideTenant } from "../engine/cross-tenant.js";
6
7
  import { createSystemUser } from "../engine/system-user.js";
7
8
  import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
8
- import { AccessDeniedError, FeatureDisabledError, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, } from "../errors/index.js";
9
+ import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, } from "../errors/index.js";
9
10
  import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
10
11
  import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
11
12
  import { getStreamVersion, loadAggregate, loadAggregateAsOf, } from "../event-store/event-store.js";
@@ -178,6 +179,7 @@ export function applyMemberResolutionReadOnly(handlerContext) {
178
179
  restoreStream: denyMemberResolutionWrite,
179
180
  snapshotAggregate: denyMemberResolutionWrite,
180
181
  queryAsMember: denyMemberResolutionWrite,
182
+ forTenant: undefined,
181
183
  resolveActiveMembership: denyMemberResolutionWrite,
182
184
  scheduleAfterCommit: () => {
183
185
  throw memberResolutionReadOnlyDenied();
@@ -226,9 +228,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
226
228
  });
227
229
  const identitySwitchGrantReason = identitySwitchEscapeHatch?.reason ??
228
230
  (isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
229
- const buildTenantScopedDb = (source, signal) => createTenantDb(source, user.tenantId, isSystem ? "system" : "tenant", context.tracer, context.meter, signal, {
230
- globalWrites: escapeHatchFor(writeEscapeHatch, "globalWrites"),
231
- unsafeRaw: escapeHatchFor(handlerEscapeHatch, "unsafeRaw"),
231
+ const buildTenantScopedDb = (source, signal, foreignTenantId) => createTenantDb(source, foreignTenantId ?? user.tenantId, isSystem && foreignTenantId === undefined ? "system" : "tenant", context.tracer, context.meter, signal, {
232
+ ...(foreignTenantId === undefined && {
233
+ globalWrites: escapeHatchFor(writeEscapeHatch, "globalWrites"),
234
+ unsafeRaw: escapeHatchFor(handlerEscapeHatch, "unsafeRaw"),
235
+ }),
232
236
  report: reportEscapeHatch,
233
237
  memberReadOnly: isMemberResolutionPrincipal(user),
234
238
  personalDataGate: buildPersonalDataGate(registry, origin),
@@ -309,6 +313,26 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
309
313
  });
310
314
  }
311
315
  : undefined;
316
+ // Write handlers only, and not r.systemScope() ones (they have ctx.systemDb). The gate is the
317
+ // dispatcher's tenantIdOverride predicate, so no caller reaches more than it could via
318
+ // tenantIdOverride. The foreign db carries no unsafeRaw/globalWrites grant and is tenant mode
319
+ // bound to the target; the executor accepts streamTenantId only on a db bound to that tenant.
320
+ const forTenant = !isSystem && dbSource && registry.getWriteHandler(type) !== undefined
321
+ ? (targetTenantId) => {
322
+ if (targetTenantId === user.tenantId && exposedDb) {
323
+ return { db: exposedDb, streamTenantId: undefined };
324
+ }
325
+ if (!mayOverrideTenant(user)) {
326
+ throw new AccessDeniedError({
327
+ details: { reason: FrameworkReasons.tenantOverrideRequiresSystemAdmin },
328
+ });
329
+ }
330
+ return {
331
+ db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId),
332
+ streamTenantId: targetTenantId,
333
+ };
334
+ }
335
+ : undefined;
312
336
  // ctx.files resolved per-tenant through file-foundation (lazy — the
313
337
  // provider is only resolved when a handle actually does I/O). Boot wires
314
338
  // _fileProviderResolver when a file-provider plugin is mounted; falls back
@@ -721,6 +745,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
721
745
  notify,
722
746
  ...(config && { config }),
723
747
  ...(configFor && { configFor }),
748
+ ...(forTenant && { forTenant }),
724
749
  ...(files && { files }),
725
750
  ...(derivatives && { derivatives }),
726
751
  // preSave hooks need `changes`/`previous`/`isNew`, which only exist once
@@ -9,7 +9,7 @@ export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefin
9
9
  export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.js";
10
10
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
11
11
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.js";
12
- export type { ActionFormFooterAction, ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardChartTone, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelGate, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardTimeRangeDefinition, DashboardValueFormat, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, EntityWriteExcludedFields, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
12
+ export type { ActionFormFooterAction, ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardChartTone, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelGate, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardTimeRangeDefinition, DashboardValueFormat, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, EntityWriteExcludedFields, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
13
13
  export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.js";
14
14
  export type { TargetRef } from "../engine/types/target-ref.js";
15
15
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.355.0",
3
+ "version": "0.356.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -210,8 +210,8 @@
210
210
  "./package.json": "./package.json"
211
211
  },
212
212
  "dependencies": {
213
- "@cosmicdrift/kumiko-http": "0.355.0",
214
- "@cosmicdrift/kumiko-types": "0.355.0",
213
+ "@cosmicdrift/kumiko-http": "0.356.0",
214
+ "@cosmicdrift/kumiko-types": "0.356.0",
215
215
  "bullmq": "^5.76.7",
216
216
  "bun-types": "^1.3.13",
217
217
  "hono": "^4.13.1",
@@ -227,7 +227,7 @@
227
227
  "zod": "^4.4.3"
228
228
  },
229
229
  "devDependencies": {
230
- "@cosmicdrift/kumiko-dispatcher-live": "0.355.0",
230
+ "@cosmicdrift/kumiko-dispatcher-live": "0.356.0",
231
231
  "bun-types": "^1.3.13",
232
232
  "pino-pretty": "^13.1.3"
233
233
  },
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.356.0",
4
+ "type": "improvement",
5
+ "title": "Executor create accepts streamTenantId; ctx.forTenant(tenantId) for gated cross-tenant writes",
6
+ "detail": "createEventStoreExecutor().create takes { streamTenantId } (stream, event tenant, row tenant, PII key and cache invalidation follow it, the acting user stays the operator). The override is accepted on a system-mode db and also on a tenant-mode db bound to exactly that stream tenant. New handler-context method ctx.forTenant(tenantId) returns { db, streamTenantId }: a tenant-mode db bound to the target tenant plus the stream override to pass to the executor, so a handler needs no unsafeRaw escape hatch for cross-tenant writes. A foreign tenant is gated to SystemAdmin and system identities (AccessDeniedError with reason tenant_override_requires_system_admin otherwise); the caller's own tenant returns streamTenantId undefined. It is not offered to r.systemScope() handlers. The bundled cross-tenant handlers (tenant removeMember/updateMemberRoles, tier setTenantTier, custom-fields system fields, template-resolver upserts/set, managed-pages set, compliance-profiles setProfile) no longer rewrite event.user.tenantId; the event actor is the operator. tenant cancelPendingInvitation takes an optional streamTenantId."
7
+ },
8
+ {
9
+ "version": "0.356.0",
10
+ "type": "breaking",
11
+ "title": "Job ctx.queryAs is gated like ctx.writeAs",
12
+ "migration": "Jobs calling ctx.queryAs with createSystemUser(...) or any identity other than the job's own caller now fail with AccessDeniedError (system_identity_switch_denied / identity_switch_denied). Declare r.systemScope() on the job's feature or escapeHatch: { reason } on the job, the same grant ctx.writeAs already needs. The escape-hatch guard exempts jobs by name, so this surfaces only at runtime; grep your jobs for ctx.queryAs. Known consumer sites: offlot-app channel-text generate-texts.job, campaign-calendar sync, campaign-channel-foundation seed-starter-channels."
13
+ },
2
14
  {
3
15
  "version": "0.355.0",
4
16
  "type": "improvement",