@cosmicdrift/kumiko-framework 0.337.1 → 0.339.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 (61) hide show
  1. package/dist/api/api-constants.d.ts +1 -0
  2. package/dist/api/api-constants.js +9 -0
  3. package/dist/api/auth-middleware.d.ts +1 -0
  4. package/dist/api/auth-middleware.js +56 -12
  5. package/dist/api/routes.d.ts +2 -1
  6. package/dist/api/routes.js +29 -6
  7. package/dist/api/server.js +20 -2
  8. package/dist/bun-db/query.js +8 -2
  9. package/dist/compliance/sub-processors.js +10 -1
  10. package/dist/crypto/event-pii.js +13 -4
  11. package/dist/crypto/index.d.ts +1 -1
  12. package/dist/crypto/index.js +1 -1
  13. package/dist/crypto/pii-ciphertext-format.d.ts +4 -0
  14. package/dist/crypto/pii-ciphertext-format.js +11 -0
  15. package/dist/crypto/pii-field-encryption.d.ts +4 -2
  16. package/dist/crypto/pii-field-encryption.js +50 -21
  17. package/dist/crypto/system-event-pii.js +2 -2
  18. package/dist/db/blind-index-cleanup.d.ts +1 -0
  19. package/dist/db/blind-index-cleanup.js +11 -0
  20. package/dist/db/event-store-executor-read.js +18 -3
  21. package/dist/db/index.d.ts +1 -1
  22. package/dist/db/index.js +1 -1
  23. package/dist/db/queries/backfill-pii.js +4 -0
  24. package/dist/db/tenant-db.js +25 -13
  25. package/dist/engine/boot-validator/access-declarations.js +13 -7
  26. package/dist/engine/boot-validator/entity-handler.d.ts +2 -0
  27. package/dist/engine/boot-validator/entity-handler.js +45 -0
  28. package/dist/engine/boot-validator/index.js +2 -1
  29. package/dist/engine/boot-validator/pii-retention.js +41 -1
  30. package/dist/engine/config-helpers.d.ts +2 -1
  31. package/dist/engine/config-helpers.js +1 -0
  32. package/dist/engine/define-handler.js +1 -0
  33. package/dist/engine/extensions/escape-hatch-usage.js +3 -1
  34. package/dist/engine/extensions/user-data.d.ts +1 -1
  35. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  36. package/dist/engine/feature-ast/extractors/hooks.js +7 -1
  37. package/dist/engine/feature-ast/pattern-change-schema.d.ts +40 -0
  38. package/dist/engine/feature-ast/pattern-change-schema.js +2 -0
  39. package/dist/engine/feature-config-events-jobs.js +5 -1
  40. package/dist/engine/feature-entity-handlers.js +1 -0
  41. package/dist/engine/feature-manifest.d.ts +8 -0
  42. package/dist/engine/feature-manifest.js +9 -0
  43. package/dist/engine/feature-ui-extensions.js +9 -0
  44. package/dist/engine/index.d.ts +3 -2
  45. package/dist/engine/index.js +1 -0
  46. package/dist/engine/registry-ingest.d.ts +1 -0
  47. package/dist/engine/registry-ingest.js +9 -0
  48. package/dist/engine/registry.js +2 -1
  49. package/dist/engine/steps/retry.js +0 -1
  50. package/dist/engine/steps/wait-for-event.js +0 -1
  51. package/dist/engine/steps/wait.js +0 -4
  52. package/dist/engine/steps/workflow-system-events.d.ts +2 -0
  53. package/dist/engine/steps/workflow-system-events.js +89 -0
  54. package/dist/engine/types/index.d.ts +1 -1
  55. package/dist/jobs/job-runner.js +2 -1
  56. package/dist/pipeline/append-event-core.d.ts +3 -1
  57. package/dist/pipeline/append-event-core.js +8 -4
  58. package/dist/pipeline/dispatch-shared.js +37 -7
  59. package/dist/pipeline/system-identity-switch.js +10 -7
  60. package/package.json +4 -4
  61. package/src/changes.json +102 -0
@@ -3,4 +3,5 @@ import type { DbRunner } from "./connection.js";
3
3
  export declare function nullBlindIndexesForSubject(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, subjectKey: string): Promise<void>;
4
4
  export declare function subjectRowExistsInTenant(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, subjectId: string, tenantId: string): Promise<boolean>;
5
5
  export declare function recordRowExistsInTenant(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, entityName: string, recordId: string, tenantId: string): Promise<boolean>;
6
+ export declare function recordEventsOwnedExclusivelyByTenant(db: DbRunner, aggregateType: string, aggregateId: string, tenantId: string): Promise<boolean>;
6
7
  export declare function recordRowOwningTenantId(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, entityName: string, recordId: string): Promise<string | undefined>;
