@cosmicdrift/kumiko-framework 0.360.0 → 0.361.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 (73) hide show
  1. package/dist/api/server.js +11 -1
  2. package/dist/crypto/index.d.ts +1 -1
  3. package/dist/crypto/index.js +1 -1
  4. package/dist/crypto/owner-transition-rekey.d.ts +2 -0
  5. package/dist/crypto/owner-transition-rekey.js +30 -0
  6. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  7. package/dist/crypto/pii-field-encryption.js +5 -0
  8. package/dist/crypto/subject-resolver.d.ts +2 -0
  9. package/dist/crypto/subject-resolver.js +14 -1
  10. package/dist/db/apply-entity-event.js +0 -5
  11. package/dist/db/eagerload.js +2 -2
  12. package/dist/db/entity-field-encryption.js +3 -2
  13. package/dist/db/entity-table-meta.js +3 -3
  14. package/dist/db/event-store-executor-context.d.ts +1 -0
  15. package/dist/db/event-store-executor-context.js +9 -2
  16. package/dist/db/event-store-executor-write.js +31 -5
  17. package/dist/db/event-store-executor.d.ts +2 -1
  18. package/dist/db/event-store-executor.js +1 -0
  19. package/dist/db/index.d.ts +3 -3
  20. package/dist/db/index.js +2 -2
  21. package/dist/db/migrate-generator.d.ts +1 -0
  22. package/dist/db/migrate-generator.js +12 -0
  23. package/dist/db/queries/backfill-pii.d.ts +2 -0
  24. package/dist/db/queries/backfill-pii.js +120 -15
  25. package/dist/db/table-builder.d.ts +3 -1
  26. package/dist/db/table-builder.js +3 -6
  27. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  28. package/dist/engine/boot-validator/entity-handler.js +38 -1
  29. package/dist/engine/boot-validator/index.js +2 -1
  30. package/dist/engine/boot-validator/query-output-columns.js +3 -0
  31. package/dist/engine/boot-validator/screens.d.ts +1 -1
  32. package/dist/engine/boot-validator/screens.js +89 -14
  33. package/dist/engine/define-handler.js +1 -3
  34. package/dist/engine/factories.d.ts +2 -0
  35. package/dist/engine/index.d.ts +1 -1
  36. package/dist/engine/schema-builder.js +2 -2
  37. package/dist/engine/screen-helpers.d.ts +1 -0
  38. package/dist/engine/screen-helpers.js +9 -0
  39. package/dist/engine/soft-delete-cleanup.js +12 -1
  40. package/dist/engine/types/index.d.ts +1 -1
  41. package/dist/errors/classes.d.ts +7 -0
  42. package/dist/errors/classes.js +12 -0
  43. package/dist/errors/index.d.ts +1 -1
  44. package/dist/errors/index.js +1 -1
  45. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +8 -0
  46. package/dist/files/assert-file-refs-belong-to-tenant.js +50 -0
  47. package/dist/files/bind-pending-file-refs.d.ts +1 -0
  48. package/dist/files/bind-pending-file-refs.js +13 -13
  49. package/dist/files/dispose-removed-file-refs.d.ts +8 -0
  50. package/dist/files/dispose-removed-file-refs.js +51 -0
  51. package/dist/files/file-ref-entity.d.ts +1 -1
  52. package/dist/files/file-ref-entity.js +1 -1
  53. package/dist/files/file-routes.d.ts +2 -0
  54. package/dist/files/file-routes.js +28 -4
  55. package/dist/files/purge-expired-file-ref-bytes.d.ts +14 -0
  56. package/dist/files/purge-expired-file-ref-bytes.js +34 -0
  57. package/dist/files/record-write-check.d.ts +21 -0
  58. package/dist/files/record-write-check.js +64 -0
  59. package/dist/files/types.d.ts +1 -0
  60. package/dist/files/types.js +3 -0
  61. package/dist/i18n/required-surface-keys.js +10 -0
  62. package/dist/migrations/pii-event-backfill.d.ts +1 -0
  63. package/dist/migrations/pii-event-backfill.js +16 -2
  64. package/dist/pipeline/cascade-handler.js +17 -2
  65. package/dist/pipeline/dispatch-write.js +6 -6
  66. package/dist/schema-cli.js +12 -6
  67. package/dist/search/reindex-entity.js +3 -4
  68. package/dist/stack/test-stack.d.ts +3 -1
  69. package/dist/stack/test-stack.js +1 -0
  70. package/dist/ui-types/index.d.ts +2 -2
  71. package/dist/ui-types/index.js +1 -1
  72. package/package.json +4 -4
  73. package/src/changes.json +90 -0
