@cosmicdrift/kumiko-framework 0.163.3 → 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/pg-error.ts +1 -1
- package/src/db/queries/event-store.ts +14 -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 +138 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +5 -8
- package/src/event-store/event-store.ts +12 -23
- package/src/event-store/events-schema.ts +10 -3
- package/src/event-store/index.ts +1 -2
- 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
|
@@ -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
|
-
}
|