@cosmicdrift/kumiko-framework 0.354.0 → 0.355.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/db/entity-field-encryption.d.ts +10 -1
- package/dist/db/entity-field-encryption.js +31 -2
- package/dist/db/event-store-executor-context.d.ts +8 -2
- package/dist/db/event-store-executor-context.js +41 -9
- package/dist/db/event-store-executor-read.js +22 -11
- package/dist/db/event-store-executor-write.js +95 -56
- package/dist/db/queries/event-store.d.ts +1 -0
- package/dist/db/queries/event-store.js +11 -0
- package/dist/engine/build-app-schema.js +32 -5
- package/dist/engine/cross-tenant.d.ts +2 -1
- package/dist/engine/cross-tenant.js +18 -14
- package/dist/engine/entity-handlers.js +17 -16
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-entity-handlers.js +1 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/registry-validate.d.ts +7 -1
- package/dist/engine/registry-validate.js +82 -2
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +35 -2
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +4 -1
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/event-store/snapshot.d.ts +1 -0
- package/dist/event-store/snapshot.js +12 -3
- package/dist/jobs/job-runner.js +32 -17
- package/dist/observability/escape-hatch-report.d.ts +5 -2
- package/dist/observability/escape-hatch-report.js +52 -22
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/pipeline/dispatch-query.js +4 -0
- package/dist/pipeline/dispatch-shared.js +33 -6
- package/dist/pipeline/dispatch-stream.js +4 -0
- package/dist/pipeline/dispatcher-utils.d.ts +4 -0
- package/dist/pipeline/dispatcher-utils.js +10 -0
- package/dist/pipeline/dispatcher.js +7 -7
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/pre-handler-gates.js +4 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +4 -4
- package/src/changes.json +41 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { configureEventPiiCatalog } from "../crypto/event-pii.js";
|
|
2
2
|
import { hasSearchablePlaintext, isSensitiveLabelField } from "../db/entity-field-encryption.js";
|
|
3
|
+
import { entityEventName } from "../db/event-store-executor-context.js";
|
|
3
4
|
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
|
|
4
5
|
import { resolveName } from "./handler-helpers.js";
|
|
5
6
|
import { parseRefTargetEntityName } from "./parse-ref-target.js";
|
|
@@ -327,6 +328,86 @@ export function validateEventMigrationVersions(state, features) {
|
|
|
327
328
|
}
|
|
328
329
|
}
|
|
329
330
|
}
|
|
331
|
+
export const ENTITY_LIFECYCLE_VERBS = [
|
|
332
|
+
"created",
|
|
333
|
+
"updated",
|
|
334
|
+
"deleted",
|
|
335
|
+
"restored",
|
|
336
|
+
"forgotten",
|
|
337
|
+
];
|
|
338
|
+
const MAX_ENTITY_EVENT_VERSION = 999;
|
|
339
|
+
function isRecord(value) {
|
|
340
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
341
|
+
}
|
|
342
|
+
// created is the flat row, updated carries changes + previous, the rest only previous.
|
|
343
|
+
function rowPartKeys(verb) {
|
|
344
|
+
if (verb === "created")
|
|
345
|
+
return "whole";
|
|
346
|
+
if (verb === "updated")
|
|
347
|
+
return ["changes", "previous"];
|
|
348
|
+
return ["previous"];
|
|
349
|
+
}
|
|
350
|
+
export function adaptRowTransformToPayload(verb, transform) {
|
|
351
|
+
const parts = rowPartKeys(verb);
|
|
352
|
+
return async (payload, ctx) => {
|
|
353
|
+
if (!isRecord(payload))
|
|
354
|
+
return payload;
|
|
355
|
+
if (parts === "whole")
|
|
356
|
+
return transform(payload, ctx);
|
|
357
|
+
const result = { ...payload };
|
|
358
|
+
for (const key of parts) {
|
|
359
|
+
const part = payload[key];
|
|
360
|
+
if (isRecord(part))
|
|
361
|
+
result[key] = await transform(part, ctx);
|
|
362
|
+
}
|
|
363
|
+
return result;
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
// Boot-time check of an entity's eventVersion/eventMigrations; returns the steps keyed by fromVersion, empty for v1.
|
|
367
|
+
function entityEventMigrationSteps(entityName, eventVersion, migrations) {
|
|
368
|
+
if (!Number.isInteger(eventVersion) ||
|
|
369
|
+
eventVersion < 1 ||
|
|
370
|
+
eventVersion > MAX_ENTITY_EVENT_VERSION) {
|
|
371
|
+
throw new Error(`Entity "${entityName}" declares eventVersion ${String(eventVersion)}; it must be an integer between 1 and ${MAX_ENTITY_EVENT_VERSION}.`);
|
|
372
|
+
}
|
|
373
|
+
if (eventVersion === 1 && migrations.length > 0) {
|
|
374
|
+
throw new Error(`Entity "${entityName}" declares eventMigrations but no eventVersion > 1. Set eventVersion to the target version.`);
|
|
375
|
+
}
|
|
376
|
+
const steps = new Map();
|
|
377
|
+
for (const m of migrations) {
|
|
378
|
+
if (m.toVersion !== m.fromVersion + 1 || m.toVersion > eventVersion) {
|
|
379
|
+
throw new Error(`Entity "${entityName}" declares an eventMigration v${m.fromVersion} → v${m.toVersion}; ` +
|
|
380
|
+
`steps must advance by exactly one and not exceed eventVersion ${eventVersion}.`);
|
|
381
|
+
}
|
|
382
|
+
steps.set(m.fromVersion, m.transform);
|
|
383
|
+
}
|
|
384
|
+
for (let v = 1; v < eventVersion; v++) {
|
|
385
|
+
if (!steps.has(v)) {
|
|
386
|
+
throw new Error(`Entity "${entityName}" declares eventVersion ${eventVersion} but no eventMigration ` +
|
|
387
|
+
`covers the step v${v} → v${v + 1}.`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return steps;
|
|
391
|
+
}
|
|
392
|
+
export function buildEntityEventUpcasterChains(state) {
|
|
393
|
+
for (const [entityName, entity] of state.entityMap) {
|
|
394
|
+
const eventVersion = entity.eventVersion ?? 1;
|
|
395
|
+
const steps = entityEventMigrationSteps(entityName, eventVersion, entity.eventMigrations ?? []);
|
|
396
|
+
if (eventVersion === 1)
|
|
397
|
+
continue;
|
|
398
|
+
for (const verb of ENTITY_LIFECYCLE_VERBS) {
|
|
399
|
+
const key = entityEventName(entityName, verb);
|
|
400
|
+
if (state.eventUpcasterMap.has(key) || state.eventMap.has(key)) {
|
|
401
|
+
throw new Error(`Entity event type "${key}" collides with an already registered event; event types must be unique.`);
|
|
402
|
+
}
|
|
403
|
+
const chain = new Map();
|
|
404
|
+
for (const [from, transform] of steps) {
|
|
405
|
+
chain.set(from, adaptRowTransformToPayload(verb, transform));
|
|
406
|
+
}
|
|
407
|
+
state.eventUpcasterMap.set(key, { currentVersion: eventVersion, chain });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
330
411
|
export function buildEventUpcasterChains(state, features) {
|
|
331
412
|
// Stitch the upcaster chain per qualified event. At this point, gaps in
|
|
332
413
|
// the chain (e.g. defineEvent version=3 but only a 1→2 migration exists)
|
|
@@ -371,7 +452,6 @@ export function validateProjectionApplyKeys(state) {
|
|
|
371
452
|
// CRUD types per source entity PLUS every domain event registered via
|
|
372
453
|
// r.defineEvent — an apply-handler for a domain event is how a projection
|
|
373
454
|
// reacts to ctx.appendEvent writes on the same aggregate stream.
|
|
374
|
-
const AUTO_EVENT_VERBS = ["created", "updated", "deleted", "restored", "forgotten"];
|
|
375
455
|
const allDomainEventNames = new Set(state.eventMap.keys());
|
|
376
456
|
for (const [projName, projDef] of state.projectionMap) {
|
|
377
457
|
const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
|
|
@@ -397,7 +477,7 @@ export function validateProjectionApplyKeys(state) {
|
|
|
397
477
|
const isEventsOnlySource = !sources.every((src) => state.entityMap.has(src));
|
|
398
478
|
for (const src of rebuildSources) {
|
|
399
479
|
if (state.entityMap.has(src)) {
|
|
400
|
-
for (const verb of
|
|
480
|
+
for (const verb of ENTITY_LIFECYCLE_VERBS)
|
|
401
481
|
validEventTypes.add(`${src}.${verb}`);
|
|
402
482
|
}
|
|
403
483
|
}
|
package/dist/engine/registry.js
CHANGED
|
@@ -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, populateSystemEvents, 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, resolveJobTriggers, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateLifecycleHookTargets, validateLiveEntities, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
|
|
6
|
+
import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEntityEventUpcasterChains, 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).
|
|
@@ -37,6 +37,7 @@ export function createRegistry(rawFeatures) {
|
|
|
37
37
|
validateRelationTargetsExist(state);
|
|
38
38
|
validateEventMigrationVersions(state, features);
|
|
39
39
|
buildEventUpcasterChains(state, features);
|
|
40
|
+
buildEntityEventUpcasterChains(state);
|
|
40
41
|
validateProjectionApplyKeys(state);
|
|
41
42
|
validateRequiredFeatures(state, features);
|
|
42
43
|
resolveNotificationTriggersAndRegisterHooks(state);
|
|
@@ -406,6 +406,38 @@ export function buildInsertSchema(entity, currencies = [...DEFAULT_CURRENCIES],
|
|
|
406
406
|
}
|
|
407
407
|
return applyTotalsMatchRefinements(entity, z.object(shape));
|
|
408
408
|
}
|
|
409
|
+
// Field types whose column is nullable and whose projection write accepts a
|
|
410
|
+
// plain `null`. Excluded: select/writeOnly text (fieldToZod is already
|
|
411
|
+
// nullable there), and types where null would not clear cleanly — NOT NULL
|
|
412
|
+
// jsonb columns (multiSelect, embedded, jsonb, multi reference), files/images
|
|
413
|
+
// (no column; stripped from updates), locatedTimestamp (split into _utc/_tz
|
|
414
|
+
// columns, a bare null targets a column that does not exist) and money (the
|
|
415
|
+
// currency column would keep its stale value).
|
|
416
|
+
const NULL_CLEARABLE_FIELD_TYPES = new Set([
|
|
417
|
+
"text",
|
|
418
|
+
"longText",
|
|
419
|
+
"boolean",
|
|
420
|
+
"number",
|
|
421
|
+
"bigInt",
|
|
422
|
+
"decimal",
|
|
423
|
+
"reference",
|
|
424
|
+
"date",
|
|
425
|
+
"timestamp",
|
|
426
|
+
"tz",
|
|
427
|
+
"file",
|
|
428
|
+
"image",
|
|
429
|
+
]);
|
|
430
|
+
function isClearableByNull(field) {
|
|
431
|
+
if (!NULL_CLEARABLE_FIELD_TYPES.has(field.type))
|
|
432
|
+
return false;
|
|
433
|
+
if (field.type === "reference" && field.multiple === true)
|
|
434
|
+
return false;
|
|
435
|
+
if (field.type === "text" && field.writeOnly === true)
|
|
436
|
+
return false;
|
|
437
|
+
const isRequired = "required" in field && field.required === true;
|
|
438
|
+
const hasDefault = "default" in field && field.default !== undefined;
|
|
439
|
+
return !isRequired && !hasDefault;
|
|
440
|
+
}
|
|
409
441
|
export function buildUpdateSchema(entity, currencies = [...DEFAULT_CURRENCIES], excludedFields = []) {
|
|
410
442
|
const shape = {};
|
|
411
443
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
@@ -421,10 +453,11 @@ export function buildUpdateSchema(entity, currencies = [...DEFAULT_CURRENCIES],
|
|
|
421
453
|
// fieldToZod still knows the default for its "" → default mapping
|
|
422
454
|
// (e.g. select) — applyDefaults: false only suppresses the schema-level
|
|
423
455
|
// `.default(...)` fallback for a genuinely omitted key.
|
|
424
|
-
|
|
456
|
+
const zodField = fieldToZod(field, currencies, {
|
|
425
457
|
applyDefaults: false,
|
|
426
458
|
isUpdate: true,
|
|
427
|
-
})
|
|
459
|
+
});
|
|
460
|
+
shape[name] = (isClearableByNull(field) ? zodField.nullable() : zodField).optional();
|
|
428
461
|
}
|
|
429
462
|
return applyTotalsMatchRefinements(entity, z.object(shape));
|
|
430
463
|
}
|
|
@@ -6,7 +6,7 @@ export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap,
|
|
|
6
6
|
export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsMap, } from "@cosmicdrift/kumiko-types/extension-options-map";
|
|
7
7
|
export type { BootCheckContext, BootCheckFn, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, 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
|
-
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";
|
|
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
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";
|
|
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";
|
package/dist/errors/reasons.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export declare const FrameworkReasons: {
|
|
|
6
6
|
readonly featureDisabled: "feature_disabled";
|
|
7
7
|
readonly systemIdentitySwitchDenied: "system_identity_switch_denied";
|
|
8
8
|
readonly identitySwitchDenied: "identity_switch_denied";
|
|
9
|
+
readonly tenantOverrideRequiresSystemAdmin: "tenant_override_requires_system_admin";
|
|
9
10
|
readonly unsafeAllTenantsDenied: "unsafe_all_tenants_denied";
|
|
10
11
|
readonly memberResolutionDenied: "member_resolution_denied";
|
|
11
12
|
readonly memberResolutionReadOnly: "member_resolution_read_only";
|
package/dist/errors/reasons.js
CHANGED
|
@@ -40,6 +40,8 @@ export const FrameworkReasons = {
|
|
|
40
40
|
// non-SYSTEM identity other than its own caller (different user, tenant,
|
|
41
41
|
// claims, origin or extra roles) without r.systemScope() or escapeHatch.
|
|
42
42
|
identitySwitchDenied: "identity_switch_denied",
|
|
43
|
+
// AccessDeniedError: a payload carried tenantIdOverride but the caller is no SystemAdmin.
|
|
44
|
+
tenantOverrideRequiresSystemAdmin: "tenant_override_requires_system_admin",
|
|
43
45
|
// AccessDeniedError: a handler/hook called ctx.queryProjection(..., { unsafeAllTenants: true })
|
|
44
46
|
// without an r.systemScope() feature or a declared escapeHatch.
|
|
45
47
|
unsafeAllTenantsDenied: "unsafe_all_tenants_denied",
|
|
@@ -24,6 +24,7 @@ export declare function loadAggregateAsOf(db: DbRunner, aggregateId: string, ten
|
|
|
24
24
|
readonly includeArchived?: boolean;
|
|
25
25
|
}): Promise<readonly StoredEvent[]>;
|
|
26
26
|
export declare function getStreamVersion(db: DbRunner, aggregateId: string, tenantId: TenantId): Promise<number>;
|
|
27
|
+
export declare function getStreamVersions(db: DbRunner, aggregateIds: readonly string[], tenantId: TenantId): Promise<Map<string, number>>;
|
|
27
28
|
/** MAX(version) for one aggregate — no tenant filter. SECURITY: existence-oracle,
|
|
28
29
|
* a caller can probe whether an aggregateId has any events regardless of tenant
|
|
29
30
|
* membership. Only call from seed/system-internal paths (idempotency checks
|
|
@@ -4,7 +4,7 @@ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
|
4
4
|
import { requestContext } from "../api/request-context.js";
|
|
5
5
|
import { encryptEventPayloadPii } from "../crypto/event-pii.js";
|
|
6
6
|
import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
|
|
7
|
-
import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, } from "../db/queries/event-store.js";
|
|
7
|
+
import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, selectStreamMaxVersions, } from "../db/queries/event-store.js";
|
|
8
8
|
import { insertOne, selectMany } from "../db/query.js";
|
|
9
9
|
import { isStreamArchived } from "./archive.js";
|
|
10
10
|
import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
|
|
@@ -223,6 +223,9 @@ export async function loadAggregateAsOf(db, aggregateId, tenantId, asOf, options
|
|
|
223
223
|
export async function getStreamVersion(db, aggregateId, tenantId) {
|
|
224
224
|
return selectStreamMaxVersion(db, aggregateId, tenantId);
|
|
225
225
|
}
|
|
226
|
+
export async function getStreamVersions(db, aggregateIds, tenantId) {
|
|
227
|
+
return selectStreamMaxVersions(db, aggregateIds, tenantId);
|
|
228
|
+
}
|
|
226
229
|
/** MAX(version) for one aggregate — no tenant filter. SECURITY: existence-oracle,
|
|
227
230
|
* a caller can probe whether an aggregateId has any events regardless of tenant
|
|
228
231
|
* membership. Only call from seed/system-internal paths (idempotency checks
|
|
@@ -2,7 +2,7 @@ export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/syst
|
|
|
2
2
|
export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, type PiiBackfillBatchOptions, type PiiBackfillBatchResult, type PiiBackfillFailure, type PiiBackfillOptions, type PiiBackfillResult, type PiiBackfillScanCache, } from "../db/queries/backfill-pii.js";
|
|
3
3
|
export { type ArchiveStreamArgs, archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
|
|
4
4
|
export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
|
|
5
|
-
export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.js";
|
|
5
|
+
export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.js";
|
|
6
6
|
export { createEventsTable, eventsTable } from "./events-schema.js";
|
|
7
7
|
export { appendProvenanceEvent, type ProvenanceEventInput } from "./provenance-append.js";
|
|
8
8
|
export { createRebuildDeadLetterTable, listRebuildDeadLetters, type RebuildDeadLetterRow, rebuildDeadLetterTable, recordRebuildDeadLetters, type SkippedApply, } from "./rebuild-dead-letter.js";
|
|
@@ -2,7 +2,7 @@ export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/syst
|
|
|
2
2
|
export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
|
|
3
3
|
export { archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
|
|
4
4
|
export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
|
|
5
|
-
export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
|
|
5
|
+
export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
|
|
6
6
|
export { createEventsTable, eventsTable } from "./events-schema.js";
|
|
7
7
|
export { appendProvenanceEvent } from "./provenance-append.js";
|
|
8
8
|
export { createRebuildDeadLetterTable, listRebuildDeadLetters, rebuildDeadLetterTable, recordRebuildDeadLetters, } from "./rebuild-dead-letter.js";
|
|
@@ -4,6 +4,7 @@ import type { DbConnection, DbRunner } from "../db/connection.js";
|
|
|
4
4
|
import type { TenantId } from "../engine/types/index.js";
|
|
5
5
|
export declare const snapshotsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
|
|
6
6
|
export declare function createSnapshotsTable(db: DbConnection): Promise<void>;
|
|
7
|
+
export declare function effectiveSnapshotGeneration(callerVersion: number, entityEventVersion: number | undefined): number;
|
|
7
8
|
export type Snapshot<TState extends Record<string, unknown> = Record<string, unknown>> = {
|
|
8
9
|
readonly aggregateId: string;
|
|
9
10
|
readonly tenantId: TenantId;
|
|
@@ -68,6 +68,14 @@ export async function createSnapshotsTable(db) {
|
|
|
68
68
|
}
|
|
69
69
|
await unsafePushTables(db, { kumikoSnapshots: snapshotsTable });
|
|
70
70
|
}
|
|
71
|
+
const ENTITY_EVENT_VERSION_RADIX = 1000;
|
|
72
|
+
// Entity eventVersion 1 keeps the caller generation (existing snapshots stay valid);
|
|
73
|
+
// higher versions map to negative values, disjoint from caller generations (>= 1).
|
|
74
|
+
export function effectiveSnapshotGeneration(callerVersion, entityEventVersion) {
|
|
75
|
+
if (entityEventVersion === undefined || entityEventVersion === 1)
|
|
76
|
+
return callerVersion;
|
|
77
|
+
return -(callerVersion * ENTITY_EVENT_VERSION_RADIX + entityEventVersion);
|
|
78
|
+
}
|
|
71
79
|
// Upsert-style save so re-snapshotting the same (aggregateId, version) is
|
|
72
80
|
// idempotent. Caller can retake a snapshot at the same version without
|
|
73
81
|
// bespoke error handling — useful when a feature's snapshot policy runs
|
|
@@ -120,9 +128,10 @@ export async function loadAggregateWithSnapshot(db, aggregateId, tenantId, reduc
|
|
|
120
128
|
return { state: initial, version: 0, snapshotHit: false };
|
|
121
129
|
}
|
|
122
130
|
}
|
|
123
|
-
const
|
|
131
|
+
const callerVersion = options?.snapshotVersion ?? 1;
|
|
132
|
+
const generationFor = (aggregateType) => effectiveSnapshotGeneration(callerVersion, options?.entityEventVersionOf?.(aggregateType));
|
|
124
133
|
const stored = await loadLatestSnapshot(db, aggregateId, tenantId);
|
|
125
|
-
const snapshot = stored && stored.snapshotVersion ===
|
|
134
|
+
const snapshot = stored && stored.snapshotVersion === generationFor(stored.aggregateType) ? stored : null;
|
|
126
135
|
const baseState = snapshot ? snapshot.state : initial;
|
|
127
136
|
const afterVersion = snapshot ? snapshot.version : 0;
|
|
128
137
|
const delta = await loadEventsAfterVersion(db, aggregateId, tenantId, afterVersion);
|
|
@@ -141,7 +150,7 @@ export async function loadAggregateWithSnapshot(db, aggregateId, tenantId, reduc
|
|
|
141
150
|
aggregateType: lastDelta.aggregateType,
|
|
142
151
|
version: latestVersion,
|
|
143
152
|
state,
|
|
144
|
-
snapshotVersion:
|
|
153
|
+
snapshotVersion: generationFor(lastDelta.aggregateType),
|
|
145
154
|
});
|
|
146
155
|
}
|
|
147
156
|
catch {
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -11,9 +11,10 @@ import { InternalError } from "../errors/index.js";
|
|
|
11
11
|
import { isKumikoError } from "../errors/kumiko-error.js";
|
|
12
12
|
import { createFileContext } from "../files/file-handle.js";
|
|
13
13
|
import { createFallbackLogger } from "../logging/index.js";
|
|
14
|
-
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, emitJobLastSuccess, emitJobQueueDepth, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
|
|
14
|
+
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
|
|
15
15
|
import { createDistributedLock } from "../pipeline/distributed-lock.js";
|
|
16
16
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
17
|
+
import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
|
|
17
18
|
import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
|
|
18
19
|
import { bridgeStub } from "../testing/handler-context.js";
|
|
19
20
|
// Shape-checked after the BullMQ/Redis JSON round-trip. Unlike `_writeOrigin`
|
|
@@ -333,6 +334,7 @@ export function createJobRunner(options) {
|
|
|
333
334
|
if (context.meter)
|
|
334
335
|
registerStandardMetrics(context.meter);
|
|
335
336
|
// Instance-scoped, not module-level: each runner (and each test) audits a system cron's declared hatch once.
|
|
337
|
+
const escapeHatchReportWindow = createEscapeHatchReportWindow();
|
|
336
338
|
const systemCronEscapeHatchDedup = createEscapeHatchProcessDedup([
|
|
337
339
|
"unsafe-raw",
|
|
338
340
|
"global-write",
|
|
@@ -365,7 +367,8 @@ export function createJobRunner(options) {
|
|
|
365
367
|
tenantId: SYSTEM_TENANT_ID,
|
|
366
368
|
actor: systemUser.id,
|
|
367
369
|
sink: context._escapeHatchAuditSink,
|
|
368
|
-
log:
|
|
370
|
+
log: errorLogger,
|
|
371
|
+
window: escapeHatchReportWindow,
|
|
369
372
|
});
|
|
370
373
|
const result = await handler.handler({ type: ACTIVE_TENANT_IDS_QUERY_NAME, payload: {}, user: systemUser }, {
|
|
371
374
|
db: systemModeDb,
|
|
@@ -815,7 +818,9 @@ export function createJobRunner(options) {
|
|
|
815
818
|
tenantId,
|
|
816
819
|
actor: jobSystemUser.id,
|
|
817
820
|
sink: context._escapeHatchAuditSink,
|
|
818
|
-
log:
|
|
821
|
+
log: errorLogger,
|
|
822
|
+
window: escapeHatchReportWindow,
|
|
823
|
+
...(triggeredById !== null && { caller: triggeredById }),
|
|
819
824
|
...(isSystemCronRun && { processDedup: systemCronEscapeHatchDedup }),
|
|
820
825
|
...(context.meter && { meter: context.meter }),
|
|
821
826
|
});
|
|
@@ -855,6 +860,25 @@ export function createJobRunner(options) {
|
|
|
855
860
|
// Per-job-run reader, created lazily on queryAsMember's first call and
|
|
856
861
|
// cached for the rest of this run.
|
|
857
862
|
let memberReader;
|
|
863
|
+
const identitySwitchHatch = escapeHatchFor(jobDef.escapeHatch, "systemIdentity");
|
|
864
|
+
const identitySwitchGrantReason = identitySwitchHatch?.reason ??
|
|
865
|
+
(isSystemJob ? `r.systemScope() feature of job "${jobName}"` : undefined);
|
|
866
|
+
const ungatedQueryAs = (user, qn, payload) => {
|
|
867
|
+
if (!dispatchWriteRef) {
|
|
868
|
+
throw new Error("JobContext.queryAs called before dispatcher attached — call attachDispatcher() first");
|
|
869
|
+
}
|
|
870
|
+
return dispatchWriteRef.queryAs(user, qn, payload, jobOrigin);
|
|
871
|
+
};
|
|
872
|
+
// Only writeAs is gated for jobs; queryAs stays ungated (fw#2859).
|
|
873
|
+
const gatedIdentitySwitch = createGatedIdentitySwitch(`job "${jobName}"`, jobSystemUser, isSystemJob || identitySwitchHatch !== undefined, {
|
|
874
|
+
queryAs: ungatedQueryAs,
|
|
875
|
+
writeAs: async (user, qn, payload) => {
|
|
876
|
+
if (!dispatchWriteRef) {
|
|
877
|
+
throw new Error("JobContext.writeAs called before dispatcher attached — call attachDispatcher() first");
|
|
878
|
+
}
|
|
879
|
+
return dispatchWriteRef.write(user, qn, payload, jobOrigin);
|
|
880
|
+
},
|
|
881
|
+
}, { reason: identitySwitchGrantReason, report: reportEscapeHatch });
|
|
858
882
|
const jobContext = {
|
|
859
883
|
...context,
|
|
860
884
|
db: jobDb ?? missingDbProxy(jobName),
|
|
@@ -884,18 +908,8 @@ export function createJobRunner(options) {
|
|
|
884
908
|
}
|
|
885
909
|
return dispatchWriteRef.write(jobSystemUser, qn, payload, jobOrigin);
|
|
886
910
|
},
|
|
887
|
-
writeAs:
|
|
888
|
-
|
|
889
|
-
throw new Error("JobContext.writeAs called before dispatcher attached — call attachDispatcher() first");
|
|
890
|
-
}
|
|
891
|
-
return dispatchWriteRef.write(user, qn, payload, jobOrigin);
|
|
892
|
-
},
|
|
893
|
-
queryAs: (user, qn, payload) => {
|
|
894
|
-
if (!dispatchWriteRef) {
|
|
895
|
-
throw new Error("JobContext.queryAs called before dispatcher attached — call attachDispatcher() first");
|
|
896
|
-
}
|
|
897
|
-
return dispatchWriteRef.queryAs(user, qn, payload, jobOrigin);
|
|
898
|
-
},
|
|
911
|
+
writeAs: gatedIdentitySwitch.writeAs,
|
|
912
|
+
queryAs: ungatedQueryAs,
|
|
899
913
|
queryAsMember: (userId, qn, payload) => {
|
|
900
914
|
if (!dispatchWriteRef) {
|
|
901
915
|
throw new Error("JobContext.queryAsMember called before dispatcher attached — call attachDispatcher() first");
|
|
@@ -970,7 +984,7 @@ export function createJobRunner(options) {
|
|
|
970
984
|
// When parentContext is set, the new parent-aware StartSpanOptions
|
|
971
985
|
// plumbs it through to startSpan — no manual try/finally needed.
|
|
972
986
|
try {
|
|
973
|
-
await tracer.withSpan("job.execute", {
|
|
987
|
+
await withEscapeHatchAuditScope(() => tracer.withSpan("job.execute", {
|
|
974
988
|
attributes: {
|
|
975
989
|
"job.name": jobName,
|
|
976
990
|
"job.id": jobId,
|
|
@@ -988,7 +1002,7 @@ export function createJobRunner(options) {
|
|
|
988
1002
|
...(consumerLane !== undefined ? { "kumiko.job.consumer_lane": consumerLane } : {}),
|
|
989
1003
|
},
|
|
990
1004
|
...(parentContext ? { parent: parentContext } : {}),
|
|
991
|
-
}, runInSpan);
|
|
1005
|
+
}, runInSpan));
|
|
992
1006
|
}
|
|
993
1007
|
finally {
|
|
994
1008
|
// Release the sequential lock value-matched (Lua compare-and-delete
|
|
@@ -1191,6 +1205,7 @@ export function createJobRunner(options) {
|
|
|
1191
1205
|
readinessTimeout.cancel();
|
|
1192
1206
|
}
|
|
1193
1207
|
await Promise.all([queues.api.close(), queues.worker.close()]);
|
|
1208
|
+
await flushEscapeHatchAudits();
|
|
1194
1209
|
if (lockRedis) {
|
|
1195
1210
|
// quit() drains in-flight commands; disconnect() would cancel them
|
|
1196
1211
|
// and risk a half-released lock.
|
|
@@ -14,16 +14,19 @@ export type EscapeHatchProcessDedup = {
|
|
|
14
14
|
readonly seen: Set<string>;
|
|
15
15
|
};
|
|
16
16
|
export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
|
|
17
|
+
export declare function withEscapeHatchAuditScope<T>(run: () => Promise<T>): Promise<T>;
|
|
18
|
+
export declare function flushEscapeHatchAudits(): Promise<void>;
|
|
17
19
|
export declare function reportEscapeHatchUse(event: EscapeHatchUseEvent, deps: {
|
|
18
20
|
readonly sink?: EscapeHatchAuditSink;
|
|
19
|
-
readonly log
|
|
21
|
+
readonly log: Pick<Logger, "warn" | "error">;
|
|
20
22
|
}): void;
|
|
21
23
|
export declare function createEscapeHatchReporter(opts: {
|
|
22
24
|
readonly handler: string;
|
|
23
25
|
readonly tenantId: TenantId;
|
|
24
26
|
readonly actor: string;
|
|
27
|
+
readonly caller?: string | undefined;
|
|
25
28
|
readonly sink?: EscapeHatchAuditSink;
|
|
26
|
-
readonly log
|
|
29
|
+
readonly log: Pick<Logger, "warn" | "error">;
|
|
27
30
|
readonly window?: EscapeHatchReportWindow;
|
|
28
31
|
readonly processDedup?: EscapeHatchProcessDedup;
|
|
29
32
|
readonly meter?: Meter;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createFallbackLogger } from "../logging/utils.js";
|
|
1
3
|
import { emitEscapeHatchUse } from "./standard-metrics.js";
|
|
2
4
|
export const ESCAPE_HATCH_USED_SIGNAL = "security:escape-hatch-used";
|
|
3
5
|
const DEFAULT_WINDOW_MS = 60_000;
|
|
@@ -30,21 +32,35 @@ export function createEscapeHatchProcessDedup(kinds) {
|
|
|
30
32
|
}
|
|
31
33
|
// Default window for createEscapeHatchReporter callers that don't pass their own.
|
|
32
34
|
const fallbackReportWindow = createEscapeHatchReportWindow();
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
35
|
+
// Sink writes that have started and not settled yet, process-wide: shutdown flushes them.
|
|
36
|
+
const processPendingAudits = new Set();
|
|
37
|
+
// The sink writes of the dispatch or job run currently executing.
|
|
38
|
+
const scopePendingAudits = new AsyncLocalStorage();
|
|
39
|
+
function trackPendingAudit(write) {
|
|
40
|
+
processPendingAudits.add(write);
|
|
41
|
+
scopePendingAudits.getStore()?.add(write);
|
|
42
|
+
void write.finally(() => processPendingAudits.delete(write));
|
|
43
|
+
}
|
|
44
|
+
// Awaits the audit writes started inside `run` once it has returned, so callers place it around
|
|
45
|
+
// the transaction and the write is durable when the dispatch or run ends. Nested scopes belong
|
|
46
|
+
// to the outermost one (a nested dispatch runs inside its caller's transaction, which must not
|
|
47
|
+
// wait on the sink). Sink failures are already logged by reportEscapeHatchUse and never throw here.
|
|
48
|
+
export async function withEscapeHatchAuditScope(run) {
|
|
49
|
+
if (scopePendingAudits.getStore() !== undefined)
|
|
50
|
+
return run();
|
|
51
|
+
const pending = new Set();
|
|
52
|
+
try {
|
|
53
|
+
return await scopePendingAudits.run(pending, run);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await Promise.allSettled([...pending]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export async function flushEscapeHatchAudits() {
|
|
60
|
+
while (processPendingAudits.size > 0) {
|
|
61
|
+
await Promise.allSettled([...processPendingAudits]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
48
64
|
function eventFields(event) {
|
|
49
65
|
return {
|
|
50
66
|
handler: event.handler,
|
|
@@ -52,26 +68,38 @@ function eventFields(event) {
|
|
|
52
68
|
reason: event.reason,
|
|
53
69
|
tenantId: event.tenantId,
|
|
54
70
|
actor: event.actor,
|
|
71
|
+
...(event.caller !== undefined && { caller: event.caller }),
|
|
55
72
|
...(event.target && { targetId: event.target.id, targetTenantId: event.target.tenantId }),
|
|
56
73
|
};
|
|
57
74
|
}
|
|
58
75
|
export function reportEscapeHatchUse(event, deps) {
|
|
59
|
-
const logger = deps.log ?? consoleLogger;
|
|
60
76
|
const fields = eventFields(event);
|
|
61
77
|
if (deps.sink) {
|
|
62
|
-
|
|
63
|
-
|
|
78
|
+
const write = deps.sink(event).catch((err) => {
|
|
79
|
+
deps.log.error(`${ESCAPE_HATCH_USED_SIGNAL}: audit sink failed`, {
|
|
64
80
|
...fields,
|
|
65
81
|
error: err instanceof Error ? err.message : String(err),
|
|
66
82
|
});
|
|
67
83
|
});
|
|
68
|
-
|
|
84
|
+
trackPendingAudit(write);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
deps.log.warn(ESCAPE_HATCH_USED_SIGNAL, fields);
|
|
69
88
|
}
|
|
70
|
-
logger.warn(ESCAPE_HATCH_USED_SIGNAL, fields);
|
|
71
89
|
}
|
|
72
90
|
export function createEscapeHatchReporter(opts) {
|
|
73
91
|
const window = opts.window ?? fallbackReportWindow;
|
|
74
92
|
const now = opts.now ?? Date.now;
|
|
93
|
+
const deps = { sink: opts.sink, log: opts.log };
|
|
94
|
+
const eventFor = (kind, reason, target) => ({
|
|
95
|
+
handler: opts.handler,
|
|
96
|
+
kind,
|
|
97
|
+
reason,
|
|
98
|
+
tenantId: opts.tenantId,
|
|
99
|
+
actor: opts.actor,
|
|
100
|
+
...(opts.caller !== undefined && { caller: opts.caller }),
|
|
101
|
+
target,
|
|
102
|
+
});
|
|
75
103
|
return (kind, reason, target) => {
|
|
76
104
|
if (opts.meter)
|
|
77
105
|
emitEscapeHatchUse(opts.meter, opts.handler, kind);
|
|
@@ -82,7 +110,7 @@ export function createEscapeHatchReporter(opts) {
|
|
|
82
110
|
if (processDedup.seen.has(processKey))
|
|
83
111
|
return;
|
|
84
112
|
processDedup.seen.add(processKey);
|
|
85
|
-
reportEscapeHatchUse(
|
|
113
|
+
reportEscapeHatchUse(eventFor(kind, reason, target), deps);
|
|
86
114
|
// skip: audited above, the window dedup below only serves the other kinds
|
|
87
115
|
return;
|
|
88
116
|
}
|
|
@@ -92,13 +120,14 @@ export function createEscapeHatchReporter(opts) {
|
|
|
92
120
|
reason,
|
|
93
121
|
opts.tenantId,
|
|
94
122
|
opts.actor,
|
|
123
|
+
opts.caller ?? null,
|
|
95
124
|
target?.id ?? null,
|
|
96
125
|
target?.tenantId ?? null,
|
|
97
126
|
]);
|
|
98
127
|
// skip: same (handler, kind, reason, target) already reported within the window — dedup
|
|
99
128
|
if (!window.shouldReport(key, now()))
|
|
100
129
|
return;
|
|
101
|
-
reportEscapeHatchUse(
|
|
130
|
+
reportEscapeHatchUse(eventFor(kind, reason, target), deps);
|
|
102
131
|
};
|
|
103
132
|
}
|
|
104
133
|
// Actor for escape-hatch uses whose caller did not thread one through. Stays
|
|
@@ -109,5 +138,6 @@ export function fallbackEscapeHatchReporter(tenantId) {
|
|
|
109
138
|
handler: "<unattributed>",
|
|
110
139
|
tenantId,
|
|
111
140
|
actor: "<unattributed>",
|
|
141
|
+
log: createFallbackLogger("escape-hatch"),
|
|
112
142
|
});
|
|
113
143
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { type ConsoleProviderOptions, createConsoleProvider } from "./console-provider.js";
|
|
2
2
|
export { observabilityContext } from "./context.js";
|
|
3
|
-
export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, type EscapeHatchProcessDedup, type EscapeHatchReportWindow, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
|
|
3
|
+
export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, type EscapeHatchProcessDedup, type EscapeHatchReportWindow, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "./escape-hatch-report.js";
|
|
4
4
|
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
|
|
5
5
|
export { generateSpanId, generateTraceId } from "./ids.js";
|
|
6
6
|
export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Public surface of the observability module.
|
|
2
2
|
export { createConsoleProvider } from "./console-provider.js";
|
|
3
3
|
export { observabilityContext } from "./context.js";
|
|
4
|
-
export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
|
|
4
|
+
export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "./escape-hatch-report.js";
|
|
5
5
|
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
|
|
6
6
|
export { generateSpanId, generateTraceId } from "./ids.js";
|
|
7
7
|
export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { hasAccess } from "../engine/access.js";
|
|
2
|
+
import { tenantOverrideDenied } from "../engine/cross-tenant.js";
|
|
2
3
|
import { filterReadFields } from "../engine/field-access.js";
|
|
3
4
|
import { NotFoundError, validationErrorFromZod } from "../errors/index.js";
|
|
4
5
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
@@ -41,6 +42,9 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
|
|
|
41
42
|
if (!parsed.success) {
|
|
42
43
|
throw validationErrorFromZod(parsed.error);
|
|
43
44
|
}
|
|
45
|
+
const overrideDenied = tenantOverrideDenied(user, parsed.data);
|
|
46
|
+
if (overrideDenied)
|
|
47
|
+
throw overrideDenied;
|
|
44
48
|
// Trash opt-in rides the validated query payload: only the entity-list
|
|
45
49
|
// schema (and custom query schemas that opt in) carries `includeDeleted`,
|
|
46
50
|
// so other handlers never see the flag. Visibility filters still apply
|