@cosmicdrift/kumiko-framework 0.336.1 → 0.338.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/api/api-constants.d.ts +1 -0
  2. package/dist/api/api-constants.js +9 -0
  3. package/dist/api/auth-middleware.js +45 -21
  4. package/dist/api/auth-routes.d.ts +1 -0
  5. package/dist/api/auth-routes.js +20 -0
  6. package/dist/api/redis-sse-broker.d.ts +1 -0
  7. package/dist/api/redis-sse-broker.js +4 -5
  8. package/dist/api/server.js +14 -4
  9. package/dist/api/sse-route.js +14 -1
  10. package/dist/bun-db/query.js +6 -4
  11. package/dist/compliance/sub-processors.js +10 -1
  12. package/dist/crypto/kek-source.js +1 -0
  13. package/dist/db/blind-index-cleanup.d.ts +1 -0
  14. package/dist/db/blind-index-cleanup.js +12 -1
  15. package/dist/db/event-store-executor-read.js +30 -4
  16. package/dist/db/index.d.ts +1 -1
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/queries/event-consumer.d.ts +2 -2
  19. package/dist/db/queries/event-consumer.js +5 -4
  20. package/dist/db/queries/event-store.js +6 -6
  21. package/dist/db/queries/projection-rebuild.js +4 -4
  22. package/dist/db/queries/seed-context.js +6 -6
  23. package/dist/db/tenant-db.js +16 -8
  24. package/dist/derivatives/derivatives-context.js +5 -2
  25. package/dist/engine/boot-validator/access-declarations.js +13 -7
  26. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  27. package/dist/engine/boot-validator/entity-handler.js +26 -0
  28. package/dist/engine/boot-validator/index.js +2 -1
  29. package/dist/engine/boot-validator/pii-retention.js +11 -1
  30. package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
  31. package/dist/engine/boot-validator/projection-list-screens.js +13 -0
  32. package/dist/engine/boot-validator/screens.js +12 -0
  33. package/dist/engine/build-config-feature-schema.js +4 -0
  34. package/dist/engine/config-helpers.d.ts +2 -1
  35. package/dist/engine/config-helpers.js +1 -0
  36. package/dist/engine/define-feature.js +1 -0
  37. package/dist/engine/extensions/escape-hatch-usage.js +3 -1
  38. package/dist/engine/feature-ast/extractors/hooks.js +7 -1
  39. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  40. package/dist/engine/feature-ast/extractors/index.js +1 -1
  41. package/dist/engine/feature-ast/extractors/jobs-routes.js +6 -2
  42. package/dist/engine/feature-ast/extractors/round3.d.ts +2 -1
  43. package/dist/engine/feature-ast/extractors/round3.js +11 -0
  44. package/dist/engine/feature-ast/extractors/shared.js +1 -3
  45. package/dist/engine/feature-ast/index.d.ts +1 -1
  46. package/dist/engine/feature-ast/parse.js +3 -1
  47. package/dist/engine/feature-ast/patch.d.ts +3 -0
  48. package/dist/engine/feature-ast/patch.js +8 -11
  49. package/dist/engine/feature-ast/patcher.d.ts +6 -1
  50. package/dist/engine/feature-ast/patcher.js +3 -0
  51. package/dist/engine/feature-ast/pattern-change-schema.d.ts +630 -1
  52. package/dist/engine/feature-ast/pattern-change-schema.js +19 -2
  53. package/dist/engine/feature-ast/patterns.d.ts +8 -2
  54. package/dist/engine/feature-ast/patterns.js +1 -0
  55. package/dist/engine/feature-ast/render.js +9 -0
  56. package/dist/engine/feature-builder-state.d.ts +2 -1
  57. package/dist/engine/feature-builder-state.js +1 -0
  58. package/dist/engine/feature-config-events-jobs.d.ts +4 -1
  59. package/dist/engine/feature-config-events-jobs.js +25 -1
  60. package/dist/engine/feature-manifest.d.ts +1 -0
  61. package/dist/engine/feature-manifest.js +1 -0
  62. package/dist/engine/feature-ui-extensions.js +24 -21
  63. package/dist/engine/index.d.ts +1 -1
  64. package/dist/engine/pattern-library/library.js +2 -1
  65. package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
  66. package/dist/engine/pattern-library/static-schemas.js +47 -4
  67. package/dist/engine/registry-facade.js +7 -0
  68. package/dist/engine/registry-ingest.js +7 -0
  69. package/dist/engine/registry-state.d.ts +2 -1
  70. package/dist/engine/registry-state.js +1 -0
  71. package/dist/engine/registry-validate.d.ts +1 -1
  72. package/dist/engine/registry-validate.js +39 -17
  73. package/dist/engine/registry.js +2 -2
  74. package/dist/engine/types/index.d.ts +3 -3
  75. package/dist/engine/types/index.js +1 -1
  76. package/dist/engine/types/screen.d.ts +1 -1
  77. package/dist/engine/types/screen.js +1 -1
  78. package/dist/errors/classes.d.ts +5 -0
  79. package/dist/errors/classes.js +15 -0
  80. package/dist/errors/index.d.ts +1 -1
  81. package/dist/errors/index.js +1 -1
  82. package/dist/i18n/required-surface-keys.js +3 -5
  83. package/dist/jobs/job-runner.js +2 -1
  84. package/dist/migrations/pending-rebuilds.d.ts +2 -0
  85. package/dist/migrations/pending-rebuilds.js +3 -0
  86. package/dist/pipeline/dispatch-query.js +3 -5
  87. package/dist/pipeline/dispatch-shared.js +32 -5
  88. package/dist/pipeline/dispatch-stream.js +2 -4
  89. package/dist/pipeline/dispatch-write.js +2 -4
  90. package/dist/pipeline/event-dispatcher-delivery.d.ts +0 -1
  91. package/dist/pipeline/event-dispatcher-delivery.js +1 -8
  92. package/dist/pipeline/event-dispatcher.js +16 -25
  93. package/dist/pipeline/handler-access-error.d.ts +3 -0
  94. package/dist/pipeline/handler-access-error.js +17 -0
  95. package/dist/pipeline/irreversible-operation-gate.js +9 -3
  96. package/dist/pipeline/member-read-only-transaction.js +8 -3
  97. package/dist/pipeline/system-hooks.js +7 -1
  98. package/dist/pipeline/system-identity-switch.js +11 -8
  99. package/dist/search/meilisearch-adapter.js +0 -2
  100. package/dist/stack/push-entity-projection-tables.js +5 -3
  101. package/dist/stack/redis.d.ts +1 -0
  102. package/dist/stack/redis.js +11 -2
  103. package/dist/stack/table-helpers.d.ts +3 -1
  104. package/dist/stack/table-helpers.js +9 -7
  105. package/dist/stack/test-stack.js +1 -7
  106. package/dist/testing/handler-context.js +3 -6
  107. package/dist/ui-types/index.d.ts +1 -1
  108. package/dist/ui-types/index.js +1 -1
  109. package/dist/upgrade-cli.js +29 -2
  110. package/package.json +4 -4
  111. package/src/changes.json +153 -0
  112. package/src/scripts/codemod/collect-source-files.ts +48 -0
  113. package/src/scripts/codemod/migrate-cross-tenant.ts +3 -23
  114. package/src/scripts/codemod/migrate-db-raw.ts +6 -23
  115. package/src/scripts/codemod/migrate-open-to-all.ts +4 -24
  116. package/src/scripts/codemod/unprocessable-error-details-reason.ts +47 -3
