@cosmicdrift/kumiko-framework 0.352.0 → 0.353.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 (49) hide show
  1. package/dist/api/pii-leak-guard.js +3 -0
  2. package/dist/api/route-registrars.d.ts +2 -1
  3. package/dist/api/route-registrars.js +8 -0
  4. package/dist/api/server.js +11 -3
  5. package/dist/api/sse-route.js +11 -1
  6. package/dist/bun-db/query.js +7 -2
  7. package/dist/compliance/sub-processors.js +5 -0
  8. package/dist/crypto/pii-field-encryption.d.ts +2 -3
  9. package/dist/crypto/pii-field-encryption.js +2 -3
  10. package/dist/db/entity-table-meta.js +11 -0
  11. package/dist/db/event-store-executor-context.js +20 -25
  12. package/dist/db/migrate-generator.js +9 -1
  13. package/dist/db/queries/backfill-pii.d.ts +1 -0
  14. package/dist/db/queries/backfill-pii.js +13 -0
  15. package/dist/db/queries/shadow-swap.js +5 -2
  16. package/dist/engine/boot-validator/access-declarations.js +16 -1
  17. package/dist/engine/boot-validator/entity-handler.js +13 -0
  18. package/dist/engine/boot-validator/screens.js +45 -15
  19. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  20. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  21. package/dist/engine/build-app-schema.js +30 -2
  22. package/dist/engine/build-config-feature-schema.js +6 -0
  23. package/dist/engine/dedupe-features.js +17 -1
  24. package/dist/engine/entity-handlers.js +15 -6
  25. package/dist/engine/feature-builder-state.d.ts +2 -7
  26. package/dist/engine/feature-config-events-jobs.js +3 -0
  27. package/dist/engine/feature-entity-handlers.js +1 -1
  28. package/dist/engine/i18n-key.js +14 -3
  29. package/dist/engine/index.d.ts +1 -1
  30. package/dist/engine/index.js +1 -1
  31. package/dist/engine/pipeline.js +18 -1
  32. package/dist/engine/project-app-schema-for-roles.js +8 -2
  33. package/dist/engine/role-assignment.d.ts +2 -0
  34. package/dist/engine/role-assignment.js +2 -0
  35. package/dist/engine/schema-builder.js +8 -0
  36. package/dist/engine/where-rule-lint.js +6 -3
  37. package/dist/env/index.d.ts +4 -0
  38. package/dist/env/index.js +4 -0
  39. package/dist/es-ops/context.js +19 -1
  40. package/dist/files/file-ref-entity.d.ts +6 -0
  41. package/dist/files/file-ref-table.d.ts +6 -0
  42. package/dist/files/file-routes.js +3 -3
  43. package/dist/files/types.js +21 -1
  44. package/dist/observability/metrics-wiring.d.ts +1 -1
  45. package/dist/observability/metrics-wiring.js +2 -2
  46. package/dist/pipeline/member-reader.js +1 -0
  47. package/dist/testing/shared-entities.d.ts +10 -0
  48. package/package.json +4 -4
  49. package/src/changes.json +30 -0
@@ -13,6 +13,9 @@ const CIPHERTEXT_REDACT_RE = new RegExp(CIPHERTEXT_RE.source, "g");
13
13
  // red; prod redacts + logs instead of shipping the blob. Scans unconditionally:
14
14
  // legacy ciphertext rows can outlive a subject KMS that later became
15
15
  // unconfigured, and the marker check itself needs no KMS access to run.
