@cosmicdrift/kumiko-framework 0.354.0 → 0.355.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 (47) 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 +8 -2
  4. package/dist/db/event-store-executor-context.js +41 -9
  5. package/dist/db/event-store-executor-read.js +22 -11
  6. package/dist/db/event-store-executor-write.js +95 -56
  7. package/dist/db/queries/event-store.d.ts +1 -0
  8. package/dist/db/queries/event-store.js +11 -0
  9. package/dist/engine/build-app-schema.js +32 -5
  10. package/dist/engine/cross-tenant.d.ts +2 -1
  11. package/dist/engine/cross-tenant.js +18 -14
  12. package/dist/engine/entity-handlers.js +17 -16
  13. package/dist/engine/factories.d.ts +2 -0
  14. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  15. package/dist/engine/feature-entity-handlers.js +1 -0
  16. package/dist/engine/index.d.ts +1 -1
  17. package/dist/engine/index.js +1 -1
  18. package/dist/engine/registry-validate.d.ts +7 -1
  19. package/dist/engine/registry-validate.js +82 -2
  20. package/dist/engine/registry.js +2 -1
  21. package/dist/engine/schema-builder.js +35 -2
  22. package/dist/engine/types/index.d.ts +1 -1
  23. package/dist/errors/reasons.d.ts +1 -0
  24. package/dist/errors/reasons.js +2 -0
  25. package/dist/event-store/event-store.d.ts +1 -0
  26. package/dist/event-store/event-store.js +4 -1
  27. package/dist/event-store/index.d.ts +1 -1
  28. package/dist/event-store/index.js +1 -1
  29. package/dist/event-store/snapshot.d.ts +1 -0
  30. package/dist/event-store/snapshot.js +12 -3
  31. package/dist/jobs/job-runner.js +32 -17
  32. package/dist/observability/escape-hatch-report.d.ts +5 -2
  33. package/dist/observability/escape-hatch-report.js +52 -22
  34. package/dist/observability/index.d.ts +1 -1
  35. package/dist/observability/index.js +1 -1
  36. package/dist/pipeline/dispatch-query.js +4 -0
  37. package/dist/pipeline/dispatch-shared.js +33 -6
  38. package/dist/pipeline/dispatch-stream.js +4 -0
  39. package/dist/pipeline/dispatcher-utils.d.ts +4 -0
  40. package/dist/pipeline/dispatcher-utils.js +10 -0
  41. package/dist/pipeline/dispatcher.js +7 -7
  42. package/dist/pipeline/index.d.ts +1 -1
  43. package/dist/pipeline/index.js +1 -1
  44. package/dist/pipeline/pre-handler-gates.js +4 -0
  45. package/dist/ui-types/index.d.ts +1 -1
  46. package/package.json +4 -4
  47. package/src/changes.json +41 -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,8 @@ 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 streamTenantFor: (user: SessionUser, requestedStreamTenantId?: TenantId) => TenantId;
29
+ readonly streamTenantOverrideFailure: (db: TenantDb, row: Record<string, unknown>, requestedStreamTenantId: TenantId | undefined) => WriteFailure | undefined;
25
30
  readonly idFilter: (id: EntityId) => WhereObject;
26
31
  readonly loadById: (id: EntityId, db: TenantDb) => Promise<Record<string, unknown> | null>;
27
32
  readonly assertStreamWritable: (db: TenantDb, id: EntityId, tenantId: TenantId) => Promise<void>;
@@ -53,8 +58,9 @@ export type ExecutorContext = {
53
58
  readonly encryptForStorage: (row: Record<string, unknown>, user: SessionUser, opts?: {
54
59
  onlyKeys?: Iterable<string>;
55
60
  subjectSource?: Record<string, unknown>;
61
+ keyTenantId?: TenantId | undefined;
56
62
  }) => Promise<Record<string, unknown>>;
57
- readonly decryptForRead: (row: Record<string, unknown>) => Promise<Record<string, unknown>>;
63
+ readonly decryptForRead: (row: Record<string, unknown>, opts?: DecryptForReadOptions) => Promise<Record<string, unknown>>;
58
64
  readonly applyDefaults: (payload: Record<string, unknown>) => Record<string, unknown>;
59
65
  readonly stripSensitive: (payload: Record<string, unknown> | undefined) => Record<string, unknown>;
60
66
  };