@@ -3,7 +3,7 @@ import { dedupeFeatures } from "./dedupe-features.js";
3
3
  import { buildRegistryFacade } from "./registry-facade.js";
4
4
  import { populateClaimsAndAuth, populateConfigKeys, populateEvents, populateExtensionsAndSeeds, populateFeatureCore, populateHandlers, populateHooks, populateJobsAndNotifications, populateMetricsAndSecrets, populateProjectionsAndTables, populateScreensNavWorkspaces, populateTranslations, } from "./registry-ingest.js";
5
5
  import { createInitialState } from "./registry-state.js";
6
- import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateJobTriggers, validateLifecycleHookTargets, validateLiveEntities, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
6
+ import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveJobTriggers, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateLifecycleHookTargets, validateLiveEntities, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
7
7
  // This is where the magic happens. By "magic" I mean: precomputed maps.
8
8
  // I build everything once at boot (hooks, relations, searchable fields, ...)
9
9
  // so nothing has to iterate over objects at runtime. O(1) instead of O(n*m).
@@ -41,7 +41,7 @@ export function createRegistry(rawFeatures) {
41
41
  resolveNotificationTriggersAndRegisterHooks(state);
42
42
  validateLifecycleHookTargets(state);
43
43
  validateEntityHookTargets(state, features);
44
- validateJobTriggers(state);
44
+ resolveJobTriggers(state);
45
45
  validateBootGates(state);
46
46
  validateLiveEntities(state);
47
47
  validateJobBackoff(state);
@@ -1,10 +1,10 @@
1
1
  export { isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-types/agent-exposure";
2
- export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
2
+ export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
3
3
  export type { QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput, } from "@cosmicdrift/kumiko-types/define-handler";
4
4
  export type { EntityCrudRegistrar, EntityCrudVerb, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions, } from "@cosmicdrift/kumiko-types/entity-handlers";
5
5
  export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, } from "@cosmicdrift/kumiko-types/event-type-map";
6
6
  export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsMap, } from "@cosmicdrift/kumiko-types/extension-options-map";