16
+ // Buffers every JSON body (no streaming, roughly double peak memory for large
17
+ // exports) on purpose: a size threshold or opt-out would be a leak bypass for
18
+ // exactly the large responses most likely to carry raw rows.
16
19
  export function piiCiphertextResponseGuard() {
17
20
  return async (c, next) => {
18
21
  await next();
@@ -1,10 +1,11 @@
1
- import { Hono } from "hono";
1
+ import { Hono, type MiddlewareHandler } from "hono";
2
2
  import type { DbConnection } from "../db/connection.js";
3
3
  import type { Lifecycle } from "../lifecycle/index.js";
4
4
  import type { Meter, PrometheusMeter } from "../observability/index.js";
5
5
  import type { EventConsumer } from "../pipeline/event-dispatcher.js";
6
6
  export declare const DEFAULT_MAX_REQUEST_BYTES = 1048576;
7
7
  export declare function registerBodyLimit(app: Hono, maxBytes: number): void;
8
+ export declare function bodyLimitForRoutePath(path: string, maxBytes: number): readonly MiddlewareHandler[];
8
9
  export type MetricsRouteOptions = {
9
10
  readonly token?: string;
10
11
  readonly path?: string;
@@ -31,6 +31,14 @@ export function registerBodyLimit(app, maxBytes) {
31
31
  return limit(c, next);
32
32
  });
33
33
  }
34
+ // registerBodyLimit only covers /api/*; signature routes outside it (webhook
35
+ // receivers, OAuth callbacks) read the raw body before verify() runs and so
36
+ // need the same cap on their own handler chain.
37
+ export function bodyLimitForRoutePath(path, maxBytes) {
38
+ if (maxBytes <= 0 || path === "/api" || path.startsWith("/api/"))
39
+ return [];
40
+ return [bodyLimit({ maxSize: maxBytes })];
41
+ }
34
42
  // Timing-safe string compare — equal-length strings compare every byte
35
43
  // regardless of where they diverge; different lengths return false without
36
44
  // iterating. Prevents side-channel leaks on the Bearer-token check.
@@ -40,7 +40,7 @@ import { piiCiphertextResponseGuard } from "./pii-leak-guard.js";
40
40
  import { createDefaultSseBroker } from "./redis-sse-broker.js";
41
41
  import { requestContext } from "./request-context.js";
42
42
  import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware.js";
43
- import { DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerHealthRoutes, registerMetricsRoute, registerVersionRoute, } from "./route-registrars.js";
43
+ import { bodyLimitForRoutePath, DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerHealthRoutes, registerMetricsRoute, registerVersionRoute, } from "./route-registrars.js";
44
44
  import { createApiRoutes, handleUncaughtRouteError } from "./routes.js";
45
45
  import { createSseRoute } from "./sse-route.js";
46
46
  import { buildWebSocketSessionRevalidator, createWebSocketConnectionLimiter, hasWebSocketUpgradeServer, isWebSocketUpgradeRequest, WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER, WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES, } from "./websocket-route.js";
@@ -782,7 +782,9 @@ export function buildServer(options) {
782
782
  dispatchSystemQuery,
783
783
  clientIpResolver,
784
784
  });
785
- mountHonoRoute(app, route.method, route.path, honoHandler);
785
+ mountHonoRoute(app, route.method, route.path, honoHandler, route.entry === ExtraRouteEntries.signature
786
+ ? bodyLimitForRoutePath(route.path, options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES)
787
+ : []);
786
788
  }
787
789
  }
788
790
  // /version-Default registriert NACH feature-routes — Hono "first match
@@ -927,6 +929,7 @@ function unauthenticatedResponse(c) {
927
929
  }, 401);
928
930
  }
929
931
  const webSocketLog = createFallbackLogger("websocket");
932
+ const extraRouteLog = createFallbackLogger("extra-route");
930
933
  function webSocketErrorResponse(c, status, code, message) {
931
934
  return c.json({ error: { code, httpStatus: status, message } }, status);
932
935
  }
@@ -1069,10 +1072,15 @@ function buildExtraRouteHonoHandler(route, shared) {
1069
1072
  }
1070
1073
  return c.json(e.body, e.status);
1071
1074
  }
1075
+ // The body goes to unauthenticated callers; infra errors thrown by
1076
+ // verify() (secrets, DB) must not leak, so the detail stays in the log.
1077
+ extraRouteLog.error(`signature verify() threw on ${c.req.path}`, {
1078
+ error: e instanceof Error ? e.message : String(e),
1079
+ });
1072
1080
  return c.json({
1073
1081
  error: {
1074
1082
  code: "extra_route_signature_invalid",
1075
- message: e instanceof Error ? e.message : String(e),
1083
+ message: "signature verification failed",
1076
1084
  },
1077
1085
  }, 401);
1078
1086
  }
@@ -3,7 +3,7 @@ import { streamSSE } from "hono/streaming";
3
3
  import { tenantChannel } from "../engine/constants.js";
4
4
  import { isAnonymousSessionUser } from "../engine/system-user.js";
5
5
  import { Routes } from "./api-constants.js";
6
- import { getUser } from "./auth-middleware.js";
6
+ import { getAuthTokenExpiry, getUser } from "./auth-middleware.js";
7
7
  import { accessInvalidationCredentialFor } from "./sse-broker.js";
8
8
  /**
9
9
  * Heartbeat-Cadence für SSE-Streams.
@@ -81,6 +81,9 @@ function decideWireFrame(event, user, options) {
81
81
  return undefined;
82
82
  return { name: event.type, data };
83
83
  }
84
+ // setTimeout overflows (fires immediately) above 2^31-1 ms; a token that
85
+ // outlives it just gets its stream recycled early, the client reconnects.
86
+ const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
84
87
  export function createSseRoute(broker, options) {
85
88
  const route = new Hono();
86
89
  route.get(Routes.sse, async (c) => {
@@ -88,6 +91,9 @@ export function createSseRoute(broker, options) {
88
91
  // Channel is server-derived from authenticated user — never trust client input.
89
92
  // Allowing ?channel=... would let any authenticated user subscribe to other tenants' feeds.
90
93
  const channel = tenantChannel(user.tenantId);
94
+ // JWT expiry raises no access-invalidation event, so the stream must end
95
+ // itself when the token it was opened with expires.
96
+ const tokenExpiresAtSec = getAuthTokenExpiry(c);
91
97
  return streamSSE(c, async (stream) => {
92
98
  let resolveEnded;
93
99
  const ended = new Promise((resolve) => {
@@ -105,6 +111,9 @@ export function createSseRoute(broker, options) {
105
111
  stream.writeSSE({ event: frame.name, data: JSON.stringify(frame.data) });
106
112
  }, closeStream);
107
113
  const unsubscribeAccessInvalidation = broker.subscribeAccessInvalidation(user.id, closeStream, accessInvalidationCredentialFor(user));
114
+ const expiryTimer = tokenExpiresAtSec === undefined
115
+ ? undefined
116
+ : setTimeout(closeStream, Math.min(MAX_TIMER_DELAY_MS, Math.max(0, tokenExpiresAtSec * 1000 - Date.now())));
108
117
  let released = false;
109
118
  const release = () => {
110
119
  // skip: onAbort and the finally block both release, the second call is a no-op
@@ -113,6 +122,7 @@ export function createSseRoute(broker, options) {
113
122
  released = true;
114
123
  // A client disconnect must also wake the heartbeat sleep, not only a server close.
115
124
  resolveEnded();
125
+ clearTimeout(expiryTimer);
116
126
  broker.removeClient(channel, clientId);
117
127
  unsubscribeAccessInvalidation();
118
128
  };
@@ -507,6 +507,11 @@ function buildWhereClause(info, where, startIndex, qualifier) {
507
507
  parts.push(`${ref(col)} @> $${idx++}${p.sql}`);
508
508
  values.push(p.bound);
509
509
  }
510
+ // A jsonb column holding the scalar itself is never `@>` an array of it.
511
+ if (isJsonbScalar(v)) {
512
+ parts.push(`${ref(col)} = $${idx++}::text::jsonb`);
513
+ values.push(JSON.stringify(v));
514
+ }
510
515
  }
511
516
  return parts.length > 0 ? `(${parts.join(" OR ")})` : "FALSE";
512
517
  }
@@ -592,8 +597,8 @@ function buildWhereClause(info, where, startIndex, qualifier) {
592
597
  if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
593
598
  const p = prepareJsonbValue([opVal]);
594
599
  if (p && p.kind === "param") {
595
- conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql}))`);
596
- values.push(p.bound);
600
+ conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql} OR ${ref(col)} = $${idx++}::text::jsonb))`);
601
+ values.push(p.bound, JSON.stringify(opVal));
597
602
  continue;
598
603
  }
599
604
  }
@@ -13,6 +13,11 @@
13
13
  // Lead-Time aus dem Compliance-Profile (typisch 30d). Cron-Job kommt
14
14
  // in Sprint 1 (compliance-profiles).
15
15
  //
16
+ // SubProcessor has no "removed" state yet: a removed entry is deleted from
17
+ // the array, so until the notification cron exists a removal is documented
18
+ // only in the changelog. Removed so far: Mailbox.org (mailbox hosting),
19
+ // replaced by ALL-INKL.COM on 2026-09-02.
20
+ //
16
21
  // Quelle: docs/plans/datenschutz/compliance-profiles.md "Sub-Processor-
17
22
  // Management" + docs/plans/datenschutz/legal-artifacts.md.
18
23
  /**
@@ -1,9 +1,8 @@
1
1
  import type { EntityDefinition } from "../engine/types/fields.js";
2
2
  import type { TenantId } from "../engine/types/identifiers.js";
3
- import { type KmsAdapter, type KmsContext, type LocalKeyKmsAdapter, type SubjectId } from "./kms-adapter.js";
3
+ import { type KmsAdapter, type KmsContext, type LocalKeyKmsAdapter, PII_ERASED_SENTINEL, type SubjectId } from "./kms-adapter.js";
4
4
  import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON } from "./pii-ciphertext-format.js";
5
- export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON };
6
- export declare const PII_ERASED_SENTINEL = "[[erased]]";
5
+ export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL };
7
6
  export declare function encryptPiiValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
8
7
  export declare function encryptPiiJsonValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: unknown, kmsCtx: KmsContext, field: string): Promise<string>;
9
8
  export declare function decryptPiiValueForSubject(kms: LocalKeyKmsAdapter, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
@@ -16,12 +16,11 @@
16
16
  // a v2<->v3 prefix swap fails GCM instead of silently changing the type.
17
17
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
18
18
  import { assertIrreversibleOperationAllowed } from "../pipeline/irreversible-operation-gate.js";
19
- import { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, subjectIdFromKey, subjectIdToKey, } from "./kms-adapter.js";
19
+ import { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, PII_ERASED_SENTINEL, subjectIdFromKey, subjectIdToKey, } from "./kms-adapter.js";
20
20
  import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_CIPHERTEXT_PREFIX_V1, } from "./pii-ciphertext-format.js";
21
21
  import { resolveSubjectForField } from "./subject-resolver.js";
22
22
  // Spec value (crypto-shredding.md) — renderers show it verbatim.
23
- export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON };
24
- export const PII_ERASED_SENTINEL = "[[erased]]";
23
+ export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL };
25
24
  const IV_LENGTH = 12;
26
25
  const AUTH_TAG_LENGTH = 16;
27
26
  function buildAad(subject, field, isJson) {
@@ -222,6 +222,17 @@ export function deriveEntityTableMeta(entityName, entity, options) {
222
222
  assertUnmanagedTableName(tableName, "deriveEntityTableMeta/buildEntityTableMeta");
223
223
  }
224
224
  const idType = entity.idType ?? "uuid";
225
+ if (source === "unmanaged" && idType === "serial") {
226
+ // Unmanaged direct-write stores bypass validateRecordOwnedSubjects (it only walks
227
+ // feature.entities), yet encryptForDirectWrite still keys recordOwned fields as
228
+ // `record:<entity>:<id>`; forgetSubject requires a UUID, so the value could never be shredded.
229
+ const recordOwnedField = Object.entries(entity.fields).find(
230
+ // @cast-boundary schema-walk — PII flags live on the resolved field annotation
231
+ ([, field]) => field.recordOwned === true);
232
+ if (recordOwnedField) {
233
+ throw new Error(`deriveEntityTableMeta("${entityName}", { source: "unmanaged" }): field "${recordOwnedField[0]}" is recordOwned but the entity declares idType: "serial" — the record subject key is "record:${entityName}:<id>" and forgetSubject validates that id as a UUID, so this field could never be shredded. Use idType: "uuid", or give the field a different personal stance.`);
234
+ }
235
+ }
225
236
  // Base columns first, then user-fields. User-fields with the same
226
237
  // pg-name as a base-column OVERRIDE the base-column (last-wins, same
227
238
  // behavior as drizzle's `{ ...base, ...fields }` spread in the table
@@ -4,7 +4,7 @@ import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues
4
4
  import { executeRawQueryRead } from "../db/queries/raw-sql.js";
5
5
  import { shiftParams } from "../engine/ownership.js";
6
6
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
7
- import { UniqueViolationError, writeFailure } from "../errors/index.js";
7
+ import { InternalError, UniqueViolationError, writeFailure, } from "../errors/index.js";
8
8
  import { ArchivedStreamError, isStreamArchived } from "../event-store/index.js";
9
9
  import { assertUnreachable } from "../utils/index.js";
10
10
  import { rehydrateCompoundTypes } from "./compound-types.js";
@@ -289,31 +289,26 @@ export function buildExecutorContext(table, entity, options) {
289
289
  ...(await executeRawQueryRead(tenantDbRunner(db), sqlText, params)),
290
290
  ];
291
291
  }
292
- // Combined, atomic read for the `expect:` precondition: the projection
293
- // row's expect-checked fields AND the events table's current MAX(version)
294
- // for this aggregate, in ONE SQL statement.
295
- //
296
- // Two separate reads leave a gap: applyEntityEvent writes the projection in
297
- // a separate statement after its event commits, so a second reader can see
298
- // a version that already reflects a concurrent writer's event while its read
299
- // of the expect fields still shows the pre-write row. One query runs against
300
- // a single consistent snapshot.
301
- //
302
- // The row's own `version` column can't be trusted instead: it is only in
303
- // lock-step with the events for rows this executor wrote. A raw-seeded row
304
- // (test fixtures, legacy data) can carry a default version with zero
305
- // matching events, so deriving expectedVersion from it makes the append
306
- // target a non-existent predecessor and fail.
307
- // The events table's MAX(version) (0 for such a row) is the only value
308
- // append() can safely use as expectedVersion — exactly what
309
- // getStreamVersion() already returns for every other (non-`expect`)
310
- // caller; this reads it in the same statement as the expect columns
311
- // instead of a second round-trip.
312
- //
313
- // Reads raw column values — no decryptForRead pass — so `expect:` only
314
- // supports plain (non-pii, non-encrypted) columns: business-state fields
315
- // like status flags or foreign-key ids, not PII.
292
+ // Reads the `expect:` fields and the aggregate's MAX(version) in ONE statement:
293
+ // projection rows are written after their event commits, so two reads can
294
+ // pair a post-write version with a pre-write row.
295
+ // row.version can't stand in for MAX(version) — raw-seeded/legacy rows carry
296
+ // a default version with no matching events, so the append would target a
297
+ // non-existent predecessor.
298
+ // Raw column values, no decrypt pass: plain (non-pii, non-encrypted) columns only.
316
299
  async function loadExpectSnapshot(db, id, streamTenantId, expectKeys) {
300
+ for (const key of expectKeys) {
301
+ if (table[key] === undefined) {
302
+ throw new InternalError({
303
+ message: `expect: "${key}" is not a column of entity "${entityName}"`,
304
+ });
305
+ }
306
+ if (encryptedFields.has(key) || piiSubjectFields.includes(key)) {
307
+ throw new InternalError({
308
+ message: `expect: "${key}" on entity "${entityName}" is stored encrypted and cannot be compared as plaintext`,
309
+ });
310
+ }
311
+ }
317
312
  const quote = (name) => `"${name.replace(/"/g, '""')}"`;
318
313
  const columnOf = (field) => quote(table[field]?.name ?? toSnakeCase(field));
319
314
  const tableName = String(table[KUMIKO_NAME_SYMBOL]);
@@ -122,7 +122,10 @@ function diffOneTable(prev, next) {
122
122
  const uniqueChanged = (prevI.unique ?? false) === (idx.unique ?? false)
123
123
  ? undefined
124
124
  : { from: prevI.unique ?? false, to: idx.unique ?? false };
125
- const whereSqlChanged = prevI.whereSql === idx.whereSql ? undefined : { from: prevI.whereSql, to: idx.whereSql };
125
+ // Whitespace-only re-rendering of the predicate must not drop and rebuild a large index.
126
+ const whereSqlChanged = whereSqlEqual(prevI.whereSql, idx.whereSql)
127
+ ? undefined
128
+ : { from: prevI.whereSql, to: idx.whereSql };
126
129
  const needsManualWhereChanged = (prevI.needsManualWhere ?? false) === (idx.needsManualWhere ?? false)
127
130
  ? undefined
128
131
  : { from: prevI.needsManualWhere ?? false, to: idx.needsManualWhere ?? false };
@@ -297,6 +300,11 @@ function renderColumnChange(tableName, change) {
297
300
  function toSingleLine(text) {
298
301
  return text.replace(/\s+/g, " ").trim();
299
302
  }
303
+ function whereSqlEqual(a, b) {
304
+ if (a === undefined || b === undefined)
305
+ return a === b;
306
+ return toSingleLine(a) === toSingleLine(b);
307
+ }
300
308
  function describeIndexChange(change) {
301
309
  const parts = [];
302
310
  if (change.columnsChanged) {
@@ -16,6 +16,7 @@ export type PiiBackfillResult = {
16
16
  };
17
17
  export type PiiBackfillScanCache = {
18
18
  forgottenAggregates?: ReadonlySet<string>;
19
+ destroyedTenants?: ReadonlySet<string>;
19
20
  };
20
21
  export type PiiBackfillBatchResult = PiiBackfillResult & {
21
22
  readonly lastEventId: string | null;
@@ -380,8 +380,21 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
380
380
  const forgottenAggregates = await loadForgottenAggregates();
381
381
  if (forgottenAggregates.has(aggregateId))
382
382
  return true;
383
+ if (subject.kind === "record" && subject.entity === "tenant") {
384
+ return (await loadDestroyedTenants()).has(subject.id);
385
+ }
383
386
  return subject.kind === "user" && forgottenAggregates.has(subject.userId);
384
387
  }
388
+ // A tenant destroyed before its name was record-owned never had a record key
389
+ // to erase, and destroy tombstones the row instead of emitting *.forgotten —
390
+ // read_tenants.destroyed_at is the only durable marker.
391
+ async function loadDestroyedTenants() {
392
+ if (scanCache.destroyedTenants)
393
+ return scanCache.destroyedTenants;
394
+ const rows = (await raw.unsafe(`SELECT id::text AS id FROM read_tenants WHERE destroyed_at IS NOT NULL`));
395
+ scanCache.destroyedTenants = new Set(rows.map((r) => r.id));
396
+ return scanCache.destroyedTenants;
397
+ }
385
398
  }
386
399
  // Stage 1 (payload) then stage 2 (projection row, when supplied) — stage 3
387
400
  // (erase) is the caller's call, made from the "unresolved" outcome.
@@ -362,10 +362,13 @@ export async function countColumnDrift(tx, tableName, meta) {
362
362
  // object depends on the live table the swap fails loud and the whole rebuild
363
363
  // rolls back, leaving the old table untouched.
364
364
  export async function swapShadowIntoLive(tx, tableName) {
365
- // Re-checked right before DROP: RLS could have been enabled after the early check.
366
- await assertLiveTableHasNoRowLevelSecurity(tx, tableName);
367
365
  const raw = asRawClient(tx);
368
366
  const ident = quoteTableIdent(tableName);
367
+ // Held before the re-check so a concurrent ENABLE ROW LEVEL SECURITY either commits first
368
+ // (and is seen) or waits behind the DROP. Without it the catalog read takes no lock, and
369
+ // the unfenced paths (MSP rebuild, projection with no subscribed events) would drop RLS.
370
+ await raw.unsafe(`LOCK TABLE public.${ident} IN ACCESS EXCLUSIVE MODE`);
371
+ await assertLiveTableHasNoRowLevelSecurity(tx, tableName);
369
372
  await raw.unsafe(`DROP TABLE public.${ident}`);
370
373
  await raw.unsafe(`ALTER TABLE ${SCHEMA_IDENT}.${ident} SET SCHEMA public`);
371
374
  }
@@ -1,4 +1,4 @@
1
- import { escapeHatchGrantsProblem } from "@cosmicdrift/kumiko-types/handlers";
1
+ import { escapeHatchGrantsProblem, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import { accessAllowsAnonymous, declaredPersonalData, personalFieldNames, } from "../personal-data-fields.js";
3
3
  import { ANONYMOUS_ROLE } from "../system-user.js";
4
4
  import { collectZodObjectKeys } from "./zod-shape.js";
@@ -143,6 +143,20 @@ function validateAnonymousPersonalData(feature, handlerName, handler) {
143
143
  'request shares one caller identity, so owner-binding via from("user:id", ...) does not ' +
144
144
  "vouch for it; protection comes from the handler's required rateLimit (per ip).");
145
145
  }
146
+ // The rateLimit is the only protection of a public-intake handler, so the
147
+ // generic anonymous opt-out (rateLimit: { disabled: true }) must not apply.
148
+ function validatePublicIntakeRateLimit(feature, handlerName, handler) {
149
+ // skip: not a public-intake handler, or its rateLimit is active
150
+ if (declaredPersonalData(handler.access) !== "public-intake")
151
+ return;
152
+ // skip: rateLimit is active
153
+ if (!isRateLimitDisabled(handler.rateLimit))
154
+ return;
155
+ throw new Error(`[Feature ${feature.name}] write handler "${handlerName}" declares ` +
156
+ 'access.personalData: "public-intake" with rateLimit: { disabled: true } — the rateLimit ' +
157
+ 'is the only protection for anonymous personal-data writes. Declare rateLimit: { per: "ip" ' +
158
+ 'or "ip+handler", ... } instead.');
159
+ }
146
160
  export function validateAccessDeclarations(feature) {
147
161
  for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
148
162
  validateOpenToAllReason(feature, "write", handlerName, handler.access);
@@ -150,6 +164,7 @@ export function validateAccessDeclarations(feature) {
150
164
  validatePersonalDataValue(feature, handlerName, handler.access);
151
165
  validateOpenToAllPersonalData(feature, handlerName, handler);
152
166
  validateAnonymousPersonalData(feature, handlerName, handler);
167
+ validatePublicIntakeRateLimit(feature, handlerName, handler);
153
168
  }
154
169
  for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
155
170
  validateOpenToAllReason(feature, "query", handlerName, handler.access);
@@ -1,8 +1,10 @@
1
+ import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
1
2
  import { isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
2
3
  import { ZodObject } from "zod";
3
4
  import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key.js";
4
5
  import { access as accessPresets } from "../config-helpers.js";
5
6
  import { parseRefTarget } from "../parse-ref-target.js";
7
+ import { isSelectOptionTone } from "./select-option-tone.js";
6
8
  export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
7
9
  // Field-Namen die typischerweise PII enthalten. Ohne `pii: true` /
8
10
  // `userOwned` / `tenantOwned` / `allowPlaintext`-Marker → Boot-Warning.
@@ -672,14 +674,25 @@ function validateCheckboxDisplayOptions(field, fieldName, entityName) {
672
674
  throw new Error(`MultiSelect field "${fieldName}" on entity "${entityName}" sets maxRows, which is only valid with display: "checkboxes".`);
673
675
  }
674
676
  }
677
+ function assertSelectOptionTones(field, path, featureName) {
678
+ const tones = field.type === "select" ? Object.entries(field.optionTones ?? {}) : [];
679
+ for (const [optionValue, tone] of tones) {
680
+ if (!isSelectOptionTone(tone)) {
681
+ throw new Error(`[Feature ${featureName}] Entity select field "${path}" optionTones["${optionValue}"] is ` +
682
+ `"${String(tone)}" — expected one of ${SELECT_OPTION_TONES.join(", ")}.`);
683
+ }
684
+ }
685
+ }
675
686
  export function validateEntitySelectOptionsQuery(feature) {
676
687
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
677
688
  for (const [fieldName, field] of Object.entries(entity.fields)) {
678
689
  assertNoSelectOptionsQuery(field, `${entityName}.${fieldName}`, feature.name);
690
+ assertSelectOptionTones(field, `${entityName}.${fieldName}`, feature.name);
679
691
  if (field.type !== "embedded")
680
692
  continue;
681
693
  for (const [subName, subField] of Object.entries(field.schema ?? {})) {
682
694
  assertNoSelectOptionsQuery(subField, `${entityName}.${fieldName}.${subName}`, feature.name);
695
+ assertSelectOptionTones(subField, `${entityName}.${fieldName}.${subName}`, feature.name);
683
696
  }
684
697
  }
685
698
  }
@@ -4,7 +4,7 @@
4
4
  // into validateColumnRendererForm, splitting them would create a
5
5
  // same-folder require cycle.
6
6
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
7
- import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
7
+ import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs, SELECT_OPTION_TONES, } from "@cosmicdrift/kumiko-types/fields";
8
8
  import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
9
9
  import { rowMetaFieldNames } from "../../db/table-builder.js";
10
10
  import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
@@ -12,9 +12,10 @@ import { isEncryptedAtRest } from "../config-helpers.js";
12
12
  import { parseRefTarget } from "../parse-ref-target.js";
13
13
  import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
14
14
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js";
15
- import { isExtensionEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
15
+ import { isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
16
16
  import { metricField } from "../types/index.js";
17
17
  import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
18
+ import { isSelectOptionTone } from "./select-option-tone.js";
18
19
  // entityList and projectionList both allow a rowAction to double as the
19
20
  // row-body click target (rowClick: true, fw#1708/#2164) — at most one per
20
21
  // screen, or the renderer can't tell which one should fire.
@@ -345,19 +346,41 @@ function validateDrawerTargetAction(featureName, screenId, screenKind, actionLab
345
346
  `kind:"drawer" mounts an actionForm inside a Drawer widget — point "screen" at an ` +
346
347
  `actionForm screen, or use kind:"navigate" for a full-page target.`);
347
348
  }
348
- // A prefill key the target form doesn't declare is dropped silently by the
349
- // renderer (mergeSearchParamsIntoInitial iterates the target's fields), so
350
- // the typo would only show up as an empty field at click time.
349
+ // The renderer (mergeSearchParamsIntoInitial) silently drops a prefill key
350
+ // when the target doesn't declare the field, marks it sensitive/password,
351
+ // or doesn't render it in its layout — each would only show up as an empty
352
+ // field at click time.
351
353
  // skip: no params extractor — nothing to check against the target's fields.
352
354
  if (action.params === undefined)
353
355
  return;
354
- for (const fieldName of rowFieldExtractorKeys(action.params)) {
355
- if (Object.hasOwn(target.fields, fieldName))
356
+ validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, action.screen, target, action.params);
357
+ }
358
+ function validateDrawerPrefillKeys(where, targetScreenId, target, params) {
359
+ const renderedFieldNames = new Set();
360
+ for (const section of target.layout.sections) {
361
+ if (!isFieldsEditSection(section))
356
362
  continue;
357
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
358
- `params prefills "${fieldName}", which drawer-target "${action.screen}" does not declare as a ` +
359
- `field — the renderer would drop it and leave the form empty. Target fields: ` +
360
- `${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
363
+ for (const spec of sectionFieldSpecs(section))
364
+ renderedFieldNames.add(normalizeEditField(spec).field);
365
+ }
366
+ for (const fieldName of rowFieldExtractorKeys(params)) {
367
+ if (!Object.hasOwn(target.fields, fieldName)) {
368
+ throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" does not declare as a ` +
369
+ `field — the renderer would drop it and leave the form empty. Target fields: ` +
370
+ `${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
371
+ }
372
+ const targetField = target.fields[fieldName];
373
+ const isSensitiveOrPassword = targetField !== undefined &&
374
+ (("sensitive" in targetField && targetField.sensitive === true) ||
375
+ ("format" in targetField && targetField.format === "password"));
376
+ if (isSensitiveOrPassword) {
377
+ throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" marks as ` +
378
+ `sensitive or password — the renderer never prefills those fields and would leave the form empty.`);
379
+ }
380
+ if (!renderedFieldNames.has(fieldName)) {
381
+ throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" does not render in ` +
382
+ `its layout — the renderer only prefills fields the layout shows and would drop it.`);
383
+ }
361
384
  }
362
385
  }
363
386
  // The target can be any screen, so this checks the resolved target's
@@ -564,7 +587,7 @@ function validateFieldsXorGroups(errorPrefix, section) {
564
587
  throw new Error(`${errorPrefix} section "${section.title}" declares both fields and groups — pass fields: [] ` +
565
588
  `when using groups.`);
566
589
  }
567
- if (section.fields.length === 0 && section.groups === undefined) {
590
+ if (section.fields.length === 0 && (section.groups?.length ?? 0) === 0) {
568
591
  throw new Error(`${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
569
592
  `fields (or groups) to it.`);
570
593
  }
@@ -1048,6 +1071,12 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1048
1071
  }
1049
1072
  }
1050
1073
  }
1074
+ for (const [statusValue, tone] of Object.entries(screen.header?.statusTones ?? {})) {
1075
+ if (!isSelectOptionTone(tone)) {
1076
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) header.statusTones["${statusValue}"] ` +
1077
+ `is "${String(tone)}" — expected one of ${SELECT_OPTION_TONES.join(", ")}.`);
1078
+ }
1079
+ }
1051
1080
  if (Array.isArray(screen.header?.subtitle)) {
1052
1081
  if (screen.header.subtitleHref !== undefined) {
1053
1082
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets header.subtitleHref ` +
@@ -1782,8 +1811,9 @@ function validateDashboardChartDisplay(featureName, screenId, panel) {
1782
1811
  throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it.`);
1783
1812
  }
1784
1813
  validateStackedAreaOnlyProps(where, panel);
1785
- if (panel.legendTotals !== undefined && typeof panel.legendTotals !== "boolean") {
1786
- throw new Error(`${where} has a non-boolean legendTotals.`);
1814
+ const nonBooleanProp = ["brush", "legendTotals"].find((prop) => panel[prop] !== undefined && typeof panel[prop] !== "boolean");
1815
+ if (nonBooleanProp !== undefined) {
1816
+ throw new Error(`${where} has a non-boolean ${nonBooleanProp}.`);
1787
1817
  }
1788
1818
  for (const [key, color] of Object.entries(panel.seriesColors ?? {})) {
1789
1819
  if (typeof color !== "string" || color.trim() === "") {
@@ -1800,7 +1830,7 @@ function validateStackedAreaOnlyProps(where, panel) {
1800
1830
  // skip: stacked-area is the one chart kind that supports these props
1801
1831
  if (panel.chart === "stacked-area")
1802
1832
  return;
1803
- const stackedAreaOnly = ["markerKinds", "legendTotals", "ranges"].filter((prop) => panel[prop] !== undefined);
1833
+ const stackedAreaOnly = ["markerKinds", "legendTotals", "ranges", "brush"].filter((prop) => panel[prop] !== undefined);
1804
1834
  if (stackedAreaOnly.length > 0) {
1805
1835
  throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it.`);
1806
1836
  }
@@ -0,0 +1,2 @@
1
+ import { type SelectOptionTone } from "@cosmicdrift/kumiko-types/fields";
2
+ export declare function isSelectOptionTone(value: unknown): value is SelectOptionTone;
@@ -0,0 +1,4 @@
1
+ import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
2
+ export function isSelectOptionTone(value) {
3
+ return SELECT_OPTION_TONES.some((tone) => tone === value);
4
+ }
@@ -268,6 +268,7 @@ export function findNonJsonSafePath(value, path) {
268
268
  // one that contradicts the schema.
269
269
  function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreenQn, encryptedConfigKeyQns) {
270
270
  return Object.entries(screens).map(([shortId, screen]) => {
271
+ assertValidScreenMultilineRows(featureName, shortId, screen);
271
272
  if (screen.type === "configEdit")
272
273
  return projectConfigEditScreen(screen, encryptedConfigKeyQns);
273
274
  if (screen.type === "projectionList")
@@ -330,7 +331,7 @@ function deriveProjectionListCapabilities(schema) {
330
331
  function projectEntities(entities) {
331
332
  const out = {};
332
333
  for (const [name, entity] of Object.entries(entities)) {
333
- out[name] = projectEntity(entity);
334
+ out[name] = projectEntity(name, entity);
334
335
  }
335
336
  return out;
336
337
  }
@@ -339,9 +340,10 @@ function projectEntities(entities) {
339
340
  // einzeln auszuhandeln, walken wir die Field-Map und filtern auf die
340
341
  // Whitelist. Was nicht durchkommt: Server-only-runtime wie ZodValidate-
341
342
  // Functions, Computed-Functions, Default-Functions.
342
- function projectEntity(entity) {
343
+ function projectEntity(entityName, entity) {
343
344
  const fieldsOut = {};
344
345
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
346
+ assertValidMultilineRows(`Entity "${entityName}"`, fieldName, fieldDef);
345
347
  fieldsOut[fieldName] = projectField(fieldDef);
346
348
  }
347
349
  // derivedFields MÜSSEN mit ins Client-Schema (nur die Metadaten, nicht die
@@ -380,6 +382,32 @@ function projectEntity(entity) {
380
382
  function projectDerivedField(derivedDef) {
381
383
  return { valueType: derivedDef.valueType };
382
384
  }
385
+ // A textarea height that is not a positive integer cannot render anywhere; fail
386
+ // at boot with the field name instead of letting each renderer guess a fallback.
387
+ function assertValidMultilineRows(owner, fieldName, fieldDef) {
388
+ const multiline = fieldDef.type === "text" || fieldDef.type === "longText" ? fieldDef.multiline : undefined;
389
+ const rows = typeof multiline === "object" ? multiline.rows : undefined;
390
+ // Non-number values are not JSON-safe and get dropped by the projection below.
391
+ if (typeof rows === "number" && (!Number.isInteger(rows) || rows < 1)) {
392
+ throw new Error(`${owner} field "${fieldName}": multiline.rows must be a positive integer, got ${rows}`);
393
+ }
394
+ }
395
+ // Screen forms carry their own inline field maps into the client schema.
396
+ function assertValidScreenMultilineRows(featureName, shortId, screen) {
397
+ const owner = `Screen "${featureName}:${shortId}"`;
398
+ const check = (fields) => {
399
+ for (const [fieldName, fieldDef] of Object.entries(fields)) {
400
+ assertValidMultilineRows(owner, fieldName, fieldDef);
401
+ }
402
+ };
403
+ if (screen.type === "configEdit" || screen.type === "actionForm")
404
+ check(screen.fields);
405
+ if (screen.type === "secretMint") {
406
+ check(screen.fields);
407
+ if (screen.confirm !== undefined)
408
+ check(screen.confirm.fields);
409
+ }
410
+ }
383
411
  // Per-field whitelist. Every forwarded value must be JSON-safe (literal, or
384
412
  // an array/plain-object built only from JSON-safe values) — even though the
385
413
  // FieldDefinition types only type `default` as a literal, the sample-authoring