@@ -131,6 +131,17 @@ export async function recordRowExistsInTenant(db, features, entityName, recordId
131
131
  const rows = await executeRawQueryRead(db, `SELECT 1 FROM ${quoteIdent(tableName)} WHERE id = $1 AND tenant_id = $2 LIMIT 1`, [recordId, tenantId]);
132
132
  return rows.length > 0;
133
133
  }
134
+ // Provenance fallback for record subjects whose projection row is gone (hard
135
+ // delete) or that have no registered entity at all (custom aggregates): the
136
+ // event stream still names the owning tenant. The unique index is per tenant
137
+ // and the subject key is global, so another tenant could open a stream under
138
+ // the same aggregate id; any foreign event voids the proof.
139
+ export async function recordEventsOwnedExclusivelyByTenant(db, aggregateType, aggregateId, tenantId) {
140
+ const rows = await executeRawQueryRead(db, `SELECT 1 FROM "kumiko_events" WHERE aggregate_type = $1 AND aggregate_id = $2::uuid AND tenant_id = $3::uuid
141
+ AND NOT EXISTS (SELECT 1 FROM "kumiko_events" WHERE aggregate_type = $1 AND aggregate_id = $2::uuid AND tenant_id <> $3::uuid)
142
+ LIMIT 1`, [aggregateType, aggregateId, tenantId]);
143
+ return rows.length > 0;
144
+ }
134
145
  // Owning tenant of a record subject's row, for callers that must evaluate
135
146
  // tenant-scoped policy for the row's real owner (not the acting user's tenant).
136
147
  export async function recordRowOwningTenantId(db, features, entityName, recordId) {
@@ -258,7 +258,15 @@ function isSoftDeletedTargetHidden(targetEntity, targetTable) {
258
258
  // unscoped SQL, a match in a foreign tenant must never surface a row here
259
259
  // (fw#2660's hard constraint). Mirrors the main table's own tenant filter
260
260
  // (see the `table["tenantId"]` check further down in list()).
261
- async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, db) {
261
+ async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db) {
262
+ // The label must not leak through match counts: a viewer who cannot read the
263
+ // target row or its label field would otherwise probe names by searching.
264
+ const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
265
+ if (labelAccess.kind !== "pass")
266
+ return undefined;
267
+ const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
268
+ if (ownership.kind === "empty")
269
+ return undefined;
262
270
  const labelCol = physicalColumnName(targetTable, descriptor.labelField);
263
271
  const subParams = [`%${escapeLikePattern(searchTerm)}%`];
264
272
  let tenantClause = "";
@@ -266,12 +274,19 @@ async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEnt
266
274
  subParams.push(db.tenantId, SYSTEM_TENANT_ID);
267
275
  tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
268
276
  }
277
+ let ownershipClause = "";
278
+ if (ownership.kind === "sql") {
279
+ const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, subParams.length);
280
+ ownershipClause = ` AND ${shifted.sqlText}`;
281
+ for (const p of shifted.params)
282
+ subParams.push(p);
283
+ }
269
284
  const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
270
285
  ? ` AND "${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
271
286
  : "";
272
287
  // ::text cast covers a non-text labelField (e.g. a number/select column
273
288
  // used as label) — Postgres has no ILIKE for those types otherwise.
274
- const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause}${deletedClause} ` +
289
+ const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause}${ownershipClause}${deletedClause} ` +
275
290
  `LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
276
291
  const rows = await executeRawQueryRead(tenantDbRunner(db), sql, subParams);
277
292
  if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
@@ -328,7 +343,7 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
328
343
  const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
329
344
  const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
330
345
  if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
331
- return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, db);
346
+ return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db);
332
347
  }
333
348
  if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
334
349
  // skip: no plaintext anywhere to match — boot rejects this normally,
@@ -1,5 +1,5 @@
1
1
  export { assertExistsIn } from "./assert-exists-in.js";