7
- export type { BootCheckContext, BootCheckFn, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretOptions, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
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
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";
@@ -18,7 +18,7 @@ export type { FromRule, FromRuleKind, OwnershipClause, OwnershipMap, OwnershipRe
18
18
  export type { EntityProjectionExtension, MspErrorMode, MspErrorPolicy, MultiStreamApplyFn, MultiStreamProjectionDefinition, ProjectionDefinition, 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
- export { metricField } from "@cosmicdrift/kumiko-types/screen";
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";
24
24
  export type { WebSocketConnection, WebSocketMessageData, WebSocketRouteConnectDeps, WebSocketRouteDefinition, WebSocketSessionHandlers, } from "@cosmicdrift/kumiko-types/websocket-route";
@@ -3,7 +3,7 @@
3
3
  export { isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-types/agent-exposure";
4
4
  export { isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
5
5
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-types/identifiers";
6
- export { metricField } from "@cosmicdrift/kumiko-types/screen";
6
+ export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-types/screen";
7
7
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, isFileField } from "../field-helpers.js";
8
8
  export { resolveName, withResponseData } from "../handler-helpers.js";
9
9
  export { HookPhases } from "../hook-helpers.js";
@@ -1,2 +1,2 @@
1
1
  export type * from "@cosmicdrift/kumiko-types/screen";
2
- export { metricField } from "@cosmicdrift/kumiko-types/screen";
2
+ export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-types/screen";
@@ -1 +1 @@
1
- export { metricField } from "@cosmicdrift/kumiko-types/screen";
1
+ export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-types/screen";
@@ -24,6 +24,11 @@ export declare class AccessDeniedError extends KumikoError {
24
24
  readonly httpStatus = 403;
25
25
  constructor(opts?: Pick<ErrorOpts, "message" | "i18nKey" | "i18nParams" | "details" | "cause">);
26
26
  }
27
+ export declare class UnauthenticatedError extends KumikoError {
28
+ readonly code = "unauthenticated";
29
+ readonly httpStatus = 401;
30
+ constructor(opts?: Pick<ErrorOpts, "message" | "details" | "cause">);
31
+ }
27
32
  export type NotFoundDetails = {
28
33
  readonly entity: string;
29
34
  readonly id?: string;
@@ -42,6 +42,21 @@ export class AccessDeniedError extends KumikoError {
42
42
  });
43
43
  }
44
44
  }
45
+ // A caller without a signed-in principal (the synthesised anonymousAccess
46
+ // user) hit a handler that needs one. 401, not 403: an expired auth cookie is
47
+ // dropped by the browser, so this is how a lapsed session reaches the server.
48
+ export class UnauthenticatedError extends KumikoError {
49
+ code = "unauthenticated";
50
+ httpStatus = 401;
51
+ constructor(opts) {
52
+ super({
53
+ message: opts?.message ?? "this handler requires a signed-in user",
54
+ i18nKey: "auth.errors.missingToken",
55
+ ...(opts?.details !== undefined && { details: opts.details }),
56
+ ...(opts?.cause && { cause: opts.cause }),
57
+ });
58
+ }
59
+ }
45
60
  export class NotFoundError extends KumikoError {
46
61
  code = "not_found";
47
62
  httpStatus = 404;
@@ -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, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
2
+ export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, 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";
@@ -1,4 +1,4 @@
1
- export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
1
+ export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, 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";
@@ -144,6 +144,9 @@ function pushRelatedListSectionKeys(out, section) {
144
144
  }
145
145
  for (const action of section.rowActions ?? [])
146
146
  pushRowActionKeys(out, action);
147
+ pushKey(out, section.groupBy?.label);
148
+ for (const label of Object.values(section.groupBy?.labels ?? {}))
149
+ pushKey(out, label);
147
150
  }
148
151
  function pushExpandableRowKeys(out, section) {
149
152
  pushRelatedListSectionKeys(out, section);
@@ -330,11 +333,6 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
330
333
  }
331
334
  if (section.kind === "relatedList") {
332
335
  pushRelatedListSectionKeys(out, section);
333
- if (section.groupBy !== undefined) {
334
- pushKey(out, section.groupBy.label);
335
- for (const label of Object.values(section.groupBy.labels ?? {}))
336
- pushKey(out, label);
337
- }
338
336
  continue;
339
337
  }
340
338
  if (isWriteFormEditSection(section)) {
@@ -1,3 +1,4 @@
1
+ import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import { Queue, UnrecoverableError, Worker } from "bullmq";
2
3
  import { Redis } from "ioredis";
3
4
  import { requestContext } from "../api/request-context.js";
@@ -703,7 +704,7 @@ export function createJobRunner(options) {
703
704
  : undefined;
704
705
  const jobDb = configDb
705
706
  ? createTenantDb(configDb, tenantId, "tenant", context.tracer, context.meter, undefined, {
706
- unsafeRaw: jobDef.escapeHatch,
707
+ unsafeRaw: escapeHatchFor(jobDef.escapeHatch, "unsafeRaw"),
707
708
  report: reportEscapeHatch,
708
709
  ...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
709
710
  })
@@ -1,6 +1,7 @@
1
1
  import type { DbConnection } from "../db/connection.js";
2
2
  import type { Registry } from "../engine/types/index.js";
3
3
  import type { JobRunner } from "../jobs/index.js";
4
+ import type { Logger } from "../logging/types.js";
4
5
  import { type RebuildResult } from "../pipeline/index.js";
5
6
  export declare const pendingRebuildsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
6
7
  export declare function createPendingRebuildsTable(db: DbConnection): Promise<void>;
@@ -56,6 +57,7 @@ export declare function rebuildProjectionOrMultiStream(name: string, deps: {
56
57
  readonly errorPolicy?: {
57
58
  readonly skipApplyErrors?: boolean;
58
59
  };
60
+ readonly logger?: Pick<Logger, "error"> & Partial<Pick<Logger, "warn" | "debug">>;
59
61
  }): Promise<RebuildResult>;
60
62
  /** Logs a finished queue drain and throws when any projection failed; the
61
63
  * failed tables stay queued. Shared by every `schema apply` entry point so the
@@ -72,6 +72,9 @@ export async function clearPendingRebuilds(db, rows) {
72
72
  export async function rebuildProjectionOrMultiStream(name, deps) {
73
73
  const { db, registry, signal, errorPolicy } = deps;
74
74
  if (registry.getAllMultiStreamProjections().has(name)) {
75
+ if (errorPolicy?.skipApplyErrors === true) {
76
+ createFallbackLogger("migrations:pending-rebuilds", deps.logger).warn(`skipApplyErrors ignored for multi-stream projection "${name}"; declare msp.errorMode.rebuild instead`, { projection: name });
77
+ }
75
78
  return rebuildMultiStreamProjection(name, {
76
79
  db,
77
80
  registry,
@@ -1,8 +1,9 @@
1
1
  import { hasAccess } from "../engine/access.js";
2
2
  import { filterReadFields } from "../engine/field-access.js";
3
- import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors/index.js";
3
+ import { NotFoundError, validationErrorFromZod } from "../errors/index.js";
4
4
  import { assertNoSecretLeak } from "../secrets/index.js";
5
5
  import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runHandlerInstrumented, } from "./dispatch-shared.js";
6
+ import { handlerAccessError } from "./handler-access-error.js";
6
7
  import { runInMemberReadOnlyTransaction } from "./member-read-only-transaction.js";
7
8
  // Standalone query execution — used by the public dispatcher.query() and
8
9
  // by ctx.query/ctx.queryAs inside handlers. Runs the handler, applies
@@ -34,10 +35,7 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
34
35
  // so in normal boots this branch shouldn't fire — the guard is belt-and-
35
36
  // suspenders in case a handler sneaks through (e.g. runtime injection).
36
37
  if (!hasAccess(user, handler.access)) {
37
- throw new AccessDeniedError({
38
- message: `access denied for ${type}`,
39
- details: { handler: type },
40
- });
38
+ throw handlerAccessError(user, type);
41
39
  }
42
40
  const parsed = handler.schema.safeParse(payload);
43
41
  if (!parsed.success) {
@@ -1,9 +1,11 @@
1
+ import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import { requestContext, runWithOrigin } from "../api/request-context.js";
2
3
  import { runInSavepoint, selectMany } from "../db/query.js";
3
4
  import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, } from "../db/tenant-db.js";
4
5
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
6
+ import { createSystemUser } from "../engine/system-user.js";
5
7
  import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
6
- import { FeatureDisabledError, InternalError, memberResolutionReadOnlyDenied, VersionConflictError, } from "../errors/index.js";
8
+ import { AccessDeniedError, FeatureDisabledError, InternalError, memberResolutionReadOnlyDenied, VersionConflictError, } from "../errors/index.js";
7
9
  import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
8
10
  import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
9
11
  import { getStreamVersion, loadAggregate, loadAggregateAsOf, } from "../event-store/event-store.js";
@@ -148,7 +150,8 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
148
150
  const handlerEscapeHatch = writeEscapeHatch ??
149
151
  registry.getQueryHandler(type)?.escapeHatch ??
150
152
  registry.getStreamHandler(type)?.escapeHatch;
151
- const hasIdentitySwitchGrant = isSystem || handlerEscapeHatch !== undefined;
153
+ const identitySwitchEscapeHatch = escapeHatchFor(handlerEscapeHatch, "systemIdentity");
154
+ const hasIdentitySwitchGrant = isSystem || identitySwitchEscapeHatch !== undefined;
152
155
  const featureName = registry.getHandlerFeature(type);
153
156
  const reportEscapeHatch = createEscapeHatchReporter({
154
157
  handler: type,
@@ -158,11 +161,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
158
161
  log: context.log,
159
162
  window: ctx.escapeHatchReportWindow,
160
163
  });
161
- const identitySwitchGrantReason = handlerEscapeHatch?.reason ??
164
+ const identitySwitchGrantReason = identitySwitchEscapeHatch?.reason ??
162
165
  (isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
163
166
  const buildTenantScopedDb = (source, signal) => createTenantDb(source, user.tenantId, isSystem ? "system" : "tenant", context.tracer, context.meter, signal, {
164
- globalWrites: writeEscapeHatch,
165
- unsafeRaw: handlerEscapeHatch,
167
+ globalWrites: escapeHatchFor(writeEscapeHatch, "globalWrites"),
168
+ unsafeRaw: escapeHatchFor(handlerEscapeHatch, "unsafeRaw"),
166
169
  report: reportEscapeHatch,
167
170
  memberReadOnly: isMemberResolutionPrincipal(user),
168
171
  personalDataGate: buildPersonalDataGate(registry, origin),
@@ -212,6 +215,29 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
212
215
  secrets: context.secrets,
213
216
  })
214
217
  : undefined;
218
+ // Cross-tenant accessor for SystemAdmin/system callers. The
219
+ // identity is the target tenant's system user so the caller's own user-scope
220
+ // values never resolve into another tenant's config; the db is tenant-mode
221
+ // scoped to the target.
222
+ const configFactory = context._configAccessorFactory;
223
+ const configFor = configFactory && dbSource
224
+ ? (targetTenantId) => {
225
+ if (targetTenantId === user.tenantId && config)
226
+ return config;
227
+ if (!isSystemIdentity(user) && !user.roles.includes("SystemAdmin")) {
228
+ throw new AccessDeniedError({
229
+ message: "ctx.configFor(otherTenant) requires the system role or SystemAdmin",
230
+ details: { reason: "config_for_requires_system_admin" },
231
+ });
232
+ }
233
+ const targetSystemUser = createSystemUser(targetTenantId);
234
+ return configFactory({
235
+ user: { id: targetSystemUser.id, tenantId: targetTenantId },
236
+ db: createTenantDb(dbSource, targetTenantId, "tenant", context.tracer, context.meter),
237
+ secrets: context.secrets,
238
+ });
239
+ }
240
+ : undefined;
215
241
  // ctx.files resolved per-tenant through file-foundation (lazy — the
216
242
  // provider is only resolved when a handle actually does I/O). Boot wires
217
243
  // _fileProviderResolver when a file-provider plugin is mounted; falls back
@@ -584,6 +610,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
584
610
  log,
585
611
  notify,
586
612
  ...(config && { config }),
613
+ ...(configFor && { configFor }),
587
614
  ...(files && { files }),
588
615
  ...(derivatives && { derivatives }),
589
616
  // preSave hooks need `changes`/`previous`/`isNew`, which only exist once
@@ -3,6 +3,7 @@ import { hasAccess } from "../engine/access.js";
3
3
  import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
4
4
  import { assertNoSecretLeak } from "../secrets/index.js";
5
5
  import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runStreamInstrumented, } from "./dispatch-shared.js";
6
+ import { handlerAccessError } from "./handler-access-error.js";
6
7
  // Standalone stream execution — used by the public dispatcher.stream().
7
8
  // Chunk-by-chunk analog of executeQuery: same gate order (feature → rate-
8
9
  // limit → access → validation → handler), but yields incrementally instead
@@ -25,10 +26,7 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
25
26
  await ensureFeatureEnabled(ctx, type, user.tenantId);
26
27
  await enforceRateLimit(ctx, handler.rateLimit, type, user, registry.isHandlerSystemScoped(type));
27
28
  if (!hasAccess(user, handler.access)) {
28
- throw new AccessDeniedError({
29
- message: `access denied for ${type}`,
30
- details: { handler: type },
31
- });
29
+ throw handlerAccessError(user, type);
32
30
  }
33
31
  const parsed = handler.schema.safeParse(payload);
34
32
  if (!parsed.success) {
@@ -13,6 +13,7 @@ import { AccessDeniedError, FrameworkReasons, InternalError, isKumikoError, memb
13
13
  import { assertNoSecretLeak } from "../secrets/index.js";
14
14
  import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, checkFeatureEnabled, enforceRateLimit, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
15
15
  import { describeShape, extractNestedSpecs, isLifecycleResult, isWriteResultShape, prefixValidationPath, wrapToKumiko, } from "./dispatcher-utils.js";
16
+ import { handlerAccessError } from "./handler-access-error.js";
16
17
  import { runProjections } from "./projections-runner.js";
17
18
  function getTable(ctx, entityName) {
18
19
  const { registry, tableCache } = ctx;
@@ -322,10 +323,7 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
322
323
  // so in normal boots this branch shouldn't fire — the guard is belt-and-
323
324
  // suspenders in case a handler sneaks through (e.g. runtime injection).
324
325
  if (!hasAccess(user, handler.access)) {
325
- return writeFailure(new AccessDeniedError({
326
- message: `access denied for ${type}`,
327
- details: { handler: type },
328
- }));
326
+ return writeFailure(handlerAccessError(user, type));
329
327
  }
330
328
  const parsed = handler.schema.safeParse(payload);
331
329
  if (!parsed.success) {
@@ -67,5 +67,4 @@ export type PersistedConsumerOutcome = DeliveryOutcome & {
67
67
  readonly pendingGaps: readonly PendingGapEntry[];
68
68
  };
69
69
  export declare function persistConsumerOutcome(tx: DbTx, name: string, instanceId: string, outcome: PersistedConsumerOutcome): Promise<void>;
70
- export declare function persistConsumerPassFailure(tx: DbTx, name: string, instanceId: string, errorMessage: string): Promise<void>;
71
70
  export declare function emitLagFromTx(tx: DbTx, consumerName: string, instanceId: string, cursor: bigint, meter: Meter): Promise<void>;
@@ -4,7 +4,7 @@
4
4
  // resolve against (same #1438 dual-package-hazard pattern as event-store.ts).
5
5
  import { Temporal as TemporalPolyfill } from "temporal-polyfill";
6
6
  import { requestContext } from "../api/request-context.js";
7
- import { insertConsumerIfAbsent, markConsumerProcessing, rearmDeadConsumer, recordConsumerPassFailure, selectConsumerForUpdate, selectConsumerForUpdateSkipLocked, selectProvablyIdleConsumerPairs, updateConsumerDeliveryOutcome, } from "../db/queries/event-consumer.js";
7
+ import { insertConsumerIfAbsent, markConsumerProcessing, rearmDeadConsumer, selectConsumerForUpdate, selectConsumerForUpdateSkipLocked, selectProvablyIdleConsumerPairs, updateConsumerDeliveryOutcome, } from "../db/queries/event-consumer.js";
8
8
  import { selectEventsHeadId, selectPendingAndNewEventRows, } from "../db/queries/event-store.js";
9
9
  import { coerceRow, extractTableInfo } from "../db/query.js";
10
10
  import { qnScope } from "../engine/qualified-name.js";
@@ -319,13 +319,6 @@ function reportSkippedEvent(consumer, eventId, e, errMessage, context) {
319
319
  export async function persistConsumerOutcome(tx, name, instanceId, outcome) {
320
320
  await updateConsumerDeliveryOutcome(tx, name, instanceId, outcome);
321
321
  }
322
- // Best-effort record of a pass that threw before persistConsumerOutcome
323
- // could run (event-dispatcher.ts's processConsumer catch). The caller opens
324
- // its OWN transaction for this — the one that rolled back never committed
325
- // anything, including this write, if it happened inside the same tx.
326
- export async function persistConsumerPassFailure(tx, name, instanceId, errorMessage) {
327
- await recordConsumerPassFailure(tx, name, instanceId, errorMessage);
328
- }
329
322
  // Emit the lag gauge inside the consumer pass's tx so ops sees a snapshot
330
323
  // consistent with the cursor we just advanced to. `MAX(id)` on the events
331
324
  // table is an O(1) reverse-index scan — cheap even under load.
@@ -1,9 +1,9 @@
1
- import { selectSnapshotXmax, selectSnapshotXmin } from "../db/queries/event-consumer.js";
1
+ import { recordConsumerPassFailure, selectSnapshotXmax, selectSnapshotXmin, } from "../db/queries/event-consumer.js";
2
2
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
3
3
  import { EVENTS_PUBSUB_CHANNEL } from "../event-store/index.js";
4
4
  import { emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, getFallbackMeter, getFallbackTracer, } from "../observability/index.js";
5
5
  import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state.js";
6
- import { acquireConsumerState, consumerInstanceId, deliverEvents, emitLagFromTx, fetchPendingEvents, markProcessing, persistConsumerOutcome, persistConsumerPassFailure, preRegisterConsumers, selectIdleConsumerKeys, } from "./event-dispatcher-delivery.js";
6
+ import { acquireConsumerState, consumerInstanceId, deliverEvents, emitLagFromTx, fetchPendingEvents, markProcessing, persistConsumerOutcome, preRegisterConsumers, selectIdleConsumerKeys, } from "./event-dispatcher-delivery.js";
7
7
  import { partitionBurntGaps, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges.js";
8
8
  const DEFAULT_BATCH_SIZE = 200;
9
9
  const DEFAULT_POLL_MS = 100;
@@ -35,6 +35,15 @@ export function createEventDispatcher(options) {
35
35
  }
36
36
  const tracer = options.tracer ?? getFallbackTracer();
37
37
  const meter = options.meter ?? getFallbackMeter();
38
+ // A standalone dispatcher has no logger on its context; console.error keeps the failure in pod logs.
39
+ const logDispatcherError = (message) => {
40
+ if (context.log) {
41
+ context.log.error(message);
42
+ }
43
+ else {
44
+ console.error(message);
45
+ }
46
+ };
38
47
  // Tracks which (consumer, instanceId) pairs already fired
39
48
  // kumiko_event_consumer_rearm_exhausted_total, so a consumer stuck dead
40
49
  // across many poll passes emits the ops-signal once, not every pass
@@ -220,7 +229,7 @@ export function createEventDispatcher(options) {
220
229
  //
221
230
  // A failure here (e.g. a transient DB blip) must not abort the whole
222
231
  // pass — timer/LISTEN callers swallow doPass()'s rejection silently
223
- // (see start()), which would mean no log, no persistConsumerPassFailure,
232
+ // (see start()), which would mean no log, no recordConsumerPassFailure,
224
233
  // and no backoff for every consumer this tick. Falling back to "nothing
225
234
  // proven idle" instead routes every consumer through its normal
226
235
  // acquireConsumerState path, whose own try/catch already covers this.
@@ -254,12 +263,7 @@ export function createEventDispatcher(options) {
254
263
  emitEventDispatcherListenConnected(meter, false);
255
264
  const msg = e instanceof Error ? e.message : String(e);
256
265
  const logMsg = `[event-dispatcher] idle pre-check failed, falling back to per-consumer locking every tick until it recovers: ${msg}`;
257
- if (context.log) {
258
- context.log.error(logMsg);
259
- }
260
- else {
261
- console.error(logMsg);
262
- }
266
+ logDispatcherError(logMsg);
263
267
  }
264
268
  }
265
269
  // Every consumer runs its own turn concurrently (own TX, own cursor,
@@ -396,15 +400,7 @@ export function createEventDispatcher(options) {
396
400
  // callers and at-least-once-with-duplicate-delivery on the next pass;
397
401
  // neither is what we want, so ops needs to see it.
398
402
  const msg = e instanceof Error ? e.message : String(e);
399
- if (context.log) {
400
- context.log.error(`[event-dispatcher] ${consumer.name} pass failed: ${msg}`);
401
- }
402
- else {
403
- // No logger wired onto this context (e.g. createEventDispatcher used
404
- // standalone) — console.error is the only way ops still sees this in
405
- // pod logs instead of the failure vanishing with the rolled-back tx.
406
- console.error(`[event-dispatcher] ${consumer.name} pass failed: ${msg}`);
407
- }
403
+ logDispatcherError(`[event-dispatcher] ${consumer.name} pass failed: ${msg}`);
408
404
  span.setStatus("error", msg);
409
405
  // The whole pass rolled back, so markProcessing/persistConsumerOutcome
410
406
  // never committed — attempts/last_error are stuck at their pre-pass
@@ -413,18 +409,13 @@ export function createEventDispatcher(options) {
413
409
  // so any failure here is swallowed after logging.
414
410
  try {
415
411
  await db.begin(async (tx) => {
416
- await persistConsumerPassFailure(tx, consumer.name, instanceId, msg);
412
+ await recordConsumerPassFailure(tx, consumer.name, instanceId, msg);
417
413
  });
418
414
  }
419
415
  catch (persistErr) {
420
416
  const persistMsg = persistErr instanceof Error ? persistErr.message : String(persistErr);
421
417
  const failureMsg = `[event-dispatcher] ${consumer.name} failed to persist pass-failure state: ${persistMsg}`;
422
- if (context.log) {
423
- context.log.error(failureMsg);
424
- }
425
- else {
426
- console.error(failureMsg);
427
- }
418
+ logDispatcherError(failureMsg);
428
419
  }
429
420
  // Escalate the backoff so this consumer isn't retried on the very
430
421
  // next tick — see PASS_FAILURE_BASE_BACKOFF_MS/MAX_MS above.
@@ -0,0 +1,3 @@
1
+ import type { SessionUser } from "../engine/types/index.js";
2
+ import { type KumikoError } from "../errors/index.js";
3
+ export declare function handlerAccessError(user: Pick<SessionUser, "roles">, type: string): KumikoError;
@@ -0,0 +1,17 @@
1
+ import { isAnonymousSessionUser } from "../engine/system-user.js";
2
+ import { AccessDeniedError, UnauthenticatedError } from "../errors/index.js";
3
+ // Anonymous callers get 401 so a client whose session lapsed (browser dropped
4
+ // the expired auth cookie) can tell "sign in again" apart from "signed in but
5
+ // not allowed", which stays 403.
6
+ export function handlerAccessError(user, type) {
7
+ if (isAnonymousSessionUser(user)) {
8
+ return new UnauthenticatedError({
9
+ message: `${type} requires a signed-in user`,
10
+ details: { handler: type },
11
+ });
12
+ }
13
+ return new AccessDeniedError({
14
+ message: `access denied for ${type}`,
15
+ details: { handler: type },
16
+ });
17
+ }
@@ -28,11 +28,17 @@ function assertEntryHandlerHighRisk(args) {
28
28
  },
29
29
  });
30
30
  }
31
+ // Stream handlers have no agent hint, so the "declare risk high" advice cannot apply to them.
32
+ function highRiskRemedy(entryHandler) {
33
+ return entryHandler.qn.includes(":stream:")
34
+ ? 'stream handlers cannot declare an agent risk and always resolve "mid" — dispatch a risk "high" write handler directly instead'
35
+ : `declare agent: { risk: "high" } on "${entryHandler.qn}"`;
36
+ }
31
37
  export function assertIrreversibleOperationAllowed(operation) {
32
38
  assertEntryHandlerHighRisk({
33
39
  reason: FrameworkReasons.irreversibleOperationRequiresHighRisk,
34
40
  buildMessage: (entryHandler) => `Handler "${entryHandler.qn}" performs an irreversible ${operation} but resolves agent risk ` +
35
- `"${entryHandler.risk}" — declare agent: { risk: "high" } on "${entryHandler.qn}". The ` +
41
+ `"${entryHandler.risk}" — ${highRiskRemedy(entryHandler)}. The ` +
36
42
  "directly called handler must carry it; delegating via ctx.write/writeAs does not.",
37
43
  details: { operation },
38
44
  });
@@ -49,8 +55,8 @@ export function assertInstructionFieldWriteAllowed(entityName, verb, writtenFiel
49
55
  assertEntryHandlerHighRisk({
50
56
  reason: FrameworkReasons.instructionFieldWriteRequiresHighRisk,
51
57
  buildMessage: (entryHandler) => `Handler "${entryHandler.qn}" ${verb}s "${entityName}" field(s) ${fields.join(", ")} ` +
52
- `flagged readAsInstruction: true but resolves agent risk "${entryHandler.risk}" — declare ` +
53
- `agent: { risk: "high" } on "${entryHandler.qn}". The directly called handler must carry ` +
58
+ `flagged readAsInstruction: true but resolves agent risk "${entryHandler.risk}" — ` +
59
+ `${highRiskRemedy(entryHandler)}. The directly called handler must carry ` +
54
60
  "it; delegating via ctx.write/writeAs does not.",
55
61
  details: { entityName, verb, fields },
56
62
  });
@@ -6,8 +6,13 @@ const PG_READ_ONLY_SQLSTATE = "25006";
6
6
  function isReadOnlyTransactionViolation(e) {
7
7
  return extractPgError(e)?.code === PG_READ_ONLY_SQLSTATE;
8
8
  }
9
+ class SavepointDiscard extends Error {
10
+ constructor() {
11
+ super("savepoint discarded on purpose");
12
+ this.name = "SavepointDiscard";
13
+ }
14
+ }
9
15
  async function runInDiscardedReadOnlySavepoint(tx, fn) {
10
- const discardMarker = {};
11
16
  const settled = {};
12
17
  try {
13
18
  await runInSavepoint(tx, async (sp) => {
@@ -16,11 +21,11 @@ async function runInDiscardedReadOnlySavepoint(tx, fn) {
16
21
  // @cast-boundary driver savepoint handle — structurally a DbTx, same shape runInSavepoint's caller relies on
17
22
  settled.outcome = { value: await fn(sp) };
18
23
  // Always discarded: RELEASE SAVEPOINT would leave the outer tx read-only too.
19
- throw discardMarker;
24
+ throw new SavepointDiscard();
20
25
  });
21
26
  }
22
27
  catch (e) {
23
- if (e !== discardMarker)
28
+ if (!(e instanceof SavepointDiscard))
24
29
  throw e;
25
30
  }
26
31
  if (!settled.outcome) {
@@ -204,7 +204,13 @@ async function readProjectionRowForSearch(ctx, registry, entityName, entity, ten
204
204
  const row = await createTenantDb(baseDb, tenantId).fetchOne(table, where);
205
205
  if (!row)
206
206
  return undefined;
207
- return rehydrateCompoundTypes(row, entity);
207
+ const state = rehydrateCompoundTypes(row, entity);
208
+ // The event payload never carries sensitive fields; extensions must not see them via the row either.
209
+ for (const [name, field] of Object.entries(entity.fields)) {
210
+ if ("sensitive" in field && field.sensitive === true)
211
+ delete state[name];
212
+ }
213
+ return state;
208
214
  }
209
215
  // #1610 — subject-annotated searchable fields are ciphertext in the event
210
216
  // payload; decrypt into the derived index only. No KMS → omit ciphertext
@@ -1,3 +1,4 @@
1
+ import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import { withSystemDbUnsafeRawGrant, withUnsafeRawGrant, } from "../db/tenant-db.js";
2
3
  import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
3
4
  import { AccessDeniedError, FrameworkReasons, InternalError } from "../errors/index.js";
@@ -13,7 +14,7 @@ function hasSameClaims(caller, asUser) {
13
14
  if (keys.length !== Object.keys(asUserClaims).length)
14
15
  return false;
15
16
  return keys.every((key) => Object.hasOwn(asUserClaims, key) &&
16
- JSON.stringify(callerClaims[key]) === JSON.stringify(asUserClaims[key]));
17
+ Bun.deepEquals(callerClaims[key], asUserClaims[key], true));
17
18
  }
18
19
  // claims drive ownership row filters and origin makes a ctx read-only, so both must match exactly.
19
20
  export function isSelfDelegation(caller, asUser) {
@@ -212,6 +213,8 @@ export function withHookEscapeHatchGrant(context, callerLabel, escapeHatch) {
212
213
  const ctxDb = readDbLikeValue(context, "db");
213
214
  const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
214
215
  const ctxSystemDb = readDbLikeValue(context, "systemDb");
216
+ const identityHatch = escapeHatchFor(escapeHatch, "systemIdentity");
217
+ const rawHatch = escapeHatchFor(escapeHatch, "unsafeRaw");
215
218
  if (!ctxQueryAs &&
216
219
  !ctxWriteAs &&
217
220
  !ctxResolveActiveMembership &&
@@ -224,21 +227,21 @@ export function withHookEscapeHatchGrant(context, callerLabel, escapeHatch) {
224
227
  }
225
228
  return {
226
229
  ...context,
227
- ...gatedIdentitySwitchFields(callerLabel, escapeHatch, ctxQueryAs, ctxWriteAs),
230
+ ...gatedIdentitySwitchFields(callerLabel, identityHatch, ctxQueryAs, ctxWriteAs),
228
231
  ...(ctxResolveActiveMembership &&
229
- escapeHatch === undefined && {
232
+ identityHatch === undefined && {
230
233
  resolveActiveMembership: deniedResolveActiveMembership(callerLabel),
231
234
  }),
232
- ...gatedMemberReaderField(callerLabel, escapeHatch, ctxQueryAsMember),
233
- ...gatedProjectionReaderField(callerLabel, escapeHatch, ctxQueryProjection),
235
+ ...gatedMemberReaderField(callerLabel, identityHatch, ctxQueryAsMember),
236
+ ...gatedProjectionReaderField(callerLabel, identityHatch, ctxQueryProjection),
234
237
  // @cast-boundary engine-bridge — withUnsafeRawGrant passes non-TenantDb values (e.g. a guard Proxy) through unchanged.
235
- ...(ctxDb && { db: withUnsafeRawGrant(ctxDb, escapeHatch) }),
238
+ ...(ctxDb && { db: withUnsafeRawGrant(ctxDb, rawHatch) }),
236
239
  ...(ctxDbOutsideTransaction && {
237
- dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction, escapeHatch),
240
+ dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction, rawHatch),
238
241
  }),
239
242
  // @cast-boundary engine-bridge — withSystemDbUnsafeRawGrant passes non-UncheckedSystemDb values through unchanged.
240
243
  ...(ctxSystemDb && {
241
- systemDb: withSystemDbUnsafeRawGrant(ctxSystemDb, escapeHatch, callerLabel),
244
+ systemDb: withSystemDbUnsafeRawGrant(ctxSystemDb, rawHatch, callerLabel),
242
245
  }),
243
246
  };
244
247
  }