@cosmicdrift/kumiko-framework 0.353.0 → 0.354.1

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 (165) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/redis-sse-broker.d.ts +1 -0
  4. package/dist/api/redis-sse-broker.js +16 -10
  5. package/dist/api/request-context.d.ts +1 -0
  6. package/dist/api/request-context.js +5 -0
  7. package/dist/api/routes.js +4 -2
  8. package/dist/api/server.js +28 -3
  9. package/dist/api/sse-broker.d.ts +7 -3
  10. package/dist/api/sse-route.js +2 -6
  11. package/dist/api/token-expiry-timer.d.ts +1 -0
  12. package/dist/api/token-expiry-timer.js +10 -0
  13. package/dist/bun-db/query.js +29 -2
  14. package/dist/crypto/event-pii.js +4 -2
  15. package/dist/crypto/kms-wiring.js +31 -5
  16. package/dist/db/eagerload.d.ts +8 -3
  17. package/dist/db/eagerload.js +85 -81
  18. package/dist/db/event-store-executor-context.d.ts +12 -0
  19. package/dist/db/event-store-executor-context.js +59 -18
  20. package/dist/db/event-store-executor-read.js +81 -31
  21. package/dist/db/event-store-executor-write.js +54 -3
  22. package/dist/db/index.d.ts +1 -1
  23. package/dist/db/migrate-generator.js +1 -1
  24. package/dist/db/queries/backfill-pii.d.ts +1 -0
  25. package/dist/db/queries/backfill-pii.js +11 -6
  26. package/dist/db/queries/event-consumer.d.ts +2 -3
  27. package/dist/db/queries/event-consumer.js +54 -43
  28. package/dist/db/queries/event-store.d.ts +5 -0
  29. package/dist/db/queries/event-store.js +51 -3
  30. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  31. package/dist/db/queries/pii-backfill-state.js +16 -5
  32. package/dist/db/queries/shadow-swap.js +32 -3
  33. package/dist/db/render-ddl.js +6 -6
  34. package/dist/db/table-builder.js +6 -7
  35. package/dist/db/tenant-db.d.ts +5 -2
  36. package/dist/db/tenant-db.js +47 -7
  37. package/dist/derivatives/variant-key.d.ts +1 -0
  38. package/dist/derivatives/variant-key.js +3 -0
  39. package/dist/engine/boot-validator/access-declarations.js +5 -0
  40. package/dist/engine/boot-validator/config-deps.js +3 -0
  41. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  42. package/dist/engine/boot-validator/entity-handler.js +40 -0
  43. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  44. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  45. package/dist/engine/boot-validator/index.js +6 -1
  46. package/dist/engine/boot-validator/nav.js +3 -1
  47. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  48. package/dist/engine/boot-validator/ownership.js +32 -1
  49. package/dist/engine/boot-validator/pii-retention.js +8 -1
  50. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  51. package/dist/engine/boot-validator/screens.js +98 -7
  52. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  53. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  54. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  55. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  56. package/dist/engine/build-app-schema.js +3 -0
  57. package/dist/engine/changeset-changes.d.ts +1 -0
  58. package/dist/engine/changeset-changes.js +49 -33
  59. package/dist/engine/define-feature.js +11 -0
  60. package/dist/engine/entity-handlers.d.ts +3 -3
  61. package/dist/engine/entity-handlers.js +55 -34
  62. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  63. package/dist/engine/escape-hatch-reason.js +8 -0
  64. package/dist/engine/extension-names.d.ts +8 -9
  65. package/dist/engine/extension-names.js +8 -9
  66. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  67. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  68. package/dist/engine/factories.d.ts +1 -1
  69. package/dist/engine/feature-changelog.d.ts +2 -0
  70. package/dist/engine/feature-changelog.js +6 -0
  71. package/dist/engine/feature-config-events-jobs.js +41 -4
  72. package/dist/engine/handler-field-references.d.ts +3 -0
  73. package/dist/engine/handler-field-references.js +31 -0
  74. package/dist/engine/i18n-key.js +2 -3
  75. package/dist/engine/index.d.ts +8 -2
  76. package/dist/engine/index.js +6 -1
  77. package/dist/engine/ownership.js +3 -4
  78. package/dist/engine/project-app-schema-for-roles.js +3 -1
  79. package/dist/engine/registry-ingest.d.ts +1 -0
  80. package/dist/engine/registry-ingest.js +8 -3
  81. package/dist/engine/run-pipeline.js +12 -1
  82. package/dist/engine/schema-builder.js +35 -2
  83. package/dist/engine/screen-helpers.d.ts +13 -0
  84. package/dist/engine/screen-helpers.js +31 -0
  85. package/dist/engine/soft-delete-cleanup.js +3 -2
  86. package/dist/engine/steps/_read-source.d.ts +4 -0
  87. package/dist/engine/steps/_read-source.js +22 -0
  88. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  89. package/dist/engine/steps/read-find-many.js +2 -1
  90. package/dist/engine/steps/read-find-one.js +2 -1
  91. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  92. package/dist/engine/steps/workflow-step-error.js +13 -0
  93. package/dist/engine/transfer-adjacency.d.ts +24 -0
  94. package/dist/engine/transfer-adjacency.js +107 -0
  95. package/dist/entrypoint/index.d.ts +2 -0
  96. package/dist/entrypoint/index.js +2 -0
  97. package/dist/env/index.js +45 -23
  98. package/dist/errors/classes.d.ts +8 -0
  99. package/dist/errors/classes.js +13 -0
  100. package/dist/errors/index.d.ts +2 -1
  101. package/dist/errors/index.js +2 -1
  102. package/dist/errors/reasons.d.ts +1 -0
  103. package/dist/errors/reasons.js +3 -0
  104. package/dist/errors/subject-erased.d.ts +2 -0
  105. package/dist/errors/subject-erased.js +9 -0
  106. package/dist/event-store/admin-api.js +14 -10
  107. package/dist/event-store/event-store.d.ts +1 -0
  108. package/dist/event-store/event-store.js +14 -9
  109. package/dist/event-store/events-schema.js +2 -1
  110. package/dist/event-store/index.d.ts +1 -1
  111. package/dist/event-store/index.js +1 -1
  112. package/dist/files/file-handle.d.ts +1 -1
  113. package/dist/files/file-handle.js +30 -6
  114. package/dist/files/file-routes.js +10 -4
  115. package/dist/files/resolve-content-type.d.ts +2 -0
  116. package/dist/files/resolve-content-type.js +8 -0
  117. package/dist/i18n/required-surface-keys.d.ts +2 -0
  118. package/dist/i18n/required-surface-keys.js +8 -0
  119. package/dist/jobs/index.d.ts +1 -1
  120. package/dist/jobs/job-runner.d.ts +13 -0
  121. package/dist/jobs/job-runner.js +128 -22
  122. package/dist/migrations/pii-event-backfill.js +46 -25
  123. package/dist/observability/index.d.ts +1 -1
  124. package/dist/observability/index.js +1 -1
  125. package/dist/observability/standard-metrics.d.ts +4 -0
  126. package/dist/observability/standard-metrics.js +12 -0
  127. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  128. package/dist/pipeline/dispatch-shared.js +74 -9
  129. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  130. package/dist/pipeline/dispatch-stream.js +19 -12
  131. package/dist/pipeline/dispatcher.d.ts +3 -1
  132. package/dist/pipeline/dispatcher.js +10 -15
  133. package/dist/pipeline/event-consumer-state.js +0 -26
  134. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  135. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  136. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  137. package/dist/pipeline/event-dispatcher.js +48 -7
  138. package/dist/pipeline/index.d.ts +2 -0
  139. package/dist/pipeline/index.js +1 -0
  140. package/dist/pipeline/member-read-only-transaction.js +25 -5
  141. package/dist/pipeline/msp-rebuild.js +3 -3
  142. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  143. package/dist/pipeline/pending-gap-ranges.js +22 -0
  144. package/dist/pipeline/system-hooks.js +4 -4
  145. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  146. package/dist/pipeline/system-identity-switch.js +5 -4
  147. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  148. package/dist/redis/cache-sync-topics.d.ts +1 -0
  149. package/dist/redis/cache-sync-topics.js +1 -0
  150. package/dist/redis/client.d.ts +1 -0
  151. package/dist/redis/client.js +7 -0
  152. package/dist/redis/index.d.ts +1 -1
  153. package/dist/redis/index.js +1 -1
  154. package/dist/redis/pubsub-signal.d.ts +1 -0
  155. package/dist/redis/pubsub-signal.js +3 -0
  156. package/dist/search/meilisearch-adapter.js +34 -4
  157. package/dist/stack/test-stack.d.ts +4 -1
  158. package/dist/stack/test-stack.js +9 -2
  159. package/dist/testing/e2e-generator.js +7 -2
  160. package/dist/ui-types/index.d.ts +3 -3
  161. package/dist/ui-types/index.js +2 -2
  162. package/dist/upgrade-cli.js +7 -2
  163. package/package.json +12 -4
  164. package/src/changes.json +209 -0
  165. 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";
