@cosmicdrift/kumiko-framework 0.307.0 → 0.309.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 (174) hide show
  1. package/package.json +5 -5
  2. package/src/__tests__/anonymous-access.integration.test.ts +1 -1
  3. package/src/__tests__/error-contract.integration.test.ts +1 -1
  4. package/src/__tests__/field-access.integration.test.ts +1 -1
  5. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +1 -1
  6. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +1 -1
  7. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +1 -1
  8. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +1 -1
  9. package/src/__tests__/full-stack.integration.test.ts +1 -1
  10. package/src/__tests__/transition-guard.integration.test.ts +1 -1
  11. package/src/api/__tests__/api.test.ts +1 -1
  12. package/src/api/__tests__/batch.integration.test.ts +1 -1
  13. package/src/api/__tests__/body-limit.test.ts +1 -1
  14. package/src/api/__tests__/dispatcher-live.integration.test.ts +1 -1
  15. package/src/api/__tests__/extra-routes.integration.test.ts +1 -1
  16. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +1 -1
  17. package/src/api/__tests__/nested-write-ownership.integration.test.ts +1 -1
  18. package/src/api/__tests__/nested-write.integration.test.ts +1 -1
  19. package/src/api/__tests__/pii-leak-guard.integration.test.ts +1 -1
  20. package/src/api/__tests__/redis-sse-broker.integration.test.ts +73 -6
  21. package/src/api/__tests__/request-locale.integration.test.ts +1 -1
  22. package/src/api/__tests__/server-boot-guards.test.ts +1 -1
  23. package/src/api/__tests__/server-error-logging.test.ts +1 -1
  24. package/src/api/__tests__/sse-broker.test.ts +107 -13
  25. package/src/api/__tests__/stream-escape-hatch.integration.test.ts +1 -1
  26. package/src/api/auth-routes.ts +1 -1
  27. package/src/api/redis-sse-broker.ts +51 -11
  28. package/src/api/server.ts +11 -0
  29. package/src/api/sse-broker.ts +61 -22
  30. package/src/bun-db/__tests__/postgres-closed-socket-write.integration.test.ts +87 -0
  31. package/src/bun-db/connection.ts +2 -0
  32. package/src/changes.json +61 -0
  33. package/src/compliance/override-schema.ts +1 -1
  34. package/src/crypto/__tests__/event-pii.test.ts +1 -1
  35. package/src/crypto/kms-adapter.ts +1 -1
  36. package/src/db/__tests__/feature-table-sources.test.ts +2 -2
  37. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
  38. package/src/db/collect-table-metas.ts +3 -6
  39. package/src/db/feature-table-sources.ts +3 -4
  40. package/src/db/queries/event-consumer.ts +98 -6
  41. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
  42. package/src/derivatives/delete-stored-file.ts +86 -0
  43. package/src/derivatives/index.ts +2 -0
  44. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
  45. package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
  46. package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
  47. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  48. package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
  49. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
  50. package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
  51. package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
  52. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
  53. package/src/engine/__tests__/boot-validator.test.ts +35 -1
  54. package/src/engine/__tests__/build-app-schema.test.ts +1 -1
  55. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
  56. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
  57. package/src/engine/__tests__/engine.test.ts +1 -1
  58. package/src/engine/__tests__/event-helpers.test.ts +1 -1
  59. package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
  60. package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
  61. package/src/engine/__tests__/extends-registrar.test.ts +1 -1
  62. package/src/engine/__tests__/hook-phases.test.ts +1 -1
  63. package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
  64. package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
  65. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  66. package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
  67. package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
  68. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
  69. package/src/engine/__tests__/post-query-hook.test.ts +1 -1
  70. package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
  71. package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
  72. package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
  73. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
  74. package/src/engine/__tests__/registry.test.ts +1 -1
  75. package/src/engine/__tests__/screen.test.ts +1 -1
  76. package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
  77. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
  78. package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
  79. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
  80. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
  81. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
  82. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
  83. package/src/engine/boot-validator/screens.ts +83 -0
  84. package/src/engine/define-handler.ts +2 -1
  85. package/src/engine/entity-handlers.ts +2 -1
  86. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
  87. package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
  88. package/src/engine/feature-builder-state.ts +2 -1
  89. package/src/engine/feature-config-events-jobs.ts +2 -1
  90. package/src/engine/feature-entity-handlers.ts +2 -1
  91. package/src/engine/schema-builder.ts +1 -1
  92. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  93. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
  94. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  95. package/src/env/__tests__/compose-env-schema.test.ts +1 -1
  96. package/src/env/__tests__/dry-run.test.ts +1 -1
  97. package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
  98. package/src/env/_zod-introspect.ts +1 -1
  99. package/src/env/dry-run.ts +1 -1
  100. package/src/env/index.ts +1 -1
  101. package/src/errors/__tests__/classes.test.ts +1 -1
  102. package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
  103. package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
  104. package/src/event-store/__tests__/event-attribution.integration.test.ts +1 -1
  105. package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
  106. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
  107. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
  108. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
  109. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +1 -1
  110. package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
  111. package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
  112. package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
  113. package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
  114. package/src/observability/__tests__/observability.integration.test.ts +1 -1
  115. package/src/observability/metrics-wiring.ts +1 -1
  116. package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
  117. package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
  118. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
  119. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
  120. package/src/pipeline/__tests__/dispatcher.test.ts +1 -1
  121. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +1 -1
  122. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +1 -1
  123. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +1 -1
  124. package/src/pipeline/__tests__/event-dedup.integration.test.ts +1 -1
  125. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +1 -1
  126. package/src/pipeline/__tests__/event-dispatcher-batch-delivery.test.ts +178 -0
  127. package/src/pipeline/__tests__/event-dispatcher-batch-handler.integration.test.ts +143 -0
  128. package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +192 -0
  129. package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
  130. package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
  131. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
  132. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
  133. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
  134. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  135. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  136. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
  137. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
  138. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
  139. package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
  140. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
  141. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +1 -1
  142. package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
  143. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
  144. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
  145. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
  146. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
  147. package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
  148. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
  149. package/src/pipeline/dispatch-stream.ts +6 -1
  150. package/src/pipeline/event-dispatcher-delivery.ts +132 -37
  151. package/src/pipeline/event-dispatcher.ts +121 -17
  152. package/src/pipeline/index.ts +1 -0
  153. package/src/pipeline/msp-rebuild.ts +39 -1
  154. package/src/pipeline/pending-gap-ranges.ts +48 -0
  155. package/src/pipeline/system-hooks.ts +223 -68
  156. package/src/pipeline/write-origin.ts +1 -1
  157. package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
  158. package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
  159. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
  160. package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
  161. package/src/search/derive-search-adapter-config.ts +27 -0
  162. package/src/search/index.ts +1 -0
  163. package/src/search/meilisearch-adapter.ts +96 -14
  164. package/src/secrets/__tests__/contains-secret.test.ts +1 -1
  165. package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
  166. package/src/stack/__tests__/setup-test-stack-entity-tables.integration.test.ts +65 -0
  167. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  168. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
  169. package/src/stack/test-stack.ts +61 -54
  170. package/src/testing/__tests__/e2e-generator.test.ts +1 -1
  171. package/src/testing/__tests__/real-providers.test.ts +9 -1
  172. package/src/testing/e2e-generator.ts +1 -1
  173. package/src/testing/index.ts +1 -1
  174. package/src/testing/real-providers.ts +12 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.307.0",