@@ -1,5 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { ROLES } from "../auth/roles.js";
3
+ import { createTenantDb } from "../db/tenant-db.js";
3
4
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
4
5
  import { buildAppSchema } from "../engine/build-app-schema.js";
5
6
  import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
@@ -12,6 +13,7 @@ import { createFileContext } from "../files/file-handle.js";
12
13
  import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes.js";
13
14
  import { makeFileProviderResolver } from "../files/provider-resolver.js";
14
15
  import { createRecordReadCheck } from "../files/record-read-check.js";
16
+ import { createRecordWriteCheck } from "../files/record-write-check.js";
15
17
  import { createFallbackLogger } from "../logging/index.js";
16
18
  import { createNoopProvider, DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, registerStandardMetrics, wrapRedisClient, } from "../observability/index.js";
17
19
  import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership.js";
@@ -687,11 +689,19 @@ export function buildServer(options) {
687
689
  if (!fileDb)
688
690
  throw new Error("file routes require db in context");
689
691
  const routeOptions = readFilesRouteOptions(options.registry);
692
+ const canReadRecord = createRecordReadCheck(options.registry, dispatcher.query);
690
693
  app.route("/api", createFileRoutes({
691
694
  db: fileDb,
692
695
  registry: options.registry,
693
696
  resolveProvider: fileProviderResolver,
694
- canReadRecord: createRecordReadCheck(options.registry, dispatcher.query),
697
+ canReadRecord,
698
+ canWriteRecord: createRecordWriteCheck({
699
+ registry: options.registry,
700
+ canReadRecord,
701
+ tenantDbFor: (tenantId) => createTenantDb(fileDb, tenantId, "tenant", undefined, undefined, undefined, {
702
+ projectionRegistry: options.registry,
703
+ }),
704
+ }),
695
705
  ...(routeOptions.accessGuard ? { accessGuard: routeOptions.accessGuard } : {}),
696
706
  ...(routeOptions.privilegedRoles ? { privilegedRoles: routeOptions.privilegedRoles } : {}),
697
707
  ...(routeOptions.maxUploadSize ? { maxUploadSize: routeOptions.maxUploadSize } : {}),
@@ -8,4 +8,4 @@ export { type ActiveKmsWiring, buildPgKmsOptions, type KmsWiring, type KmsWiring
8
8
  export { createPgKmsAdapter, PgKmsAdapter, type PgKmsAdapterOptions, type RewrapOptions, type RewrapResult, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
9
9
  export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, type EncryptPiiOptions, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
10
10
  export { createRequestKmsCache, type RequestKmsCache, } from "./request-kms-cache.js";
11
- export { collectPiiSubjectFields, collectSearchableSubjectFields, type ResolveSubjectOptions, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
11
+ export { collectPiiEncryptedFields, collectPiiSubjectFields, collectSearchableSubjectFields, type ResolveSubjectOptions, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
@@ -8,4 +8,4 @@ export { buildPgKmsOptions, requireKmsWiring, requireKmsWiringAsync, resolveKmsW
8
8
  export { createPgKmsAdapter, PgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
9
9
  export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
10
10
  export { createRequestKmsCache, } from "./request-kms-cache.js";
11
- export { collectPiiSubjectFields, collectSearchableSubjectFields, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
11
+ export { collectPiiEncryptedFields, collectPiiSubjectFields, collectSearchableSubjectFields, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
@@ -0,0 +1,2 @@
1
+ import type { EntityDefinition } from "../engine/types/fields.js";
2
+ export declare function personalFieldsRekeyedOnOwnerTransition(entity: EntityDefinition, previous: Record<string, unknown>, mergedNew: Record<string, unknown>, submittedChanges: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,30 @@
1
+ import { PII_ERASED_SENTINEL } from "./kms-adapter.js";
2
+ import { isPiiCiphertext } from "./pii-ciphertext-format.js";
3
+ import { nonEmptyString } from "./subject-resolver.js";
4
+ // Personal fields keep their stored value when an update moves the row to a
5
+ // new owner, so without re-keying they would stay shreddable only by the old
6
+ // owner. Returns the unchanged-by-the-caller values that must be written again
7
+ // so they encrypt under the new owner. Owner → absent is not a transition:
8
+ // the value stays with the last owner so that owner's forget still erases it.
9
+ export function personalFieldsRekeyedOnOwnerTransition(entity, previous, mergedNew, submittedChanges) {
10
+ const rekeyed = {};
11
+ for (const [name, field] of Object.entries(entity.fields)) {
12
+ if (!("userOwned" in field) || field.userOwned === undefined)
13
+ continue;
14
+ if ("recordOwned" in field && field.recordOwned === true)
15
+ continue;
16
+ if (name in submittedChanges)
17
+ continue;
18
+ const { ownerField } = field.userOwned;
19
+ const nextOwner = nonEmptyString(mergedNew[ownerField]);
20
+ if (nextOwner === null || nextOwner === nonEmptyString(previous[ownerField]))
21
+ continue;
22
+ const value = previous[name];
23
+ if (value === null || value === undefined || value === PII_ERASED_SENTINEL)
24
+ continue;
25
+ if (typeof value === "string" && isPiiCiphertext(value))
26
+ continue;
27
+ rekeyed[name] = value;
28
+ }
29
+ return rekeyed;
30
+ }
@@ -3,6 +3,7 @@ import type { TenantId } from "../engine/types/identifiers.js";
3
3
  import { type KmsAdapter, type KmsContext, type LocalKeyKmsAdapter, PII_ERASED_SENTINEL, type SubjectId } from "./kms-adapter.js";
4
4
  import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON } from "./pii-ciphertext-format.js";
5
5
  export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL };
6
+ export declare function piiCiphertextSubject(value: string): SubjectId;
6
7
  export declare function encryptPiiValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
7
8
  export declare function encryptPiiJsonValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: unknown, kmsCtx: KmsContext, field: string): Promise<string>;
8
9
  export declare function decryptPiiValueForSubject(kms: LocalKeyKmsAdapter, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
@@ -87,6 +87,11 @@ async function getOrCreateDek(kms, subject, ctx) {
87
87
  }
88
88
  return kms.getKey(subject, ctx);
89
89
  }
90
+ // The subject a ciphertext is keyed under, read from its inline prefix without
91
+ // touching the KMS.
92
+ export function piiCiphertextSubject(value) {
93
+ return parseCiphertext(value).subject;
94
+ }
90
95
  // Single-value encrypt for callers that resolve the subject themselves
91
96
  // (event-pii catalog, backfill). Ciphertext/sentinel inputs pass through —
92
97
  // idempotent like the field-map variant. `field` names the value's storage
@@ -12,6 +12,7 @@ export interface ResolveSubjectOptions {
12
12
  readonly ownerFallback?: Record<string, unknown>;
13
13
  readonly ignoreWhenAbsent?: boolean;
14
14
  }
15
+ export declare function nonEmptyString(value: unknown): string | null;
15
16
  /**
16
17
  * Maps a pii-annotated field to the subject whose key encrypts it.
17
18
  * Returns null for fields without any PII annotation (stored plaintext).
@@ -28,5 +29,6 @@ export interface EventSubjectEnvelope {
28
29
  }
29
30
  export declare function resolveEventSubject(fieldName: string, spec: EventPiiSubject, payload: Record<string, unknown>, envelope: EventSubjectEnvelope): SubjectId | null;
30
31
  export declare function collectPiiSubjectFields(entity: EntityDefinition): readonly string[];
32
+ export declare function collectPiiEncryptedFields(entity: EntityDefinition): readonly string[];
31
33
  /** Subject-annotated fields that may be plaintext in the derived search index (#1610). */
32
34
  export declare function collectSearchableSubjectFields(entity: EntityDefinition): readonly string[];
@@ -11,7 +11,7 @@ export class SubjectResolutionError extends Error {
11
11
  this.name = "SubjectResolutionError";
12
12
  }
13
13
  }
14
- function nonEmptyString(value) {
14
+ export function nonEmptyString(value) {
15
15
  return typeof value === "string" && value.length > 0 ? value : null;
16
16
  }
17
17
  function rowIdAsString(row) {
@@ -142,6 +142,19 @@ export function collectPiiSubjectFields(entity) {
142
142
  .filter(([, field]) => hasSubjectAnnotation(field))
143
143
  .map(([name]) => name);
144
144
  }
145
+ function isFileReferenceField(field) {
146
+ return (field.type === "file" ||
147
+ field.type === "image" ||
148
+ field.type === "files" ||
149
+ field.type === "images");
150
+ }
151
+ // File columns hold only the fileRef uuid; the binary is erased by the file-ref
152
+ // user-data hook, so these stay annotated (forget) but are never ciphertext.
153
+ export function collectPiiEncryptedFields(entity) {
154
+ return Object.entries(entity.fields)
155
+ .filter(([, field]) => hasSubjectAnnotation(field) && !isFileReferenceField(field))
156
+ .map(([name]) => name);
157
+ }
145
158
  /** Subject-annotated fields that may be plaintext in the derived search index (#1610). */
146
159
  export function collectSearchableSubjectFields(entity) {
147
160
  return Object.entries(entity.fields)
@@ -109,9 +109,6 @@ export async function applyEntityEvent(event, table, entity, tx) {
109
109
  case "updated": {
110
110
  // payload-Shape: { changes, previous } — siehe event-store-executor.ts.
111
111
  const rawChanges = (event.payload["changes"] ?? {}); // @cast-boundary engine-payload
112
- // Plural file fields (files/images) have no entity-table column — the
113
- // array of UUIDs lives in the event payload only. Strip them from the
114
- // UPDATE so Postgres doesn't error with "column X does not exist".
115
112
  const changes = {};
116
113
  for (const [key, value] of Object.entries(rawChanges)) {
117
114
  const field = entity.fields[key];
@@ -121,8 +118,6 @@ export async function applyEntityEvent(event, table, entity, tx) {
121
118
  // columns without an entity field and must still be applied.
122
119
  if (field === undefined && !(key in table))
123
120
  continue;
124
- if (field?.type === "files" || field?.type === "images")
125
- continue;
126
121
  changes[key] = value;
127
122
  }
128
123
  const rows = await updateMany(tx, table, {
@@ -18,7 +18,7 @@
18
18
  // Custom handlers must pass the viewer explicitly so the parentRef gate is
19
19
  // never skipped silently.
20
20
  import { requestContext } from "../api/request-context.js";
21
- import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, } from "../crypto/index.js";
21
+ import { collectPiiEncryptedFields, configuredPiiSubjectKms, decryptPiiFieldValues, } from "../crypto/index.js";
22
22
  import { selectMany } from "../db/query.js";
23
23
  import { filterReadFields } from "../engine/field-access.js";
24
24
  import { buildOwnershipClause, combineClauses, shiftParams, tableNameOf, } from "../engine/ownership.js";
@@ -162,7 +162,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db, view
162
162
  const rawRefRows = scope.kind === "restricted"
163
163
  ? lookedUpRows.filter((r) => scope.readableIds.has(String(r["id"])))
164
164
  : lookedUpRows;
165
- const piiFields = collectPiiSubjectFields(refEntity);
165
+ const piiFields = collectPiiEncryptedFields(refEntity);
166
166
  const encryptedFields = collectEncryptedFieldNames(refEntity);
167
167
  const kms = configuredPiiSubjectKms();
168
168
  const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName, viewer, piiFields, encryptedFields, kms);
@@ -3,7 +3,7 @@
3
3
  // StoredEnvelope in the TEXT column, kekVersion per value, legacy
4
4
  // single-key ciphertexts (pre-envelope ENCRYPTION_KEY era) readable via
5
5
  // the cipher's legacy fallback until re-encrypted.
6
- import { collectPiiSubjectFields } from "../crypto/index.js";
6
+ import { collectPiiEncryptedFields } from "../crypto/index.js";
7
7
  import { createLogger } from "../logging/index.js";
8
8
  import { createEnvMasterKeyProvider } from "../secrets/env-master-key-provider.js";
9
9
  export function collectEncryptedFieldNames(entity) {
@@ -19,7 +19,8 @@ export function collectEncryptedFieldNames(entity) {
19
19
  // depending on runtime KMS wiring (PII) — either way it has no plaintext
20
20
  // column to compare against directly.
21
21
  export function isSensitiveLabelField(entity, field) {
22
- return (collectEncryptedFieldNames(entity).has(field) || collectPiiSubjectFields(entity).includes(field));
22
+ return (collectEncryptedFieldNames(entity).has(field) ||
23
+ collectPiiEncryptedFields(entity).includes(field));
23
24
  }
24
25
  // `searchable: true` on a text field means it is indexed plaintext
25
26
  // somewhere findable by term — a plain text field's own column, or (for a
@@ -15,7 +15,7 @@
15
15
  // entity-shaped base columns. Escape hatch: no forced audit trail for the
16
16
  // hand-built path; app author owns tenant scoping / versioning. Prefer a
17
17
  // `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
18
- import { collectPiiSubjectFields } from "../crypto/index.js";
18
+ import { collectPiiEncryptedFields } from "../crypto/index.js";
19
19
  import { SQL_EXPR_BRAND } from "./dialect.js";
20
20
  import { READ_MODEL_PREFIX, SOFT_DELETE_LIVE_ROW_PREDICATE, toSnakeCase, toTableName, } from "./table-builder.js";
21
21
  // Standard base columns for event-sourced read-model tables. Mirrors
@@ -192,7 +192,7 @@ function fieldToColumnMeta(name, field, entity) {
192
192
  return [{ name: snake, pgType: "uuid", notNull: field.required === true }];
193
193
  case "files":
194
194
  case "images":
195
- return [];
195
+ return [{ name: snake, pgType: "jsonb", notNull: true, defaultSql: "'[]'::jsonb" }];
196
196
  default:
197
197
  return [];
198
198
  }
@@ -345,7 +345,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
345
345
  }
346
346
  }
347
347
  }
348
- const piiSubjectFields = collectPiiSubjectFields(entity);
348
+ const piiSubjectFields = collectPiiEncryptedFields(entity);
349
349
  return {
350
350
  tableName,
351
351
  columns,
@@ -65,6 +65,7 @@ export type ExecutorContext = {
65
65
  ownerFallback?: Record<string, unknown>;
66
66
  }) => Promise<Record<string, unknown>>;
67
67
  readonly decryptForRead: (row: Record<string, unknown>, opts?: DecryptForReadOptions) => Promise<Record<string, unknown>>;
68
+ readonly ownerTransitionRekeys: (previous: Record<string, unknown>, mergedNew: Record<string, unknown>, submittedChanges: Record<string, unknown>) => Record<string, unknown>;
68
69
  readonly applyDefaults: (payload: Record<string, unknown>) => Record<string, unknown>;
69
70
  readonly stripSensitive: (payload: Record<string, unknown> | undefined) => Record<string, unknown>;
70
71
  };
@@ -1,6 +1,7 @@
1
1
  import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
2
  import { requestContext } from "../api/request-context.js";
3
- import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
3
+ import { collectPiiEncryptedFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
4
+ import { personalFieldsRekeyedOnOwnerTransition } from "../crypto/owner-transition-rekey.js";
4
5
  import { executeRawQueryRead } from "../db/queries/raw-sql.js";
5
6
  import { shiftParams } from "../engine/ownership.js";
6
7
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
@@ -170,7 +171,7 @@ export function buildExecutorContext(table, entity, options) {
170
171
  }
171
172
  const encryptedFields = collectEncryptedFieldNames(entity);
172
173
  const hasEncryptedFields = encryptedFields.size > 0;
173
- const piiSubjectFields = collectPiiSubjectFields(entity);
174
+ const piiSubjectFields = collectPiiEncryptedFields(entity);
174
175
  const hasPiiFields = piiSubjectFields.length > 0;
175
176
  function fieldCipher() {
176
177
  if (options.encryption)
@@ -244,6 +245,11 @@ export function buildExecutorContext(table, entity, options) {
244
245
  }
245
246
  return out;
246
247
  }
248
+ function ownerTransitionRekeys(previous, mergedNew, submittedChanges) {
249
+ if (!hasPiiFields || !piiKms())
250
+ return {};
251
+ return personalFieldsRekeyedOnOwnerTransition(entity, previous, mergedNew, submittedChanges);
252
+ }
247
253
  function applyDefaults(payload) {
248
254
  if (Object.keys(fieldDefaults).length === 0)
249
255
  return payload;
@@ -430,6 +436,7 @@ export function buildExecutorContext(table, entity, options) {
430
436
  loadExpectSnapshot,
431
437
  encryptForStorage,
432
438
  decryptForRead,
439
+ ownerTransitionRekeys,
433
440
  applyDefaults,
434
441
  stripSensitive,
435
442
  };
@@ -2,9 +2,9 @@ import { checkWriteFieldOwnership } from "../engine/field-access.js";
2
2
  import { instructionFieldNames } from "../engine/instruction-fields.js";
3
3
  import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
4
4
  import { buildInsertSchema, buildUpdateSchema } from "../engine/schema-builder.js";
5
- import { findUnavailableSelectOptions, } from "../engine/screen-helpers.js";
5
+ import { findUnavailableSelectOptions, isRecordLocked, } from "../engine/screen-helpers.js";
6
6
  import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
7
- import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, UnprocessableError, writeFailure, } from "../errors/index.js";
7
+ import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RecordLockedError, UnprocessableError, writeFailure, } from "../errors/index.js";
8
8
  import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
9
9
  import { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "../pipeline/irreversible-operation-gate.js";
10
10
  import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline/projections-runner.js";
@@ -118,8 +118,21 @@ 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, streamTenantOverrideFailure, streamTenantCreateFailure, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
121
+ const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, streamTenantCreateFailure, encryptForStorage, decryptForRead, ownerTransitionRekeys, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
122
122
  const entityInstructionFieldNames = instructionFieldNames(entity);
123
+ // update and delete funnel through here, so custom handlers and nested writes cannot
124
+ // bypass the lock whatever they return. Setting the lock (unlocked row) passes.
125
+ // Framework system writes that name a writePurpose (retention, erasure) are exempt:
126
+ // statutory deletion must reach locked rows, and forget (Art. 17) never checks the lock
127
+ // at all. The options are server-code only; no request field is ever copied into them.
128
+ function recordLockedFailure(row, id, user, options) {
129
+ if (options?.unsafeSkipLockGuard === true ||
130
+ (options?.writePurpose !== undefined && isFrameworkSystemUser(user)) ||
131
+ !isRecordLocked(entity.lockedWhen, row)) {
132
+ return undefined;
133
+ }
134
+ return writeFailure(new RecordLockedError({ entityName, entityId: id }));
135
+ }
123
136
  const eventVersion = entity.eventVersion ?? 1;
124
137
  let insertSchema;
125
138
  let updateSchema;
@@ -407,6 +420,9 @@ export function createWriteVerbs(ctx) {
407
420
  },
408
421
  }));
409
422
  }
423
+ const lockedUpdate = recordLockedFailure(previous, payload.id, user, updateOptions);
424
+ if (lockedUpdate)
425
+ return lockedUpdate;
410
426
  const unavailableOnUpdate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
411
427
  if (unavailableOnUpdate)
412
428
  return unavailableOnUpdate;
@@ -449,9 +465,16 @@ export function createWriteVerbs(ctx) {
449
465
  const submittedChanges = updateOptions?.skipUnchanged
450
466
  ? Object.fromEntries(Object.entries(changes).filter(([key, value]) => !isUnchangedValue(value, previous[key])))
451
467
  : changes;
452
- const flatChangesPlain = flattenCompoundTypes(submittedChanges, entity);
468
+ // fw#3670: crypto-shredding follows the current owner, so personal
469
+ // fields not in this update are re-written under the new owner's key.
470
+ // Owner -> absent is not re-keyed; the value stays under the old owner.
471
+ const changesToPersist = {
472
+ ...submittedChanges,
473
+ ...ownerTransitionRekeys(previous, mergedNew, submittedChanges),
474
+ };
475
+ const flatChangesPlain = flattenCompoundTypes(changesToPersist, entity);
453
476
  const flatChanges = await encryptForStorage(flatChangesPlain, user, {
454
- onlyKeys: Object.keys(submittedChanges),
477
+ onlyKeys: Object.keys(changesToPersist),
455
478
  subjectSource: mergedNew,
456
479
  keyTenantId: streamTenantId,
457
480
  });
@@ -583,6 +606,9 @@ export function createWriteVerbs(ctx) {
583
606
  },
584
607
  }));
585
608
  }
609
+ const lockedDelete = recordLockedFailure(existing, payload.id, user, options);
610
+ if (lockedDelete)
611
+ return lockedDelete;
586
612
  await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
587
613
  // Stream-version authoritative (see update() for rationale).
588
614
  const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
@@ -5,7 +5,8 @@ import type { EntityCache } from "../pipeline/entity-cache.js";
5
5
  import type { SearchAdapter } from "../search/types.js";
6
6
  import type { EnvelopeCipher } from "../secrets/envelope-cipher.js";
7
7
  import { type Table } from "./event-store-executor-context.js";
8
- export type { EventStoreExecutor } from "@cosmicdrift/kumiko-types/event-store-executor-types";
8
+ export type { EventStoreExecutor, LockGuardOption, WritePurpose, } from "@cosmicdrift/kumiko-types/event-store-executor-types";
9
+ export { WritePurposes } from "@cosmicdrift/kumiko-types/event-store-executor-types";
9
10
  export type { EntityLifecycleVerb } from "./event-store-executor-context.js";
10
11
  export { entityEventName } from "./event-store-executor-context.js";
11
12
  export type EventStoreExecutorOptions = {
@@ -1,6 +1,7 @@
1
1
  import { buildExecutorContext } from "./event-store-executor-context.js";
2
2
  import { createReadVerbs } from "./event-store-executor-read.js";
3
3
  import { createWriteVerbs } from "./event-store-executor-write.js";
4
+ export { WritePurposes } from "@cosmicdrift/kumiko-types/event-store-executor-types";
4
5
  export { entityEventName } from "./event-store-executor-context.js";
5
6
  export function createEventStoreExecutor(table, entity, options) {
6
7
  const ctx = buildExecutorContext(table, entity, options);
@@ -15,11 +15,11 @@ export { collectEncryptedFieldNames, configuredEntityFieldEncryption, configureE
15
15
  export { entityTableFromRegistry } from "./entity-table-from-registry.js";
16
16
  export type { BuildEntityTableMetaOptions, ColumnMeta, CompositePrimaryKeyMeta, EntityTableMeta, IndexMeta, PgType, UnmanagedTableInput, } from "./entity-table-meta.js";
17
17
  export { buildEntityTableMeta, defineUnmanagedTable, deriveEntityTableMeta, } from "./entity-table-meta.js";
18
- export type { EntityLifecycleVerb, EventStoreExecutor, EventStoreExecutorOptions, } from "./event-store-executor.js";
19
- export { createEventStoreExecutor, entityEventName } from "./event-store-executor.js";
18
+ export type { EntityLifecycleVerb, EventStoreExecutor, EventStoreExecutorOptions, LockGuardOption, WritePurpose, } from "./event-store-executor.js";
19
+ export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.js";
20
20
  export { enumerateFeatureTableSources, type FeatureTableSource, } from "./feature-table-sources.js";
21
21
  export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.js";
22
- export { assertValidMigrationName, diffSnapshots, type GenerateMigrationInput, type GenerateMigrationOutput, generateMigration, loadSnapshotJson, renderMigrationSql, type SchemaDiff, type Snapshot, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
22
+ export { assertUniqueTableNames, assertValidMigrationName, diffSnapshots, type GenerateMigrationInput, type GenerateMigrationOutput, generateMigration, loadSnapshotJson, renderMigrationSql, type SchemaDiff, type Snapshot, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
23
23
  export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, type Migration, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
24
24
  export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
25
25
  export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, type PgErrorInfo, } from "./pg-error.js";
package/dist/db/index.js CHANGED
@@ -10,10 +10,10 @@ export { collectReferenceFields, enrichRowWithReferences, enrichWithReferences,
10
10
  export { collectEncryptedFieldNames, configuredEntityFieldEncryption, configureEntityFieldEncryption, decryptEntityFieldValues, encryptEntityFieldValues, } from "./entity-field-encryption.js";
11
11
  export { entityTableFromRegistry } from "./entity-table-from-registry.js";
12
12
  export { buildEntityTableMeta, defineUnmanagedTable, deriveEntityTableMeta, } from "./entity-table-meta.js";
13
- export { createEventStoreExecutor, entityEventName } from "./event-store-executor.js";
13
+ export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.js";
14
14
  export { enumerateFeatureTableSources, } from "./feature-table-sources.js";
15
15
  export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.js";
16
- export { assertValidMigrationName, diffSnapshots, generateMigration, loadSnapshotJson, renderMigrationSql, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
16
+ export { assertUniqueTableNames, assertValidMigrationName, diffSnapshots, generateMigration, loadSnapshotJson, renderMigrationSql, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
17
17
  export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
18
18
  export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
19
19
  export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, } from "./pg-error.js";
@@ -83,5 +83,6 @@ export type GenerateMigrationOutput = {
83
83
  };
84
84
  export declare const MIGRATION_NAME_RE: RegExp;
85
85
  export declare function assertValidMigrationName(name: string): void;
86
+ export declare function assertUniqueTableNames(metas: readonly EntityTableMeta[]): void;
86
87
  export declare function generateMigration(input: GenerateMigrationInput): GenerateMigrationOutput;
87
88
  export {};
@@ -428,8 +428,20 @@ export function assertValidMigrationName(name) {
428
428
  throw new Error(`Invalid migration name "${name}" — must start with a letter or digit, then letters/digits/"-"/"_" only (max 64 chars).`);
429
429
  }
430
430
  }
431
+ export function assertUniqueTableNames(metas) {
432
+ const counts = new Map();
433
+ for (const { tableName } of metas)
434
+ counts.set(tableName, (counts.get(tableName) ?? 0) + 1);
435
+ const duplicates = [...counts].filter(([, count]) => count > 1);
436
+ if (duplicates.length > 0) {
437
+ throw new Error(duplicates
438
+ .map(([tableName, count]) => `ENTITY_METAS lists table "${tableName}" ${count} times — the migration diff would silently use only one definition. collectTableMetas(features) already includes every table a feature registers (r.storeTable, entities, projections); remove the hand-added meta for "${tableName}".`)
439
+ .join("\n"));
440
+ }
441
+ }
431
442
  export function generateMigration(input) {
432
443
  assertValidMigrationName(input.name);
444
+ assertUniqueTableNames(input.metas);
433
445
  const nextSnapshot = snapshotFromMetas(input.metas);
434
446
  const diff = diffSnapshots(input.prevSnapshot, nextSnapshot);
435
447
  const retiredDrops = findRetiredTableDrops(input.priorMigrationsSql ?? [], nextSnapshot);
@@ -9,6 +9,7 @@ export type PiiBackfillResult = {
9
9
  readonly updatedEvents: number;
10
10
  readonly encryptedFields: number;
11
11
  readonly erasedFields: number;
12
+ readonly rekeyedFields: number;
12
13
  readonly ownerFromProjection: number;
13
14
  readonly erasedUnresolvable: number;
14
15
  readonly deletedSnapshots: number;
@@ -30,6 +31,7 @@ export type PiiBackfillOptions = {
30
31
  readonly dryRun?: boolean;
31
32
  readonly resolveOwnerFromProjection?: boolean;
32
33
  readonly eraseUnresolvableSubjects?: boolean;
34
+ readonly rekeyTenantKeyedOwnerFields?: boolean;
33
35
  };
34
36
  export type PiiBackfillBatchOptions = PiiBackfillOptions & {
35
37
  readonly afterEventId?: bigint | string;