@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.
- package/package.json +5 -5
- package/src/__tests__/anonymous-access.integration.test.ts +1 -1
- package/src/__tests__/error-contract.integration.test.ts +1 -1
- package/src/__tests__/field-access.integration.test.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +1 -1
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/__tests__/transition-guard.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +1 -1
- package/src/api/__tests__/batch.integration.test.ts +1 -1
- package/src/api/__tests__/body-limit.test.ts +1 -1
- package/src/api/__tests__/dispatcher-live.integration.test.ts +1 -1
- package/src/api/__tests__/extra-routes.integration.test.ts +1 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +1 -1
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +1 -1
- package/src/api/__tests__/nested-write.integration.test.ts +1 -1
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +1 -1
- package/src/api/__tests__/redis-sse-broker.integration.test.ts +73 -6
- package/src/api/__tests__/request-locale.integration.test.ts +1 -1
- package/src/api/__tests__/server-boot-guards.test.ts +1 -1
- package/src/api/__tests__/server-error-logging.test.ts +1 -1
- package/src/api/__tests__/sse-broker.test.ts +107 -13
- package/src/api/__tests__/stream-escape-hatch.integration.test.ts +1 -1
- package/src/api/auth-routes.ts +1 -1
- package/src/api/redis-sse-broker.ts +51 -11
- package/src/api/server.ts +11 -0
- package/src/api/sse-broker.ts +61 -22
- package/src/bun-db/__tests__/postgres-closed-socket-write.integration.test.ts +87 -0
- package/src/bun-db/connection.ts +2 -0
- package/src/changes.json +61 -0
- package/src/compliance/override-schema.ts +1 -1
- package/src/crypto/__tests__/event-pii.test.ts +1 -1
- package/src/crypto/kms-adapter.ts +1 -1
- package/src/db/__tests__/feature-table-sources.test.ts +2 -2
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
- package/src/db/collect-table-metas.ts +3 -6
- package/src/db/feature-table-sources.ts +3 -4
- package/src/db/queries/event-consumer.ts +98 -6
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
- package/src/derivatives/delete-stored-file.ts +86 -0
- package/src/derivatives/index.ts +2 -0
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +35 -1
- package/src/engine/__tests__/build-app-schema.test.ts +1 -1
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
- package/src/engine/__tests__/engine.test.ts +1 -1
- package/src/engine/__tests__/event-helpers.test.ts +1 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
- package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
- package/src/engine/__tests__/extends-registrar.test.ts +1 -1
- package/src/engine/__tests__/hook-phases.test.ts +1 -1
- package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
- package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
- package/src/engine/__tests__/post-query-hook.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/registry.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +1 -1
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
- package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
- package/src/engine/boot-validator/screens.ts +83 -0
- package/src/engine/define-handler.ts +2 -1
- package/src/engine/entity-handlers.ts +2 -1
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
- package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
- package/src/engine/feature-builder-state.ts +2 -1
- package/src/engine/feature-config-events-jobs.ts +2 -1
- package/src/engine/feature-entity-handlers.ts +2 -1
- package/src/engine/schema-builder.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
- package/src/env/__tests__/compose-env-schema.test.ts +1 -1
- package/src/env/__tests__/dry-run.test.ts +1 -1
- package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
- package/src/env/_zod-introspect.ts +1 -1
- package/src/env/dry-run.ts +1 -1
- package/src/env/index.ts +1 -1
- package/src/errors/__tests__/classes.test.ts +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
- package/src/event-store/__tests__/event-attribution.integration.test.ts +1 -1
- package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
- package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
- package/src/observability/__tests__/observability.integration.test.ts +1 -1
- package/src/observability/metrics-wiring.ts +1 -1
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
- package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +1 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +1 -1
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +1 -1
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-dedup.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-dispatcher-batch-delivery.test.ts +178 -0
- package/src/pipeline/__tests__/event-dispatcher-batch-handler.integration.test.ts +143 -0
- package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +192 -0
- package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
- package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
- package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
- package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
- package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +1 -1
- package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
- package/src/pipeline/dispatch-stream.ts +6 -1
- package/src/pipeline/event-dispatcher-delivery.ts +132 -37
- package/src/pipeline/event-dispatcher.ts +121 -17
- package/src/pipeline/index.ts +1 -0
- package/src/pipeline/msp-rebuild.ts +39 -1
- package/src/pipeline/pending-gap-ranges.ts +48 -0
- package/src/pipeline/system-hooks.ts +223 -68
- package/src/pipeline/write-origin.ts +1 -1
- package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
- package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
- package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
- package/src/search/derive-search-adapter-config.ts +27 -0
- package/src/search/index.ts +1 -0
- package/src/search/meilisearch-adapter.ts +96 -14
- package/src/secrets/__tests__/contains-secret.test.ts +1 -1
- package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-entity-tables.integration.test.ts +65 -0
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
- package/src/stack/test-stack.ts +61 -54
- package/src/testing/__tests__/e2e-generator.test.ts +1 -1
- package/src/testing/__tests__/real-providers.test.ts +9 -1
- package/src/testing/e2e-generator.ts +1 -1
- package/src/testing/index.ts +1 -1
- 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.
|
|
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.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "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": "
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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,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
|
|
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
|
|
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,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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
-
import
|
|
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 {
|
|
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
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
|
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";
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -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
|
|
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
|
|
64
|
-
// session
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
return
|
|
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
|
-
|
|
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,
|
|
126
|
-
signal.publish(`${INVALIDATION_PREFIX}${userId}`,
|
|
165
|
+
publishAccessInvalidation(userId, scope) {
|
|
166
|
+
signal.publish(`${INVALIDATION_PREFIX}${userId}`, encodeInvalidationScope(scope));
|
|
127
167
|
},
|
|
128
168
|
|
|
129
169
|
close: signal.close,
|