3
+ "version": "0.309.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.307.0",
202
- "@cosmicdrift/kumiko-types": "0.307.0",
201
+ "@cosmicdrift/kumiko-http": "0.309.0",
202
+ "@cosmicdrift/kumiko-types": "0.309.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -208,14 +208,14 @@
208
208
  "jose": "^6.2.3",
209
209
  "meilisearch": "^0.58.0",
210
210
  "pino": "^10.3.1",
211
- "postgres": "^3.4.9",
211
+ "postgres": "npm:@bender0oo0/postgres@3.4.9-kumiko.1",
212
212
  "temporal-polyfill": "^0.3.2",
213
213
  "ts-morph": "^28.0.0",
214
214
  "uuid": "^14.0.0",
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.307.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.309.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -7,7 +7,7 @@
7
7
  // Bun.SQL-only setup. KEIN postgres-js, KEIN setupTestStack.
8
8
 
9
9
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
- import { z } from "zod";
10
+ import * as z from "zod";
11
11
  import type { AnonymousAccessConfig } from "../api/auth-middleware";
12
12
  import { createEventStoreExecutor } from "../db/event-store-executor";
13
13
  import { asRawClient, selectMany } from "../db/query";
@@ -4,7 +4,7 @@
4
4
  // and docs promise. If you change the contract, this is the file that moves.
5
5
 
6
6
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
- import { z } from "zod";
7
+ import * as z from "zod";
8
8
  import { createEventStoreExecutor } from "../db/event-store-executor";
9
9
  import { asRawClient } from "../db/query";
10
10
  import { buildEntityTable } from "../db/table-builder";
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { buildServer } from "../api/server";
4
4
  import { createEventStoreExecutor } from "../db/event-store-executor";
5
5
  import { buildEntityTable } from "../db/table-builder";
@@ -1,4 +1,4 @@
1
- import { z } from "zod";
1
+ import * as z from "zod";
2
2
 
3
3
  export const listWidgets = {
4
4
  name: "widget:list",
@@ -1,4 +1,4 @@
1
- import { z } from "zod";
1
+ import * as z from "zod";
2
2
 
3
3
  export const listWidgets = {
4
4
  name: "widget:list",
@@ -1,4 +1,4 @@
1
- import { z } from "zod";
1
+ import * as z from "zod";
2
2
 
3
3
  export const listHandler = {
4
4
  name: "widget:list",
@@ -1,4 +1,4 @@
1
- import { z } from "zod";
1
+ import * as z from "zod";
2
2
 
3
3
  export const listHandler = {
4
4
  name: "widget:list",
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { updateRows } from "@cosmicdrift/kumiko-framework/testing";
3
- import { z } from "zod";
3
+ import * as z from "zod";
4
4
  import { createEventStoreExecutor } from "../db/event-store-executor";
5
5
  import { selectMany } from "../db/query";
6
6
  import { defineFeature, type EntityId, type HandlerContext, type SaveContext } from "../engine";
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { updateRows } from "@cosmicdrift/kumiko-framework/testing";
3
- import { z } from "zod";
3
+ import * as z from "zod";
4
4
  import { createEventStoreExecutor } from "../db/event-store-executor";
5
5
  import { buildEntityTable } from "../db/table-builder";
6
6
  import {
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { parseSseFrames } from "@cosmicdrift/kumiko-dispatcher-live";
3
3
  import { Hono } from "hono";
4
- import { z } from "zod";
4
+ import * as z from "zod";
5
5
  import {
6
6
  createEntity,
7
7
  createRegistry,
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
3
- import { z } from "zod";
3
+ import * as z from "zod";
4
4
  import type { TableColumns } from "../../db/dialect";
5
5
  import { createEventStoreExecutor } from "../../db/event-store-executor";
6
6
  import { asRawClient, selectMany } from "../../db/query";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
4
4
  import { BODY_LIMIT_OPT_OUT_PATHS, Routes } from "../api-constants";
5
5
  import { buildServer } from "../server";
@@ -1,7 +1,7 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
3
3
  import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
4
- import { z } from "zod";
4
+ import * as z from "zod";
5
5
  import { generateToken } from "../../api/tokens";
6
6
  import { createEventStoreExecutor } from "../../db/event-store-executor";
7
7
  import { asRawClient, selectMany } from "../../db/query";
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
6
  import { createHmac } from "node:crypto";
7
- import { z } from "zod";
7
+ import * as z from "zod";
8
8
  import { createRegistry, defineFeature, type TenantId } from "../../engine";
9
9
  import { RateLimitError } from "../../errors";
10
10
  import { setupTestStack, type TestStack, TestUsers } from "../../stack";
@@ -5,7 +5,7 @@
5
5
  // httpRoute, same client IP, DO hit the limit.
6
6
 
7
7
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
- import { z } from "zod";
8
+ import * as z from "zod";
9
9
  import { createEntity, createTextField, defineFeature } from "../../engine";
10
10
  import type { TenantId } from "../../engine/types/identifiers";
11
11
  import { RateLimitError } from "../../errors";
@@ -8,7 +8,7 @@
8
8
  // setupTestStack + stack.http.write — never createTestDispatcher.
9
9
 
10
10
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
11
- import { z } from "zod";
11
+ import * as z from "zod";
12
12
  import { createEventStoreExecutor } from "../../db/event-store-executor";
13
13
  import { executeRawQuery } from "../../db/queries/raw-sql";
14
14
  import { asRawClient, selectMany } from "../../db/query";
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createEventStoreExecutor } from "../../db/event-store-executor";
4
4
  import { asRawClient, selectMany } from "../../db/query";
5
5
  import { buildEntityTable } from "../../db/table-builder";
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
7
7
  import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
8
- import { z } from "zod";
8
+ import * as z from "zod";
9
9
  import { configurePiiSubjectKms, InMemoryKmsAdapter } from "../../crypto";
10
10
  import { defineFeature } from "../../engine/define-feature";
11
11
  import { defineQueryHandler } from "../../engine/define-handler";
@@ -127,7 +127,7 @@ describe("createRedisSseBroker", () => {
127
127
  expect(invalidatedA).toBe(false);
128
128
  });
129
129
 
130
- test("publishAccessInvalidation with a keptSessionId spares only the listener whose own sid matches exactly", async () => {
130
+ test("publishAccessInvalidation with an all-except-session scope spares only the listener whose own sid matches exactly", async () => {
131
131
  const podA = trackedBroker();
132
132
  const podB = trackedBroker();
133
133
  const userId = `user-${generateId()}`;
@@ -142,9 +142,9 @@ describe("createRedisSseBroker", () => {
142
142
  () => {
143
143
  invalidatedKept = true;
144
144
  },
145
- sidKept,
145
+ { sid: sidKept },
146
146
  );
147
- // No ownSid — mirrors a PAT/bearer stream, always invalidated (fail-closed).
147
+ // No sid — mirrors a PAT/bearer stream, always invalidated (fail-closed).
148
148
  podA.subscribeAccessInvalidation(userId, () => {
149
149
  invalidatedSidless = true;
150
150
  });
@@ -155,11 +155,14 @@ describe("createRedisSseBroker", () => {
155
155
  () => {
156
156
  invalidatedAlreadyRevoked = true;
157
157
  },
158
- sidAlreadyRevoked,
158
+ { sid: sidAlreadyRevoked },
159
159
  );
160
160
 
161
161
  await waitFor(() => {
162
- podB.publishAccessInvalidation(userId, sidKept);
162
+ podB.publishAccessInvalidation(userId, {
163
+ kind: "all-except-session",
164
+ keptSessionId: sidKept,
165
+ });
163
166
  return invalidatedSidless && invalidatedAlreadyRevoked;
164
167
  });
165
168
  // Asserted only after the control listeners above already fired for the
@@ -168,6 +171,46 @@ describe("createRedisSseBroker", () => {
168
171
  expect(invalidatedKept).toBe(false);
169
172
  });
170
173
 
174
+ test("publishAccessInvalidation with a sessions scope closes only the matching sid, spares an unrelated sid and a PAT listener", async () => {
175
+ const podA = trackedBroker();
176
+ const podB = trackedBroker();
177
+ const userId = `user-${generateId()}`;
178
+ const sidTarget = `sid-target-${generateId()}`;
179
+ const sidOther = `sid-other-${generateId()}`;
180
+ let invalidatedTarget = false;
181
+ let invalidatedOther = false;
182
+ let invalidatedPat = false;
183
+
184
+ podA.subscribeAccessInvalidation(
185
+ userId,
186
+ () => {
187
+ invalidatedTarget = true;
188
+ },
189
+ { sid: sidTarget },
190
+ );
191
+ podA.subscribeAccessInvalidation(
192
+ userId,
193
+ () => {
194
+ invalidatedOther = true;
195
+ },
196
+ { sid: sidOther },
197
+ );
198
+ podA.subscribeAccessInvalidation(
199
+ userId,
200
+ () => {
201
+ invalidatedPat = true;
202
+ },
203
+ { patTokenId: `tok-${generateId()}` },
204
+ );
205
+
206
+ await waitFor(() => {
207
+ podB.publishAccessInvalidation(userId, { kind: "sessions", sessionIds: [sidTarget] });
208
+ return invalidatedTarget;
209
+ });
210
+ expect(invalidatedOther).toBe(false);
211
+ expect(invalidatedPat).toBe(false);
212
+ });
213
+
171
214
  test("a legacy unscoped invalidation message (bare `1`, pre-scoping pod) still invalidates every listener, sid or not", async () => {
172
215
  const podA = trackedBroker();
173
216
  const publisherRaw = new (await import("ioredis")).default(testRedis.redisUrl);
@@ -179,7 +222,7 @@ describe("createRedisSseBroker", () => {
179
222
  () => {
180
223
  invalidatedWithSid = true;
181
224
  },
182
- `sid-${generateId()}`,
225
+ { sid: `sid-${generateId()}` },
183
226
  );
184
227
 
185
228
  try {
@@ -193,6 +236,30 @@ describe("createRedisSseBroker", () => {
193
236
  }
194
237
  });
195
238
 
239
+ test("a malformed sessions-scope wire payload (empty sessionIds, no keptSessionId key) falls back to userwide", async () => {
240
+ const podA = trackedBroker();
241
+ const publisherRaw = new (await import("ioredis")).default(testRedis.redisUrl);
242
+ const userId = `user-${generateId()}`;
243
+ let invalidated = false;
244
+
245
+ podA.subscribeAccessInvalidation(userId, () => {
246
+ invalidated = true;
247
+ });
248
+
249
+ try {
250
+ await waitFor(async () => {
251
+ await publisherRaw.publish(
252
+ `kumiko:sse:inval:${userId}`,
253
+ JSON.stringify({ scope: "sessions", sessionIds: [] }),
254
+ );
255
+ return invalidated;
256
+ });
257
+ expect(invalidated).toBe(true);
258
+ } finally {
259
+ publisherRaw.disconnect();
260
+ }
261
+ });
262
+
196
263
  test("a malformed message on the channel namespace is dropped, not thrown, and does not kill delivery", async () => {
197
264
  const podA = trackedBroker();
198
265
  const publisherRaw = new (await import("ioredis")).default(testRedis.redisUrl);
@@ -10,7 +10,7 @@ import {
10
10
  setupTestStack,
11
11
  type TestStack,
12
12
  } from "@cosmicdrift/kumiko-framework/stack";
13
- import { z } from "zod";
13
+ import * as z from "zod";
14
14
  import { LOCALE_HEADER_NAME } from "../api-constants";
15
15
 
16
16
  const localeProbe = defineFeature("locale-probe", (r) => {
@@ -1,7 +1,7 @@
1
1
  // buildServer boot-time guards + httpRoute verb wiring (PUT branch).
2
2
 
3
3
  import { describe, expect, spyOn, test } from "bun:test";
4
- import { z } from "zod";
4
+ import * as z from "zod";
5
5
  import {
6
6
  createEntity,
7
7
  createFileField,
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createRegistry, defineFeature } from "../../engine";
4
4
  import { RateLimitError, UnprocessableError } from "../../errors";
5
5
  import { TestUsers } from "../../stack";
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { createSseBroker, type SseBroker, type SseEvent } from "../sse-broker";
2
+ import {
3
+ createSseBroker,
4
+ type SseBroker,
5
+ type SseEvent,
6
+ shouldInvalidateListener,
7
+ } from "../sse-broker";
3
8
 
4
9
  function requireAccessInvalidation(broker: SseBroker) {
5
10
  const subscribe = broker.subscribeAccessInvalidation;
@@ -122,52 +127,141 @@ describe("SSE broker", () => {
122
127
  expect(() => publish("nobody-listening")).not.toThrow();
123
128
  });
124
129
 
125
- test("publishAccessInvalidation with a keptSessionId spares only the listener whose own sid matches exactly", () => {
130
+ test("publishAccessInvalidation with an all-except-session scope spares only the listener whose own sid matches exactly", () => {
126
131
  const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
127
132
  const spared = mock();
128
133
  const unrelated = mock();
129
134
 
130
- subscribe("user-a", spared, "sid-kept");
131
- subscribe("user-a", unrelated, "sid-other");
132
- publish("user-a", "sid-kept");
135
+ subscribe("user-a", spared, { sid: "sid-kept" });
136
+ subscribe("user-a", unrelated, { sid: "sid-other" });
137
+ publish("user-a", { kind: "all-except-session", keptSessionId: "sid-kept" });
133
138
 
134
139
  expect(spared).not.toHaveBeenCalled();
135
140
  expect(unrelated).toHaveBeenCalledTimes(1);
136
141
  });
137
142
 
138
- test("publishAccessInvalidation with a keptSessionId still closes a listener whose sid was already revoked through an eventless path (keep-list, not a kill-list)", () => {
143
+ test("all-except-session still closes a listener whose sid was already revoked through an eventless path (keep-list, not a kill-list)", () => {
139
144
  const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
140
145
  const alreadyRevoked = mock();
141
146
 
142
147
  // Simulates a stream from a session logged out earlier via a path that
143
148
  // never appended session-revoked — the keep-list must not accidentally
144
149
  // exempt it just because its sid isn't the freshly-kept one.
145
- subscribe("user-a", alreadyRevoked, "sid-logged-out-earlier");
146
- publish("user-a", "sid-kept");
150
+ subscribe("user-a", alreadyRevoked, { sid: "sid-logged-out-earlier" });
151
+ publish("user-a", { kind: "all-except-session", keptSessionId: "sid-kept" });
147
152
 
148
153
  expect(alreadyRevoked).toHaveBeenCalledTimes(1);
149
154
  });
150
155
 
151
- test("publishAccessInvalidation with a keptSessionId still fires a listener with no sid of its own (fail-closed, e.g. a PAT/bearer stream)", () => {
156
+ test("all-except-session still fires a listener with no sid of its own (fail-closed, e.g. a PAT/bearer stream)", () => {
152
157
  const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
153
158
  const sidless = mock();
154
159
 
155
160
  subscribe("user-a", sidless);
156
- publish("user-a", "sid-kept");
161
+ publish("user-a", { kind: "all-except-session", keptSessionId: "sid-kept" });
157
162
 
158
163
  expect(sidless).toHaveBeenCalledTimes(1);
159
164
  });
160
165
 
161
- test("publishAccessInvalidation with no keptSessionId (unscoped) still invalidates every listener, matching pre-scoping behavior", () => {
166
+ test("no scope (unscoped) still invalidates every listener, matching pre-scoping behavior", () => {
162
167
  const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
163
168
  const first = mock();
164
169
  const second = mock();
165
170
 
166
- subscribe("user-a", first, "sid-1");
167
- subscribe("user-a", second, "sid-2");
171
+ subscribe("user-a", first, { sid: "sid-1" });
172
+ subscribe("user-a", second, { sid: "sid-2" });
168
173
  publish("user-a");
169
174
 
170
175
  expect(first).toHaveBeenCalledTimes(1);
171
176
  expect(second).toHaveBeenCalledTimes(1);
172
177
  });
178
+
179
+ test("sessions scope closes only the matching sid, spares an unrelated sid and a PAT listener", () => {
180
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
181
+ const matching = mock();
182
+ const otherSid = mock();
183
+ const patListener = mock();
184
+
185
+ subscribe("user-a", matching, { sid: "sid-1" });
186
+ subscribe("user-a", otherSid, { sid: "sid-2" });
187
+ subscribe("user-a", patListener, { patTokenId: "tok-1" });
188
+ publish("user-a", { kind: "sessions", sessionIds: ["sid-1"] });
189
+
190
+ expect(matching).toHaveBeenCalledTimes(1);
191
+ expect(otherSid).not.toHaveBeenCalled();
192
+ expect(patListener).not.toHaveBeenCalled();
193
+ });
194
+
195
+ test("sessions scope closes a credential-less listener (fail-closed)", () => {
196
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
197
+ const credentialless = mock();
198
+
199
+ subscribe("user-a", credentialless);
200
+ publish("user-a", { kind: "sessions", sessionIds: ["sid-1"] });
201
+
202
+ expect(credentialless).toHaveBeenCalledTimes(1);
203
+ });
204
+
205
+ test("pat-tokens scope closes only the matching tokenId, spares an unrelated PAT and a session listener", () => {
206
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
207
+ const matching = mock();
208
+ const otherToken = mock();
209
+ const sessionListener = mock();
210
+
211
+ subscribe("user-a", matching, { patTokenId: "tok-1" });
212
+ subscribe("user-a", otherToken, { patTokenId: "tok-2" });
213
+ subscribe("user-a", sessionListener, { sid: "sid-1" });
214
+ publish("user-a", { kind: "pat-tokens", tokenIds: ["tok-1"] });
215
+
216
+ expect(matching).toHaveBeenCalledTimes(1);
217
+ expect(otherToken).not.toHaveBeenCalled();
218
+ expect(sessionListener).not.toHaveBeenCalled();
219
+ });
220
+
221
+ test("pat-tokens scope closes a credential-less listener (fail-closed)", () => {
222
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
223
+ const credentialless = mock();
224
+
225
+ subscribe("user-a", credentialless);
226
+ publish("user-a", { kind: "pat-tokens", tokenIds: ["tok-1"] });
227
+
228
+ expect(credentialless).toHaveBeenCalledTimes(1);
229
+ });
230
+ });
231
+
232
+ describe("shouldInvalidateListener matrix", () => {
233
+ const sidListener = { sid: "sid-1" };
234
+ const patListener = { patTokenId: "tok-1" };
235
+ const bareListener = {};
236
+
237
+ test("user scope always closes", () => {
238
+ const scope = { kind: "user" as const };
239
+ expect(shouldInvalidateListener(sidListener, scope)).toBe(true);
240
+ expect(shouldInvalidateListener(patListener, scope)).toBe(true);
241
+ expect(shouldInvalidateListener(bareListener, scope)).toBe(true);
242
+ });
243
+
244
+ test("all-except-session spares only the exact kept sid, closes everything else including credential-less", () => {
245
+ const scope = { kind: "all-except-session" as const, keptSessionId: "sid-1" };
246
+ expect(shouldInvalidateListener(sidListener, scope)).toBe(false);
247
+ expect(shouldInvalidateListener({ sid: "sid-2" }, scope)).toBe(true);
248
+ expect(shouldInvalidateListener(patListener, scope)).toBe(true);
249
+ expect(shouldInvalidateListener(bareListener, scope)).toBe(true);
250
+ });
251
+
252
+ test("sessions scope closes a matching sid and a credential-less listener, spares everything else", () => {
253
+ const scope = { kind: "sessions" as const, sessionIds: ["sid-1"] };
254
+ expect(shouldInvalidateListener(sidListener, scope)).toBe(true);
255
+ expect(shouldInvalidateListener({ sid: "sid-2" }, scope)).toBe(false);
256
+ expect(shouldInvalidateListener(patListener, scope)).toBe(false);
257
+ expect(shouldInvalidateListener(bareListener, scope)).toBe(true);
258
+ });
259
+
260
+ test("pat-tokens scope closes a matching tokenId and a credential-less listener, spares everything else", () => {
261
+ const scope = { kind: "pat-tokens" as const, tokenIds: ["tok-1"] };
262
+ expect(shouldInvalidateListener(patListener, scope)).toBe(true);
263
+ expect(shouldInvalidateListener({ patTokenId: "tok-2" }, scope)).toBe(false);
264
+ expect(shouldInvalidateListener(sidListener, scope)).toBe(false);
265
+ expect(shouldInvalidateListener(bareListener, scope)).toBe(true);
266
+ });
173
267
  });
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
8
  import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
9
- import { z } from "zod";
9
+ import * as z from "zod";
10
10
  import { generateToken } from "../../api/tokens";
11
11
  import { createSystemUser, defineFeature } from "../../engine";
12
12
  import type { SessionUser } from "../../engine/types";
@@ -3,7 +3,7 @@ import type { Context } from "hono";
3
3
  import { Hono } from "hono";
4
4
  import { deleteCookie, setCookie } from "hono/cookie";
5
5
  import type Redis from "ioredis";
6
- import { z } from "zod";
6
+ import * as z from "zod";
7
7
  import { buildSessionRoles } from "../engine/membership-roles";
8
8
  import { createAnonymousUser, createSystemUser } from "../engine/system-user";
9
9
  import {
@@ -1,4 +1,5 @@
1
1
  import { createRedisPubSubSignal } from "../redis/pubsub-signal";
2
+ import type { AccessInvalidationScope } from "./sse-broker";
2
3
  import { createSseBroker, type SseBroker, type SseEvent } from "./sse-broker";
3
4
 
4
5
  // Channel namespace for cross-replica fanout (fw#2625). Every pod publishes
@@ -60,15 +61,54 @@ function isSseEvent(value: unknown): value is SseEvent {
60
61
  );
61
62
  }
62
63
 
63
- // Invalidation payload: `1` means userwide, `{ keptSessionId }` spares that one
64
- // session. Anything else falls back to userwide. Older pods never read the
65
- // payload and invalidate userwide, which keeps a mixed rolling deploy safe.
66
- function extractInvalidationKeptSessionId(payload: unknown): string | undefined {
67
- if (typeof payload !== "object" || payload === null || !("keptSessionId" in payload)) {
68
- return undefined;
64
+ // Invalidation payload: `1` means userwide, `{ keptSessionId }` spares that
65
+ // one session (all-except-session), `{ scope: "sessions", sessionIds }` /
66
+ // `{ scope: "pat-tokens", tokenIds }` narrow to those credentials. A newer
67
+ // scope payload NEVER carries a `keptSessionId` key, by construction —
68
+ // that's what makes it safe for an older pod (which only ever reads
69
+ // `keptSessionId`) to treat any of these as userwide instead of silently
70
+ // sparing something it doesn't understand: fail-closed on a mixed rolling
71
+ // deploy. Anything malformed also falls back to userwide.
72
+ function parseInvalidationScope(payload: unknown): AccessInvalidationScope {
73
+ if (typeof payload !== "object" || payload === null) return { kind: "user" };
74
+ const record = payload as Record<string, unknown>;
75
+
76
+ if ("scope" in record) {
77
+ const { scope } = record;
78
+ if (scope === "sessions") {
79
+ const sessionIds = readNonEmptyStringArray(record["sessionIds"]);
80
+ if (sessionIds) return { kind: "sessions", sessionIds };
81
+ }
82
+ if (scope === "pat-tokens") {
83
+ const tokenIds = readNonEmptyStringArray(record["tokenIds"]);
84
+ if (tokenIds) return { kind: "pat-tokens", tokenIds };
85
+ }
86
+ return { kind: "user" };
87
+ }
88
+
89
+ if ("keptSessionId" in record) {
90
+ const { keptSessionId } = record;
91
+ if (typeof keptSessionId === "string" && keptSessionId.length > 0) {
92
+ return { kind: "all-except-session", keptSessionId };
93
+ }
94
+ return { kind: "user" };
69
95
  }
70
- const { keptSessionId } = payload;
71
- return typeof keptSessionId === "string" && keptSessionId.length > 0 ? keptSessionId : undefined;
96
+
97
+ return { kind: "user" };
98
+ }
99
+
100
+ function readNonEmptyStringArray(value: unknown): string[] | undefined {
101
+ if (!Array.isArray(value) || value.length === 0) return undefined;
102
+ return value.every((v): v is string => typeof v === "string") ? value : undefined;
103
+ }
104
+
105
+ // Mirror of parseInvalidationScope's wire shapes. "sessions"/"pat-tokens"
106
+ // deliberately never include a `keptSessionId` key — see the parser comment.
107
+ function encodeInvalidationScope(scope: AccessInvalidationScope | undefined): unknown {
108
+ if (scope === undefined || scope.kind === "user") return 1;
109
+ if (scope.kind === "all-except-session") return { keptSessionId: scope.keptSessionId };
110
+ if (scope.kind === "sessions") return { scope: "sessions", sessionIds: scope.sessionIds };
111
+ return { scope: "pat-tokens", tokenIds: scope.tokenIds };
72
112
  }
73
113
 
74
114
  // Transport layer around a local `createSseBroker()` — all client/listener
@@ -102,7 +142,7 @@ export function createRedisSseBroker(opts: RedisSseBrokerOptions): RedisSseBroke
102
142
  if (channel.startsWith(INVALIDATION_PREFIX)) {
103
143
  inner.publishAccessInvalidation(
104
144
  channel.slice(INVALIDATION_PREFIX.length),
105
- extractInvalidationKeptSessionId(payload),
145
+ parseInvalidationScope(payload),
106
146
  );
107
147
  }
108
148
  });
@@ -122,8 +162,8 @@ export function createRedisSseBroker(opts: RedisSseBrokerOptions): RedisSseBroke
122
162
  // stream must close on every replica, not just the one that observed
123
163
  // the revocation event. Publishing (rather than calling inner directly,
124
164
  // like the in-memory broker does) is what makes that true here.
125
- publishAccessInvalidation(userId, keptSessionId) {
126
- signal.publish(`${INVALIDATION_PREFIX}${userId}`, keptSessionId ? { keptSessionId } : 1);
165
+ publishAccessInvalidation(userId, scope) {
166
+ signal.publish(`${INVALIDATION_PREFIX}${userId}`, encodeInvalidationScope(scope));
127
167
  },
128
168
 
129
169
  close: signal.close,