@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.
Files changed (32) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/consumer-cli.integration.test.ts +30 -0
  3. package/src/api/__tests__/api.test.ts +60 -0
  4. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +18 -2
  5. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +27 -18
  6. package/src/api/routes.ts +41 -22
  7. package/src/api/server.ts +14 -6
  8. package/src/bun-db/connection.ts +3 -3
  9. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +5 -12
  10. package/src/db/index.ts +0 -2
  11. package/src/db/pg-error.ts +1 -1
  12. package/src/db/queries/event-store.ts +14 -16
  13. package/src/engine/__tests__/boot-validator.test.ts +14 -0
  14. package/src/engine/__tests__/registry.test.ts +36 -0
  15. package/src/engine/boot-validator/entity-handler.ts +14 -25
  16. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +4 -0
  17. package/src/engine/feature-ast/extractors/handlers.ts +13 -18
  18. package/src/engine/registry-validate.ts +7 -2
  19. package/src/engine/types/index.ts +0 -2
  20. package/src/event-store/__tests__/event-store.integration.test.ts +138 -0
  21. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +5 -8
  22. package/src/event-store/event-store.ts +12 -23
  23. package/src/event-store/events-schema.ts +10 -3
  24. package/src/event-store/index.ts +1 -2
  25. package/src/pipeline/__tests__/dispatcher.test.ts +52 -0
  26. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  27. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +31 -18
  28. package/src/pipeline/dispatch-stream.ts +2 -7
  29. package/src/pipeline/event-dispatcher-delivery.ts +2 -1
  30. package/src/pipeline/system-hooks.ts +50 -1
  31. package/src/db/__tests__/encryption.test.ts +0 -39
  32. 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, tx);
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 maxAttempts the caller persists status=
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
- });
@@ -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
- }