@cosmicdrift/kumiko-framework 0.333.0 → 0.335.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 (172) hide show
  1. package/dist/api/auth-routes.js +19 -4
  2. package/dist/api/client-ip.js +3 -6
  3. package/dist/api/extra-route.js +3 -2
  4. package/dist/api/pii-leak-guard.js +7 -4
  5. package/dist/api/redis-sse-broker.d.ts +2 -0
  6. package/dist/api/redis-sse-broker.js +6 -7
  7. package/dist/api/routes.js +16 -17
  8. package/dist/api/server.js +14 -7
  9. package/dist/api/sse-broker.d.ts +3 -0
  10. package/dist/api/sse-broker.js +14 -0
  11. package/dist/api/sse-route.d.ts +1 -1
  12. package/dist/api/sse-route.js +39 -6
  13. package/dist/bun-db/connection.js +0 -2
  14. package/dist/bun-db/index.d.ts +1 -1
  15. package/dist/bun-db/index.js +1 -1
  16. package/dist/bun-db/query.d.ts +1 -0
  17. package/dist/bun-db/query.js +15 -4
  18. package/dist/crypto/kek-source.js +40 -36
  19. package/dist/crypto/kms-adapter.js +2 -2
  20. package/dist/db/apply-entity-event.js +8 -2
  21. package/dist/db/blind-index-cleanup.js +11 -1
  22. package/dist/db/eagerload.js +2 -1
  23. package/dist/db/entity-table-meta.js +5 -3
  24. package/dist/db/event-store-executor-context.js +13 -19
  25. package/dist/db/event-store-executor-read.js +39 -36
  26. package/dist/db/event-store-executor-write.js +15 -17
  27. package/dist/db/migrate-generator.js +27 -28
  28. package/dist/db/pg-error.js +9 -5
  29. package/dist/db/queries/backfill-pii.js +15 -5
  30. package/dist/db/queries/shadow-swap.js +5 -4
  31. package/dist/db/table-builder.d.ts +1 -0
  32. package/dist/db/table-builder.js +7 -15
  33. package/dist/db/tenant-db.js +18 -2
  34. package/dist/derivatives/variant-key.js +3 -1
  35. package/dist/engine/boot-validator/access-declarations.js +12 -3
  36. package/dist/engine/boot-validator/config-deps.js +13 -0
  37. package/dist/engine/boot-validator/entity-handler.js +13 -1
  38. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  39. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  40. package/dist/engine/boot-validator/index.js +6 -6
  41. package/dist/engine/boot-validator/nav.d.ts +2 -1
  42. package/dist/engine/boot-validator/nav.js +10 -20
  43. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  44. package/dist/engine/boot-validator/ownership.js +2 -1
  45. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  46. package/dist/engine/boot-validator/pii-retention.js +44 -4
  47. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  48. package/dist/engine/boot-validator/query-output-columns.js +41 -5
  49. package/dist/engine/boot-validator/screens.d.ts +2 -2
  50. package/dist/engine/boot-validator/screens.js +138 -36
  51. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  52. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  53. package/dist/engine/boot-validator/zod-shape.js +17 -4
  54. package/dist/engine/build-app-schema.js +65 -19
  55. package/dist/engine/build-config-feature-schema.js +106 -22
  56. package/dist/engine/changeset-changes.js +24 -10
  57. package/dist/engine/config-helpers.d.ts +2 -2
  58. package/dist/engine/entity-handlers.js +7 -2
  59. package/dist/engine/feature-ast/extractors/events.js +11 -8
  60. package/dist/engine/feature-ast/extractors/handlers.js +38 -16
  61. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  62. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  63. package/dist/engine/feature-ast/parse.d.ts +1 -0
  64. package/dist/engine/feature-ast/parse.js +18 -3
  65. package/dist/engine/feature-ast/patch.js +80 -61
  66. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  67. package/dist/engine/feature-ast/patcher.js +2 -1
  68. package/dist/engine/feature-ast/render.js +1 -1
  69. package/dist/engine/feature-entity-handlers.js +22 -0
  70. package/dist/engine/field-access.d.ts +2 -0
  71. package/dist/engine/field-access.js +40 -0
  72. package/dist/engine/index.d.ts +5 -4
  73. package/dist/engine/index.js +4 -3
  74. package/dist/engine/ownership.d.ts +1 -0
  75. package/dist/engine/ownership.js +15 -2
  76. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  77. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  78. package/dist/engine/pattern-library/shared-fields.js +17 -0
  79. package/dist/engine/registry-facade.js +3 -0
  80. package/dist/engine/role-assignment.d.ts +7 -1
  81. package/dist/engine/role-assignment.js +27 -7
  82. package/dist/engine/schema-builder.d.ts +2 -0
  83. package/dist/engine/schema-builder.js +20 -3
  84. package/dist/engine/screen-helpers.js +15 -4
  85. package/dist/engine/steps/_event-match.js +22 -3
  86. package/dist/engine/steps/_read-source.js +8 -0
  87. package/dist/engine/types/index.d.ts +1 -1
  88. package/dist/engine/where-rule-lint.js +22 -4
  89. package/dist/env/index.js +21 -1
  90. package/dist/errors/reasons.d.ts +0 -1
  91. package/dist/errors/reasons.js +2 -5
  92. package/dist/event-store/event-store.js +6 -1
  93. package/dist/event-store/provenance-append.js +4 -1
  94. package/dist/files/file-ref-entity.d.ts +6 -0
  95. package/dist/files/file-ref-table.d.ts +6 -0
  96. package/dist/files/file-routes.d.ts +0 -1
  97. package/dist/files/file-routes.js +22 -7
  98. package/dist/files/local-provider.js +13 -10
  99. package/dist/files/provider-resolver.js +20 -1
  100. package/dist/files/types.js +16 -1
  101. package/dist/i18n/request-locale.d.ts +3 -2
  102. package/dist/i18n/request-locale.js +16 -6
  103. package/dist/i18n/required-surface-keys.js +28 -35
  104. package/dist/i18n/settings-hub-keys.js +21 -4
  105. package/dist/jobs/index.d.ts +2 -2
  106. package/dist/jobs/index.js +1 -1
  107. package/dist/jobs/job-runner.d.ts +2 -0
  108. package/dist/jobs/job-runner.js +150 -59
  109. package/dist/migrations/index.d.ts +1 -1
  110. package/dist/migrations/index.js +1 -1
  111. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  112. package/dist/migrations/pending-rebuilds.js +23 -1
  113. package/dist/observability/escape-hatch-report.d.ts +1 -0
  114. package/dist/observability/escape-hatch-report.js +3 -0
  115. package/dist/observability/index.d.ts +1 -0
  116. package/dist/observability/index.js +1 -0
  117. package/dist/pipeline/dispatch-batch.js +8 -3
  118. package/dist/pipeline/dispatch-query.d.ts +1 -1
  119. package/dist/pipeline/dispatch-query.js +2 -2
  120. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  121. package/dist/pipeline/dispatch-shared.js +14 -9
  122. package/dist/pipeline/dispatch-stream.js +2 -5
  123. package/dist/pipeline/dispatch-write.js +27 -7
  124. package/dist/pipeline/dispatcher-utils.js +4 -1
  125. package/dist/pipeline/dispatcher.js +1 -2
  126. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  127. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  128. package/dist/pipeline/event-dispatcher.js +26 -11
  129. package/dist/pipeline/index.d.ts +1 -1
  130. package/dist/pipeline/index.js +1 -1
  131. package/dist/pipeline/member-read-only-transaction.js +1 -14
  132. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  133. package/dist/pipeline/msp-rebuild.js +1 -0
  134. package/dist/pipeline/system-hooks.d.ts +1 -1
  135. package/dist/pipeline/system-hooks.js +9 -7
  136. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  137. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  138. package/dist/pipeline/write-result-masking.d.ts +6 -0
  139. package/dist/pipeline/write-result-masking.js +36 -0
  140. package/dist/redis/pubsub-signal.d.ts +2 -0
  141. package/dist/redis/pubsub-signal.js +2 -2
  142. package/dist/schema-cli.js +4 -14
  143. package/dist/search/meilisearch-adapter.js +17 -6
  144. package/dist/search/purge-subject.js +8 -3
  145. package/dist/stack/drain-jobs.js +24 -12
  146. package/dist/stack/table-helpers.js +10 -8
  147. package/dist/testing/closed-connection-error.js +2 -3
  148. package/dist/testing/shared-entities.d.ts +10 -0
  149. package/dist/testing/wait-for.js +1 -0
  150. package/dist/ui-types/index.d.ts +2 -2
  151. package/dist/ui-types/index.js +1 -1
  152. package/dist/upgrade-cli.d.ts +1 -0
  153. package/dist/upgrade-cli.js +36 -15
  154. package/package.json +4 -4
  155. package/src/changes.json +218 -0
  156. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  157. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  158. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  159. package/src/scripts/codemod/pii-personal-migration.ts +56 -19
  160. package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
  161. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  162. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  163. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  164. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  165. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  166. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  167. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  168. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  169. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  170. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  171. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  172. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -866,7 +866,12 @@ export function createAuthRoutes(dispatcher, jwt, config) {
866
866
  api.post(Routes.authSwitchTenant, async (c) => {
867
867
  const user = getUser(c);
868
868
  const body = await c.req.json();
869
- const targetTenantId = body.tenantId;
869
+ // The id seeds a SYSTEM identity in resolveActiveMembership — reject anything
870
+ // that is not a non-empty string before it gets that far.
871
+ if (!isNonEmptyString(body.tenantId)) {
872
+ return c.json({ error: "invalid_tenant" }, 400);
873
+ }
874
+ const targetTenantId = body.tenantId; // @cast-boundary request-body
870
875
  if (targetTenantId === user.tenantId) {
871
876
  return c.json({ error: "already_in_tenant" }, 400);
872
877
  }
@@ -929,8 +934,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
929
934
  tenantId: targetTenantId,
930
935
  roles: mergedRoles,
931
936
  // Tenant-independent prefs must survive the switch (fw#2343).
932
- ...(user.timezone ? { timezone: user.timezone } : {}),
933
- ...(user.locale ? { locale: user.locale } : {}),
937
+ ...tenantIndependentPrefs(user),
934
938
  };
935
939
  const claims = await dispatcher.resolveAuthClaims(targetSession);
936
940
  const sessionForJwt = Object.keys(claims).length > 0 ? { ...targetSession, claims } : targetSession;
@@ -959,6 +963,15 @@ export function createAuthRoutes(dispatcher, jwt, config) {
959
963
  // validates the token + does the state change → typed failure or 200. Keeping
960
964
  // the silent-success invariant in one place means changing how the framework
961
965
  // handles "invalid_body" on a public token endpoint is one edit, not three.
966
+ function tenantIndependentPrefs(user) {
967
+ return {
968
+ ...(user.timezone ? { timezone: user.timezone } : {}),
969
+ ...(user.locale ? { locale: user.locale } : {}),
970
+ };
971
+ }
972
+ function isNonEmptyString(value) {
973
+ return typeof value === "string" && value.length > 0;
974
+ }
962
975
  function registerTokenRequestRoute(opts) {
963
976
  const body = opts.schema ?? RequestTokenBody;
964
977
  opts.api.post(opts.path, async (c) => {
@@ -974,7 +987,9 @@ function registerTokenRequestRoute(opts) {
974
987
  // they are logged — otherwise the mail silently never goes out. No email in
975
988
  // the log line (PII).
976
989
  const result = await opts.dispatcher.write(opts.requestHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID));
977
- if (!result.isSuccess) {
990
+ // rate_limited is an expected, attacker-triggerable outcome; logging it
991
+ // would flood the sink and bury real misconfiguration errors.
992
+ if (!result.isSuccess && result.error.code !== "rate_limited") {
978
993
  console.error(`[kumiko] token request handler "${opts.requestHandler}" failed: ${result.error.code}`);
979
994
  }
980
995
  return c.json({ isSuccess: true });
@@ -50,16 +50,13 @@ export const TRUSTED_PROXY_HOPS_ENV = "KUMIKO_TRUSTED_PROXY_HOPS";
50
50
  // worse than staying on the default. Digits-only (not parseInt) —
51
51
  // parseInt("0x10")/("1e3")/("2x") would silently coerce instead of failing.
52
52
  export function parseTrustedProxyHopsEnv(raw, context) {
53
- if (raw === undefined)
53
+ // Empty string = unset, matching readEnv's normalization (e.g. a blank .env template line).
54
+ if (raw === undefined || raw === "")
54
55
  return undefined;
55
56
  if (!/^\d+$/.test(raw)) {
56
57
  throw new Error(`${context}: ${TRUSTED_PROXY_HOPS_ENV} must be a non-negative integer, got "${raw}".`);
57
58
  }
58
- const parsed = Number.parseInt(raw, 10);
59
- if (!Number.isInteger(parsed) || parsed < 0) {
60
- throw new Error(`${context}: ${TRUSTED_PROXY_HOPS_ENV} must be a non-negative integer, got "${raw}".`);
61
- }
62
- return parsed;
59
+ return Number(raw);
63
60
  }
64
61
  // Instance-scoped, NOT a module singleton: the warn-once flag must reset
65
62
  // per server boot (and per test), otherwise the first test to trigger it
@@ -39,8 +39,9 @@ export class ExtraRouteRejection extends Error {
39
39
  if (status !== 503) {
40
40
  throw new RangeError("ExtraRouteRejection: retryAfterSeconds is only valid with status 503");
41
41
  }
42
- if (!Number.isInteger(retryAfterSeconds) || retryAfterSeconds < 0) {
43
- throw new RangeError("ExtraRouteRejection: retryAfterSeconds must be a non-negative integer");
42
+ // isSafeInteger, not isInteger: String(1e21) is "1e+21", not 1*DIGIT.
43
+ if (!Number.isSafeInteger(retryAfterSeconds) || retryAfterSeconds < 0) {
44
+ throw new RangeError("ExtraRouteRejection: retryAfterSeconds must be a non-negative safe integer");
44
45
  }
45
46
  }
46
47
  this.retryAfterSeconds = retryAfterSeconds;
@@ -2,8 +2,11 @@ import { PII_CIPHERTEXT_PREFIX } from "../crypto/index.js";
2
2
  const isProductionEnv = () => process.env["NODE_ENV"] === "production";
3
3
  // Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
4
4
  // older/decrypt-only format version still present in unmigrated rows.
5
- const CIPHERTEXT_MARKER = "kumiko-pii:v";
6
- const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
5
+ // Bound to the full ciphertext shape (prefix, "<kind>:<id>" subject key, base64
6
+ // blob of at least IV + GCM tag = 28 bytes) so a user-typed "kumiko-pii:v" in a
7
+ // note or name field cannot turn every read of that record into a 500.
8
+ const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^:"\s<>\\]+:[^:"\s<>\\]+:[A-Za-z0-9+/=]{40,}/;
9
+ const CIPHERTEXT_REDACT_RE = new RegExp(CIPHERTEXT_RE.source, "g");
7
10
  // A PII subject ciphertext never belongs in an API response — its presence
8
11
  // means a raw DB read (fetchOne/selectMany) leaked to the surface. Dev/test
9
12
  // fail loud (500) so a forgotten decrypt turns the first integration test
@@ -19,7 +22,7 @@ export function piiCiphertextResponseGuard() {
19
22
  return;
20
23
  const text = await c.res.clone().text();
21
24
  // skip: clean response — the common case
22
- if (!text.includes(CIPHERTEXT_MARKER))
25
+ if (!CIPHERTEXT_RE.test(text))
23
26
  return;
24
27
  const detail = `[api] JSON response for ${c.req.method} ${c.req.path} contains a PII ciphertext ` +
25
28
  `("${PII_CIPHERTEXT_PREFIX}…") — a raw DB read leaked to the API surface. ` +
@@ -32,7 +35,7 @@ export function piiCiphertextResponseGuard() {
32
35
  console.error(detail);
33
36
  const headers = new Headers(c.res.headers);
34
37
  headers.delete("content-length");
35
- c.res = new Response(text.replace(CIPHERTEXT_RE, "[pii-redacted]"), {
38
+ c.res = new Response(text.replace(CIPHERTEXT_REDACT_RE, "[pii-redacted]"), {
36
39
  status: c.res.status,
37
40
  headers,
38
41
  });
@@ -1,6 +1,8 @@
1
+ import { type RedisClientOptions } from "../redis/client.js";
1
2
  import { type SseBroker } from "./sse-broker.js";
2
3
  export type RedisSseBrokerOptions = {
3
4
  readonly redisUrl: string;
5
+ readonly clientOptions?: RedisClientOptions;
4
6
  };
5
7
  export type RedisSseBroker = SseBroker & {
6
8
  close(): Promise<void>;
@@ -1,5 +1,6 @@
1
+ import { redisClientOptionsFromEnv } from "../redis/client.js";
1
2
  import { createRedisPubSubSignal } from "../redis/pubsub-signal.js";
2
- import { createSseBroker } from "./sse-broker.js";
3
+ import { createSseBroker, readNonEmptyStringArray, } from "./sse-broker.js";
3
4
  // Channel namespace for cross-replica fanout (fw#2625). Every pod publishes
4
5
  // here and every pod's psubscribe listens here, so a push on one instance
5
6
  // reaches SSE clients connected to any other instance without a shared
@@ -26,7 +27,9 @@ export function isRedisSseBroker(broker) {
26
27
  // buildServer, so its own REDIS_URL-based default never ran.
27
28
  export function createDefaultSseBroker(env = process.env) {
28
29
  const redisUrl = env["REDIS_URL"];
29
- const ownedRedisSseBroker = redisUrl ? createRedisSseBroker({ redisUrl }) : undefined;
30
+ const ownedRedisSseBroker = redisUrl
31
+ ? createRedisSseBroker({ redisUrl, clientOptions: redisClientOptionsFromEnv(env) })
32
+ : undefined;
30
33
  return { sseBroker: ownedRedisSseBroker ?? createSseBroker(), ownedRedisSseBroker };
31
34
  }
32
35
  function isSseEvent(value) {
@@ -71,11 +74,6 @@ function parseInvalidationScope(payload) {
71
74
  }
72
75
  return { kind: "user" };
73
76
  }
74
- function readNonEmptyStringArray(value) {
75
- if (!Array.isArray(value) || value.length === 0)
76
- return undefined;
77
- return value.every((v) => typeof v === "string") ? value : undefined;
78
- }
79
77
  // Mirror of parseInvalidationScope's wire shapes. "sessions"/"pat-tokens"
80
78
  // deliberately never include a `keptSessionId` key — see the parser comment.
81
79
  function encodeInvalidationScope(scope) {
@@ -100,6 +98,7 @@ export function createRedisSseBroker(opts) {
100
98
  redisUrl: opts.redisUrl,
101
99
  channelPattern: PSUBSCRIBE_PATTERN,
102
100
  label: "sse-broker",
101
+ ...(opts.clientOptions ? { clientOptions: opts.clientOptions } : {}),
103
102
  });
104
103
  signal.onMessage((channel, payload) => {
105
104
  if (channel.startsWith(CHANNEL_PREFIX)) {
@@ -292,26 +292,33 @@ const FAULT_LOG_SILENCED_LEVELS = new Set(["error", "fatal", "silent"]);
292
292
  // `type` is client-supplied on an unknown-handler 404, so cap it before it
293
293
  // reaches the log — an unbounded field would let a caller flood the sink.
294
294
  const MAX_LOGGED_TYPE_LENGTH = 120;
295
+ // The body's `type` is only typed as string — a client can send an object, and
296
+ // a throw from inside the fault logger would turn a clean 4xx into a 500.
297
+ function loggableType(type) {
298
+ return typeof type === "string" ? type.slice(0, MAX_LOGGED_TYPE_LENGTH) : undefined;
299
+ }
295
300
  function clientFaultLoggingEnabled() {
296
301
  return !FAULT_LOG_SILENCED_LEVELS.has(process.env["LOG_LEVEL"] ?? "");
297
302
  }
298
303
  // A failing request must leave a trace even when it ends in 4xx — a paid
299
304
  // external call that 422s was invisible before (offlot#117). Status, error
300
305
  // code and duration only: message/details/stack can carry submitted values.
301
- function logClientFault(err, requestId, type) {
306
+ function logClientWarn(message, requestId, type, fields) {
302
307
  if (!clientFaultLoggingEnabled()) {
303
308
  // skip: LOG_LEVEL silences the 4xx tier — the deployment opted out of client-fault volume
304
309
  return;
305
310
  }
306
311
  const startedAt = requestContext.get()?.startedAt;
307
- createFallbackLogger("api").warn("handler rejected", {
312
+ createFallbackLogger("api").warn(message, {
308
313
  requestId,
309
- type: type?.slice(0, MAX_LOGGED_TYPE_LENGTH),
310
- status: err.httpStatus,
311
- code: err.code,
314
+ type: loggableType(type),
315
+ ...fields,
312
316
  ...(startedAt === undefined ? {} : { durationMs: Math.round(performance.now() - startedAt) }),
313
317
  });
314
318
  }
319
+ function logClientFault(err, requestId, type) {
320
+ logClientWarn("handler rejected", requestId, type, { status: err.httpStatus, code: err.code });
321
+ }
315
322
  // Identity-checked against THIS request's signal — signal.aborted alone
316
323
  // would also match a real 5xx that happens to race a disconnect.
317
324
  function isClientAbort(err) {
@@ -333,7 +340,7 @@ function logServerFault(err, requestId, type) {
333
340
  const cause = err.cause;
334
341
  createFallbackLogger("api").error("handler failed", {
335
342
  requestId,
336
- type,
343
+ type: loggableType(type),
337
344
  code: err.code,
338
345
  message: err.message,
339
346
  cause: cause instanceof Error ? cause.message : cause,
@@ -354,17 +361,9 @@ function writeErrorResponse(c, err, type) {
354
361
  function queryErrorResponse(c, err, type) {
355
362
  const requestId = requestContext.get()?.requestId;
356
363
  if (isClientAbort(err)) {
357
- if (clientFaultLoggingEnabled()) {
358
- const startedAt = requestContext.get()?.startedAt;
359
- createFallbackLogger("api").warn("request aborted by client", {
360
- requestId,
361
- type: type?.slice(0, MAX_LOGGED_TYPE_LENGTH),
362
- status: CLIENT_CLOSED_REQUEST_STATUS,
363
- ...(startedAt === undefined
364
- ? {}
365
- : { durationMs: Math.round(performance.now() - startedAt) }),
366
- });
367
- }
364
+ logClientWarn("request aborted by client", requestId, type, {
365
+ status: CLIENT_CLOSED_REQUEST_STATUS,
366
+ });
368
367
  return c.body(null, CLIENT_CLOSED_REQUEST_STATUS); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
369
368
  }
370
369
  logServerFault(err, requestId, type);
@@ -537,13 +537,20 @@ export function buildServer(options) {
537
537
  // Public auth routes (login) need to be registered BEFORE the generic
538
538
  // api routes so Hono matches them first.
539
539
  if (options.auth) {
540
- // A membershipQuery handler is registered but nothing fulfils
541
- // principalStatus — surface that misconfig at boot instead of an InternalError on first login/switch-tenant.
542
- if (options.registry.getQueryHandler(options.auth.membershipQuery) &&
543
- options.registry.getExtensionUsages(EXT_PRINCIPAL_STATUS).length === 0) {
544
- throw new Error(`[kumiko] auth membershipQuery "${options.auth.membershipQuery}" is registered but no feature ` +
545
- 'provides the "principalStatus" contract — mount the user feature (tenant switch and sign-in ' +
546
- "need it to reject blocked principals).");
540
+ // A membershipQuery handler is registered but principalStatus is not provided by exactly
541
+ // one feature — surface that misconfig at boot instead of an InternalError on first login/switch-tenant.
542
+ if (options.registry.getQueryHandler(options.auth.membershipQuery)) {
543
+ const principalStatusProviders = options.registry.getExtensionUsages(EXT_PRINCIPAL_STATUS).length;
544
+ if (principalStatusProviders === 0) {
545
+ throw new Error(`[kumiko] auth membershipQuery "${options.auth.membershipQuery}" is registered but no feature ` +
546
+ 'provides the "principalStatus" contract — mount the user feature (tenant switch and sign-in ' +
547
+ "need it to reject blocked principals).");
548
+ }
549
+ if (principalStatusProviders > 1) {
550
+ throw new Error(`[kumiko] auth membershipQuery "${options.auth.membershipQuery}" is registered but ` +
551
+ `${principalStatusProviders} features provide the "principalStatus" contract — exactly one ` +
552
+ "is expected (the bundled user feature already provides it).");
553
+ }
547
554
  }
548
555
  app.route("/api", createAuthRoutes(dispatcher, jwt, { ...options.auth, resolver: clientIpResolver }));
549
556
  }
@@ -1,3 +1,4 @@
1
+ import type { SessionUser } from "../engine/types/index.js";
1
2
  export type SseClient = {
2
3
  id: string;
3
4
  send: (event: SseEvent) => void;
@@ -7,6 +8,7 @@ export type SseEvent = {
7
8
  type: string;
8
9
  data: Record<string, unknown>;
9
10
  };
11
+ export declare function readNonEmptyStringArray(value: unknown): string[] | undefined;
10
12
  export type AccessInvalidationScope = {
11
13
  readonly kind: "user";
12
14
  } | {
@@ -23,6 +25,7 @@ export type AccessInvalidationCredential = {
23
25
  readonly sid?: string;
24
26
  readonly patTokenId?: string;
25
27
  };
28
+ export declare function accessInvalidationCredentialFor(user: Pick<SessionUser, "sid" | "pat">): AccessInvalidationCredential;
26
29
  export type SseBroker = {
27
30
  addClient(channel: string, send: (event: SseEvent) => void, close: () => void): string;
28
31
  removeClient(channel: string, clientId: string): void;
@@ -1,5 +1,19 @@
1
1
  import { userAccessChannel } from "../engine/constants.js";
2
2
  import { generateId } from "../utils/index.js";
3
+ // What a publish invalidates. "user" is the broadest (and the historical
4
+ // default — every scope narrower than this is an opt-in from a caller that
5
+ // knows exactly which credential(s) it revoked).
6
+ export function readNonEmptyStringArray(value) {
7
+ if (!Array.isArray(value) || value.length === 0)
8
+ return undefined;
9
+ return value.every((v) => typeof v === "string") ? value : undefined;
10
+ }
11
+ export function accessInvalidationCredentialFor(user) {
12
+ return {
13
+ ...(user.sid !== undefined && { sid: user.sid }),
14
+ ...(user.pat?.tokenId !== undefined && { patTokenId: user.pat.tokenId }),
15
+ };
16
+ }
3
17
  // Fail-closed: a narrow scope only spares/limits a listener it can positively
4
18
  // match against its own credential. A listener with neither sid nor
5
19
  // patTokenId of its own can't be matched by "sessions"/"pat-tokens", so it
@@ -1,5 +1,5 @@
1
1
  import { Hono } from "hono";
2
- import type { SseBroker } from "./sse-broker.js";
2
+ import { type SseBroker } from "./sse-broker.js";
3
3
  /**
4
4
  * Heartbeat-Cadence für SSE-Streams.
5
5
  *
@@ -3,6 +3,7 @@ import { streamSSE } from "hono/streaming";
3
3
  import { tenantChannel } from "../engine/constants.js";
4
4
  import { Routes } from "./api-constants.js";
5
5
  import { getUser } from "./auth-middleware.js";
6
+ import { accessInvalidationCredentialFor } from "./sse-broker.js";
6
7
  /**
7
8
  * Heartbeat-Cadence für SSE-Streams.
8
9
  *
@@ -32,6 +33,12 @@ export const SSE_HEARTBEAT_INTERVAL_MS = 15_000;
32
33
  function isEntityEventData(data) {
33
34
  return typeof data["aggregateType"] === "string";
34
35
  }
36
+ // The tenant channel fans out to every member; user-addressed frames
37
+ // (e.g. channel-in-app:event:delivered) would otherwise reach the whole tenant.
38
+ function isAddressedToOtherUser(event, userId) {
39
+ const addressee = event.data["userId"];
40
+ return typeof addressee === "string" && addressee !== userId;
41
+ }
35
42
  export function createSseRoute(broker) {
36
43
  const route = new Hono();
37
44
  route.get(Routes.sse, async (c) => {
@@ -40,20 +47,46 @@ export function createSseRoute(broker) {
40
47
  // Allowing ?channel=... would let any authenticated user subscribe to other tenants' feeds.
41
48
  const channel = tenantChannel(user.tenantId);
42
49
  return streamSSE(c, async (stream) => {
50
+ let resolveEnded;
51
+ const ended = new Promise((resolve) => {
52
+ resolveEnded = resolve;
53
+ });
54
+ const closeStream = () => {
55
+ resolveEnded();
56
+ void stream.close();
57
+ };
43
58
  const clientId = broker.addClient(channel, (event) => {
59
+ // skip: user-addressed frames on the shared tenant channel belong to their recipient only
60
+ if (isAddressedToOtherUser(event, user.id))
61
+ return;
44
62
  const wireEventName = isEntityEventData(event.data)
45
63
  ? event.data.aggregateType
46
64
  : event.type;
47
65
  stream.writeSSE({ event: wireEventName, data: JSON.stringify(event.data) });
48
- }, () => stream.close());
49
- stream.onAbort(() => {
66
+ }, closeStream);
67
+ const unsubscribeAccessInvalidation = broker.subscribeAccessInvalidation(user.id, closeStream, accessInvalidationCredentialFor(user));
68
+ let released = false;
69
+ const release = () => {
70
+ // skip: onAbort and the finally block both release, the second call is a no-op
71
+ if (released)
72
+ return;
73
+ released = true;
74
+ // A client disconnect must also wake the heartbeat sleep, not only a server close.
75
+ resolveEnded();
50
76
  broker.removeClient(channel, clientId);
51
- });
77
+ unsubscribeAccessInvalidation();
78
+ };
79
+ stream.onAbort(release);
52
80
  // Keep connection alive with heartbeat — siehe SSE_HEARTBEAT_INTERVAL_MS
53
81
  // header für die Layer-für-Layer-Begründung der 15s-Cadence.
54
- while (true) {
55
- await stream.writeSSE({ event: "ping", data: "" });
56
- await stream.sleep(SSE_HEARTBEAT_INTERVAL_MS);
82
+ try {
83
+ while (!stream.closed && !stream.aborted) {
84
+ await stream.writeSSE({ event: "ping", data: "" });
85
+ await Promise.race([stream.sleep(SSE_HEARTBEAT_INTERVAL_MS), ended]);
86
+ }
87
+ }
88
+ finally {
89
+ release();
57
90
  }
58
91
  });
59
92
  });
@@ -27,8 +27,6 @@ export function createBunDbConnection(url, options = {}) {
27
27
  if (options.connectTimeoutSeconds !== undefined) {
28
28
  pgOpts.connect_timeout = options.connectTimeoutSeconds;
29
29
  }
30
- // ponytail: "postgres" resolves to the @bender0oo0/postgres fork (nextWrite
31
- // null-socket guard, porsager/postgres#1209); switch back per kumiko-framework#3248.
32
30
  const listenClient = postgres(url, pgOpts);
33
31
  return {
34
32
  db,
@@ -1,4 +1,4 @@
1
1
  export type { BunDbConnection, BunDbConnectionOptions, BunDbRunner, BunDbTx, PgListenClient, } from "./connection.js";
2
2
  export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection.js";
3
3
  export type { SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue } from "./query.js";
4
- export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, isTimestamptzType, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
4
+ export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
@@ -1,4 +1,4 @@
1
1
  // bun-db: Bun.sql-basierte DB-API ohne drizzle.
2
2
  // Production-Pfad nach drizzle-removal.
3
3
  export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection.js";
4
- export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, isTimestamptzType, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
4
+ export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
@@ -5,6 +5,7 @@ import { type NotExecutorOnly } from "../db/table-builder.js";
5
5
  import type { DbRunner } from "@cosmicdrift/kumiko-types/db-connection";
6
6
  import type { BunDbRunner } from "./connection.js";
7
7
  export declare function asEntityTableMeta(table: unknown): EntityTableMeta | undefined;
8
+ export declare function requireEntityTableMeta(table: unknown, tableName: string): EntityTableMeta;
8
9
  type RawClient = {
9
10
  unsafe: <TRow = unknown>(sql: string, params?: readonly unknown[]) => Promise<readonly TRow[]>;
10
11
  begin: <T>(fn: (tx: unknown) => Promise<T>) => Promise<T>;
@@ -68,6 +68,13 @@ export function asEntityTableMeta(table) {
68
68
  return fromSymbol;
69
69
  return isEntityTableMeta(table) ? table : undefined;
70
70
  }
71
+ export function requireEntityTableMeta(table, tableName) {
72
+ const meta = asEntityTableMeta(table);
73
+ if (!meta) {
74
+ throw new Error(`${tableName}: table carries no EntityTableMeta — built via table()?`);
75
+ }
76
+ return meta;
77
+ }
71
78
  export function asRawClient(db) {
72
79
  const dbAny = db;
73
80
  // Direct: Bun.SQL / postgres-js Sql / postgres-js TransactionSql. All three
@@ -542,10 +549,16 @@ function buildWhereClause(info, where, startIndex) {
542
549
  const opVal = value[opKey];
543
550
  if (opVal === undefined)
544
551
  continue;
552
+ // Mirrors applyMultiSelectFilter: ordering/pattern ops have no meaning on a jsonb
553
+ // option array (`like` would crash as `jsonb ~~ jsonb`), so match nothing.
554
+ if (pgType === "jsonb" && opKey !== "ne") {
555
+ conditions.push("FALSE");
556
+ continue;
557
+ }
545
558
  if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
546
559
  const p = prepareJsonbValue([opVal]);
547
560
  if (p && p.kind === "param") {
548
- conditions.push(`NOT (${quoteIdent(col)} @> $${idx++}${p.sql})`);
561
+ conditions.push(`(${quoteIdent(col)} IS NULL OR NOT (${quoteIdent(col)} @> $${idx++}${p.sql}))`);
549
562
  values.push(p.bound);
550
563
  continue;
551
564
  }
@@ -885,9 +898,7 @@ export async function updateMany(db, table, set, where) {
885
898
  return scoped.updateMany(table, set, where);
886
899
  }
887
900
  const info = extractTableInfo(table);
888
- const setEntries = Object.entries(set)
889
- .filter(([k]) => info.hasColumn(k))
890
- .map(([k, v]) => {
901
+ const setEntries = Object.entries(set).map(([k, v]) => {
891
902
  const col = info.columnOf(k);
892
903
  const pgType = info.pgTypeOf(col);
893
904
  return { col, prepared: prepareValue(v, pgType) };
@@ -34,12 +34,12 @@ async function decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, lo
34
34
  signal: AbortSignal.timeout(DECRYPT_TIMEOUT_MS),
35
35
  });
36
36
  }
37
- catch {
37
+ catch (error) {
38
38
  if (attempt < maxAttempts) {
39
39
  await sleep(RETRY_DELAYS_MS[attempt - 1] ?? 0);
40
40
  continue;
41
41
  }
42
- throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: network error or timeout`);
42
+ throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: network error or timeout`, { cause: error });
43
43
  }
44
44
  if (!response.ok) {
45
45
  if (isRetryableStatus(response.status) && attempt < maxAttempts) {
@@ -62,60 +62,64 @@ async function decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, lo
62
62
  }
63
63
  throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: retries exhausted`);
64
64
  }
65
+ // A leftover plaintext beside a ciphertext boots green while nothing was
66
+ // migrated, which is indistinguishable from a finished cutover unless the
67
+ // boot says which source won. The line is built from the path actually taken.
65
68
  async function resolveSlot(name, env, options, fetchImpl) {
66
69
  const plaintext = env[name];
67
- if (plaintext)
68
- return plaintext;
69
70
  const ciphertext = env[`${name}_CIPHERTEXT`];
71
+ if (plaintext) {
72
+ return {
73
+ value: plaintext,
74
+ sourceLine: ciphertext
75
+ ? `${name} source=plaintext-env (ciphertext present and ignored)`
76
+ : `${name} source=plaintext-env`,
77
+ };
78
+ }
70
79
  if (!ciphertext)
71
- return undefined;
80
+ return { value: undefined, sourceLine: undefined };
72
81
  const keyId = env.PLATFORM_KEK_KMS_KEY_ID;
73
82
  const token = env.PLATFORM_KEK_KMS_TOKEN;
74
83
  if (!keyId || !token) {
75
84
  const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
76
- throw new Error(`${prefix}PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are all-or-none with a KEK ciphertext (slot ${name}) — a partial set means the KMS wiring is broken.`);
77
- }
78
- const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
79
- return decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, options.logPrefix);
80
- }
81
- // A leftover plaintext beside a ciphertext boots green while nothing was
82
- // migrated, which is indistinguishable from a finished cutover unless the
83
- // boot says which source won. Never carries a key value, only its origin.
84
- function describeKekSource(name, env) {
85
- const plaintext = env[name];
86
- const ciphertext = env[`${name}_CIPHERTEXT`];
87
- if (plaintext) {
88
- return ciphertext
89
- ? `${name} source=plaintext-env (ciphertext present and ignored)`
90
- : `${name} source=plaintext-env`;
85
+ throw new Error(`${prefix}${name}_CIPHERTEXT is set but PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are missing — a partial set means the KMS wiring is broken.`);
91
86
  }
92
- if (!ciphertext)
93
- return undefined;
94
87
  const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
95
- return `${name} source=key-manager keyId=${env.PLATFORM_KEK_KMS_KEY_ID} region=${region}`;
88
+ const value = await decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, options.logPrefix);
89
+ return {
90
+ value,
91
+ sourceLine: `${name} source=key-manager keyId=${keyId} region=${region}`,
92
+ };
96
93
  }
97
94
  // Each slot resolves independently so a rollback that clears one slot's
98
95
  // plaintext (leaving its ciphertext/_VERSION behind or gone) never blocks
99
96
  // another slot's fallback path — the trio check downstream still applies.
97
+ // Slots resolve in parallel: sequentially, a slow Key Manager would cost up to
98
+ // ~16 s per slot (3 x 5 s timeout + backoff) and could trip startup probes.
100
99
  export async function resolvePlatformKeks(env, options = {}) {
101
100
  const fetchImpl = options.fetch ?? globalThis.fetch;
101
+ const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
102
102
  const slots = options.slots ?? LEGACY_SLOTS;
103
- const resolved = {};
104
- for (const name of slots) {
105
- resolved[name] = await resolveSlot(name, env, options, fetchImpl);
103
+ // Misconfiguration must not cost a Key Manager round trip.
104
+ if (slots.includes("PLATFORM_KEK_PREVIOUS") &&
105
+ (env.PLATFORM_KEK_PREVIOUS || env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT) &&
106
+ !env.PLATFORM_KEK_PREVIOUS_VERSION) {
107
+ throw new Error(`${prefix}PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.`);
106
108
  }
107
- const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
109
+ const results = await Promise.all(slots.map((name) => resolveSlot(name, env, options, fetchImpl)));
108
110
  // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
109
111
  const log = options.log ?? console.info;
110
- for (const name of slots) {
111
- const line = describeKekSource(name, env);
112
- if (line)
113
- log(`${prefix}${line}`);
114
- }
115
- if (resolved["PLATFORM_KEK_PREVIOUS"] && !env.PLATFORM_KEK_PREVIOUS_VERSION) {
116
- throw new Error(`${prefix}PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.`);
117
- }
118
- const changed = slots.some((name) => resolved[name] !== env[name]);
112
+ const resolved = {};
113
+ slots.forEach((name, index) => {
114
+ const result = results[index];
115
+ // skip: slot without a resolved result stays unset
116
+ if (!result)
117
+ return;
118
+ resolved[name] = result.value;
119
+ if (result.sourceLine)
120
+ log(`${prefix}${result.sourceLine}`);
121
+ });
122
+ const changed = Object.keys(resolved).some((name) => resolved[name] !== env[name]);
119
123
  if (!changed)
120
124
  return env;
121
125
  return { ...env, ...resolved };
@@ -3,8 +3,8 @@ import * as z from "zod";
3
3
  export * from "@cosmicdrift/kumiko-types/kms-adapter-types";
4
4
  // The KMS error classes live here and not in kumiko-types (#1629): callers
5
5
  // branch on them with `instanceof`, which needs a single copy of the class.
6
- // `satisfies z.ZodType<SubjectId>` binds this to the TS type at compile time
7
- // (fw#2801) — mint (subjectKeyForRecord) and shred (forget-subject) can no
6
+ // `satisfies z.ZodType<SubjectId>` plus the reverse check in subject-id-schema.test.ts bind this to the TS type
7
+ // at compile time (fw#2801) — mint (subjectKeyForRecord) and shred (forget-subject) can no
8
8
  // longer drift apart on which subjects are valid, the way #2809 happened.
9
9
  export const subjectIdSchema = z.discriminatedUnion("kind", [
10
10
  z.object({ kind: z.literal("user"), userId: z.uuid() }),
@@ -114,8 +114,14 @@ export async function applyEntityEvent(event, table, entity, tx) {
114
114
  // UPDATE so Postgres doesn't error with "column X does not exist".
115
115
  const changes = {};
116
116
  for (const [key, value] of Object.entries(rawChanges)) {
117
- const fieldType = entity.fields[key]?.type;
118
- if (fieldType === "files" || fieldType === "images")
117
+ const field = entity.fields[key];
118
+ // Historical events can carry fields since removed from the entity; the
119
+ // tolerance lives here (not in updateMany) so a live typo still throws.
120
+ // Base/soft-delete columns (insertedById, deletedAt, ...) are table
121
+ // columns without an entity field and must still be applied.
122
+ if (field === undefined && !(key in table))
123
+ continue;
124
+ if (field?.type === "files" || field?.type === "images")
119
125
  continue;
120
126
  changes[key] = value;
121
127
  }