@cosmicdrift/kumiko-framework 0.334.0 → 0.336.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 (178) hide show
  1. package/dist/api/anonymous-live-entities.d.ts +2 -0
  2. package/dist/api/anonymous-live-entities.js +17 -0
  3. package/dist/api/auth-routes.js +19 -4
  4. package/dist/api/client-ip.js +3 -6
  5. package/dist/api/extra-route.js +3 -2
  6. package/dist/api/index.d.ts +2 -1
  7. package/dist/api/index.js +1 -0
  8. package/dist/api/pii-leak-guard.js +7 -4
  9. package/dist/api/redis-sse-broker.d.ts +2 -0
  10. package/dist/api/redis-sse-broker.js +6 -7
  11. package/dist/api/routes.js +16 -17
  12. package/dist/api/server.js +20 -10
  13. package/dist/api/sse-broker.d.ts +3 -0
  14. package/dist/api/sse-broker.js +14 -0
  15. package/dist/api/sse-route.d.ts +5 -2
  16. package/dist/api/sse-route.js +71 -11
  17. package/dist/bun-db/connection.js +0 -2
  18. package/dist/bun-db/index.d.ts +1 -1
  19. package/dist/bun-db/index.js +1 -1
  20. package/dist/bun-db/query.d.ts +1 -0
  21. package/dist/bun-db/query.js +15 -4
  22. package/dist/crypto/kek-source.js +40 -36
  23. package/dist/crypto/kms-adapter.js +2 -2
  24. package/dist/db/apply-entity-event.js +8 -2
  25. package/dist/db/blind-index-cleanup.js +32 -11
  26. package/dist/db/entity-table-meta.js +5 -3
  27. package/dist/db/event-store-executor-context.js +13 -19
  28. package/dist/db/event-store-executor-read.js +45 -38
  29. package/dist/db/event-store-executor-write.js +15 -17
  30. package/dist/db/migrate-generator.js +27 -28
  31. package/dist/db/pg-error.js +9 -5
  32. package/dist/db/queries/backfill-pii.js +15 -5
  33. package/dist/db/queries/shadow-swap.js +5 -4
  34. package/dist/db/table-builder.d.ts +1 -0
  35. package/dist/db/table-builder.js +7 -15
  36. package/dist/db/tenant-db.js +18 -2
  37. package/dist/derivatives/variant-key.js +3 -1
  38. package/dist/engine/boot-validator/access-declarations.js +12 -3
  39. package/dist/engine/boot-validator/entity-handler.js +13 -1
  40. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  41. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  42. package/dist/engine/boot-validator/index.js +6 -6
  43. package/dist/engine/boot-validator/nav.d.ts +2 -1
  44. package/dist/engine/boot-validator/nav.js +10 -20
  45. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  46. package/dist/engine/boot-validator/ownership.js +2 -1
  47. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  48. package/dist/engine/boot-validator/pii-retention.js +5 -2
  49. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  50. package/dist/engine/boot-validator/query-output-columns.js +53 -5
  51. package/dist/engine/boot-validator/screens.d.ts +2 -2
  52. package/dist/engine/boot-validator/screens.js +125 -46
  53. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  54. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  55. package/dist/engine/boot-validator/zod-shape.js +17 -4
  56. package/dist/engine/build-app-schema.js +65 -20
  57. package/dist/engine/build-config-feature-schema.js +8 -1
  58. package/dist/engine/changeset-changes.js +24 -10
  59. package/dist/engine/create-app.js +10 -1
  60. package/dist/engine/extension-names.d.ts +4 -0
  61. package/dist/engine/extension-names.js +4 -0
  62. package/dist/engine/feature-ast/extractors/events.js +11 -8
  63. package/dist/engine/feature-ast/extractors/handlers.js +39 -16
  64. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  65. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  66. package/dist/engine/feature-ast/parse.d.ts +1 -0
  67. package/dist/engine/feature-ast/parse.js +18 -3
  68. package/dist/engine/feature-ast/patch.js +80 -61
  69. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  70. package/dist/engine/feature-ast/patcher.js +2 -1
  71. package/dist/engine/feature-ast/render.js +1 -1
  72. package/dist/engine/feature-entity-handlers.d.ts +1 -0
  73. package/dist/engine/feature-entity-handlers.js +2 -0
  74. package/dist/engine/index.d.ts +4 -3
  75. package/dist/engine/index.js +3 -2
  76. package/dist/engine/ownership.d.ts +1 -0
  77. package/dist/engine/ownership.js +15 -2
  78. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  79. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  80. package/dist/engine/pattern-library/shared-fields.js +17 -0
  81. package/dist/engine/registry-facade.js +3 -0
  82. package/dist/engine/registry-validate.d.ts +1 -0
  83. package/dist/engine/registry-validate.js +14 -0
  84. package/dist/engine/registry.js +2 -1
  85. package/dist/engine/role-assignment.d.ts +7 -1
  86. package/dist/engine/role-assignment.js +27 -7
  87. package/dist/engine/schema-builder.d.ts +1 -0
  88. package/dist/engine/schema-builder.js +9 -2
  89. package/dist/engine/screen-helpers.js +15 -4
  90. package/dist/engine/steps/_event-match.js +34 -7
  91. package/dist/engine/steps/_read-source.js +8 -0
  92. package/dist/engine/system-user.d.ts +1 -0
  93. package/dist/engine/system-user.js +3 -0
  94. package/dist/engine/where-rule-lint.js +22 -4
  95. package/dist/env/dry-run.js +10 -3
  96. package/dist/env/index.js +21 -1
  97. package/dist/errors/reasons.d.ts +0 -1
  98. package/dist/errors/reasons.js +2 -5
  99. package/dist/event-store/event-store.js +10 -2
  100. package/dist/event-store/provenance-append.js +4 -1
  101. package/dist/files/file-routes.d.ts +0 -1
  102. package/dist/files/file-routes.js +22 -7
  103. package/dist/files/local-provider.js +13 -10
  104. package/dist/files/provider-resolver.js +20 -1
  105. package/dist/files/types.js +16 -1
  106. package/dist/i18n/request-locale.d.ts +3 -2
  107. package/dist/i18n/request-locale.js +16 -6
  108. package/dist/i18n/required-surface-keys.js +23 -35
  109. package/dist/i18n/settings-hub-keys.js +3 -0
  110. package/dist/jobs/index.d.ts +2 -2
  111. package/dist/jobs/index.js +1 -1
  112. package/dist/jobs/job-runner.d.ts +2 -0
  113. package/dist/jobs/job-runner.js +172 -59
  114. package/dist/migrations/index.d.ts +1 -1
  115. package/dist/migrations/index.js +1 -1
  116. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  117. package/dist/migrations/pending-rebuilds.js +23 -1
  118. package/dist/observability/escape-hatch-report.d.ts +1 -0
  119. package/dist/observability/escape-hatch-report.js +3 -0
  120. package/dist/observability/index.d.ts +2 -1
  121. package/dist/observability/index.js +2 -1
  122. package/dist/observability/standard-metrics.d.ts +4 -0
  123. package/dist/observability/standard-metrics.js +16 -0
  124. package/dist/pipeline/dispatch-query.d.ts +1 -1
  125. package/dist/pipeline/dispatch-query.js +2 -2
  126. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  127. package/dist/pipeline/dispatch-shared.js +14 -9
  128. package/dist/pipeline/dispatch-stream.js +2 -5
  129. package/dist/pipeline/dispatch-write.js +22 -5
  130. package/dist/pipeline/dispatcher.js +1 -2
  131. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  132. package/dist/pipeline/event-dispatcher-delivery.js +5 -0
  133. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  134. package/dist/pipeline/event-dispatcher.js +30 -12
  135. package/dist/pipeline/index.d.ts +1 -1
  136. package/dist/pipeline/index.js +1 -1
  137. package/dist/pipeline/member-read-only-transaction.js +1 -14
  138. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  139. package/dist/pipeline/msp-rebuild.js +1 -0
  140. package/dist/pipeline/system-hooks.d.ts +1 -1
  141. package/dist/pipeline/system-hooks.js +19 -20
  142. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  143. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  144. package/dist/pipeline/write-origin.js +13 -6
  145. package/dist/redis/pubsub-signal.d.ts +2 -0
  146. package/dist/redis/pubsub-signal.js +2 -2
  147. package/dist/schema-cli.js +4 -14
  148. package/dist/search/meilisearch-adapter.js +17 -6
  149. package/dist/search/purge-subject.js +8 -3
  150. package/dist/stack/drain-event-consumers.js +3 -0
  151. package/dist/stack/drain-jobs.d.ts +3 -1
  152. package/dist/stack/drain-jobs.js +55 -13
  153. package/dist/stack/table-helpers.js +10 -8
  154. package/dist/testing/closed-connection-error.js +2 -3
  155. package/dist/testing/wait-for.js +1 -0
  156. package/dist/ui-types/index.d.ts +1 -1
  157. package/dist/ui-types/index.js +1 -1
  158. package/dist/upgrade-cli.d.ts +1 -0
  159. package/dist/upgrade-cli.js +36 -15
  160. package/package.json +4 -4
  161. package/src/changes.json +264 -0
  162. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  163. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  164. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  165. package/src/scripts/codemod/pii-personal-migration.ts +111 -44
  166. package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
  167. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  168. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  169. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  170. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  171. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  172. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  173. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  174. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  175. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  176. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  177. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  178. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -0,0 +1,2 @@