2
- export { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
2
+ export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
3
3
  export { collectTableMetas } from "./collect-table-metas.js";
4
4
  export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
5
5
  export { seedConfigValues } from "./config-seed.js";
package/dist/db/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { assertExistsIn } from "./assert-exists-in.js";
2
- export { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
2
+ export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
3
3
  export { collectTableMetas } from "./collect-table-metas.js";
4
4
  export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
5
5
  export { seedConfigValues } from "./config-seed.js";
@@ -300,6 +300,10 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
300
300
  const value = section[field];
301
301
  if (value === null || value === undefined)
302
302
  continue;
303
+ // jsonb PII is not retro-encrypted (consumer wave); a legacy string
304
+ // value here would otherwise get the v2 text format into a jsonb column.
305
+ if (target.entity.fields[field]?.type === "jsonb")
306
+ continue;
303
307
  if (typeof value !== "string")
304
308
  continue;
305
309
  // Already-ciphertext/sentinel fields must never enter owner
@@ -11,7 +11,7 @@ const declaredUnsafeRawRunners = new WeakMap();
11
11
  // TenantDb it was handed for its gate. Bound inside createTenantDb so rebound instances
12
12
  // (withUnsafeRawGrant, acknowledgeConventionCrossTenant) carry it too.
13
13
  const personalDataGates = new WeakMap();
14
- // Lets createTenantDb(ctx.db.unsafeRaw(reason), ...) inherit the gate. Keyed by a
14
+ // Lets createTenantDb(ctx.db.unsafeRaw(), ...) inherit the gate. Keyed by a
15
15
  // per-grant proxy, never the shared pool/tx: tagging that would gate every sibling TenantDb.
16
16
  const runnerPersonalDataGates = new WeakMap();
17
17
  function gatedRunner(runner, gate) {
@@ -96,9 +96,15 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
96
96
  }
97
97
  return dbOutsideTransaction;
98
98
  }
99
- function grantedUnsafeRawRunner(reason) {
99
+ function grantedUnsafeRawRunner(reason, forwardedDeclaredStep) {
100
100
  if (gate?.kind === "source-tenant-db") {
101
- return unsafeRawForDeclaredStep(db, reason);
101
+ const sourceRunner = declaredUnsafeRawRunners.get(db);
102
+ if (!sourceRunner) {
103
+ throw new InternalError({
104
+ message: "createSystemDbView received a TenantDb with no declared unsafeRaw runner bound.",
105
+ });
106
+ }
107
+ return sourceRunner(forwardedDeclaredStep ? reason : undefined);
102
108
  }
103
109
  if (reason.trim().length === 0) {
104
110
  throw new Error("unsafeRaw requires a non-empty reason");
@@ -109,7 +115,8 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
109
115
  `${gate.deniedCallerLabel} to allow unsafeRaw.`,
110
116
  });
111
117
  }
112
- report("unsafe-raw", reason);
118
+ // Engine-forwarded steps carry their own declared reason; a caller-supplied one never reaches the audit trail.
119
+ report("unsafe-raw", forwardedDeclaredStep ? reason : (gate?.grant?.reason ?? reason));
113
120
  const runner = tenantDbRunner(db);
114
121
  const personalDataGate = personalDataGates.get(db);
115
122
  return personalDataGate ? gatedRunner(runner, personalDataGate) : runner;
@@ -143,7 +150,7 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
143
150
  report("acknowledge-cross-tenant", reason);
144
151
  return db;
145
152
  },
146
- unsafeRaw: grantedUnsafeRawRunner,
153
+ unsafeRaw: (reason) => grantedUnsafeRawRunner(reason, false),
147
154
  outsideTransaction: {
148
155
  assertTenantMatch(tenantId) {
149
156
  if (tenantId !== db.tenantId) {
@@ -163,7 +170,14 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
163
170
  },
164
171
  },
165
172
  };
166
- declaredUnsafeRawRunners.set(uncheckedSystemDb, grantedUnsafeRawRunner);
173
+ declaredUnsafeRawRunners.set(uncheckedSystemDb, (declaredStepReason) => {
174
+ if (declaredStepReason === undefined) {
175
+ throw new InternalError({
176
+ message: "A declared unsafeRaw runner on a system db requires the step reason.",
177
+ });
178
+ }
179
+ return grantedUnsafeRawRunner(declaredStepReason, true);
180
+ });
167
181
  if (gate?.kind !== "source-tenant-db") {
168
182
  systemDbRebinders.set(uncheckedSystemDb, (grant, deniedCallerLabel) => buildUncheckedSystemDb(withUnsafeRawGrant(db, grant), dbOutsideTransaction && withUnsafeRawGrant(dbOutsideTransaction, grant), report, { kind: "hook-grant", grant, deniedCallerLabel }));
169
183
  }
@@ -419,28 +433,26 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
419
433
  // @cast-boundary type-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
420
434
  };
421
435
  }
