@cosmicdrift/kumiko-framework 0.351.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 (66) 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/seed-context.d.ts +17 -0
  16. package/dist/db/queries/seed-context.js +26 -0
  17. package/dist/db/queries/shadow-swap.js +5 -2
  18. package/dist/engine/boot-validator/access-declarations.js +16 -1
  19. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  20. package/dist/engine/boot-validator/entity-handler.js +30 -0
  21. package/dist/engine/boot-validator/index.js +2 -1
  22. package/dist/engine/boot-validator/query-refs.js +7 -2
  23. package/dist/engine/boot-validator/screens.js +60 -22
  24. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  25. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  26. package/dist/engine/build-app-schema.js +33 -2
  27. package/dist/engine/build-config-feature-schema.js +6 -0
  28. package/dist/engine/dedupe-features.js +17 -1
  29. package/dist/engine/entity-handlers.js +15 -6
  30. package/dist/engine/feature-builder-state.d.ts +2 -7
  31. package/dist/engine/feature-config-events-jobs.js +3 -0
  32. package/dist/engine/feature-entity-handlers.js +1 -1
  33. package/dist/engine/i18n-key.js +14 -3
  34. package/dist/engine/index.d.ts +1 -1
  35. package/dist/engine/index.js +1 -1
  36. package/dist/engine/pipeline.js +18 -1
  37. package/dist/engine/project-app-schema-for-roles.js +8 -2
  38. package/dist/engine/role-assignment.d.ts +2 -0
  39. package/dist/engine/role-assignment.js +2 -0
  40. package/dist/engine/schema-builder.js +8 -0
  41. package/dist/engine/where-rule-lint.js +6 -3
  42. package/dist/env/index.d.ts +4 -0
  43. package/dist/env/index.js +4 -0
  44. package/dist/es-ops/context.js +34 -2
  45. package/dist/es-ops/index.d.ts +1 -1
  46. package/dist/es-ops/types.d.ts +20 -0
  47. package/dist/event-store/index.d.ts +1 -0
  48. package/dist/event-store/index.js +1 -0
  49. package/dist/event-store/tenant-db-append.d.ts +8 -0
  50. package/dist/event-store/tenant-db-append.js +16 -0
  51. package/dist/files/file-ref-entity.d.ts +6 -0
  52. package/dist/files/file-ref-table.d.ts +6 -0
  53. package/dist/files/file-routes.js +3 -3
  54. package/dist/files/types.js +21 -1
  55. package/dist/observability/metrics-wiring.d.ts +1 -1
  56. package/dist/observability/metrics-wiring.js +2 -2
  57. package/dist/pipeline/dispatch-batch.js +5 -1
  58. package/dist/pipeline/dispatch-write.js +36 -82
  59. package/dist/pipeline/member-reader.js +1 -0
  60. package/dist/pipeline/pre-handler-gates.d.ts +9 -0
  61. package/dist/pipeline/pre-handler-gates.js +104 -0
  62. package/dist/pipeline/pre-transaction-reservations.d.ts +12 -3
  63. package/dist/pipeline/pre-transaction-reservations.js +34 -21
  64. package/dist/testing/shared-entities.d.ts +10 -0
  65. package/package.json +4 -4
  66. package/src/changes.json +57 -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.
@@ -1,3 +1,4 @@
1
+ import type { DbRunner } from "../connection.js";
1
2
  import type { AnyDb } from "../query.js";