1
+ import type { Registry } from "../engine/types/index.js";
2
+ export declare function collectAnonymousLiveEntities(registry: Registry): ReadonlySet<string>;
@@ -0,0 +1,17 @@
1
+ import { hasAccess } from "../engine/access.js";
2
+ import { ANONYMOUS_ROLE } from "../engine/system-user.js";
3
+ const ANONYMOUS_PRINCIPAL = { roles: [ANONYMOUS_ROLE] };
4
+ // Entities an anonymous /api/sse connection may receive change signals for:
5
+ // only those an anonymously callable query declares via liveEntities. No
6
+ // inference from the query name, because a signal carries the id of every
7
+ // row of the entity, including rows that query filters out (drafts).
8
+ export function collectAnonymousLiveEntities(registry) {
9
+ const entities = new Set();
10
+ for (const handler of registry.getAllQueryHandlers().values()) {
11
+ if (!hasAccess(ANONYMOUS_PRINCIPAL, handler.access))
12
+ continue;
13
+ for (const entityName of handler.liveEntities ?? [])
14
+ entities.add(entityName);
15
+ }
16
+ return entities;
17
+ }
@@ -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;
@@ -1,5 +1,6 @@
1
1
  export type { SetTenantCookieOptions } from "./anonymous-cookie.js";