@@ -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,24 @@ 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) may address another
122
+ // tenant's stream, and only the tenant of the row it just loaded.
123
+ const streamTenantOverrideFailure = (db, row, requestedStreamTenantId) => {
124
+ if (requestedStreamTenantId === undefined)
125
+ return undefined;
126
+ if (db.mode !== "system") {
127
+ return writeFailure(new AccessDeniedError({
128
+ message: `${entityName}: streamTenantId requires a system-mode db`,
129
+ }));
130
+ }
131
+ if (row["tenantId"] !== requestedStreamTenantId) {
132
+ return writeFailure(new AccessDeniedError({
133
+ message: `${entityName}: streamTenantId does not match the tenant of the addressed row`,
134
+ }));
135
+ }
136
+ return undefined;
137
+ };
114
138
  // idType default (undefined) is now "uuid" — the ES-pivot made UUID the
115
139
  // only valid aggregate-id type. Explicit `idType: "serial"` is the only
116
140
  // shape that's incompatible with the event-store and still rejected.
@@ -156,10 +180,10 @@ export function buildExecutorContext(table, entity, options) {
156
180
  function piiKms() {
157
181
  return options.kms ?? configuredPiiSubjectKms();
158
182
  }
159
- function kmsContextFor(user) {
183
+ function kmsContextFor(user, keyTenantId) {
160
184
  return {
161
185
  requestId: requestContext.get()?.requestId ?? "event-store-executor",
162
- ...(user && { tenantId: user.tenantId, userId: String(user.id) }),
186
+ ...(user && { tenantId: keyTenantId ?? user.tenantId, userId: String(user.id) }),
163
187
  };
164
188
  }
165
189
  // Async on purpose: the envelope cipher wraps/unwraps DEKs via the
@@ -176,8 +200,8 @@ export function buildExecutorContext(table, entity, options) {
176
200
  const kms = piiKms();
177
201
  if (hasPiiFields && kms) {
178
202
  try {
179
- out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
180
- tenantId: user.tenantId,
203
+ out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user, opts?.keyTenantId), {
204
+ tenantId: opts?.keyTenantId ?? user.tenantId,
181
205
  entityName,
182
206
  ...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
183
207
  ...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
@@ -198,14 +222,21 @@ export function buildExecutorContext(table, entity, options) {
198
222
  // unwrap before envelope decrypt, or the envelope cipher chokes on a
199
223
  // still-PII-wrapped string (auth-mfa.totpSecret/recoveryCodes combine both
200
224
  // markers; see pii-subject-encryption.integration.test.ts).
201
- async function decryptForRead(row) {
225
+ async function decryptForRead(row, opts) {
202
226
  let out = row;
203
227
  const kms = piiKms();
204
228
  if (hasPiiFields && kms) {
205
229
  out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
206
230
  }
207
231
  if (hasEncryptedFields) {
208
- out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher());
232
+ out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher(), opts?.degradeUndecryptable
233
+ ? {
234
+ onFieldError: (field, error) => {
235
+ logUndecryptableField({ entityName, rowId: row["id"], field, error });
236
+ opts.degradeUndecryptable?.(field);
237
+ },
238
+ }
239
+ : undefined);
209
240
  }
210
241
  return out;
211
242
  }
@@ -384,6 +415,7 @@ export function buildExecutorContext(table, entity, options) {
384
415
  searchAdapter,
385
416
  softDelete,
386
417
  streamTenantFor,
418
+ streamTenantOverrideFailure,
387
419
  idFilter,
388
420
  loadById,
389
421
  assertStreamWritable,
@@ -6,7 +6,7 @@ import { coerceRow, extractTableInfo } from "../db/query.js";
6
6
  import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
7
7
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
8
8
  import { UnprocessableError } from "../errors/index.js";
9
- import { getStreamVersion } from "../event-store/index.js";
9
+ import { getStreamVersion, getStreamVersions } from "../event-store/index.js";
10
10
  import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
11
11
  import { rehydrateCompoundTypes } from "./compound-types.js";
12
12
  import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
@@ -64,6 +64,9 @@ function toCursorSortText(value) {
64
64
  }
65
65
  return undefined;
66
66
  }
67
+ function withStreamVersion(row, streamVersion) {
68
+ return streamVersion > 0 ? { ...row, version: streamVersion } : row;
69
+ }
67
70
  // Keyset boundary for `ORDER BY <sort> <dir>, id ASC` under Postgres' DEFAULT
68
71
  // null ordering (ASC → NULLS LAST, DESC → NULLS FIRST). An explicit NULLS clause
69
72
  // would change the visible order and cost the sort its index.
@@ -661,11 +664,17 @@ export function createReadVerbs(ctx) {
661
664
  // field (ciphertext leaked to the caller).
662
665
  const tableInfo = extractTableInfo(table);
663
666
  const encryptedRows = rawRows.map((r) => rehydrateCompoundTypes(coerceRow(r, tableInfo), entity));
664
- const rows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
665
- // list rows carry the READ-ROW version (display-only), never an optimistic-lock
666
- // base — edit flows reload via detail(), which reconciles the stream version.
667
- // Cache the still-encrypted form: same at-rest guarantee as detail()'s
668
- // encryptForStorage round-trip, without paying a re-encrypt.
667
+ const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r, { degradeUndecryptable: () => { } })));
668
+ // Stream version is authoritative (see detail()): ctx.appendEvent bumps the
669
+ // stream without touching row.version, so the raw row version would hand
670
+ // clients a stale optimistic-lock base. Rows without events keep theirs.
671
+ const streamVersions = decryptedRows.length > 0
672
+ ? await getStreamVersions(runner, decryptedRows.map((r) => String(r["id"])), streamTenantFor(user))
673
+ : new Map();
674
+ const rows = decryptedRows.map((r) => withStreamVersion(r, streamVersions.get(String(r["id"])) ?? 0));
675
+ // Cache the still-encrypted raw rows (read-row version): same at-rest guarantee as
676
+ // detail()'s encryptForStorage round-trip, without a re-encrypt; detail() rejects
677
+ // a cached version that differs from the stream.
669
678
  if (entityCache && entityName && rows.length > 0) {
670
679
  await entityCache.mset(user.tenantId, entityName, encryptedRows.map((r) => ({ id: r["id"], data: r })));
671
680
  }
@@ -718,7 +727,6 @@ export function createReadVerbs(ctx) {
718
727
  // guaranteed version_conflict.
719
728
  // A cache hit only counts when its version equals the live stream version:
720
729
  // a late entityCache.set() can resurrect an old row after the writer's del().
721
- const withVersion = (row, streamVersion) => (streamVersion > 0 ? { ...row, version: streamVersion } : row);
722
730
  if (entityCache && entityName) {
723
731
  const cached = await entityCache.get(user.tenantId, entityName, payload.id);
724
732
  if (cached) {
@@ -736,7 +744,7 @@ export function createReadVerbs(ctx) {
736
744
  // Cached rows are stored re-encrypted (see the `set` below) so an
737
745
  // `encrypted` field's plaintext never sits in a second at-rest
738
746
  // store (Redis) the field-encryption feature doesn't cover.
739
- return withVersion(await decryptForRead(cached), streamVersion);
747
+ return withStreamVersion(await decryptForRead(cached, { degradeUndecryptable: () => { } }), streamVersion);
740
748
  }
741
749
  }
742
750
  }
@@ -747,9 +755,12 @@ export function createReadVerbs(ctx) {
747
755
  // Same coerce-before-rehydrate/decrypt ordering as list() above — raw
748
756
  // is snake_case (raw SQL); coerceRow maps it to camelCase.
749
757
  const rowInfo = extractTableInfo(table);
750
- const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity));
751
- const result = withVersion(coerced, snapshot.streamVersion);
752
- if (entityCache && entityName) {
758
+ const undecryptableFields = [];
759
+ const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity), { degradeUndecryptable: (field) => undecryptableFields.push(field) });
760
+ const result = withStreamVersion(coerced, snapshot.streamVersion);
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) {
753
764
  await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(result, user));
754
765
  }
755
766
  return result;