2
3
  export type SeedUserRow = {
3
4
  readonly id: string;
@@ -18,3 +19,19 @@ export type SeedTenantDbRow = {
18
19
  export declare function selectUserByEmail(db: AnyDb, email: string): Promise<SeedUserRow | null>;
19
20
  export declare function selectMembershipsOfUser(db: AnyDb, userId: string): Promise<readonly SeedMembershipDbRow[]>;
20
21
  export declare function selectAllTenants(db: AnyDb): Promise<readonly SeedTenantDbRow[]>;
22
+ export type SeedTemplateResourceDbRow = {
23
+ readonly id: string;
24
+ readonly tenant_id: string;
25
+ readonly slug: string;
26
+ readonly kind: string;
27
+ readonly locale: string;
28
+ readonly status: string;
29
+ };
30
+ export type SeedTemplateResourceDbFilter = {
31
+ readonly tenantId: string;
32
+ readonly slug?: string;
33
+ readonly kind?: string;
34
+ readonly status?: string;
35
+ readonly locale?: string;
36
+ };
37
+ export declare function selectTemplateResources(db: DbRunner, filter: SeedTemplateResourceDbFilter): Promise<readonly SeedTemplateResourceDbRow[]>;
@@ -1,4 +1,6 @@
1
1
  import { unsafeReadRetrying } from "../query.js";
2
+ import { tableExists } from "../schema-inspection.js";
3
+ import { SOFT_DELETE_LIVE_ROW_PREDICATE } from "../table-builder.js";
2
4
  export async function selectUserByEmail(db, email) {
3
5
  const rows = await unsafeReadRetrying(db, `SELECT id::text AS id, email, tenant_id::text AS tenant_id
4
6
  FROM read_users
@@ -23,3 +25,27 @@ export async function selectAllTenants(db) {
23
25
  FROM read_tenants
24
26
  ORDER BY inserted_at`, []);
25
27
  }
28
+ // Column names are fixed fragments; every filter value is a bound parameter.
29
+ // template-resolver is an optional feature — without its table the answer is
30
+ // "no templates", not an error.
31
+ export async function selectTemplateResources(db, filter) {
32
+ if (!(await tableExists(db, "read_template_resources")))
33
+ return [];
34
+ const conditions = ["tenant_id = $1", SOFT_DELETE_LIVE_ROW_PREDICATE];
35
+ const params = [filter.tenantId];
36
+ for (const [column, value] of [
37
+ ["slug", filter.slug],
38
+ ["kind", filter.kind],
39
+ ["status", filter.status],
40
+ ["locale", filter.locale],
41
+ ]) {
42
+ if (value === undefined)
43
+ continue;
44
+ params.push(value);
45
+ conditions.push(`${column} = $${params.length}`);
46
+ }
47
+ return unsafeReadRetrying(db, `SELECT id::text AS id, tenant_id::text AS tenant_id, slug, kind, locale, status
48
+ FROM read_template_resources
49
+ WHERE ${conditions.join(" AND ")}
50
+ ORDER BY slug, locale`, params);
51
+ }
@@ -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);
@@ -19,6 +19,7 @@ export declare function validateFileFields(feature: FeatureDefinition): boolean;
19
19
  export declare function validateReferenceFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
20
20
  export declare function validateEmbeddedFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
21
21
  export declare function validateEntitySelectOptionsQuery(feature: FeatureDefinition): void;
22
+ export declare function validateEntitySelectAvailabilityQuery(feature: FeatureDefinition, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
22
23
  export declare function validateMultiSelectFields(feature: FeatureDefinition): void;
23
24
  export declare function validateImageVariants(feature: FeatureDefinition): void;
24
25
  export declare function validateTransitions(feature: FeatureDefinition): void;
@@ -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
  }
@@ -692,6 +705,23 @@ function assertNoSelectOptionsQuery(field, path, featureName) {
692
705
  throw new Error(`[Feature ${featureName}] Entity select field "${path}" declares optionsQuery — not supported on entity fields because the stored value would not be validated against the query result. Use static options, or a reference field.`);
693
706
  }
694
707
  }
708
+ // A typo'd availability QN would only surface as a failed query when the form
709
+ // mounts, so it is pinned at boot like the other option queries.
710
+ export function validateEntitySelectAvailabilityQuery(feature, queryHandlers) {
711
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
712
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
713
+ if (field.type !== "select" || field.optionsAvailabilityQuery === undefined)
714
+ continue;
715
+ const query = field.optionsAvailabilityQuery;
716
+ if (query.length === 0 || !queryHandlers.has(query)) {
717
+ throw new Error(`[Feature ${feature.name}] Select field "${fieldName}" on entity "${entityName}" ` +
718
+ `declares optionsAvailabilityQuery "${query}" which is not a registered query-handler. ` +
719
+ `Check the QN spelling (expected "<feature>:query:<short>") and that the handler ` +
720
+ `is declared via r.queryHandler(...).`);
721
+ }
722
+ }
723
+ }
724
+ }
695
725
  export function validateMultiSelectFields(feature) {
696
726
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
697
727
  for (const [fieldName, field] of Object.entries(entity.fields)) {
@@ -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, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
12
+ import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectAvailabilityQuery, 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";
@@ -169,6 +169,7 @@ export function validateBoot(rawFeatures, options) {
169
169
  validateRecordOwnedSubjects(feature);
170
170
  validateApiExposureMatching(feature, allExposedApis, featureMap);
171
171
  validateEntitySelectOptionsQuery(feature);
172
+ validateEntitySelectAvailabilityQuery(feature, queryHandlerQns);
172
173
  validateEmbeddedFields(feature, featureMap, queryHandlerQns);
173
174
  validateMultiSelectFields(feature);
174
175
  validateImageVariants(feature);
@@ -45,9 +45,14 @@ function checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen) {
45
45
  }
46
46
  function checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screenType, fields) {
47
47
  for (const [fieldName, field] of Object.entries(fields)) {
48
- if (field.type !== "select" || field.optionsQuery === undefined)
48
+ if (field.type !== "select")
49
49
  continue;
50
- checkQueryRef(queryHandlers, field.optionsQuery, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) select field "${fieldName}" optionsQuery`);
50
+ if (field.optionsQuery !== undefined) {
51
+ checkQueryRef(queryHandlers, field.optionsQuery, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) select field "${fieldName}" optionsQuery`);
52
+ }
53
+ if (field.optionsAvailabilityQuery !== undefined) {
54
+ checkQueryRef(queryHandlers, field.optionsAvailabilityQuery, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) select field "${fieldName}" optionsAvailabilityQuery`);
55
+ }
51
56
  }
52
57
  }
53
58
  function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {