@cosmicdrift/kumiko-framework 0.337.1 → 0.338.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 (33) 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.js +2 -2
  4. package/dist/api/server.js +11 -1
  5. package/dist/compliance/sub-processors.js +10 -1
  6. package/dist/db/blind-index-cleanup.d.ts +1 -0
  7. package/dist/db/blind-index-cleanup.js +11 -0
  8. package/dist/db/event-store-executor-read.js +18 -3
  9. package/dist/db/index.d.ts +1 -1
  10. package/dist/db/index.js +1 -1
  11. package/dist/db/tenant-db.js +16 -8
  12. package/dist/engine/boot-validator/access-declarations.js +13 -7
  13. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  14. package/dist/engine/boot-validator/entity-handler.js +26 -0
  15. package/dist/engine/boot-validator/index.js +2 -1
  16. package/dist/engine/boot-validator/pii-retention.js +11 -1
  17. package/dist/engine/config-helpers.d.ts +2 -1
  18. package/dist/engine/config-helpers.js +1 -0
  19. package/dist/engine/extensions/escape-hatch-usage.js +3 -1
  20. package/dist/engine/feature-ast/extractors/hooks.js +7 -1
  21. package/dist/engine/feature-ast/pattern-change-schema.d.ts +40 -0
  22. package/dist/engine/feature-ast/pattern-change-schema.js +2 -0
  23. package/dist/engine/feature-config-events-jobs.js +5 -1
  24. package/dist/engine/feature-manifest.d.ts +1 -0
  25. package/dist/engine/feature-manifest.js +1 -0
  26. package/dist/engine/feature-ui-extensions.js +9 -0
  27. package/dist/engine/index.d.ts +1 -1
  28. package/dist/engine/types/index.d.ts +1 -1
  29. package/dist/jobs/job-runner.js +2 -1
  30. package/dist/pipeline/dispatch-shared.js +32 -5
  31. package/dist/pipeline/system-identity-switch.js +10 -7
  32. package/package.json +4 -4
  33. package/src/changes.json +61 -0
@@ -33,6 +33,7 @@ export declare const Routes: {
33
33
  };
34
34
  export declare const PUBLIC_API_PATHS: ReadonlySet<string>;
35
35
  export declare const NON_PUBLIC_API_PATHS: ReadonlySet<string>;
36
+ export declare function isSessionRequiredApiPath(path: string): boolean;
36
37
  export declare const BODY_LIMIT_OPT_OUT_PATHS: ReadonlySet<string>;
