@cosmicdrift/kumiko-framework 0.164.0 → 0.165.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/__tests__/consumer-cli.integration.test.ts +30 -0
- package/src/api/__tests__/api.test.ts +60 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +18 -2
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +27 -18
- package/src/api/routes.ts +41 -22
- package/src/api/server.ts +14 -6
- package/src/bun-db/connection.ts +3 -3
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +5 -12
- package/src/db/index.ts +0 -2
- package/src/db/queries/event-store.ts +0 -16
- package/src/engine/__tests__/boot-validator.test.ts +14 -0
- package/src/engine/__tests__/registry.test.ts +36 -0
- package/src/engine/boot-validator/entity-handler.ts +14 -25
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +4 -0
- package/src/engine/feature-ast/extractors/handlers.ts +13 -18
- package/src/engine/registry-validate.ts +7 -2
- package/src/engine/types/index.ts +0 -2
- package/src/event-store/__tests__/event-store.integration.test.ts +28 -14
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +5 -8
- package/src/event-store/event-store.ts +1 -17
- package/src/event-store/index.ts +0 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +52 -0
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +31 -18
- package/src/pipeline/dispatch-stream.ts +2 -7
- package/src/pipeline/event-dispatcher-delivery.ts +2 -1
- package/src/pipeline/system-hooks.ts +50 -1
- package/src/db/__tests__/encryption.test.ts +0 -39
- package/src/db/encryption.ts +0 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.165.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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.165.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.18",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.165.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -84,6 +84,36 @@ describe("runConsumerCli status", () => {
|
|
|
84
84
|
expect(code).toBe(1);
|
|
85
85
|
expect(lines.join("\n")).toContain("DATABASE_URL not set");
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
test("--instance-id flag → status looks up the per-instance consumer, not shared", async () => {
|
|
89
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
90
|
+
process.env["DATABASE_URL"] = testUrl;
|
|
91
|
+
await insertConsumerIfAbsent(testDb.db, "test:consumer:inst", "inst-1");
|
|
92
|
+
const { out, lines } = captureOut();
|
|
93
|
+
const code = await runConsumerCli(
|
|
94
|
+
["status", "test:consumer:inst", "--instance-id", "inst-1"],
|
|
95
|
+
out,
|
|
96
|
+
);
|
|
97
|
+
expect(code).toBe(0);
|
|
98
|
+
const joined = lines.join("\n");
|
|
99
|
+
expect(joined).toContain('instance_id="inst-1"');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("runConsumerCli — unknown/empty subcommand exit codes", () => {
|
|
104
|
+
test("no subcommand at all → exit 0 (usage, not an error)", async () => {
|
|
105
|
+
const { out, lines } = captureOut();
|
|
106
|
+
const code = await runConsumerCli([], out);
|
|
107
|
+
expect(code).toBe(0);
|
|
108
|
+
expect(lines.join("\n")).toContain("Subcommands:");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("an unrecognized subcommand → exit 1", async () => {
|
|
112
|
+
const { out, lines } = captureOut();
|
|
113
|
+
const code = await runConsumerCli(["bogus"], out);
|
|
114
|
+
expect(code).toBe(1);
|
|
115
|
+
expect(lines.join("\n")).toContain("Subcommands:");
|
|
116
|
+
});
|
|
87
117
|
});
|
|
88
118
|
|
|
89
119
|
describe("runConsumerCli restart", () => {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type TenantId,
|
|
9
9
|
} from "../../engine";
|
|
10
10
|
import { createTestUser, TestUsers } from "../../stack";
|
|
11
|
+
import { pumpStream } from "../routes";
|
|
11
12
|
import { buildServer } from "../server";
|
|
12
13
|
|
|
13
14
|
const JWT_SECRET = "test-secret-at-least-32-chars-long!!";
|
|
@@ -210,6 +211,65 @@ describe("POST /api/command", () => {
|
|
|
210
211
|
});
|
|
211
212
|
});
|
|
212
213
|
|
|
214
|
+
// --- pumpStream (SSE pull loop) ---
|
|
215
|
+
|
|
216
|
+
function fakeSseWriter() {
|
|
217
|
+
const frames: Array<{ event: string; data: string }> = [];
|
|
218
|
+
return {
|
|
219
|
+
frames,
|
|
220
|
+
async writeSSE(message: { event: string; data: string }) {
|
|
221
|
+
frames.push(message);
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function* delayedGenerator(values: readonly unknown[], delayMsByIndex: readonly number[]) {
|
|
227
|
+
for (let i = 0; i < values.length; i++) {
|
|
228
|
+
const delay = delayMsByIndex[i] ?? 0;
|
|
229
|
+
if (delay > 0) await Bun.sleep(delay);
|
|
230
|
+
yield values[i];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
describe("pumpStream", () => {
|
|
235
|
+
test("emits a ping when the handler is slow, then still delivers the pending chunk (no loss)", async () => {
|
|
236
|
+
const writer = fakeSseWriter();
|
|
237
|
+
// heartbeatMs (10) fires before the 40ms-delayed second chunk resolves.
|
|
238
|
+
const gen = delayedGenerator([{ i: 0 }, { i: 1 }], [0, 40]);
|
|
239
|
+
|
|
240
|
+
await pumpStream(writer, gen, 10);
|
|
241
|
+
|
|
242
|
+
const events = writer.frames.map((f) => f.event);
|
|
243
|
+
expect(events[0]).toBe("chunk");
|
|
244
|
+
expect(events).toContain("ping");
|
|
245
|
+
expect(events.at(-1)).toBe("done");
|
|
246
|
+
// Both chunks arrive despite the ping in between — no chunk dropped.
|
|
247
|
+
const chunkData = writer.frames.filter((f) => f.event === "chunk").map((f) => f.data);
|
|
248
|
+
expect(chunkData).toEqual([JSON.stringify({ i: 0 }), JSON.stringify({ i: 1 })]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("no heartbeat fires when the handler is faster than heartbeatMs — chunks then done", async () => {
|
|
252
|
+
const writer = fakeSseWriter();
|
|
253
|
+
const gen = delayedGenerator([{ i: 0 }, { i: 1 }, { i: 2 }], [0, 0, 0]);
|
|
254
|
+
|
|
255
|
+
await pumpStream(writer, gen, 1000);
|
|
256
|
+
|
|
257
|
+
expect(writer.frames.map((f) => f.event)).toEqual(["chunk", "chunk", "chunk", "done"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("a handler generator that throws propagates the error instead of swallowing it", async () => {
|
|
261
|
+
const writer = fakeSseWriter();
|
|
262
|
+
async function* throwing() {
|
|
263
|
+
yield { i: 0 };
|
|
264
|
+
throw new Error("handler-boom");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await expect(pumpStream(writer, throwing(), 1000)).rejects.toThrow("handler-boom");
|
|
268
|
+
// The chunk before the throw still made it out.
|
|
269
|
+
expect(writer.frames.map((f) => f.event)).toEqual(["chunk"]);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
213
273
|
// --- SSE ---
|
|
214
274
|
|
|
215
275
|
describe("GET /api/sse", () => {
|
|
@@ -80,7 +80,11 @@ function preauthConfirmRequest(body: unknown): Request {
|
|
|
80
80
|
|
|
81
81
|
describe("POST /auth/mfa/preauth-confirm", () => {
|
|
82
82
|
test("is public — reachable without a JWT", async () => {
|
|
83
|
-
|
|
83
|
+
const { app } = await buildApp();
|
|
84
|
+
const res = await app.request(preauthConfirmRequest({ setupToken: "t", code: "123456" }));
|
|
85
|
+
// No Authorization header sent at all — if the route required a JWT,
|
|
86
|
+
// authMiddleware would reject with 401 before the handler ever runs.
|
|
87
|
+
expect(res.status).not.toBe(401);
|
|
84
88
|
});
|
|
85
89
|
|
|
86
90
|
test("not mounted when mfaPreauthConfirmHandler is unset", async () => {
|
|
@@ -100,12 +104,24 @@ describe("POST /auth/mfa/preauth-confirm", () => {
|
|
|
100
104
|
};
|
|
101
105
|
},
|
|
102
106
|
});
|
|
103
|
-
|
|
107
|
+
// An active limiter, not null — "before rate-limit" is only checkable
|
|
108
|
+
// when there's a rate-limit to (not) consume.
|
|
109
|
+
const { app } = await buildApp(
|
|
110
|
+
{ mfaPreauthConfirmRateLimit: createInMemoryLoginRateLimiter(2, 60_000) },
|
|
111
|
+
dispatcher,
|
|
112
|
+
);
|
|
104
113
|
const res = await app.request(preauthConfirmRequest({ setupToken: "t" }));
|
|
105
114
|
expect(res.status).toBe(400);
|
|
106
115
|
const body = (await res.json()) as { isSuccess: boolean; error: string };
|
|
107
116
|
expect(body.error).toBe("invalid_body");
|
|
108
117
|
expect(dispatched).toBe(false);
|
|
118
|
+
|
|
119
|
+
// The limiter's cap (2) must still be fully available — a malformed
|
|
120
|
+
// body must not have consumed an attempt.
|
|
121
|
+
for (let i = 0; i < 5; i++) {
|
|
122
|
+
const retry = await app.request(preauthConfirmRequest({ setupToken: "t" }));
|
|
123
|
+
expect(retry.status).toBe(400);
|
|
124
|
+
}
|
|
109
125
|
});
|
|
110
126
|
|
|
111
127
|
test("on success: dispatches to mfaPreauthConfirmHandler, mints a JWT + cookies", async () => {
|
|
@@ -2,25 +2,33 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { createInMemoryLoginRateLimiter } from "../auth-routes";
|
|
3
3
|
|
|
4
4
|
describe("createInMemoryLoginRateLimiter — sweep + cap", () => {
|
|
5
|
-
test("sweepExpired
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
test("sweepExpired removes expired entries before enforceCap can evict a fresh key", async () => {
|
|
6
|
+
// maxAttempts=1 makes every entry a single-shot probe: a second `check`
|
|
7
|
+
// on the same key only returns true if the entry was actually removed
|
|
8
|
+
// from the map (either by expiry+sweep or by enforceCap).
|
|
9
|
+
const limiter = createInMemoryLoginRateLimiter(1, 50, {
|
|
10
|
+
maxEntries: 2,
|
|
9
11
|
sweepThreshold: 2,
|
|
10
12
|
});
|
|
11
13
|
|
|
12
|
-
expect(await limiter.check("
|
|
13
|
-
expect(await limiter.check("
|
|
14
|
-
//
|
|
15
|
-
// (hits.size >= sweepThreshold) before inserting "c".
|
|
14
|
+
expect(await limiter.check("old1")).toBe(true);
|
|
15
|
+
expect(await limiter.check("old2")).toBe(true);
|
|
16
|
+
// Let both windows expire.
|
|
16
17
|
await Bun.sleep(60);
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
18
|
+
|
|
19
|
+
// hits.size (2) >= sweepThreshold (2) → sweepExpired runs first and
|
|
20
|
+
// clears old1/old2 before "new1" is inserted, so enforceCap never fires.
|
|
21
|
+
expect(await limiter.check("new1")).toBe(true);
|
|
22
|
+
expect(await limiter.check("new2")).toBe(true);
|
|
23
|
+
|
|
24
|
+
// If enforceCap had run instead of (or before) the sweep, it would have
|
|
25
|
+
// evicted "new1" as the oldest live entry, and this check would return
|
|
26
|
+
// true (fresh window) rather than false (still rate-limited).
|
|
27
|
+
expect(await limiter.check("new1")).toBe(false);
|
|
20
28
|
});
|
|
21
29
|
|
|
22
|
-
test("enforceCap drops oldest
|
|
23
|
-
const limiter = createInMemoryLoginRateLimiter(
|
|
30
|
+
test("enforceCap drops the oldest entry when the map exceeds maxEntries", async () => {
|
|
31
|
+
const limiter = createInMemoryLoginRateLimiter(1, 60_000, {
|
|
24
32
|
maxEntries: 3,
|
|
25
33
|
sweepThreshold: 10_000, // never sweep — only the hard cap matters
|
|
26
34
|
});
|
|
@@ -28,14 +36,15 @@ describe("createInMemoryLoginRateLimiter — sweep + cap", () => {
|
|
|
28
36
|
expect(await limiter.check("k1")).toBe(true);
|
|
29
37
|
expect(await limiter.check("k2")).toBe(true);
|
|
30
38
|
expect(await limiter.check("k3")).toBe(true);
|
|
31
|
-
// 4th insert trips enforceCap → drops oldest (k1).
|
|
39
|
+
// 4th insert trips enforceCap → drops the oldest entry (k1).
|
|
32
40
|
expect(await limiter.check("k4")).toBe(true);
|
|
33
41
|
|
|
34
42
|
// k1 was dropped — a fresh check starts a new window (allowed).
|
|
35
43
|
expect(await limiter.check("k1")).toBe(true);
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
|
|
45
|
+
// k4 must still be alive in the map: a check within its still-open
|
|
46
|
+
// window is rate-limited (count >= maxAttempts), proving enforceCap
|
|
47
|
+
// dropped k1 and not k4.
|
|
48
|
+
expect(await limiter.check("k4")).toBe(false);
|
|
40
49
|
});
|
|
41
50
|
});
|
package/src/api/routes.ts
CHANGED
|
@@ -147,28 +147,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
try {
|
|
150
|
-
|
|
151
|
-
while (true) {
|
|
152
|
-
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
|
153
|
-
const heartbeat = new Promise<"heartbeat">((resolve) => {
|
|
154
|
-
heartbeatTimer = setTimeout(() => resolve("heartbeat"), SSE_HEARTBEAT_INTERVAL_MS);
|
|
155
|
-
});
|
|
156
|
-
let outcome: Awaited<typeof pending> | "heartbeat";
|
|
157
|
-
try {
|
|
158
|
-
outcome = await Promise.race([pending, heartbeat]);
|
|
159
|
-
} finally {
|
|
160
|
-
clearTimeout(heartbeatTimer);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (outcome === "heartbeat") {
|
|
164
|
-
await stream.writeSSE({ event: "ping", data: "" });
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
if (outcome.done) break;
|
|
168
|
-
await stream.writeSSE({ event: "chunk", data: stringifyJson(outcome.value) });
|
|
169
|
-
pending = generator.next();
|
|
170
|
-
}
|
|
171
|
-
await stream.writeSSE({ event: "done", data: "" });
|
|
150
|
+
await pumpStream(stream, generator, SSE_HEARTBEAT_INTERVAL_MS);
|
|
172
151
|
} catch (e) {
|
|
173
152
|
const err = toKumiko(e);
|
|
174
153
|
logServerFault(err, requestId, body.type);
|
|
@@ -181,6 +160,46 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
181
160
|
return api;
|
|
182
161
|
}
|
|
183
162
|
|
|
163
|
+
export type SseWriter = {
|
|
164
|
+
readonly writeSSE: (message: { readonly event: string; readonly data: string }) => Promise<void>;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Pull loop for /api/stream: races each generator.next() against a
|
|
168
|
+
// heartbeat timer so a slow/idle handler still keeps the connection alive
|
|
169
|
+
// ("ping" frames), forwards yielded chunks as "chunk" frames, and emits
|
|
170
|
+
// "done" once the generator completes. Factored out of the route handler
|
|
171
|
+
// so the heartbeat/ping and abort/cleanup paths are unit-testable with a
|
|
172
|
+
// fast heartbeatMs instead of only reachable through SSE_HEARTBEAT_INTERVAL_MS
|
|
173
|
+
// (15s) in a full HTTP round-trip.
|
|
174
|
+
export async function pumpStream(
|
|
175
|
+
stream: SseWriter,
|
|
176
|
+
generator: AsyncGenerator<unknown>,
|
|
177
|
+
heartbeatMs: number,
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
let pending = generator.next();
|
|
180
|
+
while (true) {
|
|
181
|
+
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
|
182
|
+
const heartbeat = new Promise<"heartbeat">((resolve) => {
|
|
183
|
+
heartbeatTimer = setTimeout(() => resolve("heartbeat"), heartbeatMs);
|
|
184
|
+
});
|
|
185
|
+
let outcome: Awaited<typeof pending> | "heartbeat";
|
|
186
|
+
try {
|
|
187
|
+
outcome = await Promise.race([pending, heartbeat]);
|
|
188
|
+
} finally {
|
|
189
|
+
clearTimeout(heartbeatTimer);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (outcome === "heartbeat") {
|
|
193
|
+
await stream.writeSSE({ event: "ping", data: "" });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (outcome.done) break;
|
|
197
|
+
await stream.writeSSE({ event: "chunk", data: stringifyJson(outcome.value) });
|
|
198
|
+
pending = generator.next();
|
|
199
|
+
}
|
|
200
|
+
await stream.writeSSE({ event: "done", data: "" });
|
|
201
|
+
}
|
|
202
|
+
|
|
184
203
|
function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode = 200) {
|
|
185
204
|
return c.body(stringifyJson(body), status, { "Content-Type": "application/json" });
|
|
186
205
|
}
|
package/src/api/server.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { createEventDispatcher } from "../pipeline/event-dispatcher";
|
|
|
34
34
|
import { createLifecycleHooks, type SystemHooks } from "../pipeline/lifecycle-pipeline";
|
|
35
35
|
import { createMultiStreamApplyContext } from "../pipeline/multi-stream-apply-context";
|
|
36
36
|
import {
|
|
37
|
+
createJobTriggerEventConsumer,
|
|
37
38
|
createSearchEventConsumer,
|
|
38
39
|
createSseBroadcastEventConsumer,
|
|
39
40
|
} from "../pipeline/system-hooks";
|
|
@@ -105,12 +106,14 @@ export type ServerOptions = {
|
|
|
105
106
|
// runs a dedicated dispatcher process, or a test needs to control the
|
|
106
107
|
// consumer lifecycle manually.
|
|
107
108
|
disabled?: boolean;
|
|
108
|
-
// Opt out of the auto-built system consumers (SSE, Search)
|
|
109
|
-
// running feature r.multiStreamProjection consumers. Useful
|
|
110
|
-
// that assert only on subscriber behaviour, or for a
|
|
111
|
-
// routes SSE via a different transport. Default:
|
|
112
|
-
// respective dependency (sseBroker /
|
|
113
|
-
|
|
109
|
+
// Opt out of the auto-built system consumers (SSE, Search, Job-Trigger)
|
|
110
|
+
// while still running feature r.multiStreamProjection consumers. Useful
|
|
111
|
+
// for tests that assert only on subscriber behaviour, or for a
|
|
112
|
+
// deployment that routes SSE via a different transport. Default: sse/
|
|
113
|
+
// search enabled when the respective dependency (sseBroker /
|
|
114
|
+
// context.searchAdapter) is available; jobTrigger enabled when a
|
|
115
|
+
// jobRunner is wired via dispatcherOptions.
|
|
116
|
+
systemConsumers?: { sse?: boolean; search?: boolean; jobTrigger?: boolean };
|
|
114
117
|
// Raw postgres.js client for LISTEN/NOTIFY wake-up (Sprint E.4). When
|
|
115
118
|
// present, `.start()` subscribes to EVENTS_PUBSUB_CHANNEL — delivery
|
|
116
119
|
// latency drops from pollIntervalMs to TCP-round-trip. The poll timer
|
|
@@ -398,6 +401,8 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
398
401
|
|
|
399
402
|
const sseConsumerEnabled = options.eventDispatcher?.systemConsumers?.sse ?? true;
|
|
400
403
|
const searchConsumerEnabled = options.eventDispatcher?.systemConsumers?.search ?? true;
|
|
404
|
+
const jobTriggerConsumerEnabled = options.eventDispatcher?.systemConsumers?.jobTrigger ?? true;
|
|
405
|
+
const jobRunnerForTriggers = options.dispatcherOptions?.jobRunner;
|
|
401
406
|
|
|
402
407
|
const systemConsumers: EventConsumer[] = [];
|
|
403
408
|
if (sseConsumerEnabled) {
|
|
@@ -406,6 +411,9 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
406
411
|
if (searchConsumerEnabled && searchAdapter) {
|
|
407
412
|
systemConsumers.push(createSearchEventConsumer(searchAdapter, options.registry));
|
|
408
413
|
}
|
|
414
|
+
if (jobTriggerConsumerEnabled && jobRunnerForTriggers) {
|
|
415
|
+
systemConsumers.push(createJobTriggerEventConsumer(jobRunnerForTriggers, options.registry));
|
|
416
|
+
}
|
|
409
417
|
|
|
410
418
|
// MultiStreamProjections: one EventConsumer per MSP. Handler routes by
|
|
411
419
|
// event.type into the MSP's apply map. MSPs aggregate cross-aggregate but
|
package/src/bun-db/connection.ts
CHANGED
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
// event-dispatcher.ts. Bun.sql 1.2.20 hat kein listen() (PR oven-sh/bun#25511
|
|
7
7
|
// pending). Nach Landung des Bun-LISTEN-Supports: peer raus.
|
|
8
8
|
|
|
9
|
+
import type { PgListenClient } from "@cosmicdrift/kumiko-types/db-connection";
|
|
9
10
|
import postgres from "postgres";
|
|
10
11
|
import { readPositiveIntEnv } from "../utils/env-parse";
|
|
11
12
|
|
|
13
|
+
export type { PgListenClient };
|
|
14
|
+
|
|
12
15
|
// Bun.SQL ist callable als tagged template `sql\`...\`` PLUS hat methods
|
|
13
16
|
// (.begin / .unsafe / .end / .file / .reserve etc.). DbConnection-Type
|
|
14
17
|
// reflektiert die Instance-Shape.
|
|
@@ -21,9 +24,6 @@ export type BunDbTx = BunDbConnection;
|
|
|
21
24
|
// Beide austauschbar im normalen call-path.
|
|
22
25
|
export type BunDbRunner = BunDbConnection | BunDbTx;
|
|
23
26
|
|
|
24
|
-
// Postgres-js peer NUR für event-dispatcher LISTEN.
|
|
25
|
-
export type PgListenClient = ReturnType<typeof postgres>;
|
|
26
|
-
|
|
27
27
|
export type BunDbConnectionOptions = {
|
|
28
28
|
readonly maxConnections?: number;
|
|
29
29
|
readonly idleTimeoutSeconds?: number;
|
|
@@ -50,7 +50,7 @@ describe("event-store-executor write-verbs — entity-level ownership_denied", (
|
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
beforeAll(async () => {
|
|
53
|
-
await
|
|
53
|
+
await unsafeCreateEntityTable(testDb.db, restrictedEntity, "esWriteRestricted");
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
beforeEach(async () => {
|
|
@@ -158,7 +158,7 @@ describe("event-store-executor write-verbs — restore without softDelete", () =
|
|
|
158
158
|
});
|
|
159
159
|
|
|
160
160
|
beforeAll(async () => {
|
|
161
|
-
await
|
|
161
|
+
await unsafeCreateEntityTable(testDb.db, hardDeleteEntity, "esWriteHard");
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
beforeEach(async () => {
|
|
@@ -199,7 +199,7 @@ describe("event-store-executor write-verbs — field-level ownership_denied", ()
|
|
|
199
199
|
});
|
|
200
200
|
|
|
201
201
|
beforeAll(async () => {
|
|
202
|
-
await
|
|
202
|
+
await unsafeCreateEntityTable(testDb.db, ownedFieldEntity, "esWriteOwnedField");
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
beforeEach(async () => {
|
|
@@ -264,7 +264,7 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
|
|
|
264
264
|
});
|
|
265
265
|
|
|
266
266
|
beforeAll(async () => {
|
|
267
|
-
await
|
|
267
|
+
await unsafeCreateEntityTable(testDb.db, versionEntity, "esWriteVersion");
|
|
268
268
|
});
|
|
269
269
|
|
|
270
270
|
beforeEach(async () => {
|
|
@@ -298,13 +298,6 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
|
|
|
298
298
|
});
|
|
299
299
|
});
|
|
300
300
|
|
|
301
|
-
async function unsafeCreateEntityTableFor(
|
|
302
|
-
entity: Parameters<typeof buildEntityTable>[1],
|
|
303
|
-
name: string,
|
|
304
|
-
): Promise<void> {
|
|
305
|
-
await unsafeCreateEntityTable(testDb.db, entity, name);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
301
|
// =============================================================================
|
|
309
302
|
// Concurrent update race → EventStoreVersionConflict catch + entityCache.del
|
|
310
303
|
// on forget/restore (create/update/delete already exercise cache in the
|
|
@@ -341,7 +334,7 @@ describe("event-store-executor write-verbs — concurrent version race + cache",
|
|
|
341
334
|
});
|
|
342
335
|
|
|
343
336
|
beforeAll(async () => {
|
|
344
|
-
await
|
|
337
|
+
await unsafeCreateEntityTable(testDb.db, raceEntity, "esWriteRace");
|
|
345
338
|
});
|
|
346
339
|
|
|
347
340
|
beforeEach(async () => {
|
package/src/db/index.ts
CHANGED
|
@@ -35,8 +35,6 @@ export {
|
|
|
35
35
|
enrichRowWithReferences,
|
|
36
36
|
enrichWithReferences,
|
|
37
37
|
} from "./eagerload";
|
|
38
|
-
export type { EncryptionProvider } from "./encryption";
|
|
39
|
-
export { createEncryptionProvider } from "./encryption";
|
|
40
38
|
export {
|
|
41
39
|
collectEncryptedFieldNames,
|
|
42
40
|
configuredEntityFieldEncryption,
|
|
@@ -99,22 +99,6 @@ export async function selectAggregateMaxVersion(db: AnyDb, aggregateId: string):
|
|
|
99
99
|
return rows[0]?.v ?? 0;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/** tenant_id the aggregate's events were written under — no membership/tenant
|
|
103
|
-
* filter. A r.systemScope() aggregate (e.g. user) lives in whichever tenant
|
|
104
|
-
* its creating executor used, which need not be a tenant the subject holds a
|
|
105
|
-
* membership in. Returns null for unknown streams. */
|
|
106
|
-
export async function selectAggregateStreamTenant(
|
|
107
|
-
db: AnyDb,
|
|
108
|
-
aggregateId: string,
|
|
109
|
-
aggregateType: string,
|
|
110
|
-
): Promise<string | null> {
|
|
111
|
-
const rows = (await asRawClient(db).unsafe(
|
|
112
|
-
`SELECT "tenant_id" AS t FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = $2 ORDER BY "version" LIMIT 1`,
|
|
113
|
-
[aggregateId, aggregateType],
|
|
114
|
-
)) as ReadonlyArray<{ t: string | null }>;
|
|
115
|
-
return rows[0]?.t ?? null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
102
|
export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
|
|
119
103
|
const rows = (await asRawClient(db).unsafe(
|
|
120
104
|
`SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`,
|
|
@@ -599,6 +599,20 @@ describe("boot-validator", () => {
|
|
|
599
599
|
expect(() => validateBoot(features)).toThrow(/a:stream:chat:complete.*missing an access rule/i);
|
|
600
600
|
});
|
|
601
601
|
|
|
602
|
+
test("object-form streamHandler with access rule passes boot", () => {
|
|
603
|
+
const features = [
|
|
604
|
+
defineFeature("a", (r) => {
|
|
605
|
+
r.streamHandler({
|
|
606
|
+
name: "chat:complete",
|
|
607
|
+
schema: z.object({}),
|
|
608
|
+
handler: async function* () {},
|
|
609
|
+
access: { roles: ["User"] },
|
|
610
|
+
});
|
|
611
|
+
}),
|
|
612
|
+
];
|
|
613
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
614
|
+
});
|
|
615
|
+
|
|
602
616
|
test("accepts openToAll access rule on a stream handler", () => {
|
|
603
617
|
const features = [
|
|
604
618
|
defineFeature("a", (r) => {
|
|
@@ -139,6 +139,42 @@ describe("getAllStreamHandlers", () => {
|
|
|
139
139
|
});
|
|
140
140
|
expect(() => createRegistry([aiFeature, otherFeature])).not.toThrow();
|
|
141
141
|
});
|
|
142
|
+
|
|
143
|
+
test("two distinct feature names that kebab-collide throw on the qualified stream-handler clash", () => {
|
|
144
|
+
// "registry-test-kebab-dup" and "registryTestKebabDup" are different raw
|
|
145
|
+
// feature.name values (so the earlier Duplicate-feature guard doesn't
|
|
146
|
+
// fire) but toKebab() collapses both to the same qualified name.
|
|
147
|
+
const featureA = defineFeature("registry-test-kebab-dup", (r) => {
|
|
148
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
149
|
+
});
|
|
150
|
+
const featureB = defineFeature("registryTestKebabDup", (r) => {
|
|
151
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
152
|
+
});
|
|
153
|
+
expect(() => createRegistry([featureA, featureB])).toThrow(/Duplicate stream handler/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("object-form streamHandler registration preserves schema/access/rateLimit", () => {
|
|
157
|
+
const schema = z.object({ prompt: z.string() });
|
|
158
|
+
const handlerFn = async function* () {};
|
|
159
|
+
const feature = defineFeature("registry-test-object-form", (r) => {
|
|
160
|
+
r.streamHandler({
|
|
161
|
+
name: "chat:complete",
|
|
162
|
+
schema,
|
|
163
|
+
handler: handlerFn,
|
|
164
|
+
access: { openToAll: true },
|
|
165
|
+
rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 60 },
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const registry = createRegistry([feature]);
|
|
170
|
+
const registered = registry.getStreamHandler("registry-test-object-form:stream:chat:complete");
|
|
171
|
+
|
|
172
|
+
expect(registered).toBeDefined();
|
|
173
|
+
expect(registered?.schema).toBe(schema);
|
|
174
|
+
expect(registered?.handler).toBe(handlerFn);
|
|
175
|
+
expect(registered?.access).toEqual({ openToAll: true });
|
|
176
|
+
expect(registered?.rateLimit).toEqual({ per: "ip+handler", limit: 5, windowSeconds: 60 });
|
|
177
|
+
});
|
|
142
178
|
});
|
|
143
179
|
|
|
144
180
|
describe("extensionSelector boot-validation", () => {
|
|
@@ -101,32 +101,21 @@ const USER_BUCKETED_RATE_LIMIT_PER: ReadonlySet<string> = new Set(["user", "user
|
|
|
101
101
|
// at runtime, but we fail at boot to turn an easy-to-miss security regression
|
|
102
102
|
// into a loud configuration error.
|
|
103
103
|
export function validateHandlerAccess(feature: FeatureDefinition): void {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
validateAnonymousRateLimit(feature.name, "query", name, handler.access, handler.rateLimit);
|
|
121
|
-
}
|
|
122
|
-
for (const [name, handler] of Object.entries(feature.streamHandlers)) {
|
|
123
|
-
if (!handler.access) {
|
|
124
|
-
throw new Error(
|
|
125
|
-
`Stream handler "${feature.name}:stream:${name}" is missing an access rule. ` +
|
|
126
|
-
`Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
|
|
127
|
-
);
|
|
104
|
+
const kinds = [
|
|
105
|
+
{ kind: "write" as const, label: "Write", handlers: feature.writeHandlers },
|
|
106
|
+
{ kind: "query" as const, label: "Query", handlers: feature.queryHandlers },
|
|
107
|
+
{ kind: "stream" as const, label: "Stream", handlers: feature.streamHandlers },
|
|
108
|
+
];
|
|
109
|
+
for (const { kind, label, handlers } of kinds) {
|
|
110
|
+
for (const [name, handler] of Object.entries(handlers)) {
|
|
111
|
+
if (!handler.access) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`${label} handler "${feature.name}:${kind}:${name}" is missing an access rule. ` +
|
|
114
|
+
`Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
|
|
128
118
|
}
|
|
129
|
-
validateAnonymousRateLimit(feature.name, "stream", name, handler.access, handler.rateLimit);
|
|
130
119
|
}
|
|
131
120
|
}
|
|
132
121
|
|
|
@@ -258,6 +258,7 @@ describe("render → parse roundtrip — mixed patterns (header data + opaque bo
|
|
|
258
258
|
// from the parsed FeaturePattern shape alone.
|
|
259
259
|
const RAW_REF_FEATURE = `
|
|
260
260
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
261
|
+
import { chatCompleteHandler } from "./handlers";
|
|
261
262
|
|
|
262
263
|
const eventEntity = {
|
|
263
264
|
fields: { name: { type: "text", required: true } },
|
|
@@ -284,6 +285,7 @@ defineFeature("refs", (r) => {
|
|
|
284
285
|
r.entity("event", eventEntity);
|
|
285
286
|
r.entity("task", { fields: buildFields() });
|
|
286
287
|
r.writeHandler(makeHandler());
|
|
288
|
+
r.streamHandler(chatCompleteHandler);
|
|
287
289
|
r.screen(eventListScreen);
|
|
288
290
|
});
|
|
289
291
|
`;
|
|
@@ -297,6 +299,7 @@ describe("render → parse roundtrip — unresolved references (raw-ref sentinel
|
|
|
297
299
|
{ kind: "entity", entityName: "event", definition: { __raw: "eventEntity" } },
|
|
298
300
|
{ kind: "entity", entityName: "task", definition: { fields: { __raw: "buildFields()" } } },
|
|
299
301
|
{ kind: "writeHandler", handlerName: undefined },
|
|
302
|
+
{ kind: "streamHandler", handlerName: undefined },
|
|
300
303
|
{ kind: "screen", definition: { __raw: "eventListScreen" } },
|
|
301
304
|
]);
|
|
302
305
|
});
|
|
@@ -310,6 +313,7 @@ describe("render → parse roundtrip — unresolved references (raw-ref sentinel
|
|
|
310
313
|
expect(rendered).toContain("eventEntity");
|
|
311
314
|
expect(rendered).toContain("buildFields()");
|
|
312
315
|
expect(rendered).toContain("r.writeHandler(makeHandler())");
|
|
316
|
+
expect(rendered).toContain("r.streamHandler(chatCompleteHandler)");
|
|
313
317
|
expect(rendered).toContain("r.screen(eventListScreen);");
|
|
314
318
|
// Would only appear if buildFields()'s return value got inlined.
|
|
315
319
|
expect(rendered).not.toContain("title:");
|
|
@@ -204,21 +204,24 @@ export function extractWriteHandler(
|
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
sourceFile: SourceFile,
|
|
210
|
-
): ExtractOutput<QueryHandlerPattern> {
|
|
211
|
-
const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
|
|
212
|
-
if (parsed.kind === "error") return parsed;
|
|
213
|
-
return ok({
|
|
214
|
-
kind: "queryHandler",
|
|
207
|
+
function readHandlerFields(parsed: Extract<ExtractOutput<ParsedHandlerCall>, { kind: "pattern" }>) {
|
|
208
|
+
return {
|
|
215
209
|
source: parsed.pattern.source,
|
|
216
210
|
handlerName: parsed.pattern.handlerName,
|
|
217
211
|
schemaSource: parsed.pattern.schemaSource,
|
|
218
212
|
handlerBody: parsed.pattern.handlerBody,
|
|
219
213
|
...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
|
|
220
214
|
...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
|
|
221
|
-
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function extractQueryHandler(
|
|
219
|
+
call: CallExpression,
|
|
220
|
+
sourceFile: SourceFile,
|
|
221
|
+
): ExtractOutput<QueryHandlerPattern> {
|
|
222
|
+
const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
|
|
223
|
+
if (parsed.kind === "error") return parsed;
|
|
224
|
+
return ok({ kind: "queryHandler", ...readHandlerFields(parsed) });
|
|
222
225
|
}
|
|
223
226
|
|
|
224
227
|
export function extractStreamHandler(
|
|
@@ -227,13 +230,5 @@ export function extractStreamHandler(
|
|
|
227
230
|
): ExtractOutput<StreamHandlerPattern> {
|
|
228
231
|
const parsed = parseHandlerCall(call, sourceFile, "streamHandler");
|
|
229
232
|
if (parsed.kind === "error") return parsed;
|
|
230
|
-
return ok({
|
|
231
|
-
kind: "streamHandler",
|
|
232
|
-
source: parsed.pattern.source,
|
|
233
|
-
handlerName: parsed.pattern.handlerName,
|
|
234
|
-
schemaSource: parsed.pattern.schemaSource,
|
|
235
|
-
handlerBody: parsed.pattern.handlerBody,
|
|
236
|
-
...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
|
|
237
|
-
...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
|
|
238
|
-
});
|
|
233
|
+
return ok({ kind: "streamHandler", ...readHandlerFields(parsed) });
|
|
239
234
|
}
|
|
@@ -564,7 +564,11 @@ export function validateEntityHookTargets(
|
|
|
564
564
|
}
|
|
565
565
|
|
|
566
566
|
export function validateJobTriggers(state: RegistryState): void {
|
|
567
|
-
// Validate: job event triggers must reference existing
|
|
567
|
+
// Validate: job event triggers must reference an existing write/query
|
|
568
|
+
// handler OR an existing r.defineEvent registration. The latter is
|
|
569
|
+
// delivered async via the job-trigger event-consumer (server.ts), not
|
|
570
|
+
// the synchronous write-handler dispatch path — see
|
|
571
|
+
// createJobTriggerEventConsumer in pipeline/system-hooks.ts.
|
|
568
572
|
// Multi-Trigger-Form: jeden Eintrag im Array gegen allHandlers prüfen,
|
|
569
573
|
// auch wenn nur einer fehlt fail-fast.
|
|
570
574
|
const allHandlers = allHandlerQns(state);
|
|
@@ -575,8 +579,9 @@ export function validateJobTriggers(state: RegistryState): void {
|
|
|
575
579
|
for (const t of triggers) {
|
|
576
580
|
const rawName = resolveName(t);
|
|
577
581
|
if (allHandlers.has(rawName)) continue;
|
|
582
|
+
if (state.eventMap.has(rawName)) continue;
|
|
578
583
|
throw new Error(
|
|
579
|
-
`Job "${jobName}" triggers on "${rawName}" but no handler with that name exists`,
|
|
584
|
+
`Job "${jobName}" triggers on "${rawName}" but no handler or event with that name exists`,
|
|
580
585
|
);
|
|
581
586
|
}
|
|
582
587
|
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
// Barrel: re-exports all types from @cosmicdrift/kumiko-types, plus the
|
|
2
2
|
// runtime helpers below that stay framework-side.
|
|
3
|
-
// Duplicate types (OnDeleteStrategy, ConfigScope, ConcurrencyMode, LifecycleHookType)
|
|
4
|
-
// are defined ONLY in constants.ts — re-exported here for backwards compatibility.
|
|
5
3
|
|
|
6
4
|
export type {
|
|
7
5
|
ConfigAccessor,
|
|
@@ -200,20 +200,34 @@ describe("event-store: idempotency-key conflict", () => {
|
|
|
200
200
|
});
|
|
201
201
|
|
|
202
202
|
test("omitting idempotencyKey allows unlimited appends, unchanged from before", async () => {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
203
|
+
// Same tenant for both, unlike the different-aggregateId version this
|
|
204
|
+
// replaces — Postgres treats every NULL as distinct even in a plain
|
|
205
|
+
// unique index, so this does NOT exercise the partial index's `WHERE
|
|
206
|
+
// ... IS NOT NULL` clause specifically (that's provable only by a
|
|
207
|
+
// duplicate NON-null key, covered above). What this does pin: a fully-
|
|
208
|
+
// omitted key and an explicit `idempotencyKey: undefined` both serialize
|
|
209
|
+
// to a JSON-absent key (JSON.stringify drops undefined) and must behave
|
|
210
|
+
// identically, rather than one silently colliding on `"idempotencyKey":null`.
|
|
211
|
+
const omitted = await append(testDb.db, {
|
|
212
|
+
aggregateId: uuid(),
|
|
213
|
+
aggregateType: "task",
|
|
214
|
+
tenantId: tenantA,
|
|
215
|
+
expectedVersion: 0,
|
|
216
|
+
type: "task.created",
|
|
217
|
+
payload: {},
|
|
218
|
+
metadata: { userId: userA },
|
|
219
|
+
});
|
|
220
|
+
const explicitUndefined = await append(testDb.db, {
|
|
221
|
+
aggregateId: uuid(),
|
|
222
|
+
aggregateType: "task",
|
|
223
|
+
tenantId: tenantA,
|
|
224
|
+
expectedVersion: 0,
|
|
225
|
+
type: "task.created",
|
|
226
|
+
payload: {},
|
|
227
|
+
metadata: { userId: userA, idempotencyKey: undefined },
|
|
228
|
+
});
|
|
229
|
+
expect(omitted.version).toBe(1);
|
|
230
|
+
expect(explicitUndefined.version).toBe(1);
|
|
217
231
|
});
|
|
218
232
|
});
|
|
219
233
|
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { Glob } from "bun";
|
|
3
3
|
|
|
4
|
-
// getUnscopedAggregateStreamMaxVersion
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
const RESTRICTED_SYMBOLS = [
|
|
9
|
-
"getUnscopedAggregateStreamMaxVersion",
|
|
10
|
-
"getUnscopedAggregateStreamTenant",
|
|
11
|
-
];
|
|
4
|
+
// getUnscopedAggregateStreamMaxVersion has no tenant filter — a caller can use
|
|
5
|
+
// it to probe whether a foreign tenant's aggregate exists (see event-store.ts
|
|
6
|
+
// SECURITY doc). Restricted to known seed/system-internal callers; extend
|
|
7
|
+
// only for genuine new ones.
|
|
8
|
+
const RESTRICTED_SYMBOLS = ["getUnscopedAggregateStreamMaxVersion"];
|
|
12
9
|
|
|
13
10
|
const ALLOWED_FILES = new Set([
|
|
14
11
|
"packages/framework/src/event-store/event-store.ts",
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
insertSubsequentEventRow,
|
|
7
7
|
notifyPgChannel,
|
|
8
8
|
selectAggregateMaxVersion,
|
|
9
|
-
selectAggregateStreamTenant,
|
|
10
9
|
selectEventsHighWaterMark,
|
|
11
10
|
selectStreamMaxVersion,
|
|
12
11
|
} from "../db/queries/event-store";
|
|
@@ -260,21 +259,6 @@ export async function getUnscopedAggregateStreamMaxVersion(
|
|
|
260
259
|
return selectAggregateMaxVersion(db, aggregateId);
|
|
261
260
|
}
|
|
262
261
|
|
|
263
|
-
/** Stream tenant of an aggregate (the tenant_id its events live under), with no
|
|
264
|
-
* membership/tenant filter. SECURITY: existence-oracle, same caveat as
|
|
265
|
-
* getUnscopedAggregateStreamMaxVersion — seed/system-internal use only. Recovers
|
|
266
|
-
* the write target for a systemScope aggregate whose stream tenant isn't one of
|
|
267
|
-
* the subject's memberships. Returns null for unknown streams. */
|
|
268
|
-
export async function getUnscopedAggregateStreamTenant(
|
|
269
|
-
db: DbRunner,
|
|
270
|
-
aggregateId: string,
|
|
271
|
-
aggregateType: string,
|
|
272
|
-
): Promise<TenantId | null> {
|
|
273
|
-
const tenantId = await selectAggregateStreamTenant(db, aggregateId, aggregateType);
|
|
274
|
-
// DB-boundary: kumiko_events.tenant_id is a TenantId-shaped uuid column.
|
|
275
|
-
return tenantId as TenantId | null;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
262
|
// Global high-water-mark = MAX(events.id). Marten/Wolverine standard for
|
|
279
263
|
// projection/consumer lag math: lag = HWM - cursor. Single-row aggregate over
|
|
280
264
|
// the bigserial PK index — sub-millisecond cost. Returns 0n on an empty log
|
|
@@ -312,7 +296,7 @@ export async function loadEventsAfterVersion(
|
|
|
312
296
|
// prevent.
|
|
313
297
|
export const LOAD_ALL_EVENTS_ROW_LIMIT = 100_000;
|
|
314
298
|
|
|
315
|
-
/**
|
|
299
|
+
/** Buffers ALL matching events in memory — a memory cliff for large stores. Use `streamAllEventsByType` (yields batchwise) for production reads; this is test-only in practice. */
|
|
316
300
|
export async function loadAllEventsByType(
|
|
317
301
|
db: DbRunner,
|
|
318
302
|
aggregateType: string,
|
package/src/event-store/index.ts
CHANGED
|
@@ -2,9 +2,12 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
|
|
4
4
|
import type { TenantId } from "../../engine/types/identifiers";
|
|
5
|
+
import { createSecret } from "../../secrets/types";
|
|
5
6
|
import { createTestUser } from "../../stack";
|
|
6
7
|
import { createDispatcher } from "../dispatcher";
|
|
7
8
|
|
|
9
|
+
const streamCleanupState = { closed: false };
|
|
10
|
+
|
|
8
11
|
const echoFeature = defineFeature("echo", (r) => {
|
|
9
12
|
r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
|
|
10
13
|
|
|
@@ -36,6 +39,29 @@ const echoFeature = defineFeature("echo", (r) => {
|
|
|
36
39
|
{ access: { roles: ["Admin"] } },
|
|
37
40
|
);
|
|
38
41
|
|
|
42
|
+
r.streamHandler(
|
|
43
|
+
"item:tail-leak",
|
|
44
|
+
z.object({}),
|
|
45
|
+
async function* () {
|
|
46
|
+
yield { apiKey: createSecret("leak-me") };
|
|
47
|
+
},
|
|
48
|
+
{ access: { roles: ["Admin"] } },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
r.streamHandler(
|
|
52
|
+
"item:tail-cleanup",
|
|
53
|
+
z.object({}),
|
|
54
|
+
async function* () {
|
|
55
|
+
try {
|
|
56
|
+
yield { i: 0 };
|
|
57
|
+
yield { i: 1 };
|
|
58
|
+
} finally {
|
|
59
|
+
streamCleanupState.closed = true;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{ access: { roles: ["Admin"] } },
|
|
63
|
+
);
|
|
64
|
+
|
|
39
65
|
r.hook("validation", "item:create", (data) => {
|
|
40
66
|
if (data["name"] === "forbidden") return [{ field: "name", error: "forbidden_name" }];
|
|
41
67
|
return null;
|
|
@@ -272,6 +298,32 @@ describe("dispatcher.stream", () => {
|
|
|
272
298
|
collect(dispatcher.stream("nonexistent", {}, createTestUser({ roles: ["Admin"] }))),
|
|
273
299
|
).rejects.toMatchObject({ code: "not_found", httpStatus: 404 });
|
|
274
300
|
});
|
|
301
|
+
|
|
302
|
+
test("a chunk containing a Secret<> value aborts the stream instead of leaking it", async () => {
|
|
303
|
+
const dispatcher = createTestDispatcher();
|
|
304
|
+
|
|
305
|
+
await expect(
|
|
306
|
+
collect(
|
|
307
|
+
dispatcher.stream("echo:stream:item:tail-leak", {}, createTestUser({ roles: ["Admin"] })),
|
|
308
|
+
),
|
|
309
|
+
).rejects.toMatchObject({ message: expect.stringContaining("Secret<> leaked") });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("consumer breaking out of for-await runs the handler generator's finally block", async () => {
|
|
313
|
+
streamCleanupState.closed = false;
|
|
314
|
+
const dispatcher = createTestDispatcher();
|
|
315
|
+
const gen = dispatcher.stream(
|
|
316
|
+
"echo:stream:item:tail-cleanup",
|
|
317
|
+
{},
|
|
318
|
+
createTestUser({ roles: ["Admin"] }),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
for await (const _chunk of gen) {
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
expect(streamCleanupState.closed).toBe(true);
|
|
326
|
+
});
|
|
275
327
|
});
|
|
276
328
|
|
|
277
329
|
// --- postQuery hooks on standalone (entity-less) queries ---
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// createJobTriggerEventConsumer — proves r.job's trigger.on can fire on an
|
|
2
|
+
// r.defineEvent QN appended by a multiStreamProjection's unsafeAppendEvent
|
|
3
|
+
// (kumiko-framework#1505). Mirrors document-ingest-foundation's actual
|
|
4
|
+
// request-ingest MSP (upload → fileRef.created → an owned defineEvent),
|
|
5
|
+
// the motivating case for this fix — fileRef.created itself never reaches
|
|
6
|
+
// jobRunner.handleEvent because the upload route appends it via the raw
|
|
7
|
+
// event-store executor, not a write-handler dispatch (see #1505).
|
|
8
|
+
//
|
|
9
|
+
// Not covered here: a job triggered on a write/query-handler QN still
|
|
10
|
+
// firing exactly once (unaffected by the new consumer). The full suite
|
|
11
|
+
// stays green (e.g. the lane-routing sample), but that's not a positive
|
|
12
|
+
// test of the partition guard — no stored event's `type` is ever a
|
|
13
|
+
// handler QN in practice (entity events are "entity.verb"; custom
|
|
14
|
+
// write-handlers like lane-routing's don't append to the store at all),
|
|
15
|
+
// so `getWriteHandler`/`getQueryHandler` in the new consumer's handler is
|
|
16
|
+
// defense-in-depth for an input shape the framework doesn't currently
|
|
17
|
+
// produce, not something exercised end-to-end by any test today.
|
|
18
|
+
|
|
19
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { entityEventName } from "../../db";
|
|
22
|
+
import { defineFeature } from "../../engine";
|
|
23
|
+
import { createInMemoryFileProvider, type InMemoryFileProvider } from "../../files";
|
|
24
|
+
import { setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
25
|
+
import { waitFor } from "../../testing";
|
|
26
|
+
|
|
27
|
+
const ITEM_REQUESTED_EVENT_QN = "job-trigger-fixture:event:item-requested";
|
|
28
|
+
const FILE_REF_CREATED = entityEventName("fileRef", "created");
|
|
29
|
+
|
|
30
|
+
const processedItems: Array<{ readonly fileRefId: string }> = [];
|
|
31
|
+
|
|
32
|
+
const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
|
|
33
|
+
r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) }));
|
|
34
|
+
|
|
35
|
+
// Mirrors document-ingest-foundation's request-ingest MSP exactly: reacts
|
|
36
|
+
// to fileRef.created, appends a NEW event via unsafeAppendEvent — no
|
|
37
|
+
// write-handler behind the appended event itself.
|
|
38
|
+
r.multiStreamProjection({
|
|
39
|
+
name: "request-item",
|
|
40
|
+
apply: {
|
|
41
|
+
[FILE_REF_CREATED]: async (event, _tx, ctx) => {
|
|
42
|
+
await ctx.unsafeAppendEvent({
|
|
43
|
+
aggregateId: event.aggregateId,
|
|
44
|
+
aggregateType: "job-trigger-fixture-request",
|
|
45
|
+
type: ITEM_REQUESTED_EVENT_QN,
|
|
46
|
+
payload: { fileRefId: event.aggregateId },
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Under test: only reachable via createJobTriggerEventConsumer, since
|
|
53
|
+
// ITEM_REQUESTED_EVENT_QN is an r.defineEvent QN, not a handler QN.
|
|
54
|
+
r.job(
|
|
55
|
+
"process-item",
|
|
56
|
+
{ trigger: { on: ITEM_REQUESTED_EVENT_QN }, runIn: "worker" },
|
|
57
|
+
async (payload) => {
|
|
58
|
+
processedItems.push({ fileRefId: payload["fileRefId"] as string });
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let stack: TestStack;
|
|
64
|
+
let provider: InMemoryFileProvider;
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
provider = createInMemoryFileProvider();
|
|
68
|
+
stack = await setupTestStack({
|
|
69
|
+
features: [jobTriggerFixtureFeature],
|
|
70
|
+
files: { storageProvider: provider },
|
|
71
|
+
jobs: { consumerLane: "worker" },
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterAll(async () => {
|
|
76
|
+
await stack.cleanup();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
processedItems.length = 0;
|
|
81
|
+
provider.clear();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("job-trigger event consumer", () => {
|
|
85
|
+
test("a job triggers on an r.defineEvent QN appended by an MSP's unsafeAppendEvent", async () => {
|
|
86
|
+
const token = await stack.jwt.sign(TestUsers.admin);
|
|
87
|
+
const formData = new FormData();
|
|
88
|
+
formData.append("file", new File([Buffer.from("hello")], "note.txt", { type: "text/plain" }));
|
|
89
|
+
const res = await stack.app.request("/api/files", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
92
|
+
body: formData,
|
|
93
|
+
});
|
|
94
|
+
expect(res.status).toBe(201);
|
|
95
|
+
|
|
96
|
+
await waitFor(async () => {
|
|
97
|
+
// Drives both the MSP (appends item-requested off fileRef.created)
|
|
98
|
+
// and the new job-trigger consumer (reacts to it) — may need more
|
|
99
|
+
// than one pass since the MSP's append happens mid-drain.
|
|
100
|
+
await stack.eventDispatcher?.runOnce();
|
|
101
|
+
expect(processedItems).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(processedItems[0]?.fileRefId).toBeTruthy();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -707,30 +707,43 @@ describe("runPostSaveBatch / runPostDeleteBatch", () => {
|
|
|
707
707
|
expect(seen).toEqual([[deletectx]]);
|
|
708
708
|
});
|
|
709
709
|
|
|
710
|
-
test
|
|
710
|
+
test.each([
|
|
711
|
+
[
|
|
712
|
+
"postSaveBatch",
|
|
713
|
+
(hooks: { name: string; priority: number; fn: () => Promise<void> }[]) =>
|
|
714
|
+
({ postSaveBatch: hooks }) satisfies SystemHooks,
|
|
715
|
+
(pipeline: ReturnType<typeof createLifecycleHooks>) =>
|
|
716
|
+
pipeline.runPostSaveBatch([savectx], {}),
|
|
717
|
+
],
|
|
718
|
+
[
|
|
719
|
+
"postDeleteBatch",
|
|
720
|
+
(hooks: { name: string; priority: number; fn: () => Promise<void> }[]) =>
|
|
721
|
+
({ postDeleteBatch: hooks }) satisfies SystemHooks,
|
|
722
|
+
(pipeline: ReturnType<typeof createLifecycleHooks>) =>
|
|
723
|
+
pipeline.runPostDeleteBatch([deletectx], {}),
|
|
724
|
+
],
|
|
725
|
+
])("one %s hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown", async (_name, buildHooks, run) => {
|
|
711
726
|
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
712
727
|
const calls: string[] = [];
|
|
713
|
-
const systemHooks
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
throw new Error("batch-hook-boom");
|
|
720
|
-
},
|
|
728
|
+
const systemHooks = buildHooks([
|
|
729
|
+
{
|
|
730
|
+
name: "failing",
|
|
731
|
+
priority: 1000,
|
|
732
|
+
fn: async () => {
|
|
733
|
+
throw new Error("batch-hook-boom");
|
|
721
734
|
},
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
name: "ok",
|
|
738
|
+
priority: 1001,
|
|
739
|
+
fn: async () => {
|
|
740
|
+
calls.push("ok-ran");
|
|
728
741
|
},
|
|
729
|
-
|
|
730
|
-
|
|
742
|
+
},
|
|
743
|
+
]);
|
|
731
744
|
const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
|
|
732
745
|
// Must not throw.
|
|
733
|
-
await pipeline
|
|
746
|
+
await run(pipeline);
|
|
734
747
|
expect(calls).toEqual(["ok-ran"]);
|
|
735
748
|
expect(consoleSpy).toHaveBeenCalled();
|
|
736
749
|
consoleSpy.mockRestore();
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { DbTx } from "../db/connection";
|
|
2
1
|
import { hasAccess } from "../engine/access";
|
|
3
2
|
import type { SessionUser } from "../engine/types";
|
|
4
3
|
import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors";
|
|
@@ -22,11 +21,8 @@ export async function* executeStream(
|
|
|
22
21
|
type: string,
|
|
23
22
|
payload: unknown,
|
|
24
23
|
user: SessionUser,
|
|
25
|
-
tx?: DbTx,
|
|
26
24
|
): AsyncGenerator<unknown> {
|
|
27
|
-
yield* runStreamInstrumented(ctx, type, user, () =>
|
|
28
|
-
executeStreamInner(ctx, type, payload, user, tx),
|
|
29
|
-
);
|
|
25
|
+
yield* runStreamInstrumented(ctx, type, user, () => executeStreamInner(ctx, type, payload, user));
|
|
30
26
|
}
|
|
31
27
|
|
|
32
28
|
async function* executeStreamInner(
|
|
@@ -34,7 +30,6 @@ async function* executeStreamInner(
|
|
|
34
30
|
type: string,
|
|
35
31
|
payload: unknown,
|
|
36
32
|
user: SessionUser,
|
|
37
|
-
tx?: DbTx,
|
|
38
33
|
): AsyncGenerator<unknown> {
|
|
39
34
|
const { registry } = ctx;
|
|
40
35
|
const handler = registry.getStreamHandler(type);
|
|
@@ -58,7 +53,7 @@ async function* executeStreamInner(
|
|
|
58
53
|
throw validationErrorFromZod(parsed.error);
|
|
59
54
|
}
|
|
60
55
|
|
|
61
|
-
const handlerContext = buildHandlerContext(ctx, type, user
|
|
56
|
+
const handlerContext = buildHandlerContext(ctx, type, user);
|
|
62
57
|
const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
|
|
63
58
|
|
|
64
59
|
// Consumer-driven pull (for await) is the backpressure mechanism — the
|
|
@@ -194,7 +194,8 @@ export type DeliveryOutcome = {
|
|
|
194
194
|
|
|
195
195
|
// Deliver events to the consumer's handler in events.id order. Halt-on-
|
|
196
196
|
// poison: a throw breaks the loop, the cursor stays at the last successful
|
|
197
|
-
// event, and attempts climb. At
|
|
197
|
+
// event, and attempts climb. At the consumer's effectiveMaxAttempts
|
|
198
|
+
// (errorPolicy.maxAttempts ?? maxAttempts) the caller persists status=
|
|
198
199
|
// "dead" and the consumer is parked until ops intervenes (see
|
|
199
200
|
// restartConsumer / skipPoisonEvent).
|
|
200
201
|
export async function deliverEvents(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SseBroker } from "../api/sse-broker";
|
|
2
2
|
import type { DbRow } from "../db/connection";
|
|
3
3
|
import { tenantChannel } from "../engine/constants";
|
|
4
|
-
import type { EntityId, Registry } from "../engine/types";
|
|
4
|
+
import type { EntityId, JobRunnerRef, Registry, SessionUser } from "../engine/types";
|
|
5
5
|
import type { SearchAdapter, SearchDocument } from "../search/types";
|
|
6
6
|
import type { EventConsumer } from "./event-dispatcher";
|
|
7
7
|
|
|
@@ -249,3 +249,52 @@ export function createSseBroadcastEventConsumer(sseBroker: SseBroker): EventCons
|
|
|
249
249
|
},
|
|
250
250
|
};
|
|
251
251
|
}
|
|
252
|
+
|
|
253
|
+
// --- Job-Trigger Consumer (async, via event-dispatcher) ---
|
|
254
|
+
//
|
|
255
|
+
// r.job's `trigger.on` historically only fired via the synchronous
|
|
256
|
+
// write-handler dispatch path (dispatch-write.ts's afterCommitHooks calling
|
|
257
|
+
// jobRunner.handleEvent). Events appended any other way — an
|
|
258
|
+
// r.multiStreamProjection's ctx.unsafeAppendEvent, or a raw
|
|
259
|
+
// event-store-executor write (e.g. `files`' fileRef.created) — never
|
|
260
|
+
// reached it, so a job could never trigger on an r.defineEvent-registered
|
|
261
|
+
// event (kumiko-framework#1505).
|
|
262
|
+
//
|
|
263
|
+
// This consumer closes that gap the same way search/SSE do: read every
|
|
264
|
+
// committed event off the shared cursor and re-check job triggers. It is
|
|
265
|
+
// scoped EXACTLY to the gap — the write/query-handler-QN skip below is
|
|
266
|
+
// defense-in-depth: no stored event's `type` is a handler QN today (entity
|
|
267
|
+
// events are "entity.verb"; ctx.appendEvent enforces defineEvent-only
|
|
268
|
+
// ownership), but if that ever changes, this consumer must not re-fire a
|
|
269
|
+
// trigger the synchronous dispatch-write.ts path already handled.
|
|
270
|
+
//
|
|
271
|
+
// Delivery is at-least-once (cursor semantics) where the synchronous path
|
|
272
|
+
// is effectively once — job handlers reached via an r.defineEvent trigger
|
|
273
|
+
// must be idempotent (same expectation r.multiStreamProjection applies
|
|
274
|
+
// already carry).
|
|
275
|
+
export const JOB_TRIGGER_CONSUMER_NAME = "system:consumer:job-trigger";
|
|
276
|
+
|
|
277
|
+
export function createJobTriggerEventConsumer(
|
|
278
|
+
jobRunner: JobRunnerRef,
|
|
279
|
+
registry: Registry,
|
|
280
|
+
): EventConsumer {
|
|
281
|
+
return {
|
|
282
|
+
name: JOB_TRIGGER_CONSUMER_NAME,
|
|
283
|
+
handler: async (event) => {
|
|
284
|
+
// skip: write/query-handler QN — already dispatched synchronously by
|
|
285
|
+
// dispatch-write.ts's afterCommitHooks. Re-firing here would
|
|
286
|
+
// double-enqueue every existing handler-triggered job.
|
|
287
|
+
if (registry.getWriteHandler(event.type) || registry.getQueryHandler(event.type)) return;
|
|
288
|
+
// skip: no r.defineEvent registered under this type — nothing this
|
|
289
|
+
// consumer is responsible for.
|
|
290
|
+
if (!registry.getEvent(event.type)) return;
|
|
291
|
+
|
|
292
|
+
const user: SessionUser = {
|
|
293
|
+
id: event.metadata.userId,
|
|
294
|
+
tenantId: event.tenantId,
|
|
295
|
+
roles: [],
|
|
296
|
+
};
|
|
297
|
+
await jobRunner.handleEvent(event.type, event.payload, user);
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { createEncryptionProvider } from "../encryption";
|
|
3
|
-
|
|
4
|
-
// 32 bytes base64-encoded for AES-256
|
|
5
|
-
const TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
|
|
6
|
-
|
|
7
|
-
describe("EncryptionProvider", () => {
|
|
8
|
-
test("encrypt + decrypt roundtrip returns original", () => {
|
|
9
|
-
const provider = createEncryptionProvider(TEST_KEY);
|
|
10
|
-
const ciphertext = provider.encrypt("hello world");
|
|
11
|
-
expect(provider.decrypt(ciphertext)).toBe("hello world");
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
test("same plaintext produces different ciphertexts (random IV)", () => {
|
|
15
|
-
const provider = createEncryptionProvider(TEST_KEY);
|
|
16
|
-
const a = provider.encrypt("same");
|
|
17
|
-
const b = provider.encrypt("same");
|
|
18
|
-
expect(a).not.toBe(b);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("decrypt with different key throws", () => {
|
|
22
|
-
const key2 = Buffer.from("x]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
|
|
23
|
-
const p1 = createEncryptionProvider(TEST_KEY);
|
|
24
|
-
const p2 = createEncryptionProvider(key2);
|
|
25
|
-
const ciphertext = p1.encrypt("secret");
|
|
26
|
-
expect(() => p2.decrypt(ciphertext)).toThrow();
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("handles unicode and emoji", () => {
|
|
30
|
-
const provider = createEncryptionProvider(TEST_KEY);
|
|
31
|
-
const ciphertext = provider.encrypt("Ünïcödé 🔐");
|
|
32
|
-
expect(provider.decrypt(ciphertext)).toBe("Ünïcödé 🔐");
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test("throws on invalid key length", () => {
|
|
36
|
-
const shortKey = Buffer.from("too-short").toString("base64");
|
|
37
|
-
expect(() => createEncryptionProvider(shortKey)).toThrow(/32 bytes/);
|
|
38
|
-
});
|
|
39
|
-
});
|
package/src/db/encryption.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
-
|
|
3
|
-
const ALGORITHM = "aes-256-gcm";
|
|
4
|
-
const IV_LENGTH = 12;
|
|
5
|
-
const TAG_LENGTH = 16;
|
|
6
|
-
|
|
7
|
-
export type EncryptionProvider = {
|
|
8
|
-
encrypt(plaintext: string): string;
|
|
9
|
-
decrypt(ciphertext: string): string;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* @deprecated Legacy single-key format: base64(iv+tag+ct) with NO key id —
|
|
14
|
-
* a key change makes existing ciphertexts permanently undecryptable. Use
|
|
15
|
-
* `createEnvelopeCipher` (@cosmicdrift/kumiko-framework/secrets) for new code;
|
|
16
|
-
* this provider has no consumers left in the framework.
|
|
17
|
-
*/
|
|
18
|
-
export function createEncryptionProvider(key: string): EncryptionProvider {
|
|
19
|
-
// Key must be 32 bytes for AES-256
|
|
20
|
-
const keyBuffer = Buffer.from(key, "base64");
|
|
21
|
-
if (keyBuffer.length !== 32) {
|
|
22
|
-
throw new Error("ENCRYPTION_KEY must be 32 bytes (base64 encoded)");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return {
|
|
26
|
-
encrypt(plaintext: string): string {
|
|
27
|
-
const iv = randomBytes(IV_LENGTH);
|
|
28
|
-
const cipher = createCipheriv(ALGORITHM, keyBuffer, iv);
|
|
29
|
-
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
30
|
-
const tag = cipher.getAuthTag();
|
|
31
|
-
// Format: base64(iv + tag + ciphertext)
|
|
32
|
-
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
|
33
|
-
},
|
|
34
|
-
|
|
35
|
-
decrypt(ciphertext: string): string {
|
|
36
|
-
const data = Buffer.from(ciphertext, "base64");
|
|
37
|
-
const iv = data.subarray(0, IV_LENGTH);
|
|
38
|
-
const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
39
|
-
const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH);
|
|
40
|
-
const decipher = createDecipheriv(ALGORITHM, keyBuffer, iv);
|
|
41
|
-
decipher.setAuthTag(tag);
|
|
42
|
-
return decipher.update(encrypted) + decipher.final("utf8");
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
|
-
}
|