422
- function grantedUnsafeRawRunner(reason) {
423
- if (reason.trim().length === 0) {
424
- throw new Error("unsafeRaw requires a non-empty reason");
425
- }
436
+ function grantedUnsafeRawRunner(declaredStepReason) {
426
437
  // Ahead of the grant check: a declared escapeHatch must not buy a raw runner here either.
427
438
  if (grants?.memberReadOnly) {
428
439
  throw memberResolutionReadOnlyDenied();
429
440
  }
430
441
  if (!hasGrant(grants?.unsafeRaw)) {
431
442
  throw new AccessDeniedError({
432
- message: 'ctx.db.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
443
+ message: 'ctx.db.unsafeRaw(): rejected — declare `escapeHatch: { reason: "..." }` on ' +
433
444
  "the handler or hook to allow unsafeRaw.",
434
445
  });
435
446
  }
436
- report("unsafe-raw", reason);
447
+ // Engine-forwarded steps carry their own declared reason; otherwise the grant's reason is audited.
448
+ report("unsafe-raw", declaredStepReason ?? grants?.unsafeRaw?.reason ?? "");
437
449
  return personalDataGate ? gatedRunner(db, personalDataGate) : db;
438
450
  }
439
451
  const tenantDb = {
440
452
  tenantId,
441
453
  mode,
442
454
  global: globalTable,
443
- unsafeRaw: grantedUnsafeRawRunner,
455
+ unsafeRaw: () => grantedUnsafeRawRunner(),
444
456
  selectMany(table, where, options) {
445
457
  const filter = readWhere(table, where);
446
458
  return withDbSpan("select", table, async () => bunSelectMany(db, table, filter, options));
@@ -1,3 +1,4 @@
1
+ import { escapeHatchGrantsProblem } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import { accessAllowsAnonymous, declaredPersonalData, personalFieldNames, } from "../personal-data-fields.js";
2
3
  import { ANONYMOUS_ROLE } from "../system-user.js";
3
4
  import { collectZodObjectKeys } from "./zod-shape.js";
@@ -48,13 +49,18 @@ function validateOpenToAllReason(feature, kind, handlerName, access) {
48
49
  "authenticated user may call this handler. `{ openToAll: true }` is no longer accepted.");
49
50
  }
50
51
  function validateEscapeHatchReason(feature, kind, handlerName, escapeHatch) {
51
- // skip: no escapeHatch declared, or its reason is already non-empty
52
- if (!escapeHatch || escapeHatch.reason.trim().length > 0)
53
- return;
54
- throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
55
- `{ escapeHatch: { reason: "" } } — the reason must be a non-empty string ` +
56
- "explaining why this handler needs db.global() write access, a SYSTEM identity switch " +
57
- "or a cross-tenant ctx.queryProjection({ unsafeAllTenants: true }) read.");
52
+ if (escapeHatch) {
53
+ const grantsProblem = escapeHatchGrantsProblem(escapeHatch);
54
+ if (grantsProblem !== undefined) {
55
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid escapeHatch — ${grantsProblem}`);
56
+ }
57
+ if (escapeHatch.reason.trim().length === 0) {
58
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
59
+ `{ escapeHatch: { reason: "" } } — the reason must be a non-empty string ` +
60
+ "explaining why this handler needs db.global() write access, a SYSTEM identity switch " +
61
+ "or a cross-tenant ctx.queryProjection({ unsafeAllTenants: true }) read.");
62
+ }
63
+ }
58
64
  }
59
65
  function validatePersonalDataOnlyOnWrite(feature, kind, handlerName, access) {
60
66
  // skip: write handlers may declare personalData, or it wasn't declared here
@@ -6,6 +6,7 @@ export declare const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string>;
6
6
  /** Extensions with preSave must target an entity that has mapped write handlers. */
7
7
  export declare function validateExtensionPreSaveWiring(features: readonly FeatureDefinition[]): void;
8
8
  export declare function validateHandlerAccess(feature: FeatureDefinition): void;
9
+ export declare function validateTenantlessAnonymous(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
9
10
  export declare function validateRateLimitDisabledReason(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
10
11
  export declare function validateNoAllRoleInHandlerAccess(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>): void;
11
12
  export declare function validateAnonymousRateLimit(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
@@ -22,3 +23,4 @@ export declare function validateImageVariants(feature: FeatureDefinition): void;
22
23
  export declare function validateTransitions(feature: FeatureDefinition): void;
23
24
  export declare function validateExtendSchemaCollisions(feature: FeatureDefinition): void;
24
25
  export declare function validateDerivedFieldCollisions(feature: FeatureDefinition): void;
26
+ export declare function validateSearchableFieldReadAccess(feature: FeatureDefinition): void;
@@ -134,6 +134,25 @@ export function validateHandlerAccess(feature) {
134
134
  validateRateLimitDisabledReason(feature.name, kind, name, handler.rateLimit);
135
135
  }
136
136
  }
137
+ for (const [name, handler] of Object.entries(feature.writeHandlers)) {
138
+ validateTenantlessAnonymous(feature.name, name, handler);
139
+ }
140
+ }
141
+ export function validateTenantlessAnonymous(featureName, handlerName, handler) {
142
+ // skip: handler does not opt into tenantless anonymous calls
143
+ if (!handler.tenantlessAnonymous)
144
+ return;
145
+ const where = `write handler "${featureName}:write:${handlerName}" declares tenantlessAnonymous`;
146
+ const { access } = handler;
147
+ const isAnonymousOnly = "roles" in access && access.roles.length === 1 && access.roles[0] === "anonymous";
148
+ if (!isAnonymousOnly) {
149
+ throw new Error(`${where} but its access is not exactly { roles: ["anonymous"] } — a tenantless request ` +
150
+ `carries no principal beyond the anonymous visitor.`);
151
+ }
152
+ if (!handler.rateLimit || isRateLimitDisabled(handler.rateLimit)) {
153
+ throw new Error(`${where} without a rateLimit — a handler reachable on hosts that resolve no tenant ` +
154
+ `needs a real upper bound (rateLimit: { per: "ip", ... }); { disabled: true } does not count.`);
155
+ }
137
156
  }
138
157
  export function validateRateLimitDisabledReason(featureName, kind, handlerName, rateLimit) {
139
158
  // skip: rate limit isn't disabled, so no reason is required
@@ -784,3 +803,29 @@ export function validateDerivedFieldCollisions(feature) {
784
803
  }
785
804
  }
786
805
  }
806
+ // --- Searchable fields with restricted read access ---
807
+ // The search index is tenant-wide, not per-role: `search` matches every
808
+ // searchable field and `filterReadFields` only strips the response afterwards.
809
+ // A role that cannot read the field could still guess its value from match
810
+ // counts, so the combination is rejected until the index can filter per role.
811
+ export function validateSearchableFieldReadAccess(feature) {
812
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
813
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
814
+ if (field.type !== "text")
815
+ continue;
816
+ if (!field.searchable)
817
+ continue;
818
+ const readAccess = field.access?.read;
819
+ if (readAccess === undefined)
820
+ continue;
821
+ const isUnrestricted = Array.isArray(readAccess)
822
+ ? readAccess.length === 0
823
+ : Object.keys(readAccess).length === 0;
824
+ if (isUnrestricted)
825
+ continue;
826
+ throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be searchable with a restricted ` +
827
+ "access.read — the search index is not filtered per role, so search matches would leak " +
828
+ 'the value to roles that cannot read it. Drop `searchable`/`find: "fuzzy"` or `access.read`.');
829
+ }
830
+ }
831
+ }
@@ -9,7 +9,7 @@ import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext.
9
9
  import { validateFeatureBootChecks } from "./boot-check.js";
