@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
|
@@ -11,7 +11,7 @@ import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline
|
|
|
11
11
|
import { generateId } from "../utils/index.js";
|
|
12
12
|
import { applyEntityEvent } from "./apply-entity-event.js";
|
|
13
13
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
14
|
-
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
14
|
+
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
15
15
|
import { projectionRegistryOf } from "./projection-registry-binding.js";
|
|
16
16
|
import { runInSavepointIfSupported } from "./query.js";
|
|
17
17
|
import { assertPersonalDataWrite, tableNameOf } from "./tenant-db.js";
|
|
@@ -118,12 +118,43 @@ async function runPreSave(preSave, changes, previous, isNew, entityName, action,
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
export function createWriteVerbs(ctx) {
|
|
121
|
-
const { table, entity, entityName, entityCache, softDelete, streamTenantFor, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
|
|
121
|
+
const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, streamTenantCreateFailure, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
|
|
122
122
|
const entityInstructionFieldNames = instructionFieldNames(entity);
|
|
123
|
+
const eventVersion = entity.eventVersion ?? 1;
|
|
123
124
|
let insertSchema;
|
|
124
125
|
let updateSchema;
|
|
125
126
|
const createOutputSchema = () => (insertSchema ??= buildInsertSchema(entity));
|
|
126
127
|
const updateOutputSchema = () => (updateSchema ??= buildUpdateSchema(entity));
|
|
128
|
+
// Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
|
|
129
|
+
// selectMany(runner, ...) did not — any caller could un-delete a foreign
|
|
130
|
+
// tenant's row by id. "system"-mode dbs (r.systemScope() / escapeHatch
|
|
131
|
+
// handlers) still read unfiltered. No isDeleted filter in the read: restore
|
|
132
|
+
// targets exactly the soft-deleted row.
|
|
133
|
+
async function loadSoftDeletedRow(db, id, streamTenantId) {
|
|
134
|
+
const row = await db.fetchOne(table, { id });
|
|
135
|
+
if (!row)
|
|
136
|
+
return { failure: writeFailure(new NotFoundError(entityName, id)) };
|
|
137
|
+
const streamTenantMismatch = streamTenantOverrideFailure(db, row, streamTenantId);
|
|
138
|
+
if (streamTenantMismatch)
|
|
139
|
+
return { failure: streamTenantMismatch };
|
|
140
|
+
if (!row["isDeleted"]) {
|
|
141
|
+
return {
|
|
142
|
+
failure: writeFailure(new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" })),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return { row: row };
|
|
146
|
+
}
|
|
147
|
+
async function expectedStreamVersion(db, id, streamTenant, expect) {
|
|
148
|
+
const snapshot = await loadExpectSnapshot(db, id, streamTenant, Object.keys(expect));
|
|
149
|
+
const row = snapshot.row;
|
|
150
|
+
if (!row)
|
|
151
|
+
return writeFailure(new PreconditionFailedError({ entityId: id, field: "id" }));
|
|
152
|
+
const mismatch = Object.entries(expect).find(([key, expected]) => row[key] !== expected);
|
|
153
|
+
if (mismatch) {
|
|
154
|
+
return writeFailure(new PreconditionFailedError({ entityId: id, field: mismatch[0] }));
|
|
155
|
+
}
|
|
156
|
+
return snapshot.streamVersion;
|
|
157
|
+
}
|
|
127
158
|
return {
|
|
128
159
|
async create(payload, user, db, options) {
|
|
129
160
|
if (isForeignTenantOnGlobalEntity(entity, payload["tenantId"])) {
|
|
@@ -132,6 +163,10 @@ export function createWriteVerbs(ctx) {
|
|
|
132
163
|
"SYSTEM_TENANT_ID or omitted.",
|
|
133
164
|
});
|
|
134
165
|
}
|
|
166
|
+
const streamTenantId = options?.streamTenantId;
|
|
167
|
+
const streamTenantMismatch = streamTenantCreateFailure(db, payload, streamTenantId);
|
|
168
|
+
if (streamTenantMismatch)
|
|
169
|
+
return streamTenantMismatch;
|
|
135
170
|
const runner = tenantDbRunner(db);
|
|
136
171
|
// Respect an explicit id in the payload (seed pattern, SCIM import). Without
|
|
137
172
|
// one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
|
|
@@ -148,7 +183,7 @@ export function createWriteVerbs(ctx) {
|
|
|
148
183
|
assertInstructionFieldWriteAllowed(entityName, "create", Object.keys(data), entityInstructionFieldNames);
|
|
149
184
|
// H.2 — entity-level write-ownership on create. No oldRow exists, so
|
|
150
185
|
// only the new row is checked. No Straddle concern for creates.
|
|
151
|
-
if (!userCanCreateFieldRow(user, entity.access?.write, data)) {
|
|
186
|
+
if (!userCanCreateFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, data)) {
|
|
152
187
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
153
188
|
i18nKey: "errors.ownershipDenied",
|
|
154
189
|
details: { scope: "entity", entityName, action: "create", userId: user.id },
|
|
@@ -164,7 +199,7 @@ export function createWriteVerbs(ctx) {
|
|
|
164
199
|
// field is still evaluated against the full post-hook row (`data`) —
|
|
165
200
|
// an ownership rule can reference a column only a hook populates
|
|
166
201
|
// (kumiko-framework#1672).
|
|
167
|
-
const fieldDeniedCreate = checkWriteFieldOwnership(entity, applyDefaults(payloadWithoutId), user, undefined, data);
|
|
202
|
+
const fieldDeniedCreate = checkWriteFieldOwnership(entity, applyDefaults(payloadWithoutId), ownershipSubject(user, streamTenantId), undefined, data);
|
|
168
203
|
if (fieldDeniedCreate) {
|
|
169
204
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
170
205
|
i18nKey: "errors.ownershipDenied",
|
|
@@ -188,6 +223,7 @@ export function createWriteVerbs(ctx) {
|
|
|
188
223
|
const flatCreateData = flattenCompoundTypes(data, entity);
|
|
189
224
|
const flatData = await encryptForStorage(flatCreateData, user, {
|
|
190
225
|
subjectSource: { ...flatCreateData, id: aggregateId },
|
|
226
|
+
keyTenantId: streamTenantId,
|
|
191
227
|
});
|
|
192
228
|
// 1. Append event (same TX as the projection write — both must succeed
|
|
193
229
|
// or both roll back; the dispatcher wraps both in one transaction).
|
|
@@ -215,9 +251,10 @@ export function createWriteVerbs(ctx) {
|
|
|
215
251
|
event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
|
|
216
252
|
aggregateId,
|
|
217
253
|
aggregateType: entityName,
|
|
218
|
-
tenantId: streamTenantFor(user),
|
|
254
|
+
tenantId: streamTenantFor(user, streamTenantId),
|
|
219
255
|
expectedVersion: 0,
|
|
220
256
|
type: entityEventName(entityName, "created"),
|
|
257
|
+
eventVersion,
|
|
221
258
|
payload: flatData,
|
|
222
259
|
metadata: buildEventMetadata(user),
|
|
223
260
|
}));
|
|
@@ -226,7 +263,7 @@ export function createWriteVerbs(ctx) {
|
|
|
226
263
|
if (e instanceof EventStoreVersionConflict) {
|
|
227
264
|
let currentVersion = -1;
|
|
228
265
|
try {
|
|
229
|
-
currentVersion = await getStreamVersion(runner, aggregateId, streamTenantFor(user));
|
|
266
|
+
currentVersion = await getStreamVersion(runner, aggregateId, streamTenantFor(user, streamTenantId));
|
|
230
267
|
}
|
|
231
268
|
catch {
|
|
232
269
|
// Lookup failure — keep the sentinel.
|
|
@@ -276,7 +313,7 @@ export function createWriteVerbs(ctx) {
|
|
|
276
313
|
// Compound-Types in einem Pass.
|
|
277
314
|
const projection = await decryptForRead(rehydrateCompoundTypes(row, entity));
|
|
278
315
|
if (entityCache && entityName) {
|
|
279
|
-
await entityCache.del(user.tenantId, entityName, aggregateId);
|
|
316
|
+
await entityCache.del(streamTenantId ?? user.tenantId, entityName, aggregateId);
|
|
280
317
|
}
|
|
281
318
|
// The echoed event is the one projections see (and the dispatcher later
|
|
282
319
|
// skips), so build it before projecting.
|
|
@@ -309,6 +346,10 @@ export function createWriteVerbs(ctx) {
|
|
|
309
346
|
const previous = await loadById(payload.id, db);
|
|
310
347
|
if (!previous)
|
|
311
348
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
349
|
+
const streamTenantId = updateOptions?.streamTenantId;
|
|
350
|
+
const streamTenantMismatch = streamTenantOverrideFailure(db, previous, streamTenantId);
|
|
351
|
+
if (streamTenantMismatch)
|
|
352
|
+
return streamTenantMismatch;
|
|
312
353
|
const preSaveResult = await runPreSave(updateOptions?.preSave, payload.changes, previous, false, entityName, "update", updateOutputSchema);
|
|
313
354
|
if ("failure" in preSaveResult)
|
|
314
355
|
return preSaveResult.failure;
|
|
@@ -322,7 +363,7 @@ export function createWriteVerbs(ctx) {
|
|
|
322
363
|
// prevents the attack where role A passes old, role B passes new and
|
|
323
364
|
// aggregation would wrongly allow a row-grab.
|
|
324
365
|
const mergedNew = { ...previous, ...changes };
|
|
325
|
-
if (!userCanWriteFieldRow(user, entity.access?.write, previous, mergedNew)) {
|
|
366
|
+
if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, previous, mergedNew)) {
|
|
326
367
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
327
368
|
i18nKey: "errors.ownershipDenied",
|
|
328
369
|
details: {
|
|
@@ -350,7 +391,7 @@ export function createWriteVerbs(ctx) {
|
|
|
350
391
|
// for a checked field is still evaluated against the full post-hook
|
|
351
392
|
// row (`changes` merged onto `previous`) — an ownership rule can
|
|
352
393
|
// reference a column only a hook populates (kumiko-framework#1672).
|
|
353
|
-
const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, user, previous, changes);
|
|
394
|
+
const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, ownershipSubject(user, streamTenantId), previous, changes);
|
|
354
395
|
if (fieldDeniedUpdate) {
|
|
355
396
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
356
397
|
i18nKey: "errors.ownershipDenied",
|
|
@@ -367,7 +408,8 @@ export function createWriteVerbs(ctx) {
|
|
|
367
408
|
const unavailableOnUpdate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
|
|
368
409
|
if (unavailableOnUpdate)
|
|
369
410
|
return unavailableOnUpdate;
|
|
370
|
-
|
|
411
|
+
const streamTenant = streamTenantFor(user, streamTenantId);
|
|
412
|
+
await assertStreamWritable(db, payload.id, streamTenant);
|
|
371
413
|
// Stream-version is authoritative, not row.version. `ctx.appendEvent`
|
|
372
414
|
// can bump the stream between CRUD writes (domain event on the same
|
|
373
415
|
// aggregate); a stale row.version here would make the next CRUD write
|
|
@@ -376,22 +418,12 @@ export function createWriteVerbs(ctx) {
|
|
|
376
418
|
//
|
|
377
419
|
// With `expect:` the version comes from loadExpectSnapshot's combined
|
|
378
420
|
// query instead; see its comment for why.
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
const mismatch = Object.entries(updateOptions.expect).find(([key, expected]) => snapshot.row?.[key] !== expected);
|
|
387
|
-
if (mismatch) {
|
|
388
|
-
return writeFailure(new PreconditionFailedError({ entityId: payload.id, field: mismatch[0] }));
|
|
389
|
-
}
|
|
390
|
-
currentVersion = snapshot.streamVersion;
|
|
391
|
-
}
|
|
392
|
-
else {
|
|
393
|
-
currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
|
|
394
|
-
}
|
|
421
|
+
const expect = updateOptions?.expect;
|
|
422
|
+
const currentVersion = expect
|
|
423
|
+
? await expectedStreamVersion(db, payload.id, streamTenant, expect)
|
|
424
|
+
: await getStreamVersion(runner, String(payload.id), streamTenant);
|
|
425
|
+
if (typeof currentVersion !== "number")
|
|
426
|
+
return currentVersion;
|
|
395
427
|
if (!updateOptions?.skipOptimisticLock) {
|
|
396
428
|
if (payload.version === undefined) {
|
|
397
429
|
return writeFailure(new FrameworkVersionConflict({
|
|
@@ -419,6 +451,7 @@ export function createWriteVerbs(ctx) {
|
|
|
419
451
|
const flatChanges = await encryptForStorage(flatChangesPlain, user, {
|
|
420
452
|
onlyKeys: Object.keys(submittedChanges),
|
|
421
453
|
subjectSource: mergedNew,
|
|
454
|
+
keyTenantId: streamTenantId,
|
|
422
455
|
});
|
|
423
456
|
// The event payload carries BOTH `changes` (what the user asked for) AND
|
|
424
457
|
// `previous` (the pre-update row). Cross-aggregate projections need the
|
|
@@ -429,16 +462,19 @@ export function createWriteVerbs(ctx) {
|
|
|
429
462
|
// re-encrypt it before it's persisted so plaintext of pii/encrypted
|
|
430
463
|
// fields doesn't land in the immutable log (flatChanges is already
|
|
431
464
|
// ciphertext from encryptForStorage above).
|
|
432
|
-
const encryptedPrevious = await encryptForStorage(previous, user
|
|
465
|
+
const encryptedPrevious = await encryptForStorage(previous, user, {
|
|
466
|
+
keyTenantId: streamTenantId,
|
|
467
|
+
});
|
|
433
468
|
// Savepoint-scoped — see the create() append() above for why:
|
|
434
469
|
// confines a losing writer's unique-violation to a nested scope
|
|
435
470
|
// instead of poisoning the whole outer transaction.
|
|
436
471
|
const event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
|
|
437
472
|
aggregateId: String(payload.id),
|
|
438
473
|
aggregateType: entityName,
|
|
439
|
-
tenantId:
|
|
474
|
+
tenantId: streamTenant,
|
|
440
475
|
expectedVersion: currentVersion,
|
|
441
476
|
type: entityEventName(entityName, "updated"),
|
|
477
|
+
eventVersion,
|
|
442
478
|
payload: {
|
|
443
479
|
changes: flatChanges,
|
|
444
480
|
previous: encryptedPrevious,
|
|
@@ -472,7 +508,7 @@ export function createWriteVerbs(ctx) {
|
|
|
472
508
|
const row = result.row;
|
|
473
509
|
const data = await decryptForRead(rehydrateCompoundTypes(row, entity));
|
|
474
510
|
if (entityCache && entityName) {
|
|
475
|
-
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
511
|
+
await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
|
|
476
512
|
}
|
|
477
513
|
const echoEvent = {
|
|
478
514
|
...event,
|
|
@@ -514,7 +550,7 @@ export function createWriteVerbs(ctx) {
|
|
|
514
550
|
throw e;
|
|
515
551
|
}
|
|
516
552
|
},
|
|
517
|
-
async delete(payload, user, db) {
|
|
553
|
+
async delete(payload, user, db, options) {
|
|
518
554
|
if (isIrreversibleEntityVerb("delete", entity)) {
|
|
519
555
|
assertIrreversibleOperationAllowed(`${entityName}.delete (entity has no softDelete)`);
|
|
520
556
|
}
|
|
@@ -522,11 +558,15 @@ export function createWriteVerbs(ctx) {
|
|
|
522
558
|
const existing = await loadById(payload.id, db);
|
|
523
559
|
if (!existing)
|
|
524
560
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
561
|
+
const streamTenantId = options?.streamTenantId;
|
|
562
|
+
const streamTenantMismatch = streamTenantOverrideFailure(db, existing, streamTenantId);
|
|
563
|
+
if (streamTenantMismatch)
|
|
564
|
+
return streamTenantMismatch;
|
|
525
565
|
// H.2 — entity-level write-ownership on delete. Only the pre-delete
|
|
526
566
|
// row matters (there's no "new" row for a delete); passing existing
|
|
527
567
|
// twice to userCanWriteFieldRow makes the Straddle check trivial
|
|
528
568
|
// (same row on both sides) while keeping the multi-role-atomic shape.
|
|
529
|
-
if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
|
|
569
|
+
if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, existing, existing)) {
|
|
530
570
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
531
571
|
i18nKey: "errors.ownershipDenied",
|
|
532
572
|
details: {
|
|
@@ -538,9 +578,9 @@ export function createWriteVerbs(ctx) {
|
|
|
538
578
|
},
|
|
539
579
|
}));
|
|
540
580
|
}
|
|
541
|
-
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
581
|
+
await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
|
|
542
582
|
// Stream-version authoritative (see update() for rationale).
|
|
543
|
-
const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
|
|
583
|
+
const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
|
|
544
584
|
// Deletes carry the full pre-delete row as `previous`. That's what
|
|
545
585
|
// projections and downstream consumers need to reverse any aggregates —
|
|
546
586
|
// a `{}`-payload delete would make cross-aggregate projections impossible
|
|
@@ -552,10 +592,13 @@ export function createWriteVerbs(ctx) {
|
|
|
552
592
|
event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
|
|
553
593
|
aggregateId: String(payload.id),
|
|
554
594
|
aggregateType: entityName,
|
|
555
|
-
tenantId: streamTenantFor(user),
|
|
595
|
+
tenantId: streamTenantFor(user, streamTenantId),
|
|
556
596
|
expectedVersion: currentVersion,
|
|
557
597
|
type: entityEventName(entityName, "deleted"),
|
|
558
|
-
|
|
598
|
+
eventVersion,
|
|
599
|
+
payload: {
|
|
600
|
+
previous: await encryptForStorage(existing, user, { keyTenantId: streamTenantId }),
|
|
601
|
+
},
|
|
559
602
|
metadata: buildEventMetadata(user),
|
|
560
603
|
}));
|
|
561
604
|
}
|
|
@@ -578,7 +621,7 @@ export function createWriteVerbs(ctx) {
|
|
|
578
621
|
return writeFailure(new InternalError({ message: "projection delete: applyEntityEvent skipped" }));
|
|
579
622
|
}
|
|
580
623
|
if (entityCache && entityName) {
|
|
581
|
-
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
624
|
+
await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
|
|
582
625
|
}
|
|
583
626
|
const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
|
|
584
627
|
await projectEntityEvent(db, runner, echoEvent);
|
|
@@ -597,15 +640,19 @@ export function createWriteVerbs(ctx) {
|
|
|
597
640
|
// hard-deletes the row regardless of softDelete — and, being an auto-verb,
|
|
598
641
|
// the erasure replays on rebuild (created → forgotten → row gone). Loads
|
|
599
642
|
// without the isDeleted filter so trashed (soft-deleted) rows are erased too.
|
|
600
|
-
async forget(payload, user, db) {
|
|
643
|
+
async forget(payload, user, db, options) {
|
|
601
644
|
assertIrreversibleOperationAllowed(`${entityName}.forget`);
|
|
602
645
|
const runner = tenantDbRunner(db);
|
|
603
646
|
const raw = await db.fetchOne(table, { id: payload.id });
|
|
604
647
|
if (!raw)
|
|
605
648
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
649
|
+
const streamTenantId = options?.streamTenantId;
|
|
650
|
+
const streamTenantMismatch = streamTenantOverrideFailure(db, raw, streamTenantId);
|
|
651
|
+
if (streamTenantMismatch)
|
|
652
|
+
return streamTenantMismatch;
|
|
606
653
|
const existing = await decryptForRead(rehydrateCompoundTypes(raw, entity));
|
|
607
654
|
if (!isFrameworkSystemUser(user) &&
|
|
608
|
-
!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
|
|
655
|
+
!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, existing, existing)) {
|
|
609
656
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
610
657
|
i18nKey: "errors.ownershipDenied",
|
|
611
658
|
details: {
|
|
@@ -617,19 +664,22 @@ export function createWriteVerbs(ctx) {
|
|
|
617
664
|
},
|
|
618
665
|
}));
|
|
619
666
|
}
|
|
620
|
-
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
621
|
-
const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
|
|
667
|
+
await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
|
|
668
|
+
const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
|
|
622
669
|
let event;
|
|
623
670
|
try {
|
|
624
671
|
event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
|
|
625
672
|
aggregateId: String(payload.id),
|
|
626
673
|
aggregateType: entityName,
|
|
627
|
-
tenantId: streamTenantFor(user),
|
|
674
|
+
tenantId: streamTenantFor(user, streamTenantId),
|
|
628
675
|
expectedVersion: currentVersion,
|
|
629
676
|
type: entityEventName(entityName, "forgotten"),
|
|
677
|
+
eventVersion,
|
|
630
678
|
// Re-encrypt like delete(): `existing` came decrypted from loadById —
|
|
631
679
|
// plaintext must not land in the immutable log, least of all on forget.
|
|
632
|
-
payload: {
|
|
680
|
+
payload: {
|
|
681
|
+
previous: await encryptForStorage(existing, user, { keyTenantId: streamTenantId }),
|
|
682
|
+
},
|
|
633
683
|
metadata: buildEventMetadata(user),
|
|
634
684
|
}));
|
|
635
685
|
}
|
|
@@ -651,7 +701,7 @@ export function createWriteVerbs(ctx) {
|
|
|
651
701
|
return writeFailure(new InternalError({ message: "projection forget: applyEntityEvent skipped" }));
|
|
652
702
|
}
|
|
653
703
|
if (entityCache && entityName) {
|
|
654
|
-
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
704
|
+
await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
|
|
655
705
|
}
|
|
656
706
|
const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
|
|
657
707
|
await projectEntityEvent(db, runner, echoEvent);
|
|
@@ -666,29 +716,22 @@ export function createWriteVerbs(ctx) {
|
|
|
666
716
|
},
|
|
667
717
|
};
|
|
668
718
|
},
|
|
669
|
-
async restore(payload, user, db) {
|
|
719
|
+
async restore(payload, user, db, options) {
|
|
670
720
|
if (!softDelete) {
|
|
671
721
|
return writeFailure(new UnprocessableError("soft_delete_not_enabled", {
|
|
672
722
|
i18nKey: "errors.softDeleteNotEnabled",
|
|
673
723
|
}));
|
|
674
724
|
}
|
|
675
725
|
const runner = tenantDbRunner(db);
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
const row = await db.fetchOne(table, { id: payload.id });
|
|
682
|
-
if (!row)
|
|
683
|
-
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
684
|
-
const data = row;
|
|
685
|
-
if (!data["isDeleted"]) {
|
|
686
|
-
return writeFailure(new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" }));
|
|
687
|
-
}
|
|
726
|
+
const streamTenantId = options?.streamTenantId;
|
|
727
|
+
const target = await loadSoftDeletedRow(db, payload.id, streamTenantId);
|
|
728
|
+
if ("failure" in target)
|
|
729
|
+
return target.failure;
|
|
730
|
+
const data = target.row;
|
|
688
731
|
// H.2 — entity-level write-ownership on restore. Same shape as delete:
|
|
689
732
|
// only the stored row matters. Stored row carries pre-soft-delete
|
|
690
733
|
// teamId/... fields, so the ownership predicate still applies cleanly.
|
|
691
|
-
if (!userCanWriteFieldRow(user, entity.access?.write, data, data)) {
|
|
734
|
+
if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, data, data)) {
|
|
692
735
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
693
736
|
i18nKey: "errors.ownershipDenied",
|
|
694
737
|
details: {
|
|
@@ -700,9 +743,9 @@ export function createWriteVerbs(ctx) {
|
|
|
700
743
|
},
|
|
701
744
|
}));
|
|
702
745
|
}
|
|
703
|
-
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
746
|
+
await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
|
|
704
747
|
// Stream-version authoritative (see update() for rationale).
|
|
705
|
-
const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
|
|
748
|
+
const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
|
|
706
749
|
// Restore carries the soft-deleted snapshot as `previous` — mirror of
|
|
707
750
|
// delete for symmetry. Projections that decremented on delete use
|
|
708
751
|
// `previous` to re-increment on restore without re-querying the entity
|
|
@@ -713,9 +756,10 @@ export function createWriteVerbs(ctx) {
|
|
|
713
756
|
event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
|
|
714
757
|
aggregateId: String(payload.id),
|
|
715
758
|
aggregateType: entityName,
|
|
716
|
-
tenantId: streamTenantFor(user),
|
|
759
|
+
tenantId: streamTenantFor(user, streamTenantId),
|
|
717
760
|
expectedVersion: currentVersion,
|
|
718
761
|
type: entityEventName(entityName, "restored"),
|
|
762
|
+
eventVersion,
|
|
719
763
|
payload: { previous: data },
|
|
720
764
|
metadata: buildEventMetadata(user),
|
|
721
765
|
}));
|
|
@@ -752,7 +796,7 @@ export function createWriteVerbs(ctx) {
|
|
|
752
796
|
}
|
|
753
797
|
const restored = restoreResult.row;
|
|
754
798
|
if (entityCache && entityName) {
|
|
755
|
-
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
799
|
+
await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
|
|
756
800
|
}
|
|
757
801
|
// Read-side auto-convert for compound types, same as update/list.
|
|
758
802
|
// decryptForRead matches create/update/list/detail: the caller-facing
|
|
@@ -273,15 +273,42 @@ function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreen
|
|
|
273
273
|
return projectConfigEditScreen(screen, encryptedConfigKeyQns);
|
|
274
274
|
if (screen.type === "projectionList")
|
|
275
275
|
return projectProjectionListScreen(screen, registry);
|
|
276
|
-
if (screen.type === "
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
276
|
+
if (screen.type === "entityEdit") {
|
|
277
|
+
const { writeExcludedFields: _authoredExclusions, ...authoredScreen } = screen;
|
|
278
|
+
return {
|
|
279
|
+
...authoredScreen,
|
|
280
|
+
urlPrefillFields: sortedUrlPrefillFields(urlPrefillFieldsByScreenQn.get(qualifyEntityName(featureName, "screen", shortId))),
|
|
281
|
+
...entityEditWriteExcludedFields(featureName, screen.entity, registry),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (screen.type === "actionForm" || screen.type === "secretMint") {
|
|
285
|
+
return {
|
|
286
|
+
...screen,
|
|
287
|
+
urlPrefillFields: sortedUrlPrefillFields(urlPrefillFieldsByScreenQn.get(qualifyEntityName(featureName, "screen", shortId))),
|
|
288
|
+
};
|
|
281
289
|
}
|
|
282
290
|
return screen;
|
|
283
291
|
});
|
|
284
292
|
}
|
|
293
|
+
function sortedUrlPrefillFields(declared) {
|
|
294
|
+
return declared === undefined ? [] : [...declared].sort();
|
|
295
|
+
}
|
|
296
|
+
// The screen is the one thing the renderer reads, so the per-verb excludeFields
|
|
297
|
+
// of the entity's generic create/update handlers are projected onto it here.
|
|
298
|
+
function entityEditWriteExcludedFields(featureName, entityName, registry) {
|
|
299
|
+
const excludedFor = (verb) => registry.getWriteHandler(`${qualifyEntityName(featureName, QnTypes.write, entityName)}:${verb}`)
|
|
300
|
+
?.excludedFields;
|
|
301
|
+
const create = excludedFor("create");
|
|
302
|
+
const update = excludedFor("update");
|
|
303
|
+
if (create === undefined && update === undefined)
|
|
304
|
+
return {};
|
|
305
|
+
return {
|
|
306
|
+
writeExcludedFields: {
|
|
307
|
+
...(create !== undefined && { create }),
|
|
308
|
+
...(update !== undefined && { update }),
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
285
312
|
function collectEncryptedConfigKeyQns(registry) {
|
|
286
313
|
const qns = new Set();
|
|
287
314
|
for (const [featureName, feature] of registry.features) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { AccessDeniedError } from "../errors/index.js";
|
|
2
2
|
import type { SessionUser } from "./types/index.js";
|
|
3
|
-
export declare function
|
|
3
|
+
export declare function mayOverrideTenant(user: SessionUser): boolean;
|
|
4
|
+
export declare function tenantOverrideDenied(user: SessionUser, parsedPayload: unknown): AccessDeniedError | undefined;
|
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import { ROLES } from "../auth/roles.js";
|
|
2
|
+
import { AccessDeniedError, FrameworkReasons } from "../errors/index.js";
|
|
3
|
+
export function mayOverrideTenant(user) {
|
|
4
|
+
return user.roles.includes(ROLES.SystemAdmin);
|
|
5
|
+
}
|
|
6
|
+
function carriesTenantOverride(payload) {
|
|
7
|
+
return (typeof payload === "object" &&
|
|
8
|
+
payload !== null &&
|
|
9
|
+
"tenantIdOverride" in payload &&
|
|
10
|
+
payload.tenantIdOverride !== undefined);
|
|
11
|
+
}
|
|
12
|
+
// A payload-supplied target tenant (tenantIdOverride) is the cross-tenant escape hatch:
|
|
13
|
+
// hasAccess passes a handler for any TenantAdmin, so the dispatcher refuses the field
|
|
14
|
+
// for everyone but a SystemAdmin before any handler sees it. Runs on the schema-parsed
|
|
15
|
+
// payload, so only handlers that declare the field are affected.
|
|
16
|
+
export function tenantOverrideDenied(user, parsedPayload) {
|
|
17
|
+
if (!carriesTenantOverride(parsedPayload) || mayOverrideTenant(user))
|
|
13
18
|
return undefined;
|
|
14
19
|
return new AccessDeniedError({
|
|
15
|
-
|
|
16
|
-
details: { reason: "tenant_override_requires_system_admin" },
|
|
20
|
+
details: { reason: FrameworkReasons.tenantOverrideRequiresSystemAdmin },
|
|
17
21
|
});
|
|
18
22
|
}
|
|
@@ -217,15 +217,14 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
217
217
|
// actingUserFor reads the row it is about to touch anyway, so for these calls it reports the
|
|
218
218
|
// cross-tenant use per row (with that row as target) instead of dbFor reporting once up front.
|
|
219
219
|
const reportsPerTouchedRow = (ctx) => ctx.systemDb === undefined && crossTenantReason !== undefined && !entity.systemStream;
|
|
220
|
-
// The event stream
|
|
221
|
-
// event-store-executor-context.ts), not
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
const actingUserFor = async (user, id, db, ctx) => {
|
|
220
|
+
// The event stream defaults to the acting user's tenant (streamTenantFor in
|
|
221
|
+
// event-store-executor-context.ts), not the db's — an unfiltered "system"-mode db alone
|
|
222
|
+
// reads the foreign row but appends to an empty stream in the operator's own tenant,
|
|
223
|
+
// surfacing as a bogus version_conflict. The row's tenant goes to the executor as
|
|
224
|
+
// streamTenantId so stream and PII keys follow the row while the actor stays the operator.
|
|
225
|
+
const foreignRowTenantFor = async (user, id, db, ctx) => {
|
|
227
226
|
if (crossTenantReason === undefined || entity.systemStream)
|
|
228
|
-
return
|
|
227
|
+
return undefined;
|
|
229
228
|
const row = await db.fetchOne(table, { id });
|
|
230
229
|
const rowTenantId = row?.["tenantId"];
|
|
231
230
|
if (reportsPerTouchedRow(ctx)) {
|
|
@@ -235,8 +234,8 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
235
234
|
: undefined);
|
|
236
235
|
}
|
|
237
236
|
if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId)
|
|
238
|
-
return
|
|
239
|
-
return
|
|
237
|
+
return undefined;
|
|
238
|
+
return parseTenantId(rowTenantId) ?? undefined;
|
|
240
239
|
};
|
|
241
240
|
let schema;
|
|
242
241
|
let handler;
|
|
@@ -272,14 +271,15 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
272
271
|
...submitted,
|
|
273
272
|
changes: dropEmptyWriteOnlyValues(entity, submitted.changes),
|
|
274
273
|
};
|
|
275
|
-
const
|
|
274
|
+
const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
|
|
276
275
|
// skipUnchanged (#464): API-driven updates diff against the stored
|
|
277
276
|
// row so a resubmitted-but-identical field doesn't force a fresh
|
|
278
277
|
// pii/encrypted ciphertext. Direct executor.update() callers (e.g.
|
|
279
278
|
// KEK-rotation, the user-data-rights #494 backfill) don't go through
|
|
280
279
|
// this handler and keep today's always-re-encrypt behavior, which
|
|
281
280
|
// they rely on to intentionally force a fresh event/ciphertext.
|
|
282
|
-
return executor.update(payload, user, db, {
|
|
281
|
+
return executor.update(payload, event.user, db, {
|
|
282
|
+
streamTenantId,
|
|
283
283
|
skipUnchanged: true,
|
|
284
284
|
preSave: runPreSave &&
|
|
285
285
|
((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
|
|
@@ -291,8 +291,8 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
291
291
|
handler = async (event, ctx) => {
|
|
292
292
|
const db = dbFor(ctx);
|
|
293
293
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
294
|
-
const
|
|
295
|
-
return executor.delete(payload, user, db);
|
|
294
|
+
const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
|
|
295
|
+
return executor.delete(payload, event.user, db, { streamTenantId });
|
|
296
296
|
};
|
|
297
297
|
break;
|
|
298
298
|
case "restore":
|
|
@@ -300,8 +300,8 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
300
300
|
handler = async (event, ctx) => {
|
|
301
301
|
const db = dbFor(ctx);
|
|
302
302
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
303
|
-
const
|
|
304
|
-
return executor.restore(payload, user, db);
|
|
303
|
+
const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
|
|
304
|
+
return executor.restore(payload, event.user, db, { streamTenantId });
|
|
305
305
|
};
|
|
306
306
|
break;
|
|
307
307
|
default:
|
|
@@ -315,6 +315,7 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
315
315
|
access: options.access,
|
|
316
316
|
...(options.description !== undefined && { description: options.description }),
|
|
317
317
|
...(agentHints !== undefined && { agent: agentHints }),
|
|
318
|
+
...(excludedFields.length > 0 && { excludedFields }),
|
|
318
319
|
};
|
|
319
320
|
}
|
|
320
321
|
// Append read-time derived-field values (EntityDefinition.derivedFields) to
|
|
@@ -179,6 +179,8 @@ export declare function createEntity<F, const T extends EntityTenancy = "tenant"
|
|
|
179
179
|
/** Event stream lives on SYSTEM_TENANT_ID (tenant-independent aggregate, e.g.
|
|
180
180
|
* user) instead of the creator's tenant. See EntityDefinition.systemStream. */
|
|
181
181
|
readonly systemStream?: boolean;
|
|
182
|
+
readonly eventVersion?: number;
|
|
183
|
+
readonly eventMigrations?: EntityDefinition["eventMigrations"];
|
|
182
184
|
readonly searchWeight?: number;
|
|
183
185
|
readonly defaultCurrency?: string;
|
|
184
186
|
readonly transitions?: Readonly<Record<string, Readonly<Record<string, readonly string[]>>>>;
|
|
@@ -103,6 +103,7 @@ export function buildEntityHandlerMethods(state, name) {
|
|
|
103
103
|
handler: def.handler,
|
|
104
104
|
access: def.access,
|
|
105
105
|
...agentSlots(def),
|
|
106
|
+
...(def.excludedFields && { excludedFields: def.excludedFields }),
|
|
106
107
|
...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
|
|
107
108
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
108
109
|
...(def.additionalRateLimits && { additionalRateLimits: def.additionalRateLimits }),
|