@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
@@ -1,4 +1,5 @@
1
1
  import type { EntityDefinition } from "../engine/types/index.js";
2
+ import { type Logger } from "../logging/index.js";
2
3
  import type { EnvelopeCipher } from "../secrets/envelope-cipher.js";
3
4
  export declare function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySet<string>;
4
5
  export declare function isSensitiveLabelField(entity: EntityDefinition, field: string): boolean;
@@ -6,7 +7,15 @@ export declare function hasSearchablePlaintext(entity: EntityDefinition, field:
6
7
  export declare function encryptEntityFieldValues(row: Record<string, unknown>, encryptedFields: ReadonlySet<string>, cipher: EnvelopeCipher, opts?: {
7
8
  onlyKeys?: Iterable<string>;
8
9
  }): Promise<Record<string, unknown>>;
9
- export declare function decryptEntityFieldValues(row: Record<string, unknown>, encryptedFields: ReadonlySet<string>, cipher: EnvelopeCipher): Promise<Record<string, unknown>>;
10
+ export declare function logUndecryptableField(info: {
11
+ entityName: string | undefined;
12
+ rowId: unknown;
13
+ field: string;
14
+ error: unknown;
15
+ }, logger?: Logger): void;
16
+ export declare function decryptEntityFieldValues(row: Record<string, unknown>, encryptedFields: ReadonlySet<string>, cipher: EnvelopeCipher, opts?: {
17
+ onFieldError?: (field: string, error: unknown) => void;
18
+ }): Promise<Record<string, unknown>>;
10
19
  export declare function configureEntityFieldEncryption(cipher: EnvelopeCipher | undefined): void;
11
20
  export declare function resolveEntityFieldEncryption(): EnvelopeCipher;
12
21
  export declare function configuredEntityFieldEncryption(): EnvelopeCipher | undefined;
@@ -4,6 +4,7 @@
4
4
  // single-key ciphertexts (pre-envelope ENCRYPTION_KEY era) readable via
5
5
  // the cipher's legacy fallback until re-encrypted.
6
6
  import { collectPiiSubjectFields } from "../crypto/index.js";
7
+ import { createLogger } from "../logging/index.js";
7
8
  import { createEnvMasterKeyProvider } from "../secrets/env-master-key-provider.js";
8
9
  export function collectEncryptedFieldNames(entity) {
9
10
  const names = new Set();
@@ -60,7 +61,25 @@ export async function encryptEntityFieldValues(row, encryptedFields, cipher, opt
60
61
  }
61
62
  return out;
62
63
  }
63
- export async function decryptEntityFieldValues(row, encryptedFields, cipher) {
64
+ let undecryptableFieldLogger;
65
+ function defaultUndecryptableFieldLogger() {
66
+ undecryptableFieldLogger ??= createLogger();
67
+ return undecryptableFieldLogger;
68
+ }
69
+ // Logs identifiers and the error class only: ciphertext, key material and the
70
+ // cipher's error message (which may echo envelope content) never reach the log.
71
+ export function logUndecryptableField(info, logger = defaultUndecryptableFieldLogger()) {
72
+ logger.error("encrypted field could not be decrypted; returned as null", {
73
+ entity: info.entityName,
74
+ rowId: info.rowId,
75
+ field: info.field,
76
+ errorName: info.error instanceof Error ? info.error.name : typeof info.error,
77
+ });
78
+ }
79
+ // With `onFieldError` a field that fails to decrypt (wrong key, corrupt
80
+ // envelope) reads as null and is reported instead of throwing, so one bad row
81
+ // cannot fail a whole list. Without it decrypt stays fail-loud (write paths).
82
+ export async function decryptEntityFieldValues(row, encryptedFields, cipher, opts) {
64
83
  if (encryptedFields.size === 0)
65
84
  return row;
66
85
  const out = { ...row };
@@ -71,7 +90,17 @@ export async function decryptEntityFieldValues(row, encryptedFields, cipher) {
71
90
  continue;
72
91
  if (typeof value !== "string")
73
92
  continue;
74
- out[name] = await cipher.decrypt(value, scope);
93
+ if (!opts?.onFieldError) {
94
+ out[name] = await cipher.decrypt(value, scope);
95
+ continue;
96
+ }
97
+ try {
98
+ out[name] = await cipher.decrypt(value, scope);
99
+ }
100
+ catch (error) {
101
+ out[name] = null;
102
+ opts.onFieldError(name, error);
103
+ }
75
104
  }
76
105
  return out;
77
106
  }
@@ -9,11 +9,15 @@ import type { EventStoreExecutorOptions } from "./event-store-executor.js";
9
9
  import type { TenantDb } from "./tenant-db.js";
10
10
  export type Table = TableColumns<any>;
11
11
  export declare function buildFilterWhere(field: string, op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in", value: unknown): WhereObject | null;
12
+ export declare function ownershipSubject(user: SessionUser, streamTenantId: TenantId | undefined): SessionUser;
12
13
  export declare function isForeignTenantOnGlobalEntity(entity: EntityDefinition, tenantIdValue: unknown): boolean;
13
14
  export declare function tryMapUniqueViolation(e: unknown, entityName: string): WriteFailure | null;
14
15
  export declare function buildEventMetadata(user: SessionUser): EventMetadata;
15
16
  export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored" | "forgotten";
16
17
  export declare function entityEventName(entityName: string, verb: EntityLifecycleVerb): string;
18
+ export type DecryptForReadOptions = {
19
+ readonly degradeUndecryptable?: (field: string) => void;
20
+ };
17
21
  export type ExecutorContext = {
18
22
  readonly table: Table;
19
23
  readonly entity: EntityDefinition;
@@ -21,7 +25,9 @@ export type ExecutorContext = {
21
25
  readonly entityCache?: EntityCache;
22
26
  readonly searchAdapter?: SearchAdapter;
23
27
  readonly softDelete: boolean;
24
- readonly streamTenantFor: (user: SessionUser) => TenantId;
28
+ readonly streamTenantCreateFailure: (db: TenantDb, payload: Record<string, unknown>, requestedStreamTenantId: TenantId | undefined) => WriteFailure | undefined;
29
+ readonly streamTenantFor: (user: SessionUser, requestedStreamTenantId?: TenantId) => TenantId;
30
+ readonly streamTenantOverrideFailure: (db: TenantDb, row: Record<string, unknown>, requestedStreamTenantId: TenantId | undefined) => WriteFailure | undefined;
25
31
  readonly idFilter: (id: EntityId) => WhereObject;
26
32
  readonly loadById: (id: EntityId, db: TenantDb) => Promise<Record<string, unknown> | null>;
27
33
  readonly assertStreamWritable: (db: TenantDb, id: EntityId, tenantId: TenantId) => Promise<void>;
@@ -53,8 +59,9 @@ export type ExecutorContext = {
53
59
  readonly encryptForStorage: (row: Record<string, unknown>, user: SessionUser, opts?: {
54
60
  onlyKeys?: Iterable<string>;
55
61
  subjectSource?: Record<string, unknown>;
62
+ keyTenantId?: TenantId | undefined;
56
63
  }) => Promise<Record<string, unknown>>;
57
- readonly decryptForRead: (row: Record<string, unknown>) => Promise<Record<string, unknown>>;
64
+ readonly decryptForRead: (row: Record<string, unknown>, opts?: DecryptForReadOptions) => Promise<Record<string, unknown>>;
58
65
  readonly applyDefaults: (payload: Record<string, unknown>) => Record<string, unknown>;
59
66
  readonly stripSensitive: (payload: Record<string, unknown> | undefined) => Record<string, unknown>;
60
67
  };
@@ -4,11 +4,11 @@ import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues
4
4
  import { executeRawQueryRead } from "../db/queries/raw-sql.js";
5
5
  import { shiftParams } from "../engine/ownership.js";
6
6
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
7
- import { InternalError, subjectErasedConflict, UniqueViolationError, writeFailure, } from "../errors/index.js";
7
+ import { AccessDeniedError, InternalError, subjectErasedConflict, UniqueViolationError, writeFailure, } from "../errors/index.js";
8
8
  import { ArchivedStreamError, isStreamArchived } from "../event-store/index.js";
9
9
  import { assertUnreachable } from "../utils/index.js";
10
10
  import { rehydrateCompoundTypes } from "./compound-types.js";
11
- import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
11
+ import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, logUndecryptableField, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
12
12
  import { constraintOf, isUniqueViolation } from "./pg-error.js";
13
13
  import { toSnakeCase } from "./table-builder.js";
14
14
  import { tenantDbRunner } from "./tenant-db-runner.js";
@@ -39,6 +39,13 @@ export function buildFilterWhere(field, op, value) {
39
39
  return assertUnreachable(op, "filter op");
40
40
  }
41
41
  }
42
+ // Tenant-based write-ownership rules (from("user:tenantId", "tenantId")) compare the caller's
43
+ // tenant with the row's. An explicit cross-tenant grant (streamTenantId, checked against the
44
+ // loaded row) is evaluated as the operator acting inside the target tenant. Only this
45
+ // evaluation sees the target tenant: stream actor, metadata and PII keys keep the real identity.
46
+ export function ownershipSubject(user, streamTenantId) {
47
+ return streamTenantId === undefined ? user : { ...user, tenantId: streamTenantId };
48
+ }
42
49
  // Returns the scalar default of a field, or undefined if the field's type
43
50
  // doesn't carry a default or no default was declared. Only scalar types
44
51
  // (text/number/boolean/select) support creation-time defaults — money/date/
@@ -110,7 +117,27 @@ export function buildExecutorContext(table, entity, options) {
110
117
  // user) lives on SYSTEM_TENANT_ID deterministically — every op addresses it
111
118
  // there. Everything else stays on the caller's tenant (byte-identical to the
112
119
  // old hardcoded user.tenantId). Single source of truth for the stream key.
113
- const streamTenantFor = (user) => entity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
120
+ const streamTenantFor = (user, requestedStreamTenantId) => entity.systemStream ? SYSTEM_TENANT_ID : (requestedStreamTenantId ?? user.tenantId);
121
+ // Only a system-mode db (r.systemScope() / acknowledged cross-tenant) or a tenant-mode db bound
122
+ // to exactly that stream tenant (ctx.forTenant) may address another tenant's stream, and only
123
+ // the tenant of the row it just loaded.
124
+ const streamTenantOverrideFailure = (db, row, requestedStreamTenantId) => {
125
+ if (requestedStreamTenantId === undefined)
126
+ return undefined;
127
+ if (db.mode !== "system" && db.tenantId !== requestedStreamTenantId) {
128
+ return writeFailure(new AccessDeniedError({
129
+ message: `${entityName}: streamTenantId requires a system-mode db or a db bound to that tenant`,
130
+ }));
131
+ }
132
+ if (row["tenantId"] !== requestedStreamTenantId) {
133
+ return writeFailure(new AccessDeniedError({
134
+ message: `${entityName}: streamTenantId does not match the tenant of the addressed row`,
135
+ }));
136
+ }
137
+ return undefined;
138
+ };
139
+ // Create has no loaded row: the payload's own tenantId (if any) must agree with the target stream.
140
+ const streamTenantCreateFailure = (db, payload, requestedStreamTenantId) => streamTenantOverrideFailure(db, { tenantId: payload["tenantId"] ?? requestedStreamTenantId }, requestedStreamTenantId);
114
141
  // idType default (undefined) is now "uuid" — the ES-pivot made UUID the
115
142
  // only valid aggregate-id type. Explicit `idType: "serial"` is the only
116
143
  // shape that's incompatible with the event-store and still rejected.
@@ -156,10 +183,10 @@ export function buildExecutorContext(table, entity, options) {
156
183
  function piiKms() {
157
184
  return options.kms ?? configuredPiiSubjectKms();
158
185
  }
159
- function kmsContextFor(user) {
186
+ function kmsContextFor(user, keyTenantId) {
160
187
  return {
161
188
  requestId: requestContext.get()?.requestId ?? "event-store-executor",
162
- ...(user && { tenantId: user.tenantId, userId: String(user.id) }),
189
+ ...(user && { tenantId: keyTenantId ?? user.tenantId, userId: String(user.id) }),
163
190
  };
164
191
  }
165
192
  // Async on purpose: the envelope cipher wraps/unwraps DEKs via the
@@ -176,8 +203,8 @@ export function buildExecutorContext(table, entity, options) {
176
203
  const kms = piiKms();
177
204
  if (hasPiiFields && kms) {
178
205
  try {
179
- out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
180
- tenantId: user.tenantId,
206
+ out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user, opts?.keyTenantId), {
207
+ tenantId: opts?.keyTenantId ?? user.tenantId,
181
208
  entityName,
182
209
  ...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
183
210
  ...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
@@ -198,14 +225,21 @@ export function buildExecutorContext(table, entity, options) {
198
225
  // unwrap before envelope decrypt, or the envelope cipher chokes on a
199
226
  // still-PII-wrapped string (auth-mfa.totpSecret/recoveryCodes combine both
200
227
  // markers; see pii-subject-encryption.integration.test.ts).
201
- async function decryptForRead(row) {
228
+ async function decryptForRead(row, opts) {
202
229
  let out = row;
203
230
  const kms = piiKms();
204
231
  if (hasPiiFields && kms) {
205
232
  out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
206
233
  }
207
234
  if (hasEncryptedFields) {
208
- out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher());
235
+ out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher(), opts?.degradeUndecryptable
236
+ ? {
237
+ onFieldError: (field, error) => {
238
+ logUndecryptableField({ entityName, rowId: row["id"], field, error });
239
+ opts.degradeUndecryptable?.(field);
240
+ },
241
+ }
242
+ : undefined);
209
243
  }
210
244
  return out;
211
245
  }
@@ -384,6 +418,8 @@ export function buildExecutorContext(table, entity, options) {
384
418
  searchAdapter,
385
419
  softDelete,
386
420
  streamTenantFor,
421
+ streamTenantOverrideFailure,
422
+ streamTenantCreateFailure,
387
423
  idFilter,
388
424
  loadById,
389
425
  assertStreamWritable,
@@ -664,7 +664,7 @@ export function createReadVerbs(ctx) {
664
664
  // field (ciphertext leaked to the caller).
665
665
  const tableInfo = extractTableInfo(table);
666
666
  const encryptedRows = rawRows.map((r) => rehydrateCompoundTypes(coerceRow(r, tableInfo), entity));
667
- const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
667
+ const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r, { degradeUndecryptable: () => { } })));
668
668
  // Stream version is authoritative (see detail()): ctx.appendEvent bumps the
669
669
  // stream without touching row.version, so the raw row version would hand
670
670
  // clients a stale optimistic-lock base. Rows without events keep theirs.
@@ -744,7 +744,7 @@ export function createReadVerbs(ctx) {
744
744
  // Cached rows are stored re-encrypted (see the `set` below) so an
745
745
  // `encrypted` field's plaintext never sits in a second at-rest
746
746
  // store (Redis) the field-encryption feature doesn't cover.
747
- return withStreamVersion(await decryptForRead(cached), streamVersion);
747
+ return withStreamVersion(await decryptForRead(cached, { degradeUndecryptable: () => { } }), streamVersion);
748
748
  }
749
749
  }
750
750
  }
@@ -755,9 +755,12 @@ export function createReadVerbs(ctx) {
755
755
  // Same coerce-before-rehydrate/decrypt ordering as list() above — raw
756
756
  // is snake_case (raw SQL); coerceRow maps it to camelCase.
757
757
  const rowInfo = extractTableInfo(table);
758
- const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity));
758
+ const undecryptableFields = [];
759
+ const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity), { degradeUndecryptable: (field) => undecryptableFields.push(field) });
759
760
  const result = withStreamVersion(coerced, snapshot.streamVersion);
760
- if (entityCache && entityName) {
761
+ // A degraded row must not be cached: re-encrypting its null would
762
+ // replay the missing value on later cache hits without any error log.
763
+ if (entityCache && entityName && undecryptableFields.length === 0) {
761
764
  await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(result, user));
762
765
  }
763
766
  return result;