@@ -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) —
@@ -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);
@@ -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] : []),
@@ -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;
@@ -5,6 +5,7 @@ import { isAnonymousSessionUser } from "../engine/system-user.js";
5
5
  import { Routes } from "./api-constants.js";
6
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
  *
@@ -81,9 +82,6 @@ function decideWireFrame(event, user, options) {
81
82
  return undefined;
82
83
  return { name: event.type, data };
83
84
  }
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;
87
85
  export function createSseRoute(broker, options) {
88
86
  const route = new Hono();
89
87
  route.get(Routes.sse, async (c) => {
@@ -111,9 +109,7 @@ export function createSseRoute(broker, options) {
111
109
  stream.writeSSE({ event: frame.name, data: JSON.stringify(frame.data) });
112
110
  }, closeStream);
113
111
  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())));
112
+ const expiryTimer = scheduleTokenExpiry(tokenExpiresAtSec, closeStream);
117
113
  let released = false;
118
114
  const release = () => {
119
115
  // skip: onAbort and the finally block both release, the second call is a no-op
@@ -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
  }
@@ -594,6 +608,19 @@ function buildWhereClause(info, where, startIndex, qualifier) {
594
608
  conditions.push("FALSE");
595
609
  continue;
596
610
  }
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);
618
+ if (p && p.kind === "param") {
619
+ conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql}))`);
620
+ values.push(p.bound);
621
+ continue;
622
+ }
623
+ }
597
624
  if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
598
625
  const p = prepareJsonbValue([opVal]);
599
626
  if (p && p.kind === "param") {
@@ -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,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 {};