@cosmicdrift/kumiko-framework 0.352.0 → 0.354.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 (188) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/pii-leak-guard.js +3 -0
  4. package/dist/api/redis-sse-broker.d.ts +1 -0
  5. package/dist/api/redis-sse-broker.js +16 -10
  6. package/dist/api/request-context.d.ts +1 -0
  7. package/dist/api/request-context.js +5 -0
  8. package/dist/api/route-registrars.d.ts +2 -1
  9. package/dist/api/route-registrars.js +8 -0
  10. package/dist/api/routes.js +4 -2
  11. package/dist/api/server.js +39 -6
  12. package/dist/api/sse-broker.d.ts +7 -3
  13. package/dist/api/sse-route.js +7 -1
  14. package/dist/api/token-expiry-timer.d.ts +1 -0
  15. package/dist/api/token-expiry-timer.js +10 -0
  16. package/dist/bun-db/query.js +36 -4
  17. package/dist/compliance/sub-processors.js +5 -0
  18. package/dist/crypto/event-pii.js +4 -2
  19. package/dist/crypto/kms-wiring.js +31 -5
  20. package/dist/crypto/pii-field-encryption.d.ts +2 -3
  21. package/dist/crypto/pii-field-encryption.js +2 -3
  22. package/dist/db/eagerload.d.ts +8 -3
  23. package/dist/db/eagerload.js +85 -81
  24. package/dist/db/entity-table-meta.js +11 -0
  25. package/dist/db/event-store-executor-context.d.ts +12 -0
  26. package/dist/db/event-store-executor-context.js +78 -42
  27. package/dist/db/event-store-executor-read.js +67 -25
  28. package/dist/db/event-store-executor-write.js +54 -3
  29. package/dist/db/index.d.ts +1 -1
  30. package/dist/db/migrate-generator.js +10 -2
  31. package/dist/db/queries/backfill-pii.d.ts +2 -0
  32. package/dist/db/queries/backfill-pii.js +24 -6
  33. package/dist/db/queries/event-consumer.d.ts +2 -3
  34. package/dist/db/queries/event-consumer.js +54 -43
  35. package/dist/db/queries/event-store.d.ts +4 -0
  36. package/dist/db/queries/event-store.js +40 -3
  37. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  38. package/dist/db/queries/pii-backfill-state.js +16 -5
  39. package/dist/db/queries/shadow-swap.js +37 -5
  40. package/dist/db/render-ddl.js +6 -6
  41. package/dist/db/table-builder.js +6 -7
  42. package/dist/db/tenant-db.d.ts +5 -2
  43. package/dist/db/tenant-db.js +47 -7
  44. package/dist/derivatives/variant-key.d.ts +1 -0
  45. package/dist/derivatives/variant-key.js +3 -0
  46. package/dist/engine/boot-validator/access-declarations.js +21 -1
  47. package/dist/engine/boot-validator/config-deps.js +3 -0
  48. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  49. package/dist/engine/boot-validator/entity-handler.js +53 -0
  50. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  51. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  52. package/dist/engine/boot-validator/index.js +6 -1
  53. package/dist/engine/boot-validator/nav.js +3 -1
  54. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  55. package/dist/engine/boot-validator/ownership.js +32 -1
  56. package/dist/engine/boot-validator/pii-retention.js +8 -1
  57. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  58. package/dist/engine/boot-validator/screens.js +136 -15
  59. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  60. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  61. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  62. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  63. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  64. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  65. package/dist/engine/build-app-schema.js +33 -2
  66. package/dist/engine/build-config-feature-schema.js +6 -0
  67. package/dist/engine/changeset-changes.d.ts +1 -0
  68. package/dist/engine/changeset-changes.js +49 -33
  69. package/dist/engine/dedupe-features.js +17 -1
  70. package/dist/engine/define-feature.js +11 -0
  71. package/dist/engine/entity-handlers.d.ts +3 -3
  72. package/dist/engine/entity-handlers.js +70 -40
  73. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  74. package/dist/engine/escape-hatch-reason.js +8 -0
  75. package/dist/engine/extension-names.d.ts +8 -9
  76. package/dist/engine/extension-names.js +8 -9
  77. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  78. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  79. package/dist/engine/factories.d.ts +1 -1
  80. package/dist/engine/feature-builder-state.d.ts +2 -7
  81. package/dist/engine/feature-changelog.d.ts +2 -0
  82. package/dist/engine/feature-changelog.js +6 -0
  83. package/dist/engine/feature-config-events-jobs.js +44 -4
  84. package/dist/engine/feature-entity-handlers.js +1 -1
  85. package/dist/engine/handler-field-references.d.ts +3 -0
  86. package/dist/engine/handler-field-references.js +31 -0
  87. package/dist/engine/i18n-key.js +13 -3
  88. package/dist/engine/index.d.ts +9 -3
  89. package/dist/engine/index.js +7 -2
  90. package/dist/engine/ownership.js +3 -4
  91. package/dist/engine/pipeline.js +18 -1
  92. package/dist/engine/project-app-schema-for-roles.js +11 -3
  93. package/dist/engine/registry-ingest.d.ts +1 -0
  94. package/dist/engine/registry-ingest.js +8 -3
  95. package/dist/engine/role-assignment.d.ts +2 -0
  96. package/dist/engine/role-assignment.js +2 -0
  97. package/dist/engine/run-pipeline.js +12 -1
  98. package/dist/engine/schema-builder.js +8 -0
  99. package/dist/engine/screen-helpers.d.ts +13 -0
  100. package/dist/engine/screen-helpers.js +31 -0
  101. package/dist/engine/soft-delete-cleanup.js +3 -2
  102. package/dist/engine/steps/_read-source.d.ts +4 -0
  103. package/dist/engine/steps/_read-source.js +22 -0
  104. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  105. package/dist/engine/steps/read-find-many.js +2 -1
  106. package/dist/engine/steps/read-find-one.js +2 -1
  107. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  108. package/dist/engine/steps/workflow-step-error.js +13 -0
  109. package/dist/engine/transfer-adjacency.d.ts +24 -0
  110. package/dist/engine/transfer-adjacency.js +107 -0
  111. package/dist/engine/where-rule-lint.js +6 -3
  112. package/dist/entrypoint/index.d.ts +2 -0
  113. package/dist/entrypoint/index.js +2 -0
  114. package/dist/env/index.d.ts +4 -0
  115. package/dist/env/index.js +49 -23
  116. package/dist/errors/classes.d.ts +8 -0
  117. package/dist/errors/classes.js +13 -0
  118. package/dist/errors/index.d.ts +2 -1
  119. package/dist/errors/index.js +2 -1
  120. package/dist/errors/reasons.d.ts +1 -0
  121. package/dist/errors/reasons.js +3 -0
  122. package/dist/errors/subject-erased.d.ts +2 -0
  123. package/dist/errors/subject-erased.js +9 -0
  124. package/dist/es-ops/context.js +19 -1
  125. package/dist/event-store/admin-api.js +14 -10
  126. package/dist/event-store/event-store.js +11 -9
  127. package/dist/event-store/events-schema.js +2 -1
  128. package/dist/files/file-handle.d.ts +1 -1
  129. package/dist/files/file-handle.js +30 -6
  130. package/dist/files/file-ref-entity.d.ts +6 -0
  131. package/dist/files/file-ref-table.d.ts +6 -0
  132. package/dist/files/file-routes.js +13 -7
  133. package/dist/files/resolve-content-type.d.ts +2 -0
  134. package/dist/files/resolve-content-type.js +8 -0
  135. package/dist/files/types.js +21 -1
  136. package/dist/i18n/required-surface-keys.d.ts +2 -0
  137. package/dist/i18n/required-surface-keys.js +8 -0
  138. package/dist/jobs/index.d.ts +1 -1
  139. package/dist/jobs/job-runner.d.ts +13 -0
  140. package/dist/jobs/job-runner.js +128 -22
  141. package/dist/migrations/pii-event-backfill.js +46 -25
  142. package/dist/observability/index.d.ts +1 -1
  143. package/dist/observability/index.js +1 -1
  144. package/dist/observability/metrics-wiring.d.ts +1 -1
  145. package/dist/observability/metrics-wiring.js +2 -2
  146. package/dist/observability/standard-metrics.d.ts +4 -0
  147. package/dist/observability/standard-metrics.js +12 -0
  148. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  149. package/dist/pipeline/dispatch-shared.js +74 -9
  150. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  151. package/dist/pipeline/dispatch-stream.js +19 -12
  152. package/dist/pipeline/dispatcher.d.ts +3 -1
  153. package/dist/pipeline/dispatcher.js +10 -15
  154. package/dist/pipeline/event-consumer-state.js +0 -26
  155. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  156. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  157. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  158. package/dist/pipeline/event-dispatcher.js +48 -7
  159. package/dist/pipeline/index.d.ts +2 -0
  160. package/dist/pipeline/index.js +1 -0
  161. package/dist/pipeline/member-read-only-transaction.js +25 -5
  162. package/dist/pipeline/member-reader.js +1 -0
  163. package/dist/pipeline/msp-rebuild.js +3 -3
  164. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  165. package/dist/pipeline/pending-gap-ranges.js +22 -0
  166. package/dist/pipeline/system-hooks.js +4 -4
  167. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  168. package/dist/pipeline/system-identity-switch.js +5 -4
  169. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  170. package/dist/redis/cache-sync-topics.d.ts +1 -0
  171. package/dist/redis/cache-sync-topics.js +1 -0
  172. package/dist/redis/client.d.ts +1 -0
  173. package/dist/redis/client.js +7 -0
  174. package/dist/redis/index.d.ts +1 -1
  175. package/dist/redis/index.js +1 -1
  176. package/dist/redis/pubsub-signal.d.ts +1 -0
  177. package/dist/redis/pubsub-signal.js +3 -0
  178. package/dist/search/meilisearch-adapter.js +34 -4
  179. package/dist/stack/test-stack.d.ts +4 -1
  180. package/dist/stack/test-stack.js +9 -2
  181. package/dist/testing/e2e-generator.js +7 -2
  182. package/dist/testing/shared-entities.d.ts +10 -0
  183. package/dist/ui-types/index.d.ts +3 -3
  184. package/dist/ui-types/index.js +2 -2
  185. package/dist/upgrade-cli.js +7 -2
  186. package/package.json +12 -4
  187. package/src/changes.json +228 -0
  188. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -19,7 +19,7 @@ export type { PostAuthLandingArgs, PostAuthLandingFlow, PostAuthLandingResolver,
19
19
  export { isSafeLandingPath } from "./post-auth-landing.js";
20
20
  export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker.js";
21
21
  export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.js";
22
- export { type RequestContextData, requestContext, runAsDirectCallEntry, } from "./request-context.js";
22
+ export { type RequestContextData, readonlyRequestContext as requestContext, } from "./request-context.js";
23
23
  export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./request-id-middleware.js";
24
24
  export type { MetricsRouteOptions } from "./route-registrars.js";
25
25
  export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
package/dist/api/index.js CHANGED
@@ -10,7 +10,7 @@ export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt.js";
10
10
  export { patAllows, qnMatches } from "./pat-scope.js";
11
11
  export { isSafeLandingPath } from "./post-auth-landing.js";
12
12
  export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.js";
13
- export { requestContext, runAsDirectCallEntry, } from "./request-context.js";
13
+ export { readonlyRequestContext as requestContext, } from "./request-context.js";
14
14
  export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./request-id-middleware.js";
15
15
  export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
16
16
  export { createApiRoutes } from "./routes.js";
@@ -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();
@@ -3,6 +3,7 @@ import { type SseBroker } from "./sse-broker.js";
3
3
  export type RedisSseBrokerOptions = {
4
4
  readonly redisUrl: string;
5
5
  readonly clientOptions?: RedisClientOptions;
6
+ readonly channelPrefix?: string;
6
7
  };
7
8
  export type RedisSseBroker = SseBroker & {
8
9
  readonly kind: "redis";
@@ -1,4 +1,4 @@
1
- import { redisClientOptionsFromEnv } from "../redis/client.js";
1
+ import { redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "../redis/client.js";
2
2
  import { createRedisPubSubSignal } from "../redis/pubsub-signal.js";
3
3
  import { createSseBroker, readNonEmptyStringArray, } from "./sse-broker.js";
4
4
  // Channel namespace for cross-replica fanout (fw#2625). Every pod publishes
@@ -8,7 +8,7 @@ import { createSseBroker, readNonEmptyStringArray, } from "./sse-broker.js";
8
8
  // cursor stays the mechanism for "read every event exactly once".
9
9
  const CHANNEL_PREFIX = "kumiko:sse:ch:";
10
10
  const INVALIDATION_PREFIX = "kumiko:sse:inval:";
11
- const PSUBSCRIBE_PATTERN = "kumiko:sse:*";
11
+ const PSUBSCRIBE_SUFFIX = "kumiko:sse:*";
12
12
  // Narrows on the `kind` discriminant, not on `close`: an app-owned SseBroker
13
13
  // may have its own close() and must not be mistaken for the Redis one.
14
14
  export function isRedisSseBroker(broker) {
@@ -26,7 +26,11 @@ export function isRedisSseBroker(broker) {
26
26
  export function createDefaultSseBroker(env = process.env) {
27
27
  const redisUrl = env["REDIS_URL"];
28
28
  const ownedRedisSseBroker = redisUrl
29
- ? createRedisSseBroker({ redisUrl, clientOptions: redisClientOptionsFromEnv(env) })
29
+ ? createRedisSseBroker({
30
+ redisUrl,
31
+ clientOptions: redisClientOptionsFromEnv(env),
32
+ channelPrefix: redisChannelPrefixFromEnv(env),
33
+ })
30
34
  : undefined;
31
35
  return { sseBroker: ownedRedisSseBroker ?? createSseBroker(), ownedRedisSseBroker };
32
36
  }
@@ -92,26 +96,28 @@ function encodeInvalidationScope(scope) {
92
96
  // the request.
93
97
  export function createRedisSseBroker(opts) {
94
98
  const inner = createSseBroker();
99
+ const channelPrefix = `${opts.channelPrefix ?? ""}${CHANNEL_PREFIX}`;
100
+ const invalidationPrefix = `${opts.channelPrefix ?? ""}${INVALIDATION_PREFIX}`;
95
101
  const signal = createRedisPubSubSignal({
96
102
  redisUrl: opts.redisUrl,
97
- channelPattern: PSUBSCRIBE_PATTERN,
103
+ channelPattern: `${opts.channelPrefix ?? ""}${PSUBSCRIBE_SUFFIX}`,
98
104
  label: "sse-broker",
99
105
  ...(opts.clientOptions ? { clientOptions: opts.clientOptions } : {}),
100
106
  });
101
107
  signal.onMessage((channel, payload) => {
102
- if (channel.startsWith(CHANNEL_PREFIX)) {
108
+ if (channel.startsWith(channelPrefix)) {
103
109
  if (!isSseEvent(payload)) {
104
110
  console.error(`[kumiko:sse-broker] dropping malformed event on "${channel}"`);
105
111
  // skip: malformed payload already logged above, nothing to deliver
106
112
  return;
107
113
  }
108
- inner.pushToChannel(channel.slice(CHANNEL_PREFIX.length), payload);
114
+ inner.pushToChannel(channel.slice(channelPrefix.length), payload);
109
115
  // skip: channel already routed to the SSE-event branch above, the
110
116
  // invalidation branch below is mutually exclusive with this one
111
117
  return;
112
118
  }
113
- if (channel.startsWith(INVALIDATION_PREFIX)) {
114
- inner.publishAccessInvalidation(channel.slice(INVALIDATION_PREFIX.length), parseInvalidationScope(payload));
119
+ if (channel.startsWith(invalidationPrefix)) {
120
+ inner.publishAccessInvalidation(channel.slice(invalidationPrefix.length), parseInvalidationScope(payload));
115
121
  }
116
122
  });
117
123
  return {
@@ -122,14 +128,14 @@ export function createRedisSseBroker(opts) {
122
128
  getTotalClientCount: inner.getTotalClientCount,
123
129
  subscribeAccessInvalidation: inner.subscribeAccessInvalidation,
124
130
  pushToChannel(channel, event) {
125
- signal.publish(`${CHANNEL_PREFIX}${channel}`, event);
131
+ return signal.publishConfirmed(`${channelPrefix}${channel}`, event);
126
132
  },
127
133
  // fw#1601: this is the security-critical call — a revoked session's
128
134
  // stream must close on every replica, not just the one that observed
129
135
  // the revocation event. Publishing (rather than calling inner directly,
130
136
  // like the in-memory broker does) is what makes that true here.
131
137
  publishAccessInvalidation(userId, scope) {
132
- signal.publish(`${INVALIDATION_PREFIX}${userId}`, encodeInvalidationScope(scope));
138
+ return signal.publishConfirmed(`${invalidationPrefix}${userId}`, encodeInvalidationScope(scope));
133
139
  },
134
140
  close: signal.close,
135
141
  };
@@ -25,6 +25,7 @@ export declare const requestContext: {
25
25
  get(): RequestContextData | undefined;
26
26
  generateId(): string;
27
27
  };
28
+ export declare const readonlyRequestContext: Pick<typeof requestContext, "get">;
28
29
  export declare function runWithOrigin<T>(origin: {
29
30
  readonly feature?: string;
30
31
  readonly handler?: string;
@@ -13,6 +13,11 @@ export const requestContext = {
13
13
  return generateId();
14
14
  },
15
15
  };
16
+ // Public view: `run` would let a caller replace the request scope (entryHandler,
17
+ // writeOrigin), so only the read side is exposed through the /api barrel.
18
+ export const readonlyRequestContext = {
19
+ get: requestContext.get,
20
+ };
16
21
  // Enter a scope that attributes every event written inside it. Keeps the
17
22
  // surrounding request's ids so correlation survives, and mints fresh ones
18
23
  // when there is no surrounding request (job-runner, event-dispatcher) —
@@ -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.
@@ -5,7 +5,7 @@ import { AccessDeniedError, reraiseAsKumikoError, serializeError, toKumikoError,
5
5
  import { createFallbackLogger } from "../logging/index.js";
6
6
  import { stringifyJson } from "../utils/safe-json.js";
7
7
  import { Routes } from "./api-constants.js";
8
- import { getUser } from "./auth-middleware.js";
8
+ import { getAuthTokenExpiry, getUser } from "./auth-middleware.js";
9
9
  import { patAllows } from "./pat-scope.js";
10
10
  import { requestContext } from "./request-context.js";
11
11
  import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route.js";
@@ -133,7 +133,9 @@ export function createApiRoutes(dispatcher, options = {}) {
133
133
  try {
134
134
  assertPayloadDepthAllowed(body.payload);
135
135
  assertPatAllowed(user, body.type);
136
- generator = dispatcher.stream(body.type, body.payload, user);
136
+ generator = dispatcher.stream(body.type, body.payload, user, {
137
+ tokenExpiresAtSec: getAuthTokenExpiry(c),
138
+ });
137
139
  }
138
140
  catch (e) {
139
141
  return queryErrorResponse(c, toKumiko(e), body.type);
@@ -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";
@@ -130,6 +130,9 @@ export function buildServer(options) {
130
130
  const defaults = createDefaultSseBroker();
131
131
  sseBroker = defaults.sseBroker;
132
132
  ownedRedisSseBroker = defaults.ownedRedisSseBroker;
133
+ if (!ownedRedisSseBroker) {
134
+ console.warn("[kumiko:boot] REDIS_URL is not set: cross-replica fanout disabled. SSE, access-invalidation and toggle-sync reach only the cursor-owning process, so with more than one replica clients on the other replicas miss events and revoked sessions stay open there.");
135
+ }
133
136
  }
134
137
  // Same decision as the SSE broker: an explicit dispatcherOptions value
135
138
  // (incl. null = opt out) wins, otherwise REDIS_URL turns on cross-replica
@@ -215,6 +218,21 @@ export function buildServer(options) {
215
218
  : (options.jwtSecret.keys[options.jwtSecret.signKid] ?? ""));
216
219
  const rateLimitResolver = options.context.rateLimit ??
217
220
  (wrappedRedis && wantsResolver ? createRateLimitResolver({ redis: wrappedRedis }) : undefined);
221
+ if (!rateLimitResolver) {
222
+ // The systemScope default limit only exists once a resolver does, and an app that
223
+ // declared no explicit rateLimit anywhere never gets one auto-wired (wantsL3 stays
224
+ // false) — so its cross-tenant handlers are silently unlimited.
225
+ const unlimitedSystemScopeFeatures = [...options.registry.features.values()]
226
+ .filter((feature) => feature.systemScope &&
227
+ Object.keys(feature.writeHandlers).length + Object.keys(feature.queryHandlers).length > 0)
228
+ .map((feature) => feature.name);
229
+ if (unlimitedSystemScopeFeatures.length > 0) {
230
+ console.warn(`[kumiko:boot] r.systemScope() features have handlers but no RateLimitResolver is configured, ` +
231
+ `so their default rate limit is not enforced: ${unlimitedSystemScopeFeatures.join(", ")}. ` +
232
+ "Wire a Redis client on context.redis and declare rateLimit on at least one handler (or pass " +
233
+ "context.rateLimit) to enable the resolver.");
234
+ }
235
+ }
218
236
  const contextWithObservability = {
219
237
  ...contextWithFiles,
220
238
  ...(wrappedRedis ? { redis: wrappedRedis } : {}),
@@ -342,7 +360,7 @@ export function buildServer(options) {
342
360
  // cross-feature ctx.appendEvent calls at emit-site.
343
361
  const mspOwner = msp.name.split(":")[0];
344
362
  const mspFiles = fileProviderResolver
345
- ? createFileContext(() => fileProviderResolver(event.tenantId))
363
+ ? createFileContext(() => fileProviderResolver(event.tenantId), event.tenantId)
346
364
  : undefined;
347
365
  const applyCtx = createMultiStreamApplyContext({
348
366
  registry: options.registry,
@@ -438,17 +456,19 @@ export function buildServer(options) {
438
456
  // resolver is the auto-wired one (or `context.rateLimit` if set);
439
457
  // boot-fails loudly when the caller asked for middleware without a
440
458
  // working Redis to back it.
459
+ let globalRateLimitGuard;
441
460
  if (wantsL1L2) {
442
461
  if (!rateLimitResolver) {
443
462
  throw new Error("rateLimit middleware requested but no resolver available — pass `context.redis` " +
444
463
  "or `context.rateLimit` so the resolver can be built.");
445
464
  }
446
465
  if (options.rateLimit?.global) {
447
- app.use("/api/*", globalIpRateLimit({
466
+ globalRateLimitGuard = globalIpRateLimit({
448
467
  ...options.rateLimit.global,
449
468
  resolver: rateLimitResolver,
450
469
  clientIpResolver,
451
- }));
470
+ });
471
+ app.use("/api/*", globalRateLimitGuard);
452
472
  }
453
473
  if (options.rateLimit?.auth) {
454
474
  const { path: l2Path = "/api/auth/*", ...l2Opts } = options.rateLimit.auth;
@@ -564,7 +584,12 @@ export function buildServer(options) {
564
584
  return csrfGuard(c, next);
565
585
  });
566
586
  // No PAT rate-limit here: patRouteGuardMiddleware rejects every PAT on httpRoutes before it could count.
587
+ // httpRoutes sit outside /api/*, so the pre-auth /api/* middlewares do not run for them: the
588
+ // global IP limit (an unauthenticated flood must not reach JWT verify / session checks) and the
589
+ // PII ciphertext tripwire are re-attached here. requestId, body limit and observability are not.
567
590
  const sessionOnlyHttpRouteGuards = [
591
+ ...(globalRateLimitGuard ? [globalRateLimitGuard] : []),
592
+ piiCiphertextResponseGuard(),
568
593
  sessionOnlyGuard,
569
594
  patRouteGuardMiddleware,
570
595
  ...(originGuard ? [originGuard] : []),
@@ -782,7 +807,9 @@ export function buildServer(options) {
782
807
  dispatchSystemQuery,
783
808
  clientIpResolver,
784
809
  });
785
- mountHonoRoute(app, route.method, route.path, honoHandler);
810
+ mountHonoRoute(app, route.method, route.path, honoHandler, route.entry === ExtraRouteEntries.signature
811
+ ? bodyLimitForRoutePath(route.path, options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES)
812
+ : []);
786
813
  }
787
814
  }
788
815
  // /version-Default registriert NACH feature-routes — Hono "first match
@@ -927,6 +954,7 @@ function unauthenticatedResponse(c) {
927
954
  }, 401);
928
955
  }
929
956
  const webSocketLog = createFallbackLogger("websocket");
957
+ const extraRouteLog = createFallbackLogger("extra-route");
930
958
  function webSocketErrorResponse(c, status, code, message) {
931
959
  return c.json({ error: { code, httpStatus: status, message } }, status);
932
960
  }
@@ -1069,10 +1097,15 @@ function buildExtraRouteHonoHandler(route, shared) {
1069
1097
  }
1070
1098
  return c.json(e.body, e.status);
1071
1099
  }
1100
+ // The body goes to unauthenticated callers; infra errors thrown by
1101
+ // verify() (secrets, DB) must not leak, so the detail stays in the log.
1102
+ extraRouteLog.error(`signature verify() threw on ${c.req.path}`, {
1103
+ error: e instanceof Error ? e.message : String(e),
1104
+ });
1072
1105
  return c.json({
1073
1106
  error: {
1074
1107
  code: "extra_route_signature_invalid",
1075
- message: e instanceof Error ? e.message : String(e),
1108
+ message: "signature verification failed",
1076
1109
  },
1077
1110
  }, 401);
1078
1111
  }
@@ -29,11 +29,15 @@ export declare function accessInvalidationCredentialFor(user: Pick<SessionUser,
29
29
  export type SseBroker = {
30
30
  addClient(channel: string, send: (event: SseEvent) => void, close: () => void): string;
31
31
  removeClient(channel: string, clientId: string): void;
32
- pushToChannel(channel: string, event: SseEvent): void;
32
+ pushToChannel(channel: string, event: SseEvent): void | Promise<void>;
33
33
  getClientCount(channel: string): number;
34
34
  getTotalClientCount(): number;
35
35
  subscribeAccessInvalidation(userId: string, onInvalidate: () => void, credential?: AccessInvalidationCredential): () => void;
36
- publishAccessInvalidation(userId: string, scope?: AccessInvalidationScope): void;
36
+ publishAccessInvalidation(userId: string, scope?: AccessInvalidationScope): void | Promise<void>;
37
37
  };
38
38
  export declare function shouldInvalidateListener(credential: AccessInvalidationCredential, scope: AccessInvalidationScope): boolean;
39
- export declare function createSseBroker(): SseBroker;
39
+ export type LocalSseBroker = Omit<SseBroker, "publishAccessInvalidation" | "pushToChannel"> & {
40
+ pushToChannel(channel: string, event: SseEvent): void;
41
+ publishAccessInvalidation(userId: string, scope?: AccessInvalidationScope): void;
42
+ };
43
+ export declare function createSseBroker(): LocalSseBroker;
@@ -3,8 +3,9 @@ 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
+ import { scheduleTokenExpiry } from "./token-expiry-timer.js";
8
9
  /**
9
10
  * Heartbeat-Cadence für SSE-Streams.
10
11
  *
@@ -88,6 +89,9 @@ export function createSseRoute(broker, options) {
88
89
  // Channel is server-derived from authenticated user — never trust client input.
89
90
  // Allowing ?channel=... would let any authenticated user subscribe to other tenants' feeds.
90
91
  const channel = tenantChannel(user.tenantId);
92
+ // JWT expiry raises no access-invalidation event, so the stream must end
93
+ // itself when the token it was opened with expires.
94
+ const tokenExpiresAtSec = getAuthTokenExpiry(c);
91
95
  return streamSSE(c, async (stream) => {
92
96
  let resolveEnded;
93
97
  const ended = new Promise((resolve) => {
@@ -105,6 +109,7 @@ export function createSseRoute(broker, options) {
105
109
  stream.writeSSE({ event: frame.name, data: JSON.stringify(frame.data) });
106
110
  }, closeStream);
107
111
  const unsubscribeAccessInvalidation = broker.subscribeAccessInvalidation(user.id, closeStream, accessInvalidationCredentialFor(user));
112
+ const expiryTimer = scheduleTokenExpiry(tokenExpiresAtSec, closeStream);
108
113
  let released = false;
109
114
  const release = () => {
110
115
  // skip: onAbort and the finally block both release, the second call is a no-op
@@ -113,6 +118,7 @@ export function createSseRoute(broker, options) {
113
118
  released = true;
114
119
  // A client disconnect must also wake the heartbeat sleep, not only a server close.
115
120
  resolveEnded();
121
+ clearTimeout(expiryTimer);
116
122
  broker.removeClient(channel, clientId);
117
123
  unsubscribeAccessInvalidation();
118
124
  };
@@ -0,0 +1 @@
1
+ export declare function scheduleTokenExpiry(tokenExpiresAtSec: number | undefined, onExpired: () => void): ReturnType<typeof setTimeout> | undefined;
@@ -0,0 +1,10 @@
1
+ // setTimeout overflows (fires immediately) above 2^31-1 ms; a token that
2
+ // outlives it just gets its stream recycled early, the client reconnects.
3
+ const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
4
+ // JWT expiry raises no access-invalidation event, so a long-lived stream must end itself
5
+ // when the token it was opened with expires. Callers clear the returned timer on release.
6
+ export function scheduleTokenExpiry(tokenExpiresAtSec, onExpired) {
7
+ if (tokenExpiresAtSec === undefined)
8
+ return undefined;
9
+ return setTimeout(onExpired, Math.min(MAX_TIMER_DELAY_MS, Math.max(0, tokenExpiresAtSec * 1000 - Date.now())));
10
+ }
@@ -309,7 +309,20 @@ function isTemporalPlainDateTimeLike(v) {
309
309
  // back deterministically without ever touching a local Date getter — the
310
310
  // same +value idiom instantFromDriver uses (guard-no-date-api: no
311
311
  // `.getTime()`), just anchored to a fixed zone instead of process-local.
312
- function plainDateFromDriver(value) {
312
+ // A real `date` column always arrives as a UTC-midnight Date. Anything else means the column is
313
+ // still timestamptz (declared `date` before its ALTER ran); the UTC anchor then silently shifts
314
+ // the day, so say so once per column instead of returning the wrong day quietly.
315
+ const warnedNonMidnightDateColumns = new Set();
316
+ const MS_PER_DAY = 86_400_000;
317
+ const dateColumnDriftLog = createFallbackLogger("bun-db");
318
+ function warnIfNotDateColumn(epochMs, column) {
319
+ // skip: midnight value is a correct date column, and an already-warned column is deduped
320
+ if (epochMs % MS_PER_DAY === 0 || warnedNonMidnightDateColumns.has(column))
321
+ return;
322
+ warnedNonMidnightDateColumns.add(column);
323
+ dateColumnDriftLog.warn(`column "${column}" is declared as date but returned a non-midnight timestamp; it is probably still timestamptz. Run the ALTER ... TYPE date migration, otherwise days can shift.`);
324
+ }
325
+ function plainDateFromDriver(value, column) {
313
326
  if (value === null || value === undefined)
314
327
  return null;
315
328
  if (isTemporalPlainDate(value))
@@ -326,6 +339,7 @@ function plainDateFromDriver(value) {
326
339
  }
327
340
  }
328
341
  if (value instanceof Date) {
342
+ warnIfNotDateColumn(+value, column);
329
343
  return Temporal.Instant.fromEpochMilliseconds(+value).toZonedDateTimeISO("UTC").toPlainDate();
330
344
  }
331
345
  return null;
@@ -357,7 +371,7 @@ export function coerceRow(row, info) {
357
371
  coerced = t;
358
372
  }
359
373
  else if (pgType === "date") {
360
- const d = plainDateFromDriver(value);
374
+ const d = plainDateFromDriver(value, key);
361
375
  if (d !== null)
362
376
  coerced = d;
363
377
  }
@@ -507,6 +521,11 @@ function buildWhereClause(info, where, startIndex, qualifier) {
507
521
  parts.push(`${ref(col)} @> $${idx++}${p.sql}`);
508
522
  values.push(p.bound);
509
523
  }
524
+ // A jsonb column holding the scalar itself is never `@>` an array of it.
525
+ if (isJsonbScalar(v)) {
526
+ parts.push(`${ref(col)} = $${idx++}::text::jsonb`);
527
+ values.push(JSON.stringify(v));
528
+ }
510
529
  }
511
530
  return parts.length > 0 ? `(${parts.join(" OR ")})` : "FALSE";
512
531
  }
@@ -589,14 +608,27 @@ function buildWhereClause(info, where, startIndex, qualifier) {
589
608
  conditions.push("FALSE");
590
609
  continue;
591
610
  }
592
- if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
593
- const p = prepareJsonbValue([opVal]);
611
+ // Mirrors applyMultiSelectFilter: an array `ne` means "does not contain all of these".
612
+ if (opKey === "ne" && pgType === "jsonb" && Array.isArray(opVal)) {
613
+ if (opVal.length === 0) {
614
+ conditions.push("FALSE");
615
+ continue;
616
+ }
617
+ const p = prepareJsonbValue(opVal);
594
618
  if (p && p.kind === "param") {
595
619
  conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql}))`);
596
620
  values.push(p.bound);
597
621
  continue;
598
622
  }
599
623
  }
624
+ if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
625
+ const p = prepareJsonbValue([opVal]);
626
+ if (p && p.kind === "param") {
627
+ conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql} OR ${ref(col)} = $${idx++}::text::jsonb))`);
628
+ values.push(p.bound, JSON.stringify(opVal));
629
+ continue;
630
+ }
631
+ }
600
632
  const p = prepareValue(opVal, pgType);
601
633
  if (p.kind === "literal") {
602
634
  conditions.push(`${ref(col)} ${opSym} ${p.literal}`);
@@ -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
  /**
@@ -62,8 +62,6 @@ export async function encryptEventPayloadPii(eventType, payload, envelope) {
62
62
  if (!piiFields || piiFields === "none")
63
63
  return payload;
64
64
  const kms = configuredPiiSubjectKms();
65
- if (!kms)
66
- return payload;
67
65
  let out;
68
66
  for (const [field, spec] of Object.entries(piiFields)) {
69
67
  const value = payload[field];
@@ -75,6 +73,10 @@ export async function encryptEventPayloadPii(eventType, payload, envelope) {
75
73
  const subject = resolveEventSubject(field, spec, payload, envelope);
76
74
  if (subject === null)
77
75
  continue;
76
+ // Resolved before this skip so a missing owner fails in dev exactly as it
77
+ // does in prod, instead of surfacing only once a KMS is configured.
78
+ if (!kms)
79
+ continue;
78
80
  const kmsCtx = { requestId: requestContext.get()?.requestId ?? "append-event" };
79
81
  const encrypted = typeof value === "string"
80
82
  ? await encryptPiiValueForSubject(kms, subject, value, kmsCtx, field)
@@ -58,7 +58,25 @@ export function buildPgKmsOptions(env) {
58
58
  previousKeks: { [previousVersion]: env.PLATFORM_KEK_PREVIOUS },
59
59
  };
60
60
  }
61
- function assertTrioConsistent(env, logPrefix) {
61
+ const CIPHERTEXT_CAPABLE_SLOTS = [
62
+ "PLATFORM_KEK",
63
+ "PLATFORM_KEK_PREVIOUS",
64
+ "KUMIKO_BLIND_INDEX_KEY",
65
+ ];
66
+ // The sync entry points only read plaintext slots. A ciphertext-only slot
67
+ // would otherwise surface as a misleading "trio required" error or, worse,
68
+ // a silent plaintext-PII fallback in dev.
69
+ function assertNoUnresolvedCiphertext(env, logPrefix) {
70
+ for (const slot of CIPHERTEXT_CAPABLE_SLOTS) {
71
+ if (!env[slot] && env[`${slot}_CIPHERTEXT`]) {
72
+ throw new Error(`${logPrefix ? `${logPrefix} ` : ""}${slot}_CIPHERTEXT is set but ${slot} is not — ` +
73
+ "the sync KMS wiring cannot resolve ciphertext slots; use resolveKmsWiringAsync / requireKmsWiringAsync.");
74
+ }
75
+ }
76
+ }
77
+ function assertTrioConsistent(env, logPrefix, rejectUnresolvedCiphertext) {
78
+ if (rejectUnresolvedCiphertext)
79
+ assertNoUnresolvedCiphertext(env, logPrefix);
62
80
  const trio = [env.PLATFORM_KEK, env.SUBJECT_KEYS_DATABASE_URL, env.KUMIKO_BLIND_INDEX_KEY];
63
81
  const complete = trio.every(Boolean);
64
82
  if (!complete && trio.some(Boolean)) {
@@ -81,7 +99,13 @@ function assertTrioConsistent(env, logPrefix) {
81
99
  *
82
100
  * Prod entry points that must never fall back: use `requireKmsWiring`. */
83
101
  export function resolveKmsWiring(env, options = {}) {
84
- const complete = assertTrioConsistent(env, options.logPrefix);
102
+ return wireKms(env, options, true);
103
+ }
104
+ // The async entry points resolve only their declared slots; a ciphertext slot
105
+ // outside that list is the caller's deliberate choice, so only the sync entry
106
+ // points treat a leftover ciphertext as a wiring error.
107
+ function wireKms(env, options, rejectUnresolvedCiphertext) {
108
+ const complete = assertTrioConsistent(env, options.logPrefix, rejectUnresolvedCiphertext);
85
109
  if (complete && env.PLATFORM_KEK && env.SUBJECT_KEYS_DATABASE_URL && env.KUMIKO_BLIND_INDEX_KEY) {
86
110
  const kms = createPgKmsAdapter(buildPgKmsOptions({
87
111
  ...env,
@@ -104,7 +128,9 @@ export function resolveKmsWiring(env, options = {}) {
104
128
  * flag so the return type is the active wiring, with no narrowing at the call
105
129
  * site. */
106
130
  export function requireKmsWiring(env, options = {}) {
107
- const wiring = resolveKmsWiring(env, options);
131
+ return requireActive(wireKms(env, options, true), options);
132
+ }
133
+ function requireActive(wiring, options) {
108
134
  if ("allowPlaintextPii" in wiring) {
109
135
  throw new Error(`${options.logPrefix ? `${options.logPrefix} ` : ""}PLATFORM_KEK / ` +
110
136
  "SUBJECT_KEYS_DATABASE_URL / KUMIKO_BLIND_INDEX_KEY are required here — " +
@@ -117,9 +143,9 @@ export function requireKmsWiring(env, options = {}) {
117
143
  * `resolvePlatformKeks`. The validation stays in the sync function; this only
118
144
  * adds the KEK-fetching step in front of it. */
119
145
  export async function resolveKmsWiringAsync(env, options) {
120
- return resolveKmsWiring(await resolvePlatformKeks(env, options), options);
146
+ return wireKms(await resolvePlatformKeks(env, options), options, false);
121
147
  }
122
148
  /** Async counterpart to `requireKmsWiring`, KEK-resolving like `resolveKmsWiringAsync`. */
123
149
  export async function requireKmsWiringAsync(env, options) {
124
- return requireKmsWiring(await resolvePlatformKeks(env, options), options);
150
+ return requireActive(wireKms(await resolvePlatformKeks(env, options), options, false), options);
125
151
  }
@@ -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) {
@@ -1,4 +1,5 @@
1
- import type { EntityDefinition } from "../engine/types/index.js";
1
+ import type { EntityDefinition, SessionUser } from "../engine/types/index.js";
2
+ import { type ParentVisibilityOption } from "./parent-ref-clause.js";
2
3
  import type { TenantDb } from "./tenant-db.js";
3
4
  export type EagerLoadEntityResolver = (entityName: string) => EntityDefinition | undefined;
4
5
  export type EagerloadedRow<T extends Record<string, unknown> = Record<string, unknown>> = T & {
@@ -10,9 +11,13 @@ type ReferenceFieldEntry = {
10
11
  readonly multiple: boolean;
11
12
  };
12
13
  export declare function collectReferenceFields(entity: EntityDefinition): readonly ReferenceFieldEntry[];
14
+ export type EagerLoadViewer = {
15
+ readonly user: SessionUser;
16
+ readonly parentVisibility: ParentVisibilityOption;
17
+ };
13
18
  /** Eagerload für eine Liste von Rows. Mutiert nicht — gibt eine
14
19
  * flache Kopie der Rows mit hinzugefügtem `_refs`-Property zurück. */
15
- export declare function enrichWithReferences(rows: ReadonlyArray<Record<string, unknown>>, entity: EntityDefinition, resolveEntity: EagerLoadEntityResolver, db: TenantDb): Promise<Array<Record<string, unknown>>>;
20
+ export declare function enrichWithReferences(rows: ReadonlyArray<Record<string, unknown>>, entity: EntityDefinition, resolveEntity: EagerLoadEntityResolver, db: TenantDb, viewer: EagerLoadViewer): Promise<Array<Record<string, unknown>>>;
16
21
  /** Single-Row-Variante für detail-Calls. */
17
- export declare function enrichRowWithReferences(row: Record<string, unknown>, entity: EntityDefinition, resolveEntity: EagerLoadEntityResolver, db: TenantDb): Promise<Record<string, unknown>>;
22
+ export declare function enrichRowWithReferences(row: Record<string, unknown>, entity: EntityDefinition, resolveEntity: EagerLoadEntityResolver, db: TenantDb, viewer: EagerLoadViewer): Promise<Record<string, unknown>>;
18
23
  export {};