2
2
  export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.js";
3
+ export { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
3
4
  export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.js";
4
5
  export type { AnonymousAccessConfig, AnonymousAccessResolved, AuthMiddlewareOptions, AuthSessionChecker, AuthSessionCheckResult, AuthSessionStatus, TenantExists, TenantLifecycleStatusResolver, TenantResolver, TokenVerifier, } from "./auth-middleware.js";
5
6
  export { AUTH_COOKIE_NAME, authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
@@ -26,7 +27,7 @@ export type { KumikoServer, ServerOptions } from "./server.js";
26
27
  export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server.js";
27
28
  export type { SseBroker, SseClient, SseEvent } from "./sse-broker.js";
28
29
  export { createSseBroker } from "./sse-broker.js";
29
- export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route.js";
30
+ export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS, type SseRouteOptions } from "./sse-route.js";
30
31
  export { generateToken } from "./tokens.js";
31
32
  export type { KumikoServeEnv, KumikoServerWebSocket, KumikoWebSocketData, WebSocketUpgradeServer, } from "./websocket-route.js";
32
33
  export { createKumikoWebSocketHandler, isWebSocketUpgradeRequest, kumikoWebSocketHandler, WEBSOCKET_BACKPRESSURE_LIMIT_BYTES, WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER, WEBSOCKET_HEARTBEAT_INTERVAL_MS, WEBSOCKET_MAX_PAYLOAD_BYTES, WEBSOCKET_PENDING_BUFFER_LIMIT_BYTES, WEBSOCKET_REVALIDATION_FAILURE_LIMIT, WEBSOCKET_ROUTE_PATH_PREFIX, } from "./websocket-route.js";
package/dist/api/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.js";
2
+ export { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
2
3
  export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.js";
3
4
  export { AUTH_COOKIE_NAME, authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
4
5
  export { createAuthRoutes, createInMemoryLoginRateLimiter, createRedisLoginRateLimiter, } from "./auth-routes.js";
@@ -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);
@@ -5,7 +5,7 @@ import { buildAppSchema } from "../engine/build-app-schema.js";
5
5
  import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
6
6
  import { projectAppSchemaForRoles } from "../engine/project-app-schema-for-roles.js";
7
7
  import { runsInLane } from "../engine/run-in.js";
8
- import { ANONYMOUS_ROLE, createAnonymousUser, createSystemUser } from "../engine/system-user.js";
8
+ import { createAnonymousUser, createSystemUser, isAnonymousSessionUser, } from "../engine/system-user.js";
9
9
  import { isFileField, } from "../engine/types/index.js";
10
10
  import { createFileContext } from "../files/file-handle.js";
11
11
  import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes.js";
@@ -22,6 +22,7 @@ import { createAccessInvalidationEventConsumer, createJobTriggerEventConsumer, c
22
22
  import { authEndpointRateLimit, createRateLimitResolver, globalIpRateLimit, } from "../rate-limit/index.js";
23
23
  import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config.js";
24
24
  import { assertUnreachable, generateId } from "../utils/index.js";
25
+ import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
25
26
  import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS, Routes } from "./api-constants.js";
26
27
  import { authMiddleware, getAuthTokenExpiry, getUser, } from "./auth-middleware.js";
27
28
  import { createAuthRoutes } from "./auth-routes.js";
@@ -537,20 +538,29 @@ export function buildServer(options) {
537
538
  // Public auth routes (login) need to be registered BEFORE the generic
538
539
  // api routes so Hono matches them first.
539
540
  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).");
541
+ // A membershipQuery handler is registered but principalStatus is not provided by exactly
542
+ // one feature — surface that misconfig at boot instead of an InternalError on first login/switch-tenant.
543
+ if (options.registry.getQueryHandler(options.auth.membershipQuery)) {
544
+ const principalStatusProviders = options.registry.getExtensionUsages(EXT_PRINCIPAL_STATUS).length;
545
+ if (principalStatusProviders === 0) {
546
+ throw new Error(`[kumiko] auth membershipQuery "${options.auth.membershipQuery}" is registered but no feature ` +
547
+ 'provides the "principalStatus" contract — mount the user feature (tenant switch and sign-in ' +
548
+ "need it to reject blocked principals).");
549
+ }
550
+ if (principalStatusProviders > 1) {
551
+ throw new Error(`[kumiko] auth membershipQuery "${options.auth.membershipQuery}" is registered but ` +
552
+ `${principalStatusProviders} features provide the "principalStatus" contract — exactly one ` +
553
+ "is expected (the bundled user feature already provides it).");
554
+ }
547
555
  }
548
556
  app.route("/api", createAuthRoutes(dispatcher, jwt, { ...options.auth, resolver: clientIpResolver }));
549
557
  }
550
558
  app.route("/api", createApiRoutes(dispatcher, {
551
559
  ...(options.sseHeartbeatMs !== undefined ? { sseHeartbeatMs: options.sseHeartbeatMs } : {}),
552
560
  }));
553
- app.route("/api", createSseRoute(sseBroker));
561
+ app.route("/api", createSseRoute(sseBroker, {
562
+ anonymousLiveEntities: collectAnonymousLiveEntities(options.registry),
563
+ }));
554
564
  // GET /api/schema — the only way a client obtains the AppSchema. Behind
555
565
  // jwtGuard (NON_PUBLIC_API_PATHS), so anonymousAccess's synthesised user
556
566
  // also gets rejected below — the schema never reaches an unauthenticated
@@ -843,7 +853,7 @@ c, dispatcher) {
843
853
  // real, signed-in principal and reject the synthesised anonymousAccess user
844
854
  // (SessionUser.roles includes ANONYMOUS_ROLE) the same way.
845
855
  function isMissingOrAnonymousUser(user) {
846
- return !user || user.roles.includes(ANONYMOUS_ROLE);
856
+ return !user || isAnonymousSessionUser(user);
847
857
  }
848
858
  function unauthenticatedResponse(c) {
849
859
  return c.json({
@@ -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
  *
@@ -21,4 +21,7 @@ import type { SseBroker } from "./sse-broker.js";
21
21
  * gegen versehentliches Hochsetzen.
22
22
  */
23
23
  export declare const SSE_HEARTBEAT_INTERVAL_MS = 15000;
24
- export declare function createSseRoute(broker: SseBroker): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
24
+ export type SseRouteOptions = {
25
+ readonly anonymousLiveEntities: ReadonlySet<string>;
26
+ };
27
+ export declare function createSseRoute(broker: SseBroker, options: SseRouteOptions): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
@@ -1,8 +1,10 @@
1
1
  import { Hono } from "hono";
2
2
  import { streamSSE } from "hono/streaming";
3
3
  import { tenantChannel } from "../engine/constants.js";
4
+ import { isAnonymousSessionUser } from "../engine/system-user.js";
4
5
  import { Routes } from "./api-constants.js";
5
6
  import { getUser } from "./auth-middleware.js";
7
+ import { accessInvalidationCredentialFor } from "./sse-broker.js";
6
8
  /**
7
9
  * Heartbeat-Cadence für SSE-Streams.
8
10
  *
@@ -32,7 +34,41 @@ export const SSE_HEARTBEAT_INTERVAL_MS = 15_000;
32
34
  function isEntityEventData(data) {
33
35
  return typeof data["aggregateType"] === "string";
34
36
  }
35
- export function createSseRoute(broker) {
37
+ // Whitelist, never a spread: the wire frame is a signal without field
38
+ // values even if a broker or pusher put a payload into event.data.
39
+ function toEntitySignalWire(event, data) {
40
+ return {
41
+ id: data["id"],
42
+ aggregateType: data.aggregateType,
43
+ eventType: event.type,
44
+ version: data["version"],
45
+ createdAt: data["createdAt"],
46
+ };
47
+ }
48
+ // The tenant channel fans out to every member, so a frame without an entity
49
+ // (e.g. channel-in-app:event:delivered) reaches only its addressee; a frame
50
+ // lacking userId is dropped (fail-closed).
51
+ function isAddressedTo(event, userId) {
52
+ const addressee = event.data["userId"];
53
+ return typeof addressee === "string" && addressee === userId;
54
+ }
55
+ function mayReceiveEntitySignal(user, aggregateType, options) {
56
+ return !isAnonymousSessionUser(user) || options.anonymousLiveEntities.has(aggregateType);
57
+ }
58
+ function decideWireFrame(event, user, options) {
59
+ const data = event.data;
60
+ if (isEntityEventData(data)) {
61
+ // skip: anonymous connections only get signals for entities an anonymous query declares
62
+ if (!mayReceiveEntitySignal(user, data.aggregateType, options))
63
+ return undefined;
64
+ return { name: data.aggregateType, data: toEntitySignalWire(event, data) };
65
+ }
66
+ // skip: frames without an entity belong to their addressee only
67
+ if (!isAddressedTo(event, user.id))
68
+ return undefined;
69
+ return { name: event.type, data };
70
+ }
71
+ export function createSseRoute(broker, options) {
36
72
  const route = new Hono();
37
73
  route.get(Routes.sse, async (c) => {
38
74
  const user = getUser(c);
@@ -40,20 +76,44 @@ export function createSseRoute(broker) {
40
76
  // Allowing ?channel=... would let any authenticated user subscribe to other tenants' feeds.
41
77
  const channel = tenantChannel(user.tenantId);
42
78
  return streamSSE(c, async (stream) => {
79
+ let resolveEnded;
80
+ const ended = new Promise((resolve) => {
81
+ resolveEnded = resolve;
82
+ });
83
+ const closeStream = () => {
84
+ resolveEnded();
85
+ void stream.close();
86
+ };
43
87
  const clientId = broker.addClient(channel, (event) => {
44
- const wireEventName = isEntityEventData(event.data)
45
- ? event.data.aggregateType
46
- : event.type;
47
- stream.writeSSE({ event: wireEventName, data: JSON.stringify(event.data) });
48
- }, () => stream.close());
49
- stream.onAbort(() => {
88
+ const frame = decideWireFrame(event, user, options);
89
+ // skip: decideWireFrame withholds frames this user must not see
90
+ if (!frame)
91
+ return;
92
+ stream.writeSSE({ event: frame.name, data: JSON.stringify(frame.data) });
93
+ }, closeStream);
94
+ const unsubscribeAccessInvalidation = broker.subscribeAccessInvalidation(user.id, closeStream, accessInvalidationCredentialFor(user));
95
+ let released = false;
96
+ const release = () => {
97
+ // skip: onAbort and the finally block both release, the second call is a no-op
98
+ if (released)
99
+ return;
100
+ released = true;
101
+ // A client disconnect must also wake the heartbeat sleep, not only a server close.
102
+ resolveEnded();
50
103
  broker.removeClient(channel, clientId);
51
- });
104
+ unsubscribeAccessInvalidation();
105
+ };
106
+ stream.onAbort(release);
52
107
  // Keep connection alive with heartbeat — siehe SSE_HEARTBEAT_INTERVAL_MS
53
108
  // 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);
109
+ try {
110
+ while (!stream.closed && !stream.aborted) {
111
+ await stream.writeSSE({ event: "ping", data: "" });
112
+ await Promise.race([stream.sleep(SSE_HEARTBEAT_INTERVAL_MS), ended]);
113
+ }
114
+ }
115
+ finally {
116
+ release();
57
117
  }
58
118
  });
59
119
  });
@@ -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) };