10
10
  import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyOptionsQuery, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
11
11
  import { collectDetailForScreens } from "./detail-screens.js";
12
- import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateTransitions, } from "./entity-handler.js";
12
+ import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
13
13
  import { validateEntityListScreens } from "./entity-list-screens.js";
14
14
  import { validateGdprStoragePersistence } from "./gdpr-storage.js";
15
15
  import { validateGlobalTenancyEntities } from "./global-tenancy.js";
@@ -118,6 +118,7 @@ export function validateBoot(rawFeatures, options) {
118
118
  validateCircularDeps(feature.name, featureMap);
119
119
  if (validateEncryptedFields(feature))
120
120
  hasEncryptedFields = true;
121
+ validateSearchableFieldReadAccess(feature);
121
122
  if (validateFileFields(feature))
122
123
  hasFileFields = true;
123
124
  validatePiiAndRetention(feature);
@@ -12,6 +12,12 @@ const FRAMEWORK_TIMESTAMP_FIELDS = new Set([
12
12
  // macht nur den Sanity-Check damit Tippfehler ("30days") frueh sichtbar
13
13
  // werden statt erst beim ersten Cleanup-Run.
14
14
  const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
15
+ const FILE_FIELD_TYPES = new Set([
16
+ "file",
17
+ "image",
18
+ "files",
19
+ "images",
20
+ ]);
15
21
  // Excludes subjectRef: its `personal: "ref"` union member structurally
16
22
  // forbids `anonymize` (packages/types/src/fields.ts) — #2336.
17
23
  function hasAnonymizableSubjectField(annot) {
@@ -65,6 +71,33 @@ function validateWriteOnlyField(featureName, entityName, fieldName, field) {
65
71
  }
66
72
  }
67
73
  }
74
+ // A personal jsonb field is stored as a ciphertext JSON string scalar, so
75
+ // anything that reads inside the column (index, filter, sort, search, jsonb
76
+ // operators/merges such as the custom-fields column) sees only ciphertext.
77
+ const JSONB_PERSONAL_FORBIDDEN_FLAGS = [
78
+ "sortable",
79
+ "filterable",
80
+ "searchable",
81
+ "lookupable",
82
+ ];
83
+ const CUSTOM_FIELDS_COLUMN = "customFields";
84
+ function validatePersonalJsonbField(featureName, entityName, entity, fieldName, field) {
85
+ const where = `[Feature ${featureName}] Field "${fieldName}" on entity "${entityName}"`;
86
+ const reason = "a personal jsonb field is stored as a ciphertext string, so nothing inside it can be indexed, filtered, sorted, searched or merged";
87
+ const flags = field; // @cast-boundary schema-walk
88
+ for (const flag of JSONB_PERSONAL_FORBIDDEN_FLAGS) {
89
+ if (flags[flag] !== undefined && flags[flag] !== false) {
90
+ throw new Error(`${where} combines a personal annotation on a jsonb field with { ${flag} } — ${reason}.`);
91
+ }
92
+ }
93
+ const index = entity.indexes?.find((def) => def.columns.includes(fieldName));
94
+ if (index) {
95
+ throw new Error(`${where} is a personal jsonb field used in an entity index — ${reason}. Drop the index or the personal annotation.`);
96
+ }
97
+ if (fieldName === CUSTOM_FIELDS_COLUMN) {
98
+ throw new Error(`${where} is the custom-fields jsonb column — its values are merged and searched per key, which ciphertext cannot support. Drop the personal annotation.`);
99
+ }
100
+ }
68
101
  // --- PII / Subject-Key Annotations + Retention validation ---
69
102
  //
70
103
  // Drei Klassen von Checks:
@@ -147,6 +180,9 @@ export function validatePiiAndRetention(feature) {
147
180
  throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines { sensitive: true } with { searchable: true } — sensitive means ciphertext at rest, never searchable, stripped from event echoes — authorized readers can still read it back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not. To never return the value at all, add { writeOnly: true }.`);
148
181
  }
149
182
  }
183
+ if (field.type === "jsonb" && annotCount > 0) {
184
+ validatePersonalJsonbField(feature.name, entityName, entity, fieldName, field);
185
+ }
150
186
  if (annot.userOwned) {
151
187
  const ownerName = annot.userOwned.ownerField;
152
188
  if (!ownerName || typeof ownerName !== "string") {
@@ -184,7 +220,11 @@ export function validatePiiAndRetention(feature) {
184
220
  const noAnnotation = annotCount === 0 && !annot.allowPlaintext;
185
221
  if (noAnnotation) {
186
222
  const lower = fieldName.toLowerCase();
187
- if (PII_DIRECT_NAME_HINTS.has(lower)) {
223
+ if (FILE_FIELD_TYPES.has(field.type)) {
224
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
225
+ console.warn(`[kumiko:boot] [Feature ${feature.name}] File field "${fieldName}" on entity "${entityName}" has no personal annotation. A forget with strategy "delete" then only severs the uploader link and keeps the binary. If the file holds personal data, mark it { personal: "self" } or { personal: { of: "<ownerField>" } }. If it is business data, set { personal: false, reason: "is_business_data" } to silence.`);
226
+ }
227
+ else if (PII_DIRECT_NAME_HINTS.has(lower)) {
188
228
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
189
229
  console.warn(`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a PII-typical name but no personal annotation. If this is PII, mark it { personal: "self", find: ... }. If business data, set { personal: false, reason: "is_business_data" } to silence.`);
190
230
  }
@@ -1,4 +1,4 @@
1
- import type { ConfigBacking, ConfigBounds, ConfigComputedFn, ConfigKeyDefinition, ConfigKeyType, ConfigMask, ConfigSeedDef, ConfigValue, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, OptionsQueryPayload } from "./types/index.js";
1
+ import type { ConfigBacking, ConfigBounds, ConfigComputedFn, ConfigKeyDefinition, ConfigKeyType, ConfigMask, ConfigSeedDef, ConfigValidateFn, ConfigValue, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, OptionsQueryPayload } from "./types/index.js";
2
2
  export declare function isEncryptedAtRest(def: Pick<ConfigKeyDefinition, "encrypted" | "backing">): boolean;
3
3
  export declare const access: {
4
4
  readonly all: readonly string[];
@@ -26,6 +26,7 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
26
26
  flags?: string;
27
27
  } : never;
28
28
  computed?: ConfigComputedFn<T>;
29
+ validate?: ConfigValidateFn<T>;
29
30
  allowPerRequest?: T extends "text" ? never : boolean;
30
31
  required?: boolean;
31
32
  env?: string;
@@ -60,6 +60,7 @@ function createConfigKey(scope, type, opts = {}) {
60
60
  bounds: opts.bounds, // @cast-boundary schema-walk
61
61
  ...(opts.pattern ? { pattern: opts.pattern } : {}),
62
62
  computed: opts.computed,
63
+ validate: opts.validate,
63
64
  ...(opts.allowPerRequest === true ? { allowPerRequest: true } : {}),
64
65
  ...(opts.required === true ? { required: true } : {}),
65
66
  ...(opts.env ? { env: opts.env } : {}),
@@ -51,6 +51,7 @@ export function defineWriteHandler(def,
51
51
  }),
52
52
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
53
53
  ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
54
+ ...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
54
55
  };
55
56
  if ("perform" in def && def.perform !== undefined) {
56
57
  const performDef = def.perform;
@@ -7,6 +7,8 @@ export function extensionUsageEscapeHatchReason(usage) {
7
7
  const escapeHatch = usage.options?.["escapeHatch"];
8
8
  if (typeof escapeHatch !== "object" || escapeHatch === null)
9
9
  return undefined;
10
- const reason = escapeHatch.reason;
10
+ const { reason, grants } = escapeHatch;
11
+ if (Array.isArray(grants) && !grants.includes("unsafeRaw"))
12
+ return undefined;
11
13
  return typeof reason === "string" && reason.trim().length > 0 ? reason : undefined;
12
14
  }
@@ -30,7 +30,7 @@ export type TenantUserModel = "single-user" | "multi-user";
30
30
  * fw#2914 — `db` is a tenant-filtered `TenantDb`, bound to `tenantId`
31
31
  * (or the per-user sub-tx in the forget path). Unfiltered access needs
32
32
  * `escapeHatch: { reason }` on the `r.useExtension(...)` registration,
33
- * then `ctx.db.unsafeRaw(reason)`.
33
+ * then `ctx.db.unsafeRaw()`.
34
34
  */
35
35
  /**
36
36
  * Minimal storage surface a file-aware forget hook needs to erase binaries.
@@ -32,6 +32,7 @@ const WRITE_HANDLER_KEY_KINDS = {
32
32
  unsafeSkipTransitionGuard: "modeled",
33
33
  rateLimit: "modeled",
34
34
  escapeHatch: "modeled",
35
+ tenantlessAnonymous: "opaque",
35
36
  perform: "opaque",
36
37
  };
37
38
  const QUERY_HANDLER_KEY_KINDS = {
@@ -1,3 +1,4 @@
1
+ import { isEscapeHatchGrant } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import { SyntaxKind } from "ts-morph";
2
3
  import { sourceLocationFromNode } from "../source-location.js";
3
4
  import { containsRawRefSentinel, fail, findFunctionLiteral, isPlainObject, ok, readDataLiteralNode, readNameLiteral, readNameOrRef, readNameOrRefOrList, readObjectPropertyInitializer, } from "./shared.js";
@@ -75,7 +76,12 @@ export function readOptionalEscapeHatch(value) {
75
76
  return undefined;
76
77
  if (typeof value["reason"] !== "string")
77
78
  return undefined;
78
- return { reason: value["reason"] };
79
+ const grants = value["grants"];
80
+ if (grants === undefined)
81
+ return { reason: value["reason"] };
82
+ if (!Array.isArray(grants) || !grants.every(isEscapeHatchGrant))
83
+ return undefined;
84
+ return { reason: value["reason"], grants };
79
85
  }
80
86
  // Reads the `escapeHatch` sub-property node first, not via readDataLiteralNode
81
87
  // on the whole object: a sibling property like `handler` (a closure) isn't
@@ -197,6 +197,11 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
197
197
  }, z.core.$strict>]>>;
198
198
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
199
199
  reason: z.ZodString;
200
+ grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
201
+ globalWrites: "globalWrites";
202
+ systemIdentity: "systemIdentity";
203
+ unsafeRaw: "unsafeRaw";
204
+ }>>>;
200
205
  }, z.core.$strict>, z.ZodObject<{
201
206
  __raw: z.ZodString;
202
207
  }, z.core.$strict>]>>;
@@ -249,6 +254,11 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
249
254
  }, z.core.$strict>]>>;
250
255
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
251
256
  reason: z.ZodString;
257
+ grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
258
+ globalWrites: "globalWrites";
259
+ systemIdentity: "systemIdentity";
260
+ unsafeRaw: "unsafeRaw";
261
+ }>>>;
252
262
  }, z.core.$strict>, z.ZodObject<{
253
263
  __raw: z.ZodString;
254
264
  }, z.core.$strict>]>>;
@@ -290,6 +300,11 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
290
300
  }, z.core.$strict>]>>;
291
301
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
292
302
  reason: z.ZodString;
303
+ grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
304
+ globalWrites: "globalWrites";
305
+ systemIdentity: "systemIdentity";
306
+ unsafeRaw: "unsafeRaw";
307
+ }>>>;
293
308
  }, z.core.$strict>, z.ZodObject<{
294
309
  __raw: z.ZodString;
295
310
  }, z.core.$strict>]>>;
@@ -321,6 +336,11 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
321
336
  }>>;
322
337
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
323
338
  reason: z.ZodString;
339
+ grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
340
+ globalWrites: "globalWrites";
341
+ systemIdentity: "systemIdentity";
342
+ unsafeRaw: "unsafeRaw";
343
+ }>>>;
324
344
  }, z.core.$strict>, z.ZodObject<{
325
345
  __raw: z.ZodString;
326
346
  }, z.core.$strict>]>>;
@@ -689,6 +709,11 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
689
709
  }, z.core.$strict>]>>;
690
710
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
691
711
  reason: z.ZodString;
712
+ grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
713
+ globalWrites: "globalWrites";
714
+ systemIdentity: "systemIdentity";
715
+ unsafeRaw: "unsafeRaw";
716
+ }>>>;
692
717
  }, z.core.$strict>, z.ZodObject<{
693
718
  __raw: z.ZodString;
694
719
  }, z.core.$strict>]>>;
@@ -740,6 +765,11 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
740
765
  }, z.core.$strict>]>>;
741
766
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
742
767
  reason: z.ZodString;
768
+ grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
769
+ globalWrites: "globalWrites";
770
+ systemIdentity: "systemIdentity";
771
+ unsafeRaw: "unsafeRaw";
772
+ }>>>;
743
773
  }, z.core.$strict>, z.ZodObject<{
744
774
  __raw: z.ZodString;
745
775
  }, z.core.$strict>]>>;
@@ -780,6 +810,11 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
780
810
  }, z.core.$strict>]>>;
781
811
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
782
812
  reason: z.ZodString;
813
+ grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
814
+ globalWrites: "globalWrites";
815
+ systemIdentity: "systemIdentity";
816
+ unsafeRaw: "unsafeRaw";
817
+ }>>>;
783
818
  }, z.core.$strict>, z.ZodObject<{
784
819
  __raw: z.ZodString;
785
820
  }, z.core.$strict>]>>;
@@ -810,6 +845,11 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
810
845
  }>>;
811
846
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
812
847
  reason: z.ZodString;
848
+ grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
849
+ globalWrites: "globalWrites";
850
+ systemIdentity: "systemIdentity";
851
+ unsafeRaw: "unsafeRaw";
852
+ }>>>;
813
853
  }, z.core.$strict>, z.ZodObject<{
814
854
  __raw: z.ZodString;
815
855
  }, z.core.$strict>]>>;
@@ -21,6 +21,7 @@
21
21
  // resolve this statically" markers) and, for EntityDefinition only,
22
22
  // additionally checked for field-type membership via the shared
23
23
  // `findUnknownEntityFieldTypes` helper (kept in sync with `extractEntity`).
24
+ import { ESCAPE_HATCH_GRANTS } from "@cosmicdrift/kumiko-types/handlers";
24
25
  import * as z from "zod";
25
26
  import { LifecycleHookTypes } from "../constants.js";
26
27
  import { describeUnknownFieldType, findUnknownEntityFieldTypes } from "./entity-field-types.js";
@@ -131,6 +132,7 @@ const escapeHatchSchema = z
131
132
  reason: z.string().refine((s) => s.trim().length > 0, {
132
133
  message: "reason must be non-empty",
133
134
  }),
135
+ grants: z.array(z.enum(ESCAPE_HATCH_GRANTS)).min(1).optional(),
134
136
  })
135
137
  .strict();
136
138
  const rawRefSentinelSchema = z.object({ __raw: z.string() }).strict();
@@ -1,4 +1,4 @@
1
- import { normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
1
+ import { escapeHatchGrantsProblem, normalizeEventPiiSubject, } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import { ZodObject } from "zod";
3
3
  import { resolveName } from "./handler-helpers.js";
4
4
  import { splitNamedDefinition, unwrapArrayForm } from "./object-form.js";
@@ -124,6 +124,10 @@ export function buildConfigEventsJobsMethods(state, name) {
124
124
  throw new Error(`[Feature ${name}] r.job("${jobName}") declares { escapeHatch: { reason: "" } } — ` +
125
125
  "the reason must be a non-empty string explaining why this job needs unfiltered database access.");
126
126
  }
127
+ const jobGrantsProblem = jobOptions.escapeHatch && escapeHatchGrantsProblem(jobOptions.escapeHatch);
128
+ if (jobGrantsProblem) {
129
+ throw new Error(`[Feature ${name}] r.job("${jobName}") declares an invalid escapeHatch — ${jobGrantsProblem}`);
130
+ }
127
131
  // Resolve NameOrRef(s) in trigger.on. Multi-Trigger-Form: Array
128
132
  // wird zu Array von resolved strings, Single bleibt single string —
129
133
  // job-runner unterscheidet anhand Array.isArray.
@@ -106,6 +106,7 @@ export function buildEntityHandlerMethods(state, name) {
106
106
  ...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
107
107
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
108
108
  ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
109
+ ...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
109
110
  // Forward the pipeline-build closure so boot-validators and
110
111
  // Designer/AI tooling can inspect the step list. Absent on
111
112
  // free-form handlers — defineWriteHandler only sets `perform`