37
38
  export declare const AUTH_RATE_LIMIT_EXEMPT_ROUTES: ReadonlyArray<{
38
39
  readonly method: string;
@@ -87,6 +87,15 @@ export const NON_PUBLIC_API_PATHS = new Set([
87
87
  `/api${Routes.files}`,
88
88
  `/api${Routes.schema}`,
89
89
  ]);
90
+ // Paths that need a session but no tenant: without a token they answer 401
91
+ // before any tenant resolution, so an unresolvable tenant can never turn
92
+ // "not logged in" into a 400 tenant_required.
93
+ const SESSION_REQUIRED_API_PREFIXES = [`/api${Routes.auth}/`];
94
+ const SESSION_REQUIRED_API_EXACT_PATHS = new Set([`/api${Routes.schema}`]);
95
+ export function isSessionRequiredApiPath(path) {
96
+ return (SESSION_REQUIRED_API_EXACT_PATHS.has(path) ||
97
+ SESSION_REQUIRED_API_PREFIXES.some((prefix) => path.startsWith(prefix)));
98
+ }
90
99
  // Opt-out from the default request-body-size cap (registerBodyLimit applies
91
100
  // it to all of /api/* by construction — a new route needs no entry here to
92
101
  // be covered). Only routes with their own, deliberately different size
@@ -2,7 +2,7 @@ import { getCookie } from "hono/cookie";
2
2
  import { TENANT_TEARDOWN_STATUSES } from "../engine/active-membership.js";
3
3
  import { createAnonymousUser } from "../engine/system-user.js";
4
4
  import { parseTenantId } from "../engine/types/identifiers.js";
5
- import { TENANT_COOKIE_NAME, TENANT_HEADER_NAME } from "./api-constants.js";
5
+ import { isSessionRequiredApiPath, TENANT_COOKIE_NAME, TENANT_HEADER_NAME, } from "./api-constants.js";
6
6
  import { isForeignCookieOrigin } from "./origin-middleware.js";
7
7
  const USER_KEY = "pipelineUser";
8
8
  const AUTH_TRANSPORT_KEY = "authTransport";
@@ -109,7 +109,7 @@ export function authMiddleware(jwt, options = {}) {
109
109
  // missing authentication, nicht missing tenant. Daher hier direkt
110
110
  // 401, ohne den anonymous-Tenant-Flow zu durchlaufen.
111
111
  if (anonymousAccess) {
112
- if (c.req.path.startsWith("/api/auth/")) {
112
+ if (isSessionRequiredApiPath(c.req.path)) {
113
113
  return middlewareReject(c, {
114
114
  code: "missing_token",
115
115
  status: 401,
@@ -23,7 +23,7 @@ import { authEndpointRateLimit, createRateLimitResolver, globalIpRateLimit, } fr
23
23
  import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config.js";
24
24
  import { assertUnreachable, generateId } from "../utils/index.js";
25
25
  import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
26
- import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS, Routes } from "./api-constants.js";
26
+ import { NO_ROUTE_MATCH_HEADER_NAME, NON_PUBLIC_API_PATHS, PUBLIC_API_PATHS, Routes, } from "./api-constants.js";
27
27
  import { authMiddleware, getAuthTokenExpiry, getUser, } from "./auth-middleware.js";
28
28
  import { assertSessionCallbacksPaired, createAuthRoutes, } from "./auth-routes.js";
29
29
  import { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
@@ -708,6 +708,16 @@ export function buildServer(options) {
708
708
  throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
709
709
  'use a wildcard under "/api/" — it would bypass the auth chain for every matching path.');
710
710
  }
711
+ // A `:param` segment is as broad as a wildcard, and the public matchers test
712
+ // the bare request path: "/:seg/write" and "/api/:hook" both match /api/write.
713
+ if (route.entry === ExtraRouteEntries.signature) {
714
+ const routePattern = honoPathToRegex(route.path);
715
+ const shadowedFrameworkPath = [...PUBLIC_API_PATHS, ...NON_PUBLIC_API_PATHS].find((path) => routePattern.test(path));
716
+ if (shadowedFrameworkPath !== undefined) {
717
+ throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
718
+ `match the framework path "${shadowedFrameworkPath}" — it would bypass the auth chain for it.`);
719
+ }
720
+ }
711
721
  }
712
722
  const dispatchSystemWrite = makeDispatchSystemWrite(dispatcher);
713
723
  const dispatchSystemQuery = makeDispatchSystemQuery(dispatcher);
@@ -84,13 +84,22 @@ export const KUMIKO_SUB_PROCESSORS = [
84
84
  },
85
85
  {
86
86
  name: "Scaleway SAS",
87
- purpose: "Offsite Backup Mirror (Object Storage); planned: platform key custody (Key Manager) and Transactional Email",
87
+ purpose: "Offsite Backup Mirror (Object Storage)",
88
88
  region: "EU (France)",
89
89
  dpa: "https://www.scaleway.com/en/contracts/",
90
90
  addedAt: "2026-09-18",
91
91
  appliesTo: ["all-tiers"],
92
92
  status: "active",
93
93
  },
94
+ {
95
+ name: "Scaleway SAS",
96
+ purpose: "Platform key custody (Key Manager) and Transactional Email Delivery",
97
+ region: "EU (France)",
98
+ dpa: "https://www.scaleway.com/en/contracts/",
99
+ addedAt: "2026-09-18",
100
+ appliesTo: ["all-tiers"],
101
+ status: "planned",
102
+ },
94
103
  ];
95
104
  /**
96
105
  * Filter helper — nur aktive Sub-Processors (kein status="planned").
@@ -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";
@@ -61,7 +61,7 @@ export function unsafeRawForDeclaredStep(holder, reason) {
61
61
  "createUncheckedSystemDb, or createSystemDbView — no declared unsafeRaw runner bound.",
62
62
  });
63
63
  }
64
- return runner(reason);
64
+ return runner(reason, true);
65
65
  }
66
66
  const systemDbRebinders = new WeakMap();
67
67
  // Rebinds a hook's own escapeHatch onto ctx.systemDb, mirroring withUnsafeRawGrant: always
@@ -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(reason, forwardedDeclaredStep);
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) {
@@ -419,7 +426,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
419
426
  // @cast-boundary type-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
420
427
  };
421
428
  }
422
- function grantedUnsafeRawRunner(reason) {
429
+ function grantedUnsafeRawRunner(reason, forwardedDeclaredStep) {
423
430
  if (reason.trim().length === 0) {
424
431
  throw new Error("unsafeRaw requires a non-empty reason");
425
432
  }
@@ -433,14 +440,15 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
433
440
  "the handler or hook to allow unsafeRaw.",
434
441
  });
435
442
  }
436
- report("unsafe-raw", reason);
443
+ // Engine-forwarded steps carry their own declared reason; a caller-supplied one never reaches the audit trail.
444
+ report("unsafe-raw", forwardedDeclaredStep ? reason : (grants?.unsafeRaw?.reason ?? reason));
437
445
  return personalDataGate ? gatedRunner(db, personalDataGate) : db;
438
446
  }
439
447
  const tenantDb = {
440
448
  tenantId,
441
449
  mode,
442
450
  global: globalTable,
443
- unsafeRaw: grantedUnsafeRawRunner,
451
+ unsafeRaw: (reason) => grantedUnsafeRawRunner(reason, false),
444
452
  selectMany(table, where, options) {
445
453
  const filter = readWhere(table, where);
446
454
  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
@@ -22,3 +22,4 @@ export declare function validateImageVariants(feature: FeatureDefinition): void;
22
22
  export declare function validateTransitions(feature: FeatureDefinition): void;
23
23
  export declare function validateExtendSchemaCollisions(feature: FeatureDefinition): void;
24
24
  export declare function validateDerivedFieldCollisions(feature: FeatureDefinition): void;
25
+ export declare function validateSearchableFieldReadAccess(feature: FeatureDefinition): void;
@@ -784,3 +784,29 @@ export function validateDerivedFieldCollisions(feature) {
784
784
  }
785
785
  }
786
786
  }
787
+ // --- Searchable fields with restricted read access ---
788
+ // The search index is tenant-wide, not per-role: `search` matches every
789
+ // searchable field and `filterReadFields` only strips the response afterwards.
790
+ // A role that cannot read the field could still guess its value from match
791
+ // counts, so the combination is rejected until the index can filter per role.
792
+ export function validateSearchableFieldReadAccess(feature) {
793
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
794
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
795
+ if (field.type !== "text")
796
+ continue;
797
+ if (!field.searchable)
798
+ continue;
799
+ const readAccess = field.access?.read;
800
+ if (readAccess === undefined)
801
+ continue;
802
+ const isUnrestricted = Array.isArray(readAccess)
803
+ ? readAccess.length === 0
804
+ : Object.keys(readAccess).length === 0;
805
+ if (isUnrestricted)
806
+ continue;
807
+ throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be searchable with a restricted ` +
808
+ "access.read — the search index is not filtered per role, so search matches would leak " +
809
+ 'the value to roles that cannot read it. Drop `searchable`/`find: "fuzzy"` or `access.read`.');
810
+ }
811
+ }
812
+ }
@@ -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) {
@@ -184,7 +190,11 @@ export function validatePiiAndRetention(feature) {
184
190
  const noAnnotation = annotCount === 0 && !annot.allowPlaintext;
185
191
  if (noAnnotation) {
186
192
  const lower = fieldName.toLowerCase();
187
- if (PII_DIRECT_NAME_HINTS.has(lower)) {
193
+ if (FILE_FIELD_TYPES.has(field.type)) {
194
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
195
+ 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.`);
196
+ }
197
+ else if (PII_DIRECT_NAME_HINTS.has(lower)) {
188
198
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
189
199
  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
200
  }
@@ -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 } : {}),
@@ -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
  }
@@ -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.
@@ -7,6 +7,7 @@ export type ManifestConfigKey = {
7
7
  readonly default: string | number | boolean | null;
8
8
  readonly encrypted: boolean;
9
9
  readonly computed: boolean;
10
+ readonly validated: boolean;
10
11
  readonly options: readonly string[] | null;
11
12
  readonly bounds: {
12
13
  readonly min?: number;
@@ -29,6 +29,7 @@ export function buildManifestFromRegistry(registry, options) {
29
29
  default: def.default ?? null,
30
30
  encrypted: isEncryptedAtRest(def),
31
31
  computed: def.computed !== undefined,
32
+ validated: def.validate !== undefined,
32
33
  options: def.options ?? null,
33
34
  bounds: def.bounds ?? null,
34
35
  pattern: def.pattern ?? null,
@@ -1,3 +1,4 @@
1
+ import { escapeHatchGrantsProblem } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import { WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT, WEBSOCKET_MAX_PAYLOAD_BYTES, WEBSOCKET_ROUTE_PATH_PREFIX, } from "@cosmicdrift/kumiko-types/websocket-route";
2
3
  import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
3
4
  import { LifecycleHookTypes } from "./constants.js";
@@ -117,6 +118,10 @@ export function buildUiExtensionsMethods(state, name) {
117
118
  throw new Error(`[Feature ${name}] r.hook("${type}", ...) declares { escapeHatch: { reason: "" } } — ` +
118
119
  "the reason must be a non-empty string explaining why this hook switches identity to SYSTEM.");
119
120
  }
121
+ const hookGrantsProblem = options?.escapeHatch && escapeHatchGrantsProblem(options.escapeHatch);
122
+ if (hookGrantsProblem) {
123
+ throw new Error(`[Feature ${name}] r.hook("${type}", ...) declares an invalid escapeHatch — ${hookGrantsProblem}`);
124
+ }
120
125
  // Wrapped once for both branches below; validation hooks stay unwrapped (no context).
121
126
  const hookLabel = `${type} hook of feature "${name}"`;
122
127
  const wrapped = type === "validation"
@@ -211,6 +216,10 @@ export function buildUiExtensionsMethods(state, name) {
211
216
  throw new Error(`[Feature ${name}] r.useExtension("${extensionName}", "${resolvedEntityName}", ...) declares an invalid { escapeHatch } — ` +
212
217
  `must be { reason: "<non-empty string>" } explaining why this usage needs unfiltered db access.`);
213
218
  }
219
+ const extGrantsProblem = escapeHatchGrantsProblem(escapeHatch);
220
+ if (extGrantsProblem) {
221
+ throw new Error(`[Feature ${name}] r.useExtension("${extensionName}", "${resolvedEntityName}", ...) declares an invalid { escapeHatch } — ${extGrantsProblem}`);
222
+ }
214
223
  }
215
224
  state.extensionUsages.push({
216
225
  extensionName,
@@ -73,7 +73,7 @@ export { describeWorkflowStepError } from "./steps/describe-workflow-step-error.
73
73
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
74
74
  export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
75
75
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
76
- export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
76
+ export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
77
77
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases, isAgentVisibleScreen, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
78
78
  export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
79
79
  export { runValidation } from "./validation.js";
@@ -1,5 +1,5 @@
1
1
  export { isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-types/agent-exposure";
2
- export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
2
+ export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
3
3
  export type { QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput, } from "@cosmicdrift/kumiko-types/define-handler";
4
4
  export type { EntityCrudRegistrar, EntityCrudVerb, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions, } from "@cosmicdrift/kumiko-types/entity-handlers";
5
5
  export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, } from "@cosmicdrift/kumiko-types/event-type-map";
@@ -1,3 +1,4 @@
1
+ import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import { Queue, UnrecoverableError, Worker } from "bullmq";
2
3
  import { Redis } from "ioredis";
3
4
  import { requestContext } from "../api/request-context.js";
@@ -703,7 +704,7 @@ export function createJobRunner(options) {
703
704
  : undefined;
704
705
  const jobDb = configDb
705
706
  ? createTenantDb(configDb, tenantId, "tenant", context.tracer, context.meter, undefined, {
706
- unsafeRaw: jobDef.escapeHatch,
707
+ unsafeRaw: escapeHatchFor(jobDef.escapeHatch, "unsafeRaw"),
707
708
  report: reportEscapeHatch,
708
709
  ...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
709
710
  })
@@ -1,9 +1,11 @@
1
+ import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import { requestContext, runWithOrigin } from "../api/request-context.js";
2
3
  import { runInSavepoint, selectMany } from "../db/query.js";
3
4
  import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, } from "../db/tenant-db.js";
4
5
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
6
+ import { createSystemUser } from "../engine/system-user.js";
5
7
  import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
6
- import { FeatureDisabledError, InternalError, memberResolutionReadOnlyDenied, VersionConflictError, } from "../errors/index.js";
8
+ import { AccessDeniedError, FeatureDisabledError, InternalError, memberResolutionReadOnlyDenied, VersionConflictError, } from "../errors/index.js";
7
9
  import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
8
10
  import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
9
11
  import { getStreamVersion, loadAggregate, loadAggregateAsOf, } from "../event-store/event-store.js";
@@ -148,7 +150,8 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
148
150
  const handlerEscapeHatch = writeEscapeHatch ??
149
151
  registry.getQueryHandler(type)?.escapeHatch ??
150
152
  registry.getStreamHandler(type)?.escapeHatch;
151
- const hasIdentitySwitchGrant = isSystem || handlerEscapeHatch !== undefined;
153
+ const identitySwitchEscapeHatch = escapeHatchFor(handlerEscapeHatch, "systemIdentity");
154
+ const hasIdentitySwitchGrant = isSystem || identitySwitchEscapeHatch !== undefined;
152
155
  const featureName = registry.getHandlerFeature(type);
153
156
  const reportEscapeHatch = createEscapeHatchReporter({
154
157
  handler: type,
@@ -158,11 +161,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
158
161
  log: context.log,
159
162
  window: ctx.escapeHatchReportWindow,
160
163
  });
161
- const identitySwitchGrantReason = handlerEscapeHatch?.reason ??
164
+ const identitySwitchGrantReason = identitySwitchEscapeHatch?.reason ??
162
165
  (isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
163
166
  const buildTenantScopedDb = (source, signal) => createTenantDb(source, user.tenantId, isSystem ? "system" : "tenant", context.tracer, context.meter, signal, {
164
- globalWrites: writeEscapeHatch,
165
- unsafeRaw: handlerEscapeHatch,
167
+ globalWrites: escapeHatchFor(writeEscapeHatch, "globalWrites"),
168
+ unsafeRaw: escapeHatchFor(handlerEscapeHatch, "unsafeRaw"),
166
169
  report: reportEscapeHatch,
167
170
  memberReadOnly: isMemberResolutionPrincipal(user),
168
171
  personalDataGate: buildPersonalDataGate(registry, origin),
@@ -212,6 +215,29 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
212
215
  secrets: context.secrets,
213
216
  })
214
217
  : undefined;
218
+ // Cross-tenant accessor for SystemAdmin/system callers. The
219
+ // identity is the target tenant's system user so the caller's own user-scope
220
+ // values never resolve into another tenant's config; the db is tenant-mode
221
+ // scoped to the target.
222
+ const configFactory = context._configAccessorFactory;
223
+ const configFor = configFactory && dbSource
224
+ ? (targetTenantId) => {
225
+ if (targetTenantId === user.tenantId && config)
226
+ return config;
227
+ if (!isSystemIdentity(user) && !user.roles.includes("SystemAdmin")) {
228
+ throw new AccessDeniedError({
229
+ message: "ctx.configFor(otherTenant) requires the system role or SystemAdmin",
230
+ details: { reason: "config_for_requires_system_admin" },
231
+ });
232
+ }
233
+ const targetSystemUser = createSystemUser(targetTenantId);
234
+ return configFactory({
235
+ user: { id: targetSystemUser.id, tenantId: targetTenantId },
236
+ db: createTenantDb(dbSource, targetTenantId, "tenant", context.tracer, context.meter),
237
+ secrets: context.secrets,
238
+ });
239
+ }
240
+ : undefined;
215
241
  // ctx.files resolved per-tenant through file-foundation (lazy — the
216
242
  // provider is only resolved when a handle actually does I/O). Boot wires
217
243
  // _fileProviderResolver when a file-provider plugin is mounted; falls back
@@ -584,6 +610,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
584
610
  log,
585
611
  notify,
586
612
  ...(config && { config }),
613
+ ...(configFor && { configFor }),
587
614
  ...(files && { files }),
588
615
  ...(derivatives && { derivatives }),
589
616
  // preSave hooks need `changes`/`previous`/`isNew`, which only exist once
@@ -1,3 +1,4 @@
1
+ import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import { withSystemDbUnsafeRawGrant, withUnsafeRawGrant, } from "../db/tenant-db.js";
2
3
  import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
3
4
  import { AccessDeniedError, FrameworkReasons, InternalError } from "../errors/index.js";
@@ -212,6 +213,8 @@ export function withHookEscapeHatchGrant(context, callerLabel, escapeHatch) {
212
213
  const ctxDb = readDbLikeValue(context, "db");
213
214
  const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
214
215
  const ctxSystemDb = readDbLikeValue(context, "systemDb");
216
+ const identityHatch = escapeHatchFor(escapeHatch, "systemIdentity");
217
+ const rawHatch = escapeHatchFor(escapeHatch, "unsafeRaw");
215
218
  if (!ctxQueryAs &&
216
219
  !ctxWriteAs &&
217
220
  !ctxResolveActiveMembership &&
@@ -224,21 +227,21 @@ export function withHookEscapeHatchGrant(context, callerLabel, escapeHatch) {
224
227
  }
225
228
  return {
226
229
  ...context,
227
- ...gatedIdentitySwitchFields(callerLabel, escapeHatch, ctxQueryAs, ctxWriteAs),
230
+ ...gatedIdentitySwitchFields(callerLabel, identityHatch, ctxQueryAs, ctxWriteAs),
228
231
  ...(ctxResolveActiveMembership &&
229
- escapeHatch === undefined && {
232
+ identityHatch === undefined && {
230
233
  resolveActiveMembership: deniedResolveActiveMembership(callerLabel),
231
234
  }),
232
- ...gatedMemberReaderField(callerLabel, escapeHatch, ctxQueryAsMember),
233
- ...gatedProjectionReaderField(callerLabel, escapeHatch, ctxQueryProjection),
235
+ ...gatedMemberReaderField(callerLabel, identityHatch, ctxQueryAsMember),
236
+ ...gatedProjectionReaderField(callerLabel, identityHatch, ctxQueryProjection),
234
237
  // @cast-boundary engine-bridge — withUnsafeRawGrant passes non-TenantDb values (e.g. a guard Proxy) through unchanged.
235
- ...(ctxDb && { db: withUnsafeRawGrant(ctxDb, escapeHatch) }),
238
+ ...(ctxDb && { db: withUnsafeRawGrant(ctxDb, rawHatch) }),
236
239
  ...(ctxDbOutsideTransaction && {
237
- dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction, escapeHatch),
240
+ dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction, rawHatch),
238
241
  }),
239
242
  // @cast-boundary engine-bridge — withSystemDbUnsafeRawGrant passes non-UncheckedSystemDb values through unchanged.
240
243
  ...(ctxSystemDb && {
241
- systemDb: withSystemDbUnsafeRawGrant(ctxSystemDb, escapeHatch, callerLabel),
244
+ systemDb: withSystemDbUnsafeRawGrant(ctxSystemDb, rawHatch, callerLabel),
242
245
  }),
243
246
  };
244
247
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.337.1",
3
+ "version": "0.338.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -206,8 +206,8 @@
206
206
  "./package.json": "./package.json"
207
207
  },
208
208
  "dependencies": {
209
- "@cosmicdrift/kumiko-http": "0.337.1",
210
- "@cosmicdrift/kumiko-types": "0.337.1",
209
+ "@cosmicdrift/kumiko-http": "0.338.0",
210
+ "@cosmicdrift/kumiko-types": "0.338.0",
211
211
  "bullmq": "^5.76.7",
212
212
  "bun-types": "^1.3.13",
213
213
  "hono": "^4.13.1",
@@ -223,7 +223,7 @@
223
223
  "zod": "^4.4.3"
224
224
  },
225
225
  "devDependencies": {
226
- "@cosmicdrift/kumiko-dispatcher-live": "0.337.1",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.338.0",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },
package/src/changes.json CHANGED
@@ -1,4 +1,65 @@
1
1
  [
2
+ {
3
+ "version": "0.338.0",
4
+ "type": "improvement",
5
+ "title": "ctx.configFor(tenantId) resolves config for another tenant",
6
+ "detail": "Query and write handler contexts get `configFor(tenantId)` next to `config`, present when the config feature wired its accessor factory. The accessor runs as the target tenant's system user on a db scoped to that tenant, so the caller's user-scope values never leak into the result. Calls for another tenant throw `AccessDeniedError` unless the caller is the system identity or has the `SystemAdmin` role; the caller's own tenant needs no privilege and returns the same accessor as `ctx.config`.",
7
+ "migration": "No code change needed. Handlers that compute values for a tenant other than the caller's (limits, quotas, billing previews) call `ctx.configFor(tenantId)` instead of passing `ctx.config`."
8
+ },
9
+ {
10
+ "version": "0.338.0",
11
+ "type": "improvement",
12
+ "title": "escapeHatch grants split the SYSTEM identity switch from raw access",
13
+ "detail": "escapeHatch: new optional `grants` (`systemIdentity`, `globalWrites`, `unsafeRaw`) narrows what a declaration unlocks. Without `grants` the declaration keeps unlocking all three. An empty or unknown `grants` list is a boot error. Bundled handlers that only needed the raw runner now declare `grants: [\"unsafeRaw\"]`, so they no longer gain the SYSTEM identity switch.",
14
+ "migration": "No action needed: `grants` is additive and an escapeHatch without it behaves as before.\nDeclare `grants: [\"unsafeRaw\"]` on handlers that only call ctx.db.unsafeRaw to drop the SYSTEM identity switch."
15
+ },
16
+ {
17
+ "version": "0.338.0",
18
+ "type": "improvement",
19
+ "title": "boot warning for file and image fields without a personal annotation",
20
+ "detail": "Boot validation warns for every `file`, `image`, `files` and `images` field without a `personal` annotation. A forget with strategy `delete` keeps the binary of such a field and only severs the uploader link, so the stance has to be declared.",
21
+ "migration": "Mark fields that hold personal data with `personal: \"self\"` or `personal: { of: \"<ownerField>\" }`, and business data with `personal: false, reason: \"is_business_data\"`. Unannotated fields keep their binary on forget."
22
+ },
23
+ {
24
+ "version": "0.338.0",
25
+ "type": "fix",
26
+ "title": "reference label search applies target read access on the plaintext path",
27
+ "detail": "Searching a list by a reference field's label now respects the target entity's row-level `access.read` and the label field's own `access.read` on the plaintext (ILIKE) path. Before, a viewer who could not read the target row or its label could probe names through the match results."
28
+ },
29
+ {
30
+ "version": "0.338.0",
31
+ "type": "fix",
32
+ "title": "/api/schema answers 401 instead of 400 tenant_required after logout",
33
+ "detail": "With `anonymousAccess` and an authoritative tenant resolver, a request without a token to `/api/schema` ran through tenant resolution and failed with 400 `tenant_required`. Paths that need a session but no tenant (`/api/auth/*` and `/api/schema`) are now listed in `isSessionRequiredApiPath` and answer 401 `missing_token` before any tenant lookup.",
34
+ "migration": "No code change needed."
35
+ },
36
+ {
37
+ "version": "0.338.0",
38
+ "type": "breaking",
39
+ "title": "searchable text fields must not declare a restricted access.read",
40
+ "detail": "Boot validation rejects a text field that is `searchable` (or `find: \"fuzzy\"`) and also has a restricted `access.read`. The search index is not filtered per role, so search matches leaked the value to roles that cannot read the field.",
41
+ "migration": "Remove `searchable` / `find: \"fuzzy\"` from the field, or remove the read restriction. Write-only `access.write` restrictions are unaffected."
42
+ },
43
+ {
44
+ "version": "0.338.0",
45
+ "type": "breaking",
46
+ "title": "signature routes with a :param segment or wildcard must not match framework paths",
47
+ "detail": "Boot validation rejects `entry: \"signature\"` extra routes whose pattern, including `:param` segments, matches a framework path such as `/api/write`. Such a route skipped the JWT, origin and CSRF guards for that path.",
48
+ "migration": "Give the route a static prefix, for example `/api/webhooks/:provider` instead of `/api/:provider` or `/:provider/write`."
49
+ },
50
+ {
51
+ "version": "0.338.0",
52
+ "type": "fix",
53
+ "title": "planned Scaleway purposes listed as planned sub-processor entry",
54
+ "detail": "The sub-processor list now carries the planned Scaleway purposes (key custody and transactional email) as their own `status: \"planned\"` entry. The active Scaleway entry only names the backup mirror, so the public `planned` section and change notifications see the upcoming processing."
55
+ },
56
+ {
57
+ "version": "0.338.0",
58
+ "type": "improvement",
59
+ "title": "unsafeRaw audit events carry the declared escapeHatch reason",
60
+ "detail": "`ctx.db.unsafeRaw(reason)` and `ctx.systemDb.unsafeRaw(reason)` now write the reason declared on the handler or hook `escapeHatch` into the escape-hatch audit event, not the string passed at the call. A call-site string can no longer put arbitrary text into the audit trail.",
61
+ "migration": "No action needed: calls that pass the same reason as the declaration audit unchanged. Calls with a different string now audit the declared reason."
62
+ },
2
63
  {
3
64
  "version": "0.337.1",
4
65
  "type": "improvement",