@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.
Files changed (40) hide show
  1. package/dist/db/entity-field-encryption.d.ts +10 -1
  2. package/dist/db/entity-field-encryption.js +31 -2
  3. package/dist/db/event-store-executor-context.d.ts +9 -2
  4. package/dist/db/event-store-executor-context.js +45 -9
  5. package/dist/db/event-store-executor-read.js +7 -4
  6. package/dist/db/event-store-executor-write.js +105 -61
  7. package/dist/engine/build-app-schema.js +32 -5
  8. package/dist/engine/cross-tenant.d.ts +2 -1
  9. package/dist/engine/cross-tenant.js +18 -14
  10. package/dist/engine/entity-handlers.js +17 -16
  11. package/dist/engine/factories.d.ts +2 -0
  12. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  13. package/dist/engine/feature-entity-handlers.js +1 -0
  14. package/dist/engine/index.d.ts +2 -2
  15. package/dist/engine/index.js +1 -1
  16. package/dist/engine/registry-validate.d.ts +7 -1
  17. package/dist/engine/registry-validate.js +82 -2
  18. package/dist/engine/registry.js +2 -1
  19. package/dist/engine/types/index.d.ts +3 -3
  20. package/dist/errors/reasons.d.ts +1 -0
  21. package/dist/errors/reasons.js +2 -0
  22. package/dist/event-store/snapshot.d.ts +1 -0
  23. package/dist/event-store/snapshot.js +12 -3
  24. package/dist/jobs/job-runner.js +31 -17
  25. package/dist/observability/escape-hatch-report.d.ts +5 -2
  26. package/dist/observability/escape-hatch-report.js +52 -22
  27. package/dist/observability/index.d.ts +1 -1
  28. package/dist/observability/index.js +1 -1
  29. package/dist/pipeline/dispatch-query.js +4 -0
  30. package/dist/pipeline/dispatch-shared.js +62 -10
  31. package/dist/pipeline/dispatch-stream.js +4 -0
  32. package/dist/pipeline/dispatcher-utils.d.ts +4 -0
  33. package/dist/pipeline/dispatcher-utils.js +10 -0
  34. package/dist/pipeline/dispatcher.js +7 -7
  35. package/dist/pipeline/index.d.ts +1 -1
  36. package/dist/pipeline/index.js +1 -1
  37. package/dist/pipeline/pre-handler-gates.js +4 -0
  38. package/dist/ui-types/index.d.ts +1 -1
  39. package/package.json +4 -4
  40. 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
- await assertStreamWritable(db, payload.id, streamTenantFor(user));
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
- let currentVersion;
380
- if (updateOptions?.expect) {
381
- const expectKeys = Object.keys(updateOptions.expect);
382
- const snapshot = await loadExpectSnapshot(db, payload.id, streamTenantFor(user), expectKeys);
383
- if (!snapshot.row) {
384
- return writeFailure(new PreconditionFailedError({ entityId: payload.id, field: "id" }));
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: streamTenantFor(user),
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
- payload: { previous: await encryptForStorage(existing, user) },
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: { previous: await encryptForStorage(existing, user) },
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
- // Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
677
- // selectMany(runner, ...) did not — any caller could un-delete a foreign
678
- // tenant's row by id. "system"-mode dbs (r.systemScope() / escapeHatch
679
- // handlers) still read unfiltered. No isDeleted filter here: restore
680
- // targets exactly the soft-deleted row.
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 === "actionForm" ||
277
- screen.type === "secretMint" ||
278
- screen.type === "entityEdit") {
279
- const declared = urlPrefillFieldsByScreenQn.get(qualifyEntityName(featureName, "screen", shortId));
280
- return { ...screen, urlPrefillFields: declared === undefined ? [] : [...declared].sort() };
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 crossTenantOverrideDenied(user: SessionUser, tenantIdOverride: string | undefined, i18nKey: string): AccessDeniedError | undefined;
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 { AccessDeniedError } from "../errors/index.js";
2
- // A payload-supplied target tenant (tenantIdOverride) on a TenantAdmin-reachable
3
- // handler is the cross-tenant escape hatch: hasAccess passes the handler for any
4
- // TenantAdmin, so without a SystemAdmin gate a TenantAdmin could act on another
5
- // tenant. Centralizes the check the override handlers used to inline. Returns
6
- // the denial to `throw` (queries) or wrap in `writeFailure` (writes), or
7
- // undefined when allowed. The i18nKey stays per-feature so existing
8
- // translations keep resolving.
9
- export function crossTenantOverrideDenied(user, tenantIdOverride, i18nKey) {
10
- if (tenantIdOverride === undefined)
11
- return undefined;
12
- if (user.roles.includes("SystemAdmin"))
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
- i18nKey,
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 is keyed by the acting user's tenantId (streamTenantFor in
221
- // event-store-executor-context.ts), not by the db handed to the executor — an
222
- // unfiltered "system"-mode db alone reads the foreign row but appends to an
223
- // empty stream in the operator's own tenant, surfacing as a bogus
224
- // version_conflict. Rewriting the acting tenant also keeps PII envelope keys
225
- // derived from the row's tenant rather than the operator's.
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 user;
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 user;
239
- return { ...user, tenantId: rowTenantId };
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 user = await actingUserFor(event.user, payload.id, db, ctx);
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 user = await actingUserFor(event.user, payload.id, db, ctx);
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 user = await actingUserFor(event.user, payload.id, db, ctx);
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[]>>>>;
@@ -29,6 +29,7 @@ const WRITE_HANDLER_KEY_KINDS = {
29
29
  access: "modeled",
30
30
  description: "modeled",
31
31
  agent: "modeled",
32
+ excludedFields: "opaque",
32
33
  unsafeSkipTransitionGuard: "modeled",
33
34
  rateLimit: "modeled",
34
35
  additionalRateLimits: "modeled",
@@ -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 }),