@cosmicdrift/kumiko-framework 0.354.1 → 0.356.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +9 -2
- package/dist/db/event-store-executor-context.js +45 -9
- package/dist/db/event-store-executor-read.js +7 -4
- package/dist/db/event-store-executor-write.js +105 -61
- 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 +2 -2
- 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/types/index.d.ts +3 -3
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/event-store/snapshot.d.ts +1 -0
- package/dist/event-store/snapshot.js +12 -3
- package/dist/jobs/job-runner.js +31 -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 +62 -10
- 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 +42 -0
|
@@ -3,16 +3,18 @@ import { requestContext, runWithOrigin } from "../api/request-context.js";
|
|
|
3
3
|
import { runInSavepoint, selectMany } from "../db/query.js";
|
|
4
4
|
import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, runInOwnTransaction, } from "../db/tenant-db.js";
|
|
5
5
|
import { createDerivativesContext } from "../derivatives/derivatives-context.js";
|
|
6
|
+
import { mayOverrideTenant } from "../engine/cross-tenant.js";
|
|
6
7
|
import { createSystemUser } from "../engine/system-user.js";
|
|
7
8
|
import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
|
|
8
|
-
import { AccessDeniedError, FeatureDisabledError, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, } from "../errors/index.js";
|
|
9
|
+
import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, } from "../errors/index.js";
|
|
9
10
|
import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
|
|
10
11
|
import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
|
|
11
12
|
import { getStreamVersion, loadAggregate, loadAggregateAsOf, } from "../event-store/event-store.js";
|
|
12
|
-
import { loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
|
|
13
|
+
import { effectiveSnapshotGeneration, loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
|
|
13
14
|
import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
|
|
14
15
|
import { createFileContext } from "../files/file-handle.js";
|
|
15
16
|
import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
|
|
17
|
+
import { createFallbackLogger } from "../logging/utils.js";
|
|
16
18
|
import { createEscapeHatchReporter, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
|
|
17
19
|
import { buildBucketKey, buildPayloadBucketKey, normalizePayloadBucketValue, } from "../rate-limit/index.js";
|
|
18
20
|
import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
|
|
@@ -23,7 +25,7 @@ import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolv
|
|
|
23
25
|
import { runBatch, unwrapSingle } from "./dispatch-batch.js";
|
|
24
26
|
import { executeQuery } from "./dispatch-query.js";
|
|
25
27
|
import { executeWrite } from "./dispatch-write.js";
|
|
26
|
-
import { dispatcherSpanAttributes, isFailedWriteResult, } from "./dispatcher-utils.js";
|
|
28
|
+
import { dispatcherSpanAttributes, isFailedWriteResult, NestedWriteRollback, } from "./dispatcher-utils.js";
|
|
27
29
|
import { createMemberReaderFn } from "./member-reader.js";
|
|
28
30
|
import { createGatedIdentitySwitch, createGatedMemberReader, createGatedProjectionReader, isSystemIdentity, systemIdentitySwitchDenied, } from "./system-identity-switch.js";
|
|
29
31
|
import { buildPersonalDataGate, rootWriteOrigin } from "./write-origin.js";
|
|
@@ -177,6 +179,7 @@ export function applyMemberResolutionReadOnly(handlerContext) {
|
|
|
177
179
|
restoreStream: denyMemberResolutionWrite,
|
|
178
180
|
snapshotAggregate: denyMemberResolutionWrite,
|
|
179
181
|
queryAsMember: denyMemberResolutionWrite,
|
|
182
|
+
forTenant: undefined,
|
|
180
183
|
resolveActiveMembership: denyMemberResolutionWrite,
|
|
181
184
|
scheduleAfterCommit: () => {
|
|
182
185
|
throw memberResolutionReadOnlyDenied();
|
|
@@ -220,14 +223,16 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
220
223
|
tenantId: user.tenantId,
|
|
221
224
|
actor: user.id,
|
|
222
225
|
sink: context._escapeHatchAuditSink,
|
|
223
|
-
log: context.log,
|
|
226
|
+
log: context.log ?? createFallbackLogger("dispatcher"),
|
|
224
227
|
window: ctx.escapeHatchReportWindow,
|
|
225
228
|
});
|
|
226
229
|
const identitySwitchGrantReason = identitySwitchEscapeHatch?.reason ??
|
|
227
230
|
(isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
|
|
228
|
-
const buildTenantScopedDb = (source, signal) => createTenantDb(source, user.tenantId, isSystem ? "system" : "tenant", context.tracer, context.meter, signal, {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
+
const buildTenantScopedDb = (source, signal, foreignTenantId) => createTenantDb(source, foreignTenantId ?? user.tenantId, isSystem && foreignTenantId === undefined ? "system" : "tenant", context.tracer, context.meter, signal, {
|
|
232
|
+
...(foreignTenantId === undefined && {
|
|
233
|
+
globalWrites: escapeHatchFor(writeEscapeHatch, "globalWrites"),
|
|
234
|
+
unsafeRaw: escapeHatchFor(handlerEscapeHatch, "unsafeRaw"),
|
|
235
|
+
}),
|
|
231
236
|
report: reportEscapeHatch,
|
|
232
237
|
memberReadOnly: isMemberResolutionPrincipal(user),
|
|
233
238
|
personalDataGate: buildPersonalDataGate(registry, origin),
|
|
@@ -308,6 +313,26 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
308
313
|
});
|
|
309
314
|
}
|
|
310
315
|
: undefined;
|
|
316
|
+
// Write handlers only, and not r.systemScope() ones (they have ctx.systemDb). The gate is the
|
|
317
|
+
// dispatcher's tenantIdOverride predicate, so no caller reaches more than it could via
|
|
318
|
+
// tenantIdOverride. The foreign db carries no unsafeRaw/globalWrites grant and is tenant mode
|
|
319
|
+
// bound to the target; the executor accepts streamTenantId only on a db bound to that tenant.
|
|
320
|
+
const forTenant = !isSystem && dbSource && registry.getWriteHandler(type) !== undefined
|
|
321
|
+
? (targetTenantId) => {
|
|
322
|
+
if (targetTenantId === user.tenantId && exposedDb) {
|
|
323
|
+
return { db: exposedDb, streamTenantId: undefined };
|
|
324
|
+
}
|
|
325
|
+
if (!mayOverrideTenant(user)) {
|
|
326
|
+
throw new AccessDeniedError({
|
|
327
|
+
details: { reason: FrameworkReasons.tenantOverrideRequiresSystemAdmin },
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId),
|
|
332
|
+
streamTenantId: targetTenantId,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
: undefined;
|
|
311
336
|
// ctx.files resolved per-tenant through file-foundation (lazy — the
|
|
312
337
|
// provider is only resolved when a handle actually does I/O). Boot wires
|
|
313
338
|
// _fileProviderResolver when a file-provider plugin is mounted; falls back
|
|
@@ -339,13 +364,38 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
339
364
|
// Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
|
|
340
365
|
// queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
|
|
341
366
|
const bridgeSink = afterCommitHooks ?? [];
|
|
367
|
+
// A failed inner write has already appended its event and projections when a later
|
|
368
|
+
// step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
|
|
369
|
+
// failure result. The savepoint makes the failure undo exactly the inner write; the
|
|
370
|
+
// inner afterCommit hooks only reach the outer sink if it succeeded.
|
|
371
|
+
const executeNestedWriteInSavepoint = async (asUser, targetType, payload) => {
|
|
372
|
+
if (!tx)
|
|
373
|
+
return executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
|
|
374
|
+
const innerAfterCommitHooks = [];
|
|
375
|
+
try {
|
|
376
|
+
const result = await runInSavepoint(tx, async (savepointTx) => {
|
|
377
|
+
const innerResult = await executeWrite(ctx, targetType, payload, asUser, origin, savepointTx, // @cast-boundary driver savepoint handle is a transaction handle
|
|
378
|
+
innerAfterCommitHooks);
|
|
379
|
+
if (isFailedWriteResult(innerResult))
|
|
380
|
+
throw new NestedWriteRollback(innerResult);
|
|
381
|
+
return innerResult;
|
|
382
|
+
});
|
|
383
|
+
bridgeSink.push(...innerAfterCommitHooks);
|
|
384
|
+
return result;
|
|
385
|
+
}
|
|
386
|
+
catch (e) {
|
|
387
|
+
if (e instanceof NestedWriteRollback)
|
|
388
|
+
return e.failure;
|
|
389
|
+
throw e;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
342
392
|
// Post-commit hooks have no transaction left, so a bare executeWrite would
|
|
343
393
|
// auto-commit event append, projections and inTransaction hooks separately
|
|
344
394
|
// and its afterCommit hooks would land in an already-flushed sink. A
|
|
345
395
|
// batch-of-one gives the write its own transaction and its own flush.
|
|
346
396
|
const writeAsUser = async (asUser, targetType, payload) => isPostCommit
|
|
347
397
|
? unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin))
|
|
348
|
-
:
|
|
398
|
+
: executeNestedWriteInSavepoint(asUser, targetType, payload);
|
|
349
399
|
const scheduleAfterCommit = (hook) => {
|
|
350
400
|
bridgeSink.push(hook);
|
|
351
401
|
};
|
|
@@ -560,7 +610,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
560
610
|
aggregateType: snapshotArgs.aggregateType,
|
|
561
611
|
version: snapshotArgs.version,
|
|
562
612
|
state: snapshotArgs.state,
|
|
563
|
-
snapshotVersion: snapshotArgs.snapshotVersion,
|
|
613
|
+
snapshotVersion: effectiveSnapshotGeneration(snapshotArgs.snapshotVersion ?? 1, registry.getEntity(snapshotArgs.aggregateType)?.eventVersion),
|
|
564
614
|
});
|
|
565
615
|
},
|
|
566
616
|
loadAggregateWithSnapshot: async (aggregateId, reducer, initial, loadOptions) => {
|
|
@@ -579,6 +629,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
579
629
|
const upcastCtx = { db: dbSource, tenantId: user.tenantId };
|
|
580
630
|
return loadAggregateWithSnapshot(dbSource, aggregateId, user.tenantId, reducer, initial, {
|
|
581
631
|
...loadOptions,
|
|
632
|
+
entityEventVersionOf: (aggregateType) => registry.getEntity(aggregateType)?.eventVersion,
|
|
582
633
|
upcastEvent: (event) => upcastStoredEvent(event, upcasters, upcastCtx), // @wrapper-known semantic-alias
|
|
583
634
|
});
|
|
584
635
|
},
|
|
@@ -694,6 +745,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
694
745
|
notify,
|
|
695
746
|
...(config && { config }),
|
|
696
747
|
...(configFor && { configFor }),
|
|
748
|
+
...(forTenant && { forTenant }),
|
|
697
749
|
...(files && { files }),
|
|
698
750
|
...(derivatives && { derivatives }),
|
|
699
751
|
// preSave hooks need `changes`/`previous`/`isNew`, which only exist once
|
|
@@ -1057,7 +1109,7 @@ function buildAuthClaimsContext(ctx, user) {
|
|
|
1057
1109
|
tenantId: user.tenantId,
|
|
1058
1110
|
actor: user.id,
|
|
1059
1111
|
sink: context._escapeHatchAuditSink,
|
|
1060
|
-
log: context.log,
|
|
1112
|
+
log: context.log ?? createFallbackLogger("dispatcher"),
|
|
1061
1113
|
window: ctx.escapeHatchReportWindow,
|
|
1062
1114
|
}),
|
|
1063
1115
|
projectionRegistry: ctx.registry,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
|
|
2
2
|
import { scheduleTokenExpiry } from "../api/token-expiry-timer.js";
|
|
3
3
|
import { hasAccess } from "../engine/access.js";
|
|
4
|
+
import { tenantOverrideDenied } from "../engine/cross-tenant.js";
|
|
4
5
|
import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
|
|
5
6
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
6
7
|
import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runStreamInstrumented, } from "./dispatch-shared.js";
|
|
@@ -33,6 +34,9 @@ async function* executeStreamInner(ctx, type, payload, user, origin, options) {
|
|
|
33
34
|
if (!parsed.success) {
|
|
34
35
|
throw validationErrorFromZod(parsed.error);
|
|
35
36
|
}
|
|
37
|
+
const overrideDenied = tenantOverrideDenied(user, parsed.data);
|
|
38
|
+
if (overrideDenied)
|
|
39
|
+
throw overrideDenied;
|
|
36
40
|
// Idle (heartbeat-only) streams must also cut on access revoke or token expiry — race each
|
|
37
41
|
// pull against an ended Deferred instead of a post-chunk boolean.
|
|
38
42
|
let resolveEnded;
|
|
@@ -31,6 +31,10 @@ export declare class BatchRollback extends Error {
|
|
|
31
31
|
readonly failureError: WriteErrorInfo;
|
|
32
32
|
constructor(failedIndex: number, failureError: WriteErrorInfo);
|
|
33
33
|
}
|
|
34
|
+
export declare class NestedWriteRollback extends Error {
|
|
35
|
+
readonly failure: WriteResult;
|
|
36
|
+
constructor(failure: WriteResult);
|
|
37
|
+
}
|
|
34
38
|
export type HandlerType = string | HandlerRef;
|
|
35
39
|
export declare function resolveType(type: HandlerType): string;
|
|
36
40
|
export declare function wrapToKumiko(e: unknown): KumikoError;
|
|
@@ -127,6 +127,16 @@ export class BatchRollback extends Error {
|
|
|
127
127
|
this.name = "BatchRollback";
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
+
// Thrown inside a nested write's savepoint so the driver rolls the savepoint back;
|
|
131
|
+
// the caller unwraps it into the original failure result.
|
|
132
|
+
export class NestedWriteRollback extends Error {
|
|
133
|
+
failure;
|
|
134
|
+
constructor(failure) {
|
|
135
|
+
super("nested write rollback");
|
|
136
|
+
this.failure = failure;
|
|
137
|
+
this.name = "NestedWriteRollback";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
130
140
|
export function resolveType(type) {
|
|
131
141
|
return typeof type === "string" ? type : type.name;
|
|
132
142
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { runWithWriteOrigin } from "../api/request-context.js";
|
|
2
2
|
import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
|
|
3
3
|
import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
|
|
4
|
-
import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
|
|
4
|
+
import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
|
|
5
5
|
import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-sync-topics.js";
|
|
6
6
|
import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
|
|
7
7
|
import { runBatch, unwrapSingle } from "./dispatch-batch.js";
|
|
@@ -102,14 +102,14 @@ export function createDispatcher(registry, context, options = {}) {
|
|
|
102
102
|
async write(typeOrRef, payload, user, requestId) {
|
|
103
103
|
const type = resolveType(typeOrRef);
|
|
104
104
|
// Idempotency handled inside runBatch (caches BatchResult under requestId).
|
|
105
|
-
const batchResult = await runBatch(ctx, [{ type, payload }], user, requestId);
|
|
105
|
+
const batchResult = await withEscapeHatchAuditScope(() => runBatch(ctx, [{ type, payload }], user, requestId));
|
|
106
106
|
return unwrapSingle(batchResult);
|
|
107
107
|
},
|
|
108
|
-
batch: (commands, user, requestId) => runBatch(ctx, commands, user, requestId),
|
|
108
|
+
batch: (commands, user, requestId) => withEscapeHatchAuditScope(() => runBatch(ctx, commands, user, requestId)),
|
|
109
109
|
query: (typeOrRef, payload, user) => {
|
|
110
110
|
const type = resolveType(typeOrRef);
|
|
111
111
|
const origin = rootWriteOrigin(registry, type, user);
|
|
112
|
-
return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
|
|
112
|
+
return withEscapeHatchAuditScope(() => runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin)));
|
|
113
113
|
},
|
|
114
114
|
stream: (typeOrRef, payload, user, options) => {
|
|
115
115
|
const type = resolveType(typeOrRef);
|
|
@@ -117,7 +117,7 @@ export function createDispatcher(registry, context, options = {}) {
|
|
|
117
117
|
},
|
|
118
118
|
async command(typeOrRef, payload, user) {
|
|
119
119
|
const type = resolveType(typeOrRef);
|
|
120
|
-
const batchResult = await runBatch(ctx, [{ type, payload }], user);
|
|
120
|
+
const batchResult = await withEscapeHatchAuditScope(() => runBatch(ctx, [{ type, payload }], user));
|
|
121
121
|
const result = unwrapSingle(batchResult);
|
|
122
122
|
if (!result.isSuccess) {
|
|
123
123
|
throw reraiseAsKumikoError(result.error);
|
|
@@ -129,12 +129,12 @@ export function createDispatcher(registry, context, options = {}) {
|
|
|
129
129
|
};
|
|
130
130
|
dispatcherInternals.set(dispatcher, {
|
|
131
131
|
writeWithOrigin: async (type, payload, user, inheritedOrigin) => {
|
|
132
|
-
const batchResult = await runBatch(ctx, [{ type, payload }], user, undefined, inheritedOrigin);
|
|
132
|
+
const batchResult = await withEscapeHatchAuditScope(() => runBatch(ctx, [{ type, payload }], user, undefined, inheritedOrigin));
|
|
133
133
|
return unwrapSingle(batchResult);
|
|
134
134
|
},
|
|
135
135
|
queryWithOrigin: (type, payload, user, inheritedOrigin) => {
|
|
136
136
|
const origin = effectiveWriteOrigin(rootWriteOrigin(registry, type, user), inheritedOrigin);
|
|
137
|
-
return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
|
|
137
|
+
return withEscapeHatchAuditScope(() => runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin)));
|
|
138
138
|
},
|
|
139
139
|
});
|
|
140
140
|
return dispatcher;
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { EscapeHatchReportWindow } from "../observability/escape-hatch-report.js";
|
|
2
|
-
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
|
|
2
|
+
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.js";
|
|
3
3
|
export type { AppendDomainEventCoreDeps } from "./append-event-core.js";
|
|
4
4
|
export { appendDomainEventCore } from "./append-event-core.js";
|
|
5
5
|
export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver.js";
|
package/dist/pipeline/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
|
|
1
|
+
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.js";
|
|
2
2
|
export { appendDomainEventCore } from "./append-event-core.js";
|
|
3
3
|
export { resolveAuthClaims } from "./auth-claims-resolver.js";
|
|
4
4
|
export { createCascadeDeleteHook } from "./cascade-handler.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { hasAccess } from "../engine/access.js";
|
|
2
|
+
import { tenantOverrideDenied } from "../engine/cross-tenant.js";
|
|
2
3
|
import { checkWriteFieldRoles } from "../engine/field-access.js";
|
|
3
4
|
import { runValidation } from "../engine/validation.js";
|
|
4
5
|
import { AccessDeniedError, FrameworkReasons, isKumikoError, ValidationError, validationErrorFromZod, writeFailure, } from "../errors/index.js";
|
|
@@ -81,6 +82,9 @@ async function rejectBeforeParse(ctx, handler, type, user, mode) {
|
|
|
81
82
|
return undefined;
|
|
82
83
|
}
|
|
83
84
|
async function rejectAfterParse(ctx, handler, type, user, payload, mode) {
|
|
85
|
+
const overrideDenied = tenantOverrideDenied(user, payload);
|
|
86
|
+
if (overrideDenied)
|
|
87
|
+
return writeFailure(overrideDenied);
|
|
84
88
|
if (mode === "charge") {
|
|
85
89
|
const rateLimited = await rejectPayloadRateLimited(ctx, handler, type, user, payload);
|
|
86
90
|
if (rateLimited)
|
package/dist/ui-types/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefin
|
|
|
9
9
|
export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.js";
|
|
10
10
|
export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
|
|
11
11
|
export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.js";
|
|
12
|
-
export type { ActionFormFooterAction, ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardChartTone, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelGate, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardTimeRangeDefinition, DashboardValueFormat, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
|
|
12
|
+
export type { ActionFormFooterAction, ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardChartTone, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelGate, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardTimeRangeDefinition, DashboardValueFormat, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, EntityWriteExcludedFields, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
|
|
13
13
|
export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.js";
|
|
14
14
|
export type { TargetRef } from "../engine/types/target-ref.js";
|
|
15
15
|
export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.356.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -210,8 +210,8 @@
|
|
|
210
210
|
"./package.json": "./package.json"
|
|
211
211
|
},
|
|
212
212
|
"dependencies": {
|
|
213
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
214
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
213
|
+
"@cosmicdrift/kumiko-http": "0.356.0",
|
|
214
|
+
"@cosmicdrift/kumiko-types": "0.356.0",
|
|
215
215
|
"bullmq": "^5.76.7",
|
|
216
216
|
"bun-types": "^1.3.13",
|
|
217
217
|
"hono": "^4.13.1",
|
|
@@ -227,7 +227,7 @@
|
|
|
227
227
|
"zod": "^4.4.3"
|
|
228
228
|
},
|
|
229
229
|
"devDependencies": {
|
|
230
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
230
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.356.0",
|
|
231
231
|
"bun-types": "^1.3.13",
|
|
232
232
|
"pino-pretty": "^13.1.3"
|
|
233
233
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,46 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.356.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Executor create accepts streamTenantId; ctx.forTenant(tenantId) for gated cross-tenant writes",
|
|
6
|
+
"detail": "createEventStoreExecutor().create takes { streamTenantId } (stream, event tenant, row tenant, PII key and cache invalidation follow it, the acting user stays the operator). The override is accepted on a system-mode db and also on a tenant-mode db bound to exactly that stream tenant. New handler-context method ctx.forTenant(tenantId) returns { db, streamTenantId }: a tenant-mode db bound to the target tenant plus the stream override to pass to the executor, so a handler needs no unsafeRaw escape hatch for cross-tenant writes. A foreign tenant is gated to SystemAdmin and system identities (AccessDeniedError with reason tenant_override_requires_system_admin otherwise); the caller's own tenant returns streamTenantId undefined. It is not offered to r.systemScope() handlers. The bundled cross-tenant handlers (tenant removeMember/updateMemberRoles, tier setTenantTier, custom-fields system fields, template-resolver upserts/set, managed-pages set, compliance-profiles setProfile) no longer rewrite event.user.tenantId; the event actor is the operator. tenant cancelPendingInvitation takes an optional streamTenantId."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.356.0",
|
|
10
|
+
"type": "breaking",
|
|
11
|
+
"title": "Job ctx.queryAs is gated like ctx.writeAs",
|
|
12
|
+
"migration": "Jobs calling ctx.queryAs with createSystemUser(...) or any identity other than the job's own caller now fail with AccessDeniedError (system_identity_switch_denied / identity_switch_denied). Declare r.systemScope() on the job's feature or escapeHatch: { reason } on the job, the same grant ctx.writeAs already needs. The escape-hatch guard exempts jobs by name, so this surfaces only at runtime; grep your jobs for ctx.queryAs. Known consumer sites: offlot-app channel-text generate-texts.job, campaign-calendar sync, campaign-channel-foundation seed-starter-channels."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.355.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "Entity events are versionable: eventVersion + eventMigrations on r.entity",
|
|
18
|
+
"detail": "Entities can declare eventVersion and row-shaped eventMigrations; lifecycle events are stamped with the version and upcast on rebuild and aggregate load. A version bump invalidates entity snapshots."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.355.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "excludeFields per verb reaches the renderer and the agent manifest",
|
|
24
|
+
"detail": "WriteHandlerDef carries excludedFields; entityEdit screens get writeExcludedFields per verb. The edit form shows them read-only and keeps them out of the payload, create hides them, and the agent manifest lists them per handler."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.355.0",
|
|
28
|
+
"type": "breaking",
|
|
29
|
+
"title": "Identity and tenant switches are explicitly gated; escape-hatch audit is durable",
|
|
30
|
+
"migration": "Job ctx.writeAs now needs r.systemScope() on the feature or escapeHatch: { reason } on the job; use ctx.write when the job acts as its own system user. A payload tenantIdOverride is refused by the dispatcher for non-SystemAdmin callers (handlers no longer call crossTenantOverrideDenied, which is removed; use mayOverrideTenant for other cross-tenant fields). Entity convention handlers with a cross-tenant escapeHatch now keep the operator as actor in the row stream (executor option streamTenantId, system-mode db only). createEscapeHatchReporter requires log; pass the runtime logger and share one report window per runtime. EscapeHatchUseEvent and the audit sink payload gain an optional caller. Call flushEscapeHatchAudits() before closing the database on shutdown."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.355.0",
|
|
34
|
+
"type": "fix",
|
|
35
|
+
"title": "A failed nested ctx.write is rolled back to a savepoint instead of leaking its event and projection into the outer commit",
|
|
36
|
+
"detail": "A nested `ctx.write` / `ctx.writeAs` (and workflow `call-feature`) now runs in a savepoint of the outer transaction: when the inner write fails after its event and projection were written, for example because an in-transaction hook throws, the inner change is rolled back even if the outer handler swallows the failure result and commits, and a SQL error in the inner write no longer poisons the outer transaction. The inner write's afterCommit hooks only run when it succeeded."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.355.0",
|
|
40
|
+
"type": "fix",
|
|
41
|
+
"title": "Unknown record ids show a localized empty state; an undecryptable encrypted field reads null instead of failing the whole list",
|
|
42
|
+
"detail": "Edit and detail screens show a localized empty state with a way back to the list for an unknown or deleted record id instead of a hard-coded English banner, and an entity list or detail no longer fails with a 500 when one row's encrypted field cannot be decrypted: that field reads null, the rest of the row and list are served, and the failure is logged at error level with entity, row id and field"
|
|
43
|
+
},
|
|
2
44
|
{
|
|
3
45
|
"version": "0.354.1",
|
|
4
46
|
"type": "fix",
|