@cosmicdrift/kumiko-framework 0.306.0 → 0.308.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 +4 -4
- 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 +133 -0
- 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 +144 -1
- 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 +57 -3
- package/src/api/request-context.ts +24 -0
- package/src/api/server.ts +11 -0
- package/src/api/sse-broker.ts +69 -12
- package/src/changes.json +63 -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__/tenant-db-where-merge.integration.test.ts +1 -1
- package/src/db/queries/event-consumer.ts +155 -9
- package/src/db/queries/event-store.ts +69 -0
- package/src/db/tenant-db.ts +43 -5
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
- 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 +54 -4
- package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
- package/src/event-store/admin-api.ts +5 -0
- package/src/event-store/event-store.ts +16 -7
- 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 +536 -0
- 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/jobs/job-runner.ts +61 -6
- 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-utils.test.ts +8 -0
- 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-commit-order.integration.test.ts +278 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
- package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
- package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -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 +262 -3
- 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-batch.ts +49 -19
- package/src/pipeline/dispatch-stream.ts +12 -3
- package/src/pipeline/dispatcher-utils.ts +21 -2
- package/src/pipeline/dispatcher.ts +71 -6
- package/src/pipeline/event-consumer-state.ts +26 -0
- package/src/pipeline/event-dispatcher-admin.ts +32 -5
- package/src/pipeline/event-dispatcher-delivery.ts +212 -65
- package/src/pipeline/event-dispatcher.ts +253 -49
- package/src/pipeline/index.ts +1 -0
- package/src/pipeline/msp-rebuild.ts +39 -1
- package/src/pipeline/pending-gap-ranges.ts +120 -0
- package/src/pipeline/system-hooks.ts +226 -64
- package/src/pipeline/write-origin.ts +31 -10
- 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-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 +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +1 -1
- package/src/testing/e2e-generator.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.308.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.308.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.308.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.308.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,6 +127,139 @@ describe("createRedisSseBroker", () => {
|
|
|
127
127
|
expect(invalidatedA).toBe(false);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
test("publishAccessInvalidation with an all-except-session scope spares only the listener whose own sid matches exactly", async () => {
|
|
131
|
+
const podA = trackedBroker();
|
|
132
|
+
const podB = trackedBroker();
|
|
133
|
+
const userId = `user-${generateId()}`;
|
|
134
|
+
const sidKept = `sid-kept-${generateId()}`;
|
|
135
|
+
const sidAlreadyRevoked = `sid-already-revoked-${generateId()}`;
|
|
136
|
+
let invalidatedKept = false;
|
|
137
|
+
let invalidatedSidless = false;
|
|
138
|
+
let invalidatedAlreadyRevoked = false;
|
|
139
|
+
|
|
140
|
+
podA.subscribeAccessInvalidation(
|
|
141
|
+
userId,
|
|
142
|
+
() => {
|
|
143
|
+
invalidatedKept = true;
|
|
144
|
+
},
|
|
145
|
+
{ sid: sidKept },
|
|
146
|
+
);
|
|
147
|
+
// No sid — mirrors a PAT/bearer stream, always invalidated (fail-closed).
|
|
148
|
+
podA.subscribeAccessInvalidation(userId, () => {
|
|
149
|
+
invalidatedSidless = true;
|
|
150
|
+
});
|
|
151
|
+
// Not the kept sid — mirrors a session already revoked through an
|
|
152
|
+
// eventless path (plain logout): the keep-list must still close it.
|
|
153
|
+
podA.subscribeAccessInvalidation(
|
|
154
|
+
userId,
|
|
155
|
+
() => {
|
|
156
|
+
invalidatedAlreadyRevoked = true;
|
|
157
|
+
},
|
|
158
|
+
{ sid: sidAlreadyRevoked },
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
await waitFor(() => {
|
|
162
|
+
podB.publishAccessInvalidation(userId, {
|
|
163
|
+
kind: "all-except-session",
|
|
164
|
+
keptSessionId: sidKept,
|
|
165
|
+
});
|
|
166
|
+
return invalidatedSidless && invalidatedAlreadyRevoked;
|
|
167
|
+
});
|
|
168
|
+
// Asserted only after the control listeners above already fired for the
|
|
169
|
+
// same publish — proves the pipe delivered the message at all, so a
|
|
170
|
+
// false here means the sid really was spared, not that delivery is slow.
|
|
171
|
+
expect(invalidatedKept).toBe(false);
|
|
172
|
+
});
|
|
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
|
+
|
|
214
|
+
test("a legacy unscoped invalidation message (bare `1`, pre-scoping pod) still invalidates every listener, sid or not", async () => {
|
|
215
|
+
const podA = trackedBroker();
|
|
216
|
+
const publisherRaw = new (await import("ioredis")).default(testRedis.redisUrl);
|
|
217
|
+
const userId = `user-${generateId()}`;
|
|
218
|
+
let invalidatedWithSid = false;
|
|
219
|
+
|
|
220
|
+
podA.subscribeAccessInvalidation(
|
|
221
|
+
userId,
|
|
222
|
+
() => {
|
|
223
|
+
invalidatedWithSid = true;
|
|
224
|
+
},
|
|
225
|
+
{ sid: `sid-${generateId()}` },
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
await waitFor(async () => {
|
|
230
|
+
await publisherRaw.publish(`kumiko:sse:inval:${userId}`, JSON.stringify(1));
|
|
231
|
+
return invalidatedWithSid;
|
|
232
|
+
});
|
|
233
|
+
expect(invalidatedWithSid).toBe(true);
|
|
234
|
+
} finally {
|
|
235
|
+
publisherRaw.disconnect();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
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
|
+
|
|
130
263
|
test("a malformed message on the channel namespace is dropped, not thrown, and does not kill delivery", async () => {
|
|
131
264
|
const podA = trackedBroker();
|
|
132
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;
|
|
@@ -121,4 +126,142 @@ describe("SSE broker", () => {
|
|
|
121
126
|
const { publish } = requireAccessInvalidation(createSseBroker());
|
|
122
127
|
expect(() => publish("nobody-listening")).not.toThrow();
|
|
123
128
|
});
|
|
129
|
+
|
|
130
|
+
test("publishAccessInvalidation with an all-except-session scope spares only the listener whose own sid matches exactly", () => {
|
|
131
|
+
const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
|
|
132
|
+
const spared = mock();
|
|
133
|
+
const unrelated = mock();
|
|
134
|
+
|
|
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" });
|
|
138
|
+
|
|
139
|
+
expect(spared).not.toHaveBeenCalled();
|
|
140
|
+
expect(unrelated).toHaveBeenCalledTimes(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("all-except-session still closes a listener whose sid was already revoked through an eventless path (keep-list, not a kill-list)", () => {
|
|
144
|
+
const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
|
|
145
|
+
const alreadyRevoked = mock();
|
|
146
|
+
|
|
147
|
+
// Simulates a stream from a session logged out earlier via a path that
|
|
148
|
+
// never appended session-revoked — the keep-list must not accidentally
|
|
149
|
+
// exempt it just because its sid isn't the freshly-kept one.
|
|
150
|
+
subscribe("user-a", alreadyRevoked, { sid: "sid-logged-out-earlier" });
|
|
151
|
+
publish("user-a", { kind: "all-except-session", keptSessionId: "sid-kept" });
|
|
152
|
+
|
|
153
|
+
expect(alreadyRevoked).toHaveBeenCalledTimes(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("all-except-session still fires a listener with no sid of its own (fail-closed, e.g. a PAT/bearer stream)", () => {
|
|
157
|
+
const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
|
|
158
|
+
const sidless = mock();
|
|
159
|
+
|
|
160
|
+
subscribe("user-a", sidless);
|
|
161
|
+
publish("user-a", { kind: "all-except-session", keptSessionId: "sid-kept" });
|
|
162
|
+
|
|
163
|
+
expect(sidless).toHaveBeenCalledTimes(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("no scope (unscoped) still invalidates every listener, matching pre-scoping behavior", () => {
|
|
167
|
+
const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
|
|
168
|
+
const first = mock();
|
|
169
|
+
const second = mock();
|
|
170
|
+
|
|
171
|
+
subscribe("user-a", first, { sid: "sid-1" });
|
|
172
|
+
subscribe("user-a", second, { sid: "sid-2" });
|
|
173
|
+
publish("user-a");
|
|
174
|
+
|
|
175
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
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
|
+
});
|
|
124
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,6 +61,56 @@ function isSseEvent(value: unknown): value is SseEvent {
|
|
|
60
61
|
);
|
|
61
62
|
}
|
|
62
63
|
|
|
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" };
|
|
95
|
+
}
|
|
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 };
|
|
112
|
+
}
|
|
113
|
+
|
|
63
114
|
// Transport layer around a local `createSseBroker()` — all client/listener
|
|
64
115
|
// state lives in `inner`, this only moves events across the Redis wire via
|
|
65
116
|
// the shared PubSubSignal. `pushToChannel`/`publishAccessInvalidation` never
|
|
@@ -89,7 +140,10 @@ export function createRedisSseBroker(opts: RedisSseBrokerOptions): RedisSseBroke
|
|
|
89
140
|
}
|
|
90
141
|
|
|
91
142
|
if (channel.startsWith(INVALIDATION_PREFIX)) {
|
|
92
|
-
inner.publishAccessInvalidation(
|
|
143
|
+
inner.publishAccessInvalidation(
|
|
144
|
+
channel.slice(INVALIDATION_PREFIX.length),
|
|
145
|
+
parseInvalidationScope(payload),
|
|
146
|
+
);
|
|
93
147
|
}
|
|
94
148
|
});
|
|
95
149
|
|
|
@@ -108,8 +162,8 @@ export function createRedisSseBroker(opts: RedisSseBrokerOptions): RedisSseBroke
|
|
|
108
162
|
// stream must close on every replica, not just the one that observed
|
|
109
163
|
// the revocation event. Publishing (rather than calling inner directly,
|
|
110
164
|
// like the in-memory broker does) is what makes that true here.
|
|
111
|
-
publishAccessInvalidation(userId) {
|
|
112
|
-
signal.publish(`${INVALIDATION_PREFIX}${userId}`,
|
|
165
|
+
publishAccessInvalidation(userId, scope) {
|
|
166
|
+
signal.publish(`${INVALIDATION_PREFIX}${userId}`, encodeInvalidationScope(scope));
|
|
113
167
|
},
|
|
114
168
|
|
|
115
169
|
close: signal.close,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { isPersonalDataGated, type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
3
|
import { generateId } from "../utils";
|
|
3
4
|
|
|
4
5
|
// Request-scoped propagation. Populated by the HTTP middleware and by the
|
|
@@ -48,6 +49,9 @@ export type RequestContextData = {
|
|
|
48
49
|
// performance.now() at request entry, so a failing request can report how
|
|
49
50
|
// long it ran. Monotonic — a wall-clock step cannot make it negative.
|
|
50
51
|
readonly startedAt?: number;
|
|
52
|
+
// Only ever a gated origin. Read by job enqueue and event-store.append();
|
|
53
|
+
// dispatch roots never narrow from it, only jobs inherit explicitly.
|
|
54
|
+
readonly writeOrigin?: WriteOrigin;
|
|
51
55
|
};
|
|
52
56
|
|
|
53
57
|
const storage = new AsyncLocalStorage<RequestContextData>();
|
|
@@ -87,3 +91,23 @@ export function runWithOrigin<T>(
|
|
|
87
91
|
fn,
|
|
88
92
|
);
|
|
89
93
|
}
|
|
94
|
+
|
|
95
|
+
// An ungated origin never mints a scope: seeds and boot writes keep their missing context.
|
|
96
|
+
export function runWithWriteOrigin<T>(origin: WriteOrigin, fn: () => T): T {
|
|
97
|
+
const current = requestContext.get();
|
|
98
|
+
if (!isPersonalDataGated(origin)) {
|
|
99
|
+
if (!current?.writeOrigin) return fn();
|
|
100
|
+
const { writeOrigin: _writeOrigin, ...rest } = current;
|
|
101
|
+
return requestContext.run(rest, fn);
|
|
102
|
+
}
|
|
103
|
+
const requestId = current?.requestId ?? requestContext.generateId();
|
|
104
|
+
return requestContext.run(
|
|
105
|
+
{
|
|
106
|
+
...current,
|
|
107
|
+
requestId,
|
|
108
|
+
correlationId: current?.correlationId ?? requestId,
|
|
109
|
+
writeOrigin: origin,
|
|
110
|
+
},
|
|
111
|
+
fn,
|
|
112
|
+
);
|
|
113
|
+
}
|