@cosmicdrift/kumiko-framework 0.235.3 → 0.236.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 +3 -3
- package/src/api/__tests__/redis-sse-broker.integration.test.ts +151 -0
- package/src/api/index.ts +2 -0
- package/src/api/redis-sse-broker.ts +89 -0
- package/src/api/server.ts +22 -1
- package/src/api/sse-broker.ts +4 -2
- package/src/db/__tests__/list-filter-field-access.integration.test.ts +214 -0
- package/src/db/event-store-executor-read.ts +26 -2
- package/src/db/queries/event-consumer.ts +26 -0
- package/src/entrypoint/index.ts +6 -6
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +50 -0
- package/src/pipeline/event-consumer-state.ts +31 -0
- package/src/pipeline/system-hooks.ts +19 -14
- package/src/redis/client.ts +77 -0
- package/src/redis/index.ts +4 -74
- package/src/redis/pubsub-signal.ts +107 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.236.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,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.236.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.236.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createTestRedis, type TestRedis } from "../../stack";
|
|
3
|
+
import { waitFor } from "../../testing/wait-for";
|
|
4
|
+
import { generateId } from "../../utils";
|
|
5
|
+
import { createRedisSseBroker, type RedisSseBroker } from "../redis-sse-broker";
|
|
6
|
+
import type { SseEvent } from "../sse-broker";
|
|
7
|
+
|
|
8
|
+
let testRedis: TestRedis;
|
|
9
|
+
// keyPrefix on testRedis.redis does not apply to pub/sub channel names
|
|
10
|
+
// (stack/redis.ts), so isolation from other parallel test files comes from
|
|
11
|
+
// generateId()-suffixed channel/userId values below, not from the prefix.
|
|
12
|
+
const brokers: RedisSseBroker[] = [];
|
|
13
|
+
|
|
14
|
+
function trackedBroker(): RedisSseBroker {
|
|
15
|
+
const broker = createRedisSseBroker({ redisUrl: testRedis.redisUrl });
|
|
16
|
+
brokers.push(broker);
|
|
17
|
+
return broker;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
testRedis = await createTestRedis();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterAll(async () => {
|
|
25
|
+
await testRedis.cleanup();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await Promise.all(brokers.splice(0).map((b) => b.close()));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("createRedisSseBroker", () => {
|
|
33
|
+
test("pushToChannel on one broker reaches a client registered on another (cross-pod fanout)", async () => {
|
|
34
|
+
const podA = trackedBroker();
|
|
35
|
+
const podB = trackedBroker();
|
|
36
|
+
const channel = `test-channel-${generateId()}`;
|
|
37
|
+
const received: SseEvent[] = [];
|
|
38
|
+
|
|
39
|
+
podA.addClient(
|
|
40
|
+
channel,
|
|
41
|
+
(event) => received.push(event),
|
|
42
|
+
() => {},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Retry the publish inside the predicate, not just the wait: Pub/Sub
|
|
46
|
+
// does not buffer for a subscriber whose psubscribe ack hasn't landed
|
|
47
|
+
// yet (a one-time race right after broker construction, not
|
|
48
|
+
// reproducible once real traffic starts seconds after boot) — a single
|
|
49
|
+
// fire-and-check would be flaky rather than proving the real property.
|
|
50
|
+
await waitFor(() => {
|
|
51
|
+
podB.pushToChannel(channel, { type: "unit.updated", data: { id: "1" } });
|
|
52
|
+
return received.length >= 1;
|
|
53
|
+
});
|
|
54
|
+
expect(received[0]).toEqual({ type: "unit.updated", data: { id: "1" } });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("publishAccessInvalidation on one broker fires subscribeAccessInvalidation listeners on another", async () => {
|
|
58
|
+
const podA = trackedBroker();
|
|
59
|
+
const podB = trackedBroker();
|
|
60
|
+
const userId = `user-${generateId()}`;
|
|
61
|
+
let invalidated = false;
|
|
62
|
+
|
|
63
|
+
podA.subscribeAccessInvalidation(userId, () => {
|
|
64
|
+
invalidated = true;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await waitFor(() => {
|
|
68
|
+
podB.publishAccessInvalidation(userId);
|
|
69
|
+
return invalidated;
|
|
70
|
+
});
|
|
71
|
+
expect(invalidated).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a client on channel A never receives an event published to channel B (no cross-tenant leak)", async () => {
|
|
75
|
+
const podA = trackedBroker();
|
|
76
|
+
const podB = trackedBroker();
|
|
77
|
+
const channelA = `tenant-a-${generateId()}`;
|
|
78
|
+
const channelB = `tenant-b-${generateId()}`;
|
|
79
|
+
const receivedOnA: SseEvent[] = [];
|
|
80
|
+
const receivedOnControl: SseEvent[] = [];
|
|
81
|
+
|
|
82
|
+
podA.addClient(
|
|
83
|
+
channelA,
|
|
84
|
+
(event) => receivedOnA.push(event),
|
|
85
|
+
() => {},
|
|
86
|
+
);
|
|
87
|
+
// Control channel proves the psubscribe pipe itself is alive — without
|
|
88
|
+
// this, a broken subscription would make the negative assertion below
|
|
89
|
+
// pass for the wrong reason (nothing ever arrives, on any channel).
|
|
90
|
+
podA.addClient(
|
|
91
|
+
channelB,
|
|
92
|
+
(event) => receivedOnControl.push(event),
|
|
93
|
+
() => {},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await waitFor(() => {
|
|
97
|
+
podB.pushToChannel(channelB, { type: "unit.updated", data: { id: "leak-probe" } });
|
|
98
|
+
return receivedOnControl.length >= 1;
|
|
99
|
+
});
|
|
100
|
+
expect(receivedOnA).toHaveLength(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("subscribeAccessInvalidation for one user is never fired by an invalidation for another user", async () => {
|
|
104
|
+
const podA = trackedBroker();
|
|
105
|
+
const podB = trackedBroker();
|
|
106
|
+
const userA = `user-a-${generateId()}`;
|
|
107
|
+
const userB = `user-b-${generateId()}`;
|
|
108
|
+
let invalidatedA = false;
|
|
109
|
+
let invalidatedBControl = false;
|
|
110
|
+
|
|
111
|
+
podA.subscribeAccessInvalidation(userA, () => {
|
|
112
|
+
invalidatedA = true;
|
|
113
|
+
});
|
|
114
|
+
podA.subscribeAccessInvalidation(userB, () => {
|
|
115
|
+
invalidatedBControl = true;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await waitFor(() => {
|
|
119
|
+
podB.publishAccessInvalidation(userB);
|
|
120
|
+
return invalidatedBControl;
|
|
121
|
+
});
|
|
122
|
+
expect(invalidatedA).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a malformed message on the channel namespace is dropped, not thrown, and does not kill delivery", async () => {
|
|
126
|
+
const podA = trackedBroker();
|
|
127
|
+
const publisherRaw = new (await import("ioredis")).default(testRedis.redisUrl);
|
|
128
|
+
const channel = `test-channel-${generateId()}`;
|
|
129
|
+
const received: SseEvent[] = [];
|
|
130
|
+
|
|
131
|
+
podA.addClient(
|
|
132
|
+
channel,
|
|
133
|
+
(event) => received.push(event),
|
|
134
|
+
() => {},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await waitFor(async () => {
|
|
139
|
+
// Not valid JSON — simulates a foreign/misbehaving publisher on the
|
|
140
|
+
// shared kumiko:sse:* namespace.
|
|
141
|
+
await publisherRaw.publish(`kumiko:sse:ch:${channel}`, "not-json{{{");
|
|
142
|
+
// Broker must still be alive: a real event right after must arrive.
|
|
143
|
+
podA.pushToChannel(channel, { type: "unit.updated", data: { id: "after-garbage" } });
|
|
144
|
+
return received.length >= 1;
|
|
145
|
+
});
|
|
146
|
+
expect(received[0]).toEqual({ type: "unit.updated", data: { id: "after-garbage" } });
|
|
147
|
+
} finally {
|
|
148
|
+
publisherRaw.disconnect();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
package/src/api/index.ts
CHANGED
|
@@ -41,6 +41,8 @@ export {
|
|
|
41
41
|
export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt";
|
|
42
42
|
export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt";
|
|
43
43
|
export { patAllows, qnMatches } from "./pat-scope";
|
|
44
|
+
export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker";
|
|
45
|
+
export { createRedisSseBroker } from "./redis-sse-broker";
|
|
44
46
|
export { type RequestContextData, requestContext } from "./request-context";
|
|
45
47
|
export { requestIdMiddleware } from "./request-id-middleware";
|
|
46
48
|
export { createApiRoutes } from "./routes";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createRedisPubSubSignal } from "../redis/pubsub-signal";
|
|
2
|
+
import { createSseBroker, type SseBroker, type SseEvent } from "./sse-broker";
|
|
3
|
+
|
|
4
|
+
// Channel namespace for cross-replica fanout (fw#2625). Every pod publishes
|
|
5
|
+
// here and every pod's psubscribe listens here, so a push on one instance
|
|
6
|
+
// reaches SSE clients connected to any other instance without a shared
|
|
7
|
+
// cursor — Pub/Sub for connected-client fanout, the event-store's shared
|
|
8
|
+
// cursor stays the mechanism for "read every event exactly once".
|
|
9
|
+
const CHANNEL_PREFIX = "kumiko:sse:ch:";
|
|
10
|
+
const INVALIDATION_PREFIX = "kumiko:sse:inval:";
|
|
11
|
+
const PSUBSCRIBE_PATTERN = "kumiko:sse:*";
|
|
12
|
+
|
|
13
|
+
export type RedisSseBrokerOptions = {
|
|
14
|
+
readonly redisUrl: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type RedisSseBroker = SseBroker & {
|
|
18
|
+
// Not on SseBroker — the in-memory broker has nothing to release, but
|
|
19
|
+
// this one owns two live ioredis connections (via the shared PubSubSignal).
|
|
20
|
+
// buildServer only calls this when it created the broker itself (an
|
|
21
|
+
// app-injected sseBroker owns its own lifecycle).
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function isSseEvent(value: unknown): value is SseEvent {
|
|
26
|
+
return (
|
|
27
|
+
typeof value === "object" &&
|
|
28
|
+
value !== null &&
|
|
29
|
+
typeof (value as { type?: unknown }).type === "string" &&
|
|
30
|
+
typeof (value as { data?: unknown }).data === "object" &&
|
|
31
|
+
(value as { data?: unknown }).data !== null
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Transport layer around a local `createSseBroker()` — all client/listener
|
|
36
|
+
// state lives in `inner`, this only moves events across the Redis wire via
|
|
37
|
+
// the shared PubSubSignal. `pushToChannel`/`publishAccessInvalidation` never
|
|
38
|
+
// call `inner` directly: they publish, and the signal's message handler
|
|
39
|
+
// below calls `inner` for BOTH the publishing pod and every other pod, so
|
|
40
|
+
// there's exactly one delivery path regardless of which instance receives
|
|
41
|
+
// the request.
|
|
42
|
+
export function createRedisSseBroker(opts: RedisSseBrokerOptions): RedisSseBroker {
|
|
43
|
+
const inner = createSseBroker();
|
|
44
|
+
const signal = createRedisPubSubSignal({
|
|
45
|
+
redisUrl: opts.redisUrl,
|
|
46
|
+
channelPattern: PSUBSCRIBE_PATTERN,
|
|
47
|
+
label: "sse-broker",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
signal.onMessage((channel, payload) => {
|
|
51
|
+
if (channel.startsWith(CHANNEL_PREFIX)) {
|
|
52
|
+
if (!isSseEvent(payload)) {
|
|
53
|
+
console.error(`[kumiko:sse-broker] dropping malformed event on "${channel}"`);
|
|
54
|
+
// skip: malformed payload already logged above, nothing to deliver
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
inner.pushToChannel(channel.slice(CHANNEL_PREFIX.length), payload);
|
|
58
|
+
// skip: channel already routed to the SSE-event branch above, the
|
|
59
|
+
// invalidation branch below is mutually exclusive with this one
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (channel.startsWith(INVALIDATION_PREFIX)) {
|
|
64
|
+
inner.publishAccessInvalidation(channel.slice(INVALIDATION_PREFIX.length));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
addClient: inner.addClient,
|
|
70
|
+
removeClient: inner.removeClient,
|
|
71
|
+
getClientCount: inner.getClientCount,
|
|
72
|
+
getTotalClientCount: inner.getTotalClientCount,
|
|
73
|
+
subscribeAccessInvalidation: inner.subscribeAccessInvalidation,
|
|
74
|
+
|
|
75
|
+
pushToChannel(channel, event) {
|
|
76
|
+
signal.publish(`${CHANNEL_PREFIX}${channel}`, event);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// fw#1601: this is the security-critical call — a revoked session's
|
|
80
|
+
// stream must close on every replica, not just the one that observed
|
|
81
|
+
// the revocation event. Publishing (rather than calling inner directly,
|
|
82
|
+
// like the in-memory broker does) is what makes that true here.
|
|
83
|
+
publishAccessInvalidation(userId) {
|
|
84
|
+
signal.publish(`${INVALIDATION_PREFIX}${userId}`, 1);
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
close: signal.close,
|
|
88
|
+
};
|
|
89
|
+
}
|
package/src/api/server.ts
CHANGED
|
@@ -57,6 +57,7 @@ import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
|
|
|
57
57
|
import { observabilityMiddleware } from "./observability-middleware";
|
|
58
58
|
import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
|
|
59
59
|
import { piiCiphertextResponseGuard } from "./pii-leak-guard";
|
|
60
|
+
import { createRedisSseBroker } from "./redis-sse-broker";
|
|
60
61
|
import { requestContext } from "./request-context";
|
|
61
62
|
import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware";
|
|
62
63
|
import {
|
|
@@ -316,7 +317,18 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
316
317
|
options.jwtIssuer,
|
|
317
318
|
options.jwtTtl ?? defaultJwtTtl,
|
|
318
319
|
);
|
|
319
|
-
|
|
320
|
+
// An explicit ServerOptions.sseBroker always wins (caller owns its
|
|
321
|
+
// lifecycle). Otherwise, REDIS_URL decides the default: without
|
|
322
|
+
// cross-replica fanout, replicas > 1 silently drops SSE/access-
|
|
323
|
+
// invalidation events for whichever pod didn't win the shared cursor
|
|
324
|
+
// (fw#2625) — the safe default has to be the correct one, not the
|
|
325
|
+
// single-process one.
|
|
326
|
+
const ownedRedisSseBroker = options.sseBroker
|
|
327
|
+
? undefined
|
|
328
|
+
: process.env["REDIS_URL"]
|
|
329
|
+
? createRedisSseBroker({ redisUrl: process.env["REDIS_URL"] })
|
|
330
|
+
: undefined;
|
|
331
|
+
const sseBroker = options.sseBroker ?? ownedRedisSseBroker ?? createSseBroker();
|
|
320
332
|
|
|
321
333
|
// Resolve the per-process instance identifier. Prefer explicit
|
|
322
334
|
// ServerOptions.instanceId (tests, deliberate wiring), fall back to the
|
|
@@ -587,6 +599,15 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
587
599
|
});
|
|
588
600
|
}
|
|
589
601
|
|
|
602
|
+
// Same reasoning, scoped to the broker this function created itself — an
|
|
603
|
+
// app-injected sseBroker (options.sseBroker) owns its own teardown.
|
|
604
|
+
if (options.lifecycle && ownedRedisSseBroker) {
|
|
605
|
+
const broker = ownedRedisSseBroker;
|
|
606
|
+
options.lifecycle.registerShutdownHook("redisSseBroker", async () => {
|
|
607
|
+
await broker.close();
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
590
611
|
const app = new Hono();
|
|
591
612
|
|
|
592
613
|
const sensitiveConfig = mergeSensitiveConfig(
|
package/src/api/sse-broker.ts
CHANGED
|
@@ -29,8 +29,10 @@ export type SseBroker = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
export function createSseBroker(): SseBroker {
|
|
32
|
-
//
|
|
33
|
-
//
|
|
32
|
+
// Purely local: no cross-replica fanout. buildServer wraps this in
|
|
33
|
+
// createRedisSseBroker (fw#2625) whenever REDIS_URL is set, which is what
|
|
34
|
+
// makes pushToChannel/publishAccessInvalidation reach every replica's
|
|
35
|
+
// clients — this reference implementation stays single-process only.
|
|
34
36
|
const channels = new Map<string, Map<string, SseClient>>();
|
|
35
37
|
// Set, not Map<listenerId, fn> — dedup key is callback reference. Every
|
|
36
38
|
// subscriber must pass a distinct closure (dispatch-stream.ts does, one
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// fw#2629: `list()` guarded filtering/sorting only against unknown columns,
|
|
2
|
+
// not per-field `access.read`. A caller who cannot read a field could still
|
|
3
|
+
// filter or sort by it — result count and row order become an oracle for a
|
|
4
|
+
// value the response itself strips out (filterReadFields in
|
|
5
|
+
// engine/field-access.ts). This proves the fix over real HTTP: a filter on
|
|
6
|
+
// an unreadable field must be unsatisfiable (not merely "field stripped from
|
|
7
|
+
// the response"), and a sort on an unreadable field must fall back silently
|
|
8
|
+
// to the default id-ASC order.
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { asRawClient } from "../../db/query";
|
|
12
|
+
import { defineFeature } from "../../engine/define-feature";
|
|
13
|
+
import { createEntity, createTextField } from "../../engine/factories";
|
|
14
|
+
import { from } from "../../engine/ownership";
|
|
15
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
16
|
+
|
|
17
|
+
const filterNoteEntity = createEntity({
|
|
18
|
+
table: "fa_filter_notes",
|
|
19
|
+
fields: {
|
|
20
|
+
title: createTextField({ required: true }),
|
|
21
|
+
ownerId: createTextField({ required: true }),
|
|
22
|
+
secret: createTextField({
|
|
23
|
+
filterable: true,
|
|
24
|
+
sortable: true,
|
|
25
|
+
access: { read: { Admin: "all" } },
|
|
26
|
+
}),
|
|
27
|
+
ownedNote: createTextField({
|
|
28
|
+
filterable: true,
|
|
29
|
+
access: { read: { Admin: "all", User: from("user:id", "ownerId") } },
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const LIST_QN = "filternotes:query:note:list";
|
|
35
|
+
|
|
36
|
+
const filterNotesFeature = defineFeature("filternotes", (r) => {
|
|
37
|
+
r.crud("note", filterNoteEntity, {
|
|
38
|
+
write: { access: { roles: ["Admin"] } },
|
|
39
|
+
read: { access: { openToAll: true } },
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
type NoteRow = {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly title: string;
|
|
46
|
+
readonly ownerId: string;
|
|
47
|
+
readonly secret?: string;
|
|
48
|
+
readonly ownedNote?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type ListResult = {
|
|
52
|
+
readonly rows: readonly NoteRow[];
|
|
53
|
+
readonly total?: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("list() field-level read access gates filter/sort (fw#2629)", () => {
|
|
57
|
+
let stack: TestStack;
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
stack = await setupTestStack({ features: [filterNotesFeature] });
|
|
61
|
+
await unsafeCreateEntityTable(stack.db, filterNoteEntity);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterAll(async () => {
|
|
65
|
+
await stack.cleanup();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
beforeEach(async () => {
|
|
69
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
70
|
+
await asRawClient(stack.db).unsafe('DELETE FROM "fa_filter_notes"');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const CREATE = "filternotes:write:note:create";
|
|
74
|
+
|
|
75
|
+
async function seedTwoSecrets(): Promise<void> {
|
|
76
|
+
await stack.http.write(
|
|
77
|
+
CREATE,
|
|
78
|
+
{ title: "Note A", ownerId: TestUsers.user.id, secret: "alpha" },
|
|
79
|
+
TestUsers.admin,
|
|
80
|
+
);
|
|
81
|
+
await stack.http.write(
|
|
82
|
+
CREATE,
|
|
83
|
+
{ title: "Note B", ownerId: TestUsers.user.id, secret: "beta" },
|
|
84
|
+
TestUsers.admin,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test("baseline: rows exist for a caller without read access to `secret`, but the field is stripped", async () => {
|
|
89
|
+
await seedTwoSecrets();
|
|
90
|
+
const result = await stack.http.queryOk<ListResult>(LIST_QN, { limit: 50 }, TestUsers.user);
|
|
91
|
+
|
|
92
|
+
expect(result.rows).toHaveLength(2);
|
|
93
|
+
expect(result.rows.every((r) => r.secret === undefined)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("count-channel oracle is closed: a matching and a non-matching filter on `secret` are indistinguishable", async () => {
|
|
97
|
+
await seedTwoSecrets();
|
|
98
|
+
|
|
99
|
+
const matching = await stack.http.queryOk<ListResult>(
|
|
100
|
+
LIST_QN,
|
|
101
|
+
{ limit: 50, totalCount: true, filter: { field: "secret", op: "eq", value: "alpha" } },
|
|
102
|
+
TestUsers.user,
|
|
103
|
+
);
|
|
104
|
+
const nonExistent = await stack.http.queryOk<ListResult>(
|
|
105
|
+
LIST_QN,
|
|
106
|
+
{
|
|
107
|
+
limit: 50,
|
|
108
|
+
totalCount: true,
|
|
109
|
+
filter: { field: "secret", op: "eq", value: "does-not-exist-anywhere" },
|
|
110
|
+
},
|
|
111
|
+
TestUsers.user,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(matching.rows).toHaveLength(0);
|
|
115
|
+
expect(matching.total).toBe(0);
|
|
116
|
+
expect(nonExistent.rows).toHaveLength(0);
|
|
117
|
+
expect(nonExistent.total).toBe(0);
|
|
118
|
+
expect(matching).toEqual(nonExistent);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("count-channel oracle is closed for `ne` and `in` on `secret` too", async () => {
|
|
122
|
+
await seedTwoSecrets();
|
|
123
|
+
|
|
124
|
+
const ne = await stack.http.queryOk<ListResult>(
|
|
125
|
+
LIST_QN,
|
|
126
|
+
{ limit: 50, totalCount: true, filter: { field: "secret", op: "ne", value: "alpha" } },
|
|
127
|
+
TestUsers.user,
|
|
128
|
+
);
|
|
129
|
+
const inOp = await stack.http.queryOk<ListResult>(
|
|
130
|
+
LIST_QN,
|
|
131
|
+
{
|
|
132
|
+
limit: 50,
|
|
133
|
+
totalCount: true,
|
|
134
|
+
filters: [{ field: "secret", op: "in", value: ["alpha", "beta"] }],
|
|
135
|
+
},
|
|
136
|
+
TestUsers.user,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(ne.rows).toHaveLength(0);
|
|
140
|
+
expect(ne.total).toBe(0);
|
|
141
|
+
expect(inOp.rows).toHaveLength(0);
|
|
142
|
+
expect(inOp.total).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a legitimate filter by a caller who CAN read the field still narrows results", async () => {
|
|
146
|
+
await seedTwoSecrets();
|
|
147
|
+
const result = await stack.http.queryOk<ListResult>(
|
|
148
|
+
LIST_QN,
|
|
149
|
+
{ limit: 50, totalCount: true, filter: { field: "secret", op: "eq", value: "alpha" } },
|
|
150
|
+
TestUsers.admin,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(result.rows).toHaveLength(1);
|
|
154
|
+
expect(result.rows[0]?.title).toBe("Note A");
|
|
155
|
+
expect(result.total).toBe(1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("ownership-typed filter binds the correct rule without corrupting later param placeholders", async () => {
|
|
159
|
+
await stack.http.write(
|
|
160
|
+
CREATE,
|
|
161
|
+
{ title: "Mine", ownerId: TestUsers.user.id, secret: "x", ownedNote: "shared-value" },
|
|
162
|
+
TestUsers.admin,
|
|
163
|
+
);
|
|
164
|
+
await stack.http.write(
|
|
165
|
+
CREATE,
|
|
166
|
+
{ title: "Theirs", ownerId: TestUsers.admin.id, secret: "y", ownedNote: "shared-value" },
|
|
167
|
+
TestUsers.admin,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const result = await stack.http.queryOk<ListResult>(
|
|
171
|
+
LIST_QN,
|
|
172
|
+
{
|
|
173
|
+
limit: 50,
|
|
174
|
+
totalCount: true,
|
|
175
|
+
filter: { field: "ownedNote", op: "eq", value: "shared-value" },
|
|
176
|
+
},
|
|
177
|
+
TestUsers.user,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(result.rows).toHaveLength(1);
|
|
181
|
+
expect(result.rows[0]?.title).toBe("Mine");
|
|
182
|
+
expect(result.total).toBe(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("sort-channel oracle: sorting by an unreadable field falls back to id ASC", async () => {
|
|
186
|
+
// id ASC (creation order) is [first, second]; secret DESC must reverse
|
|
187
|
+
// that ("zzz" > "aaa") — the two orders disagree, so this actually
|
|
188
|
+
// distinguishes "sort applied" from "sort silently ignored".
|
|
189
|
+
const first = await stack.http.writeOk<{ id: string }>(
|
|
190
|
+
CREATE,
|
|
191
|
+
{ title: "First", ownerId: TestUsers.user.id, secret: "aaa" },
|
|
192
|
+
TestUsers.admin,
|
|
193
|
+
);
|
|
194
|
+
const second = await stack.http.writeOk<{ id: string }>(
|
|
195
|
+
CREATE,
|
|
196
|
+
{ title: "Second", ownerId: TestUsers.user.id, secret: "zzz" },
|
|
197
|
+
TestUsers.admin,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const asUser = await stack.http.queryOk<ListResult>(
|
|
201
|
+
LIST_QN,
|
|
202
|
+
{ limit: 50, sort: "secret", sortDirection: "desc" },
|
|
203
|
+
TestUsers.user,
|
|
204
|
+
);
|
|
205
|
+
expect(asUser.rows.map((r) => r.id)).toEqual([first.id, second.id]);
|
|
206
|
+
|
|
207
|
+
const asAdmin = await stack.http.queryOk<ListResult>(
|
|
208
|
+
LIST_QN,
|
|
209
|
+
{ limit: 50, sort: "secret", sortDirection: "desc" },
|
|
210
|
+
TestUsers.admin,
|
|
211
|
+
);
|
|
212
|
+
expect(asAdmin.rows.map((r) => r.id)).toEqual([second.id, first.id]);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -2,7 +2,7 @@ import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types
|
|
|
2
2
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto";
|
|
3
3
|
import { executeRawQueryRead } from "../db/queries/raw-sql";
|
|
4
4
|
import { coerceRow, extractTableInfo } from "../db/query";
|
|
5
|
-
import { buildOwnershipClause, shiftParams } from "../engine/ownership";
|
|
5
|
+
import { buildOwnershipClause, normalizeAccessEntry, shiftParams } from "../engine/ownership";
|
|
6
6
|
import type { EntityId } from "../engine/types";
|
|
7
7
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
8
8
|
import { UnprocessableError } from "../errors";
|
|
@@ -269,7 +269,15 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
269
269
|
const physicalCol = (field: string): string =>
|
|
270
270
|
(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field);
|
|
271
271
|
const colSql = (field: string): string => `"${physicalCol(field)}"`;
|
|
272
|
-
|
|
272
|
+
// Field-level read access gates filtering and sorting the same way it
|
|
273
|
+
// gates the projected row: a caller who cannot read a field must not be
|
|
274
|
+
// able to probe its values through result counts or ordering (fw#2629).
|
|
275
|
+
const fieldReadClause = (field: string) =>
|
|
276
|
+
buildOwnershipClause(user, normalizeAccessEntry(entity.fields[field]?.access?.read), table);
|
|
277
|
+
const sortField =
|
|
278
|
+
payload.sort && table[payload.sort] && fieldReadClause(payload.sort).kind === "pass"
|
|
279
|
+
? payload.sort
|
|
280
|
+
: undefined;
|
|
273
281
|
const sortDescending = payload.sortDirection === "desc";
|
|
274
282
|
|
|
275
283
|
// Tenant-Filter (replicates TenantDb's readWhere semantics).
|
|
@@ -317,6 +325,22 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
317
325
|
// skip: unknown field — not a real column, drop the filter (injection guard)
|
|
318
326
|
return;
|
|
319
327
|
}
|
|
328
|
+
const readClause = fieldReadClause(f.field);
|
|
329
|
+
if (readClause.kind === "empty") {
|
|
330
|
+
whereSql.push("FALSE");
|
|
331
|
+
// skip: no role grants read on this field, so no row may be probed
|
|
332
|
+
// through it — an unsatisfiable filter beats an error, which would
|
|
333
|
+
// itself confirm the field exists (field-access.ts strips silently)
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (readClause.kind === "sql") {
|
|
337
|
+
const shifted = shiftParams(
|
|
338
|
+
{ sqlText: readClause.sqlText, params: readClause.params },
|
|
339
|
+
params.length,
|
|
340
|
+
);
|
|
341
|
+
whereSql.push(shifted.sqlText);
|
|
342
|
+
for (const p of shifted.params) params.push(p);
|
|
343
|
+
}
|
|
320
344
|
if (entity.fields[f.field]?.type === "multiSelect") {
|
|
321
345
|
applyMultiSelectFilter(colSql, whereSql, params, f);
|
|
322
346
|
// skip: multiSelect already handled — don't fall through to applyScreenFilter
|
|
@@ -33,6 +33,32 @@ export async function insertConsumerIfAbsent(
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// fw#2625: a consumer that moved from delivery: "per-instance" to "shared"
|
|
37
|
+
// leaves its old per-instance rows behind — no dispatcher will ever advance
|
|
38
|
+
// them again, and pruneEvents stays pinned to their stale cursor forever.
|
|
39
|
+
// Scoped by name + "not the shared sentinel" so a still-per-instance
|
|
40
|
+
// consumer's live rows (or an already-migrated __shared__ row) are never
|
|
41
|
+
// touched.
|
|
42
|
+
export async function deleteOrphanedPerInstanceConsumerRows(
|
|
43
|
+
db: AnyDb,
|
|
44
|
+
consumerNames: readonly string[],
|
|
45
|
+
// Passed in rather than imported from event-consumer-state.ts (which
|
|
46
|
+
// defines SHARED_INSTANCE_SENTINEL): that module already imports from
|
|
47
|
+
// this one for the DB queries it needs, so importing back would be a
|
|
48
|
+
// require cycle. The one caller lives in that same file and has the
|
|
49
|
+
// constant in scope.
|
|
50
|
+
sharedInstanceSentinel: string,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
// skip: nothing to delete — an empty ANY($1) would still hit the table,
|
|
53
|
+
// pointlessly, on every boot for an app with no migrated consumers yet.
|
|
54
|
+
if (consumerNames.length === 0) return;
|
|
55
|
+
await asRawClient(db).unsafe(
|
|
56
|
+
`DELETE FROM "kumiko_event_consumers"
|
|
57
|
+
WHERE "name" = ANY($1) AND "instance_id" != $2`,
|
|
58
|
+
[consumerNames, sharedInstanceSentinel],
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
36
62
|
export async function selectConsumerForUpdateSkipLocked(
|
|
37
63
|
db: AnyDb,
|
|
38
64
|
name: string,
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
// registers its own shutdown hooks (eventDispatcher.stop, jobRunner.stop)
|
|
24
24
|
// in the order they were built.
|
|
25
25
|
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
26
|
+
// SSE fanout across processes (split api/worker deploy, replicas > 1):
|
|
27
|
+
// buildServer's default sseBroker is Redis-backed whenever REDIS_URL is
|
|
28
|
+
// set (fw#2625) — a push on the worker's dispatcher reaches clients
|
|
29
|
+
// connected to any API instance via Redis Pub/Sub, no per-instance
|
|
30
|
+
// cursor needed. Without REDIS_URL, the in-memory broker still only
|
|
31
|
+
// reaches clients on the same process.
|
|
32
32
|
|
|
33
33
|
import type { Hono } from "hono";
|
|
34
34
|
import type { AuthRoutesConfig } from "../api/auth-routes";
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { insertConsumerIfAbsent } from "../../db/queries/event-consumer";
|
|
3
|
+
import { asRawClient } from "../../db/query";
|
|
2
4
|
import { createTestDb, type TestDb } from "../../stack";
|
|
3
5
|
import { createEventConsumerStateTable } from "../event-consumer-state";
|
|
4
6
|
|
|
@@ -29,3 +31,51 @@ describe("createEventConsumerStateTable — concurrent boot", () => {
|
|
|
29
31
|
).resolves.toBeDefined();
|
|
30
32
|
});
|
|
31
33
|
});
|
|
34
|
+
|
|
35
|
+
// fw#2625: sse-broadcast, access-invalidation and toggle-cache-sync moved
|
|
36
|
+
// from delivery: "per-instance" to "shared" — their old per-instance rows
|
|
37
|
+
// are orphaned and would pin pruneEvents forever if left behind.
|
|
38
|
+
describe("createEventConsumerStateTable — orphaned per-instance row cleanup (fw#2625)", () => {
|
|
39
|
+
let testDb: TestDb;
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
testDb = await createTestDb();
|
|
43
|
+
await createEventConsumerStateTable(testDb.db);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await testDb.cleanup();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
async function rowExists(name: string, instanceId: string): Promise<boolean> {
|
|
51
|
+
const rows = (await asRawClient(testDb.db).unsafe(
|
|
52
|
+
`SELECT 1 FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2`,
|
|
53
|
+
[name, instanceId],
|
|
54
|
+
)) as ReadonlyArray<unknown>;
|
|
55
|
+
return rows.length > 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test("deletes only the three migrated consumers' per-instance rows, leaving unrelated and shared rows intact", async () => {
|
|
59
|
+
await insertConsumerIfAbsent(testDb.db, "system:consumer:sse-broadcast", "pod-a");
|
|
60
|
+
await insertConsumerIfAbsent(testDb.db, "system:consumer:access-invalidation", "pod-b");
|
|
61
|
+
await insertConsumerIfAbsent(
|
|
62
|
+
testDb.db,
|
|
63
|
+
"feature-toggles:projection:toggle-cache-sync",
|
|
64
|
+
"pod-c",
|
|
65
|
+
);
|
|
66
|
+
// A consumer name not in the orphan list at all — must survive.
|
|
67
|
+
await insertConsumerIfAbsent(testDb.db, "feature-toggles:projection:unrelated", "pod-d");
|
|
68
|
+
// A consumer name that happens to already run shared — must survive.
|
|
69
|
+
await insertConsumerIfAbsent(testDb.db, "system:consumer:sse-broadcast", "__shared__");
|
|
70
|
+
|
|
71
|
+
// The bootstrap's "table already exists" branch is what runs the
|
|
72
|
+
// cleanup — re-invoke it the same way a redeploying process would.
|
|
73
|
+
await createEventConsumerStateTable(testDb.db);
|
|
74
|
+
|
|
75
|
+
expect(await rowExists("system:consumer:sse-broadcast", "pod-a")).toBe(false);
|
|
76
|
+
expect(await rowExists("system:consumer:access-invalidation", "pod-b")).toBe(false);
|
|
77
|
+
expect(await rowExists("feature-toggles:projection:toggle-cache-sync", "pod-c")).toBe(false);
|
|
78
|
+
expect(await rowExists("feature-toggles:projection:unrelated", "pod-d")).toBe(true);
|
|
79
|
+
expect(await rowExists("system:consumer:sse-broadcast", "__shared__")).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
text,
|
|
12
12
|
} from "../db/dialect";
|
|
13
13
|
import { alterTableAddColumn } from "../db/queries/ddl";
|
|
14
|
+
import { deleteOrphanedPerInstanceConsumerRows } from "../db/queries/event-consumer";
|
|
14
15
|
import { tableExists } from "../db/schema-inspection";
|
|
15
16
|
import { unsafePushTables } from "../stack";
|
|
16
17
|
|
|
@@ -90,6 +91,21 @@ export const eventConsumerStateTable = pgTable(
|
|
|
90
91
|
}),
|
|
91
92
|
);
|
|
92
93
|
|
|
94
|
+
// fw#2625: these three consumers moved from delivery: "per-instance" to
|
|
95
|
+
// "shared" (see system-hooks.ts and bundled-features/feature-toggles/
|
|
96
|
+
// feature.ts). Their old per-instance rows are now orphaned — no dispatcher
|
|
97
|
+
// will ever advance them again — and pruneEvents stays pinned to whichever
|
|
98
|
+
// one has the lowest cursor forever. Named here (not imported from
|
|
99
|
+
// system-hooks.ts / feature.ts) to avoid a pipeline → system-hooks /
|
|
100
|
+
// bundled-features dependency for three string literals; those modules'
|
|
101
|
+
// own exported constants (or, for feature-toggles, the qualified consumer
|
|
102
|
+
// name qualify() produces) are the source of truth these must match.
|
|
103
|
+
const ORPHANED_PER_INSTANCE_CONSUMER_NAMES = [
|
|
104
|
+
"system:consumer:sse-broadcast",
|
|
105
|
+
"system:consumer:access-invalidation",
|
|
106
|
+
"feature-toggles:projection:toggle-cache-sync",
|
|
107
|
+
] as const;
|
|
108
|
+
|
|
93
109
|
// Object-const form lets call sites write `ConsumerStatuses.disabled` instead
|
|
94
110
|
// of the raw string, which keeps status checks refactor-safe. The runtime
|
|
95
111
|
// value on each field is the same string the DB stores — no mapping needed.
|
|
@@ -126,8 +142,23 @@ export async function createEventConsumerStateTable(db: DbConnection): Promise<v
|
|
|
126
142
|
" NOT NULL",
|
|
127
143
|
/* ifNotExists */ true,
|
|
128
144
|
);
|
|
145
|
+
// Runs on every boot, including mid-rolling-deploy: a new pod can delete
|
|
146
|
+
// an old pod's still-in-use per-instance row out from under it. That old
|
|
147
|
+
// pod's next acquireConsumer then finds no row, delivers no more SSE/
|
|
148
|
+
// access-invalidation events for the rest of its lifetime — a one-time,
|
|
149
|
+
// few-second gap that resolves itself as clients reconnect onto new pods
|
|
150
|
+
// during the same rollout. Not a replay risk: preRegisterConsumers only
|
|
151
|
+
// (re-)inserts a row at dispatcher startup, so the deleted pod won't
|
|
152
|
+
// silently get a fresh cursor-0 row and replay history mid-flight.
|
|
153
|
+
await deleteOrphanedPerInstanceConsumerRows(
|
|
154
|
+
db,
|
|
155
|
+
ORPHANED_PER_INSTANCE_CONSUMER_NAMES,
|
|
156
|
+
SHARED_INSTANCE_SENTINEL,
|
|
157
|
+
);
|
|
129
158
|
// skip: table (+ any missing column) is already up to date
|
|
130
159
|
return;
|
|
131
160
|
}
|
|
161
|
+
// A freshly created table has no rows to clean up — the DELETE only
|
|
162
|
+
// matters for a DB that already ran the old per-instance consumers.
|
|
132
163
|
await unsafePushTables(db, { kumikoEventConsumers: eventConsumerStateTable });
|
|
133
164
|
}
|
|
@@ -294,15 +294,15 @@ export const SSE_BROADCAST_CONSUMER_NAME = "system:consumer:sse-broadcast";
|
|
|
294
294
|
export function createSseBroadcastEventConsumer(sseBroker: SseBroker): EventConsumer {
|
|
295
295
|
return {
|
|
296
296
|
name: SSE_BROADCAST_CONSUMER_NAME,
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
297
|
+
// Shared delivery: one cursor reads every event exactly once (fw#2625).
|
|
298
|
+
// Fanout to every process's own SSE clients happens below, through
|
|
299
|
+
// sseBroker.pushToChannel — a Redis-backed broker (the default once
|
|
300
|
+
// REDIS_URL is set) republishes across replicas, so every pod's
|
|
301
|
+
// clients still see the event without each pod needing its own cursor.
|
|
302
|
+
delivery: "shared",
|
|
303
|
+
// History is meaningless here — an SSE push for a client that didn't
|
|
304
|
+
// exist yet has no audience to replay it to.
|
|
305
|
+
startFrom: "now",
|
|
306
306
|
handler: async (event) => {
|
|
307
307
|
sseBroker.pushToChannel(tenantChannel(event.tenantId), {
|
|
308
308
|
type: event.type,
|
|
@@ -424,11 +424,16 @@ function readUserIdFromPreviousSnapshot(payload: Record<string, unknown>): strin
|
|
|
424
424
|
export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): EventConsumer {
|
|
425
425
|
return {
|
|
426
426
|
name: ACCESS_INVALIDATION_CONSUMER_NAME,
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
427
|
+
// Shared delivery, same reasoning as SSE broadcast (fw#2625): one
|
|
428
|
+
// cursor reads every event once and calls sseBroker.publishAccessInvalidation
|
|
429
|
+
// below, which a Redis-backed broker republishes to every replica's
|
|
430
|
+
// subscribeAccessInvalidation listeners — no per-instance cursor needed
|
|
431
|
+
// to reach every process's open streams for that user.
|
|
432
|
+
delivery: "shared",
|
|
433
|
+
// A revocation for a session/membership from before this instance
|
|
434
|
+
// existed has nothing to invalidate — replaying it would be a no-op at
|
|
435
|
+
// best and a spurious stream-close at worst.
|
|
436
|
+
startFrom: "now",
|
|
432
437
|
handler: async (event) => {
|
|
433
438
|
if (event.type === SESSION_REVOKED_EVENT_TYPE) {
|
|
434
439
|
const userId = event.payload["userId"];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Thin factory for ioredis clients with Prod-sensible defaults.
|
|
2
|
+
// ioredis itself doesn't enforce a command-timeout — an unreachable Redis
|
|
3
|
+
// will silently hang a request forever unless the caller sets
|
|
4
|
+
// `commandTimeout`. Same story for connectTimeout: the default is 10s,
|
|
5
|
+
// which is too long for a /health/ready probe with a 2s budget.
|
|
6
|
+
//
|
|
7
|
+
// Apps are expected to build their Redis clients via this helper so
|
|
8
|
+
// production defaults stay consistent and env-var wiring lives in one
|
|
9
|
+
// place. Tests / one-off scripts can still `new Redis()` directly.
|
|
10
|
+
//
|
|
11
|
+
// Split out of index.ts (fw#2625) so pubsub-signal.ts can import this
|
|
12
|
+
// without a same-directory cycle back through the barrel.
|
|
13
|
+
|
|
14
|
+
import IoRedis, { type Redis, type RedisOptions } from "ioredis";
|
|
15
|
+
import { readPositiveIntEnv } from "../utils/env-parse";
|
|
16
|
+
|
|
17
|
+
// Connection-tuning options. The fields mirror the most consequential
|
|
18
|
+
// ioredis settings; anything else can be passed through via `extra` to
|
|
19
|
+
// keep the helper from re-declaring the full ioredis surface.
|
|
20
|
+
export type RedisClientOptions = {
|
|
21
|
+
// Milliseconds to wait when opening the TCP connection. Default 5s —
|
|
22
|
+
// long enough for a cold cache/standby to handshake, short enough that
|
|
23
|
+
// /health/ready can surface "Redis is gone" inside its 2s-ish probe
|
|
24
|
+
// window (probe runs in parallel, so 5s here + 2s timeout on the probe
|
|
25
|
+
// side gives the probe a real answer instead of a hang).
|
|
26
|
+
readonly connectTimeoutMs?: number;
|
|
27
|
+
// Milliseconds to wait for a single command to round-trip. Default 10s.
|
|
28
|
+
// Without this, ioredis waits forever on a stalled connection — a
|
|
29
|
+
// single bad network partition quietly exhausts the whole command queue.
|
|
30
|
+
readonly commandTimeoutMs?: number;
|
|
31
|
+
// Retries per request before bubbling up as an error. Default 3 — enough
|
|
32
|
+
// to ride out a transient blip but not so many that a dead Redis keeps
|
|
33
|
+
// requests stuck waiting for retries.
|
|
34
|
+
readonly maxRetriesPerRequest?: number;
|
|
35
|
+
// Escape hatch for any ioredis option the helper doesn't expose
|
|
36
|
+
// explicitly (TLS config, Sentinel, ReadyCheck toggles, …).
|
|
37
|
+
readonly extra?: Readonly<
|
|
38
|
+
Omit<RedisOptions, "connectTimeout" | "commandTimeout" | "maxRetriesPerRequest">
|
|
39
|
+
>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
|
|
43
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
|
|
44
|
+
const DEFAULT_MAX_RETRIES_PER_REQUEST = 3;
|
|
45
|
+
|
|
46
|
+
export function createRedisClient(url: string, options: RedisClientOptions = {}): Redis {
|
|
47
|
+
const connectTimeout = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
48
|
+
const commandTimeout = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
49
|
+
const maxRetriesPerRequest = options.maxRetriesPerRequest ?? DEFAULT_MAX_RETRIES_PER_REQUEST;
|
|
50
|
+
|
|
51
|
+
return new IoRedis(url, {
|
|
52
|
+
...options.extra,
|
|
53
|
+
connectTimeout,
|
|
54
|
+
commandTimeout,
|
|
55
|
+
maxRetriesPerRequest,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Env-var reader — mirrors dbConnectionOptionsFromEnv's contract. Strict:
|
|
60
|
+
// malformed values throw at boot rather than silently falling back to
|
|
61
|
+
// defaults.
|
|
62
|
+
export function redisClientOptionsFromEnv(
|
|
63
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
64
|
+
): RedisClientOptions {
|
|
65
|
+
const opts: RedisClientOptions & {
|
|
66
|
+
connectTimeoutMs?: number;
|
|
67
|
+
commandTimeoutMs?: number;
|
|
68
|
+
maxRetriesPerRequest?: number;
|
|
69
|
+
} = {};
|
|
70
|
+
const connect = readPositiveIntEnv(env, "REDIS_CONNECT_TIMEOUT_MS");
|
|
71
|
+
const command = readPositiveIntEnv(env, "REDIS_COMMAND_TIMEOUT_MS");
|
|
72
|
+
const retries = readPositiveIntEnv(env, "REDIS_MAX_RETRIES_PER_REQUEST");
|
|
73
|
+
if (connect !== undefined) opts.connectTimeoutMs = connect;
|
|
74
|
+
if (command !== undefined) opts.commandTimeoutMs = command;
|
|
75
|
+
if (retries !== undefined) opts.maxRetriesPerRequest = retries;
|
|
76
|
+
return opts;
|
|
77
|
+
}
|
package/src/redis/index.ts
CHANGED
|
@@ -1,74 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// which is too long for a /health/ready probe with a 2s budget.
|
|
6
|
-
//
|
|
7
|
-
// Apps are expected to build their Redis clients via this helper so
|
|
8
|
-
// production defaults stay consistent and env-var wiring lives in one
|
|
9
|
-
// place. Tests / one-off scripts can still `new Redis()` directly.
|
|
10
|
-
|
|
11
|
-
import IoRedis, { type Redis, type RedisOptions } from "ioredis";
|
|
12
|
-
import { readPositiveIntEnv } from "../utils/env-parse";
|
|
13
|
-
|
|
14
|
-
// Connection-tuning options. The fields mirror the most consequential
|
|
15
|
-
// ioredis settings; anything else can be passed through via `extra` to
|
|
16
|
-
// keep the helper from re-declaring the full ioredis surface.
|
|
17
|
-
export type RedisClientOptions = {
|
|
18
|
-
// Milliseconds to wait when opening the TCP connection. Default 5s —
|
|
19
|
-
// long enough for a cold cache/standby to handshake, short enough that
|
|
20
|
-
// /health/ready can surface "Redis is gone" inside its 2s-ish probe
|
|
21
|
-
// window (probe runs in parallel, so 5s here + 2s timeout on the probe
|
|
22
|
-
// side gives the probe a real answer instead of a hang).
|
|
23
|
-
readonly connectTimeoutMs?: number;
|
|
24
|
-
// Milliseconds to wait for a single command to round-trip. Default 10s.
|
|
25
|
-
// Without this, ioredis waits forever on a stalled connection — a
|
|
26
|
-
// single bad network partition quietly exhausts the whole command queue.
|
|
27
|
-
readonly commandTimeoutMs?: number;
|
|
28
|
-
// Retries per request before bubbling up as an error. Default 3 — enough
|
|
29
|
-
// to ride out a transient blip but not so many that a dead Redis keeps
|
|
30
|
-
// requests stuck waiting for retries.
|
|
31
|
-
readonly maxRetriesPerRequest?: number;
|
|
32
|
-
// Escape hatch for any ioredis option the helper doesn't expose
|
|
33
|
-
// explicitly (TLS config, Sentinel, ReadyCheck toggles, …).
|
|
34
|
-
readonly extra?: Readonly<
|
|
35
|
-
Omit<RedisOptions, "connectTimeout" | "commandTimeout" | "maxRetriesPerRequest">
|
|
36
|
-
>;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
|
|
40
|
-
const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
|
|
41
|
-
const DEFAULT_MAX_RETRIES_PER_REQUEST = 3;
|
|
42
|
-
|
|
43
|
-
export function createRedisClient(url: string, options: RedisClientOptions = {}): Redis {
|
|
44
|
-
const connectTimeout = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
45
|
-
const commandTimeout = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
46
|
-
const maxRetriesPerRequest = options.maxRetriesPerRequest ?? DEFAULT_MAX_RETRIES_PER_REQUEST;
|
|
47
|
-
|
|
48
|
-
return new IoRedis(url, {
|
|
49
|
-
...options.extra,
|
|
50
|
-
connectTimeout,
|
|
51
|
-
commandTimeout,
|
|
52
|
-
maxRetriesPerRequest,
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Env-var reader — mirrors dbConnectionOptionsFromEnv's contract. Strict:
|
|
57
|
-
// malformed values throw at boot rather than silently falling back to
|
|
58
|
-
// defaults.
|
|
59
|
-
export function redisClientOptionsFromEnv(
|
|
60
|
-
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
61
|
-
): RedisClientOptions {
|
|
62
|
-
const opts: RedisClientOptions & {
|
|
63
|
-
connectTimeoutMs?: number;
|
|
64
|
-
commandTimeoutMs?: number;
|
|
65
|
-
maxRetriesPerRequest?: number;
|
|
66
|
-
} = {};
|
|
67
|
-
const connect = readPositiveIntEnv(env, "REDIS_CONNECT_TIMEOUT_MS");
|
|
68
|
-
const command = readPositiveIntEnv(env, "REDIS_COMMAND_TIMEOUT_MS");
|
|
69
|
-
const retries = readPositiveIntEnv(env, "REDIS_MAX_RETRIES_PER_REQUEST");
|
|
70
|
-
if (connect !== undefined) opts.connectTimeoutMs = connect;
|
|
71
|
-
if (command !== undefined) opts.commandTimeoutMs = command;
|
|
72
|
-
if (retries !== undefined) opts.maxRetriesPerRequest = retries;
|
|
73
|
-
return opts;
|
|
74
|
-
}
|
|
1
|
+
export type { RedisClientOptions } from "./client";
|
|
2
|
+
export { createRedisClient, redisClientOptionsFromEnv } from "./client";
|
|
3
|
+
export type { PubSubSignal, PubSubSignalOptions } from "./pubsub-signal";
|
|
4
|
+
export { createRedisPubSubSignal } from "./pubsub-signal";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createRedisClient } from "./client";
|
|
2
|
+
|
|
3
|
+
// Generic Redis Pub/Sub transport for cross-replica fanout (fw#2625).
|
|
4
|
+
// Shared by the SSE broker (api/redis-sse-broker.ts, several logical
|
|
5
|
+
// channels multiplexed under one prefix) and feature-toggles' cache-sync
|
|
6
|
+
// signal (bundled-features, a single fixed channel) — both need the same
|
|
7
|
+
// mechanics: two connections (ioredis forbids normal commands on a
|
|
8
|
+
// subscriber connection), one psubscribe per instance (no per-channel
|
|
9
|
+
// subscribe/unsubscribe), JSON payloads, and defensive parsing so a
|
|
10
|
+
// malformed or foreign message on the pattern never crashes the listener.
|
|
11
|
+
export type PubSubSignal = {
|
|
12
|
+
publish(channel: string, payload: unknown): void;
|
|
13
|
+
onMessage(listener: (channel: string, payload: unknown) => void): void;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type PubSubSignalOptions = {
|
|
18
|
+
readonly redisUrl: string;
|
|
19
|
+
// psubscribe pattern: a literal channel name is a valid (exact-match)
|
|
20
|
+
// pattern for a caller with a single fixed channel; a "prefix:*" glob
|
|
21
|
+
// is for a caller that multiplexes several logical channels under one
|
|
22
|
+
// subscription (e.g. the SSE broker's per-tenant/per-user channels).
|
|
23
|
+
readonly channelPattern: string;
|
|
24
|
+
// Included in every log line so two signals running in the same process
|
|
25
|
+
// (sse-broker, feature-toggles cache-sync) can be told apart in output.
|
|
26
|
+
readonly label: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Redis emits 'error' on connection blips (reconnects transparently); an
|
|
30
|
+
// unhandled listener crashes the process (fw#1805 — job-runner hit the same
|
|
31
|
+
// class with BullMQ's internal client).
|
|
32
|
+
function logConnectionError(label: string, role: string): (err: Error) => void {
|
|
33
|
+
return (err) => {
|
|
34
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback — no logger is threaded through PubSubSignalOptions.
|
|
35
|
+
console.error(`[kumiko:${label}] ${role} connection error:`, err.message);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createRedisPubSubSignal(opts: PubSubSignalOptions): PubSubSignal {
|
|
40
|
+
const publisher = createRedisClient(opts.redisUrl);
|
|
41
|
+
const subscriber = createRedisClient(opts.redisUrl);
|
|
42
|
+
publisher.on("error", logConnectionError(opts.label, "publisher"));
|
|
43
|
+
subscriber.on("error", logConnectionError(opts.label, "subscriber"));
|
|
44
|
+
|
|
45
|
+
const listeners = new Set<(channel: string, payload: unknown) => void>();
|
|
46
|
+
// Sole purpose: stop close() from racing the initial psubscribe (see
|
|
47
|
+
// below) and silence its logging once a shutdown is already underway —
|
|
48
|
+
// not a defense against ioredis auto-reconnecting later (quit() below
|
|
49
|
+
// never reconnects once it resolves).
|
|
50
|
+
let closed = false;
|
|
51
|
+
|
|
52
|
+
subscriber.on("pmessage", (_pattern: string, channel: string, message: string) => {
|
|
53
|
+
let payload: unknown;
|
|
54
|
+
try {
|
|
55
|
+
payload = JSON.parse(message);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback, see logConnectionError above.
|
|
58
|
+
console.error(`[kumiko:${opts.label}] dropping unparseable message on "${channel}":`, err);
|
|
59
|
+
// skip: unparseable payload already logged above, nothing to deliver
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const listener of listeners) listener(channel, payload);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ponytail: psubscribe on one pattern rather than per-channel
|
|
66
|
+
// subscribe/unsubscribe — every instance receives every message that
|
|
67
|
+
// matches, filtered by the caller. That's strictly no worse than the
|
|
68
|
+
// pre-fw#2625 per-instance-cursor model (which already read every event
|
|
69
|
+
// from Postgres in every process) and sidesteps the subscribe-ack/
|
|
70
|
+
// first-publish race a per-channel scheme would have. Revisit with
|
|
71
|
+
// per-channel subscribe only if fanout volume becomes the bottleneck.
|
|
72
|
+
//
|
|
73
|
+
// Awaited by close() below — quitting while this is still in flight (a
|
|
74
|
+
// shutdown landing immediately after construction, e.g. a test closing
|
|
75
|
+
// right after building the signal) makes ioredis rejects the queued
|
|
76
|
+
// psubscribe with "Connection is closed" instead of it completing
|
|
77
|
+
// cleanly first. Not caught here or logged when a close is already
|
|
78
|
+
// underway (`closed`), so an intentional shutdown never surfaces this
|
|
79
|
+
// as an ops-visible error.
|
|
80
|
+
const initialSubscribe = subscriber.psubscribe(opts.channelPattern).catch((err: unknown) => {
|
|
81
|
+
// skip: a close() already underway intentionally quit both connections —
|
|
82
|
+
// this rejection is the expected side effect, not an ops-visible error.
|
|
83
|
+
if (closed) return;
|
|
84
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback, see logConnectionError above.
|
|
85
|
+
console.error(`[kumiko:${opts.label}] psubscribe failed:`, err);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
publish(channel, payload) {
|
|
90
|
+
publisher.publish(channel, JSON.stringify(payload)).catch((err: unknown) => {
|
|
91
|
+
// skip: a close() already underway intentionally quit both
|
|
92
|
+
// connections — this rejection is the expected side effect.
|
|
93
|
+
if (closed) return;
|
|
94
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback, see logConnectionError above.
|
|
95
|
+
console.error(`[kumiko:${opts.label}] publish failed:`, err);
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
onMessage(listener) {
|
|
99
|
+
listeners.add(listener);
|
|
100
|
+
},
|
|
101
|
+
async close(): Promise<void> {
|
|
102
|
+
closed = true;
|
|
103
|
+
await initialSubscribe;
|
|
104
|
+
await Promise.all([publisher.quit(), subscriber.quit()]);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|