@cosmicdrift/kumiko-framework 0.326.0 → 0.327.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 (36) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/auth-middleware-transport.test.ts +23 -1
  3. package/src/api/__tests__/client-ip.test.ts +20 -0
  4. package/src/api/__tests__/origin-middleware.test.ts +65 -0
  5. package/src/api/__tests__/websocket-route.test.ts +552 -0
  6. package/src/api/auth-middleware.ts +29 -7
  7. package/src/api/client-ip.ts +15 -2
  8. package/src/api/index.ts +24 -1
  9. package/src/api/jwt.ts +5 -0
  10. package/src/api/origin-middleware.ts +27 -1
  11. package/src/api/server.ts +161 -2
  12. package/src/api/websocket-route.ts +327 -0
  13. package/src/bun-db/query.ts +7 -5
  14. package/src/changes.json +18 -0
  15. package/src/crypto/__tests__/system-event-pii.test.ts +77 -0
  16. package/src/crypto/event-pii.ts +20 -4
  17. package/src/crypto/system-event-pii.ts +57 -0
  18. package/src/db/__tests__/apply-entity-event-removed-column.integration.test.ts +109 -0
  19. package/src/engine/__tests__/describe-workflow-step-error.test.ts +17 -0
  20. package/src/engine/__tests__/steps-mail-send.test.ts +6 -5
  21. package/src/engine/__tests__/steps-webhook-send.test.ts +16 -8
  22. package/src/engine/define-feature.ts +1 -0
  23. package/src/engine/feature-builder-state.ts +3 -0
  24. package/src/engine/feature-ui-extensions.ts +52 -0
  25. package/src/engine/index.ts +5 -0
  26. package/src/engine/steps/describe-workflow-step-error.ts +6 -0
  27. package/src/engine/steps/mail-send.ts +5 -1
  28. package/src/engine/steps/retry.ts +13 -1
  29. package/src/engine/steps/webhook-send.ts +6 -16
  30. package/src/engine/types/index.ts +7 -0
  31. package/src/event-store/provenance-append.ts +1 -1
  32. package/src/event-store/transfer.ts +2 -2
  33. package/src/jobs/__tests__/jobs.integration.test.ts +31 -0
  34. package/src/jobs/job-runner.ts +18 -0
  35. package/src/pipeline/append-event-core.ts +1 -8
  36. package/src/pipeline/irreversible-operation-gate.ts +4 -3
@@ -0,0 +1,77 @@
1
+ // System events bypass the defineEvent catalog, so their PII stance lives in
2
+ // a static map. An undeclared system type must fail closed (also without a
3
+ // KMS), and every known system type must have a declared stance.
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import * as stepDispatchConstants from "../../engine/steps/_step-dispatch-constants";
7
+ import type { TenantId } from "../../engine/types/identifiers";
8
+ import { encryptEventPayloadPii } from "../event-pii";
9
+ import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
10
+ import {
11
+ configurePiiSubjectKms,
12
+ isPiiCiphertext,
13
+ resetPiiSubjectKmsForTests,
14
+ } from "../pii-field-encryption";
15
+ import type { EventSubjectEnvelope } from "../subject-resolver";
16
+ import {
17
+ AGGREGATE_TRANSFERRED_EVENT_TYPE,
18
+ SYSTEM_EVENT_PII_STANCES,
19
+ SYSTEM_EVENT_PREFIX,
20
+ } from "../system-event-pii";
21
+
22
+ const ENVELOPE: EventSubjectEnvelope = {
23
+ tenantId: "6b2f4a0e-1c9d-4f3a-9d2e-0000000000e1" as TenantId,
24
+ aggregateType: "step-dispatch",
25
+ aggregateId: "6b2f4a0e-1c9d-4f3a-9d2e-0000000000e2",
26
+ };
27
+
28
+ afterEach(() => {
29
+ resetPiiSubjectKmsForTests();
30
+ });
31
+
32
+ describe("system event PII stances", () => {
33
+ test("every system event type constant has a declared stance", () => {
34
+ const declaredTypes = [
35
+ ...Object.entries(stepDispatchConstants)
36
+ .filter(([name]) => name.endsWith("_TYPE"))
37
+ .map(([, value]: [string, unknown]) => value)
38
+ .filter(
39
+ (value): value is string =>
40
+ typeof value === "string" && value.startsWith(SYSTEM_EVENT_PREFIX),
41
+ ),
42
+ AGGREGATE_TRANSFERRED_EVENT_TYPE,
43
+ ];
44
+ expect(declaredTypes.length).toBeGreaterThanOrEqual(11);
45
+ for (const type of declaredTypes) {
46
+ expect(SYSTEM_EVENT_PII_STANCES.has(type)).toBe(true);
47
+ }
48
+ });
49
+
50
+ test("an undeclared system type fails closed, even without a configured KMS", async () => {
51
+ await expect(
52
+ encryptEventPayloadPii(`${SYSTEM_EVENT_PREFIX}made.up`, { secret: "x" }, ENVELOPE),
53
+ ).rejects.toThrow(/System event "kumiko:system:made\.up" has no PII stance/);
54
+ });
55
+
56
+ test('a "none" stance passes the payload through untouched', async () => {
57
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
58
+ const payload = { stepKind: "mail.send", status: 202 };
59
+ expect(
60
+ await encryptEventPayloadPii(stepDispatchConstants.STEP_DISPATCHED_TYPE, payload, ENVELOPE),
61
+ ).toBe(payload);
62
+ });
63
+
64
+ test("dispatch-requested encrypts its string fields under the per-dispatch record key", async () => {
65
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
66
+ const out = await encryptEventPayloadPii(
67
+ stepDispatchConstants.STEP_DISPATCH_REQUESTED_TYPE,
68
+ { stepKind: "mail.send", to: '"a@example.com"', subject: "Hi", body: "Body" },
69
+ ENVELOPE,
70
+ );
71
+ expect(out["stepKind"]).toBe("mail.send");
72
+ for (const field of ["to", "subject", "body"]) {
73
+ expect(isPiiCiphertext(out[field])).toBe(true);
74
+ expect(String(out[field])).toContain(`record:step-dispatch:${ENVELOPE.aggregateId}`);
75
+ }
76
+ });
77
+ });
@@ -4,7 +4,9 @@
4
4
  // path at all. `r.defineEvent(name, schema, { piiFields })` declares which
5
5
  // payload fields are PII and which payload field names the owning user;
6
6
  // createRegistry publishes the catalog and append() — the single write funnel
7
- // into kumiko_events — encrypts every catalogued field.
7
+ // into kumiko_events — encrypts every catalogued field. `kumiko:system:*`
8
+ // events skip the registry and take their stance from system-event-pii.ts
9
+ // instead (fail-closed when missing).
8
10
  //
9
11
  // `piiFields` is a mandatory, explicit stance (fw#2558): a payload can only
10
12
  // go uncatalogued through a declared `piiFields: "none"`, never by omission —
@@ -12,10 +14,11 @@
12
14
  // silent no-op (forgetting the option) is now either an explicit "none" or a
13
15
  // missing subject KMS at encrypt-time, both visible states, not a gap.
14
16
 
15
- import type { EventPiiFields } from "@cosmicdrift/kumiko-types/handlers";
17
+ import type { EventPiiFields, EventPiiStance } from "@cosmicdrift/kumiko-types/handlers";
16
18
  import { requestContext } from "../api/request-context";
17
19
  import { configuredPiiSubjectKms, encryptPiiValueForSubject } from "./pii-field-encryption";
18
20
  import { type EventSubjectEnvelope, resolveEventSubject } from "./subject-resolver";
21
+ import { SYSTEM_EVENT_PII_STANCES, SYSTEM_EVENT_PREFIX } from "./system-event-pii";
19
22
 
20
23
  export type EventPiiCatalog = ReadonlyMap<string, EventPiiFields>;
21
24
 
@@ -36,6 +39,19 @@ export function resetEventPiiCatalogForTests(): void {
36
39
  catalog = new Map();
37
40
  }
38
41
 
42
+ // System types are checked before the KMS gate: an undeclared one throws
43
+ // even in plaintext rollout mode.
44
+ function resolvePiiStance(eventType: string): EventPiiStance | undefined {
45
+ if (!eventType.startsWith(SYSTEM_EVENT_PREFIX)) return catalog.get(eventType);
46
+ const stance = SYSTEM_EVENT_PII_STANCES.get(eventType);
47
+ if (stance === undefined) {
48
+ throw new Error(
49
+ `System event "${eventType}" has no PII stance — declare one in SYSTEM_EVENT_PII_STANCES (crypto/system-event-pii.ts)`,
50
+ );
51
+ }
52
+ return stance;
53
+ }
54
+
39
55
  // Encrypts catalogued payload fields under the declared subject's DEK
40
56
  // (user/tenant/self — resolveEventSubject, fw#2801). No-op when the event
41
57
  // type is uncatalogued or no subject KMS is configured (plaintext rollout
@@ -51,8 +67,8 @@ export async function encryptEventPayloadPii(
51
67
  payload: Record<string, unknown>,
52
68
  envelope: EventSubjectEnvelope,
53
69
  ): Promise<Record<string, unknown>> {
54
- const piiFields = catalog.get(eventType);
55
- if (!piiFields) return payload;
70
+ const piiFields = resolvePiiStance(eventType);
71
+ if (!piiFields || piiFields === "none") return payload;
56
72
  const kms = configuredPiiSubjectKms();
57
73
  if (!kms) return payload;
58
74
 
@@ -0,0 +1,57 @@
1
+ // PII stance of every `kumiko:system:*` event. System events bypass the
2
+ // registry (append-event-core), so they never reach the defineEvent catalog;
3
+ // this static map is their catalog. encryptEventPayloadPii fails closed on a
4
+ // system type with no entry here, so a new system event cannot ship its
5
+ // payload as plaintext by omission.
6
+
7
+ import type { EventPiiStance } from "@cosmicdrift/kumiko-types/handlers";
8
+ import {
9
+ STEP_DISPATCH_FAILED_TYPE,
10
+ STEP_DISPATCH_REQUESTED_TYPE,
11
+ STEP_DISPATCHED_TYPE,
12
+ WORKFLOW_RESUMED_TYPE,
13
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
14
+ WORKFLOW_RUN_COMPLETED_TYPE,
15
+ WORKFLOW_RUN_FAILED_TYPE,
16
+ WORKFLOW_RUN_STARTED_TYPE,
17
+ WORKFLOW_WAITING_FOR_EVENT_TYPE,
18
+ WORKFLOW_WAITING_TYPE,
19
+ } from "../engine/steps/_step-dispatch-constants";
20
+
21
+ export const SYSTEM_EVENT_PREFIX = "kumiko:system:";
22
+
23
+ // Lives here (not in event-store/transfer.ts) so this module stays free of
24
+ // an event-store import cycle; transfer.ts re-exports it.
25
+ export const AGGREGATE_TRANSFERRED_EVENT_TYPE = `${SYSTEM_EVENT_PREFIX}aggregate.transferred`;
26
+
27
+ const SELF = { personal: "self" } as const;
28
+
29
+ // Workflow payloads carry `triggerPayload`, the stored payload of the trigger
30
+ // event: catalogued fields there are already ciphertext, so "none" leaks nothing.
31
+ export const SYSTEM_EVENT_PII_STANCES: ReadonlyMap<string, EventPiiStance> = new Map<
32
+ string,
33
+ EventPiiStance
34
+ >([
35
+ [
36
+ STEP_DISPATCH_REQUESTED_TYPE,
37
+ {
38
+ to: SELF,
39
+ subject: SELF,
40
+ body: SELF,
41
+ from: SELF,
42
+ url: SELF,
43
+ headersJson: SELF,
44
+ bodyJson: SELF,
45
+ },
46
+ ],
47
+ [STEP_DISPATCHED_TYPE, "none"],
48
+ [STEP_DISPATCH_FAILED_TYPE, "none"],
49
+ [AGGREGATE_TRANSFERRED_EVENT_TYPE, "none"],
50
+ [WORKFLOW_WAITING_TYPE, "none"],
51
+ [WORKFLOW_WAITING_FOR_EVENT_TYPE, "none"],
52
+ [WORKFLOW_RESUMED_TYPE, "none"],
53
+ [WORKFLOW_RUN_STARTED_TYPE, "none"],
54
+ [WORKFLOW_RUN_COMPLETED_TYPE, "none"],
55
+ [WORKFLOW_RUN_FAILED_TYPE, "none"],
56
+ [WORKFLOW_RETRY_SCHEDULED_TYPE, "none"],
57
+ ]);
@@ -0,0 +1,109 @@
1
+ // A historical `<entity>.updated` event may carry a field that has since been
2
+ // removed from the entity and its table. Replaying it during a projection
3
+ // rebuild must drop the unknown column instead of failing the whole rebuild.
4
+
5
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
6
+ import { createEntity, createTextField, defineFeature } from "../../engine";
7
+ import { append } from "../../event-store";
8
+ import { createProjectionStateTable, rebuildProjection } from "../../pipeline";
9
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
10
+ import { createEventStoreExecutor } from "../event-store-executor";
11
+ import { asRawClient } from "../query";
12
+ import { buildEntityTable } from "../table-builder";
13
+ import { createTenantDb, type TenantDb } from "../tenant-db";
14
+
15
+ const noteEntity = createEntity({
16
+ table: "read_removed_column_notes",
17
+ fields: {
18
+ title: createTextField({ personal: false, reason: "test_fixture", required: true }),
19
+ },
20
+ });
21
+ const noteTable = buildEntityTable("removed-note", noteEntity);
22
+
23
+ const feature = defineFeature("removedcol", (r) => {
24
+ r.entity("removed-note", noteEntity);
25
+ });
26
+
27
+ const admin = TestUsers.admin;
28
+ const PROJECTION = "removedcol:projection:removed-note-entity";
29
+ const AGGREGATE_ID = "00000000-0000-4000-8000-0000000000aa";
30
+
31
+ let stack: TestStack;
32
+ let tdb: TenantDb;
33
+
34
+ beforeAll(async () => {
35
+ stack = await setupTestStack({ features: [feature] });
36
+ await unsafeCreateEntityTable(stack.db, noteEntity, "removed-note");
37
+ await createProjectionStateTable(stack.db);
38
+ tdb = createTenantDb(stack.db, admin.tenantId);
39
+ });
40
+
41
+ afterAll(async () => {
42
+ await stack.cleanup();
43
+ });
44
+
45
+ beforeEach(async () => {
46
+ await asRawClient(stack.db).unsafe(
47
+ `TRUNCATE kumiko_events, read_removed_column_notes, kumiko_projections RESTART IDENTITY CASCADE`,
48
+ );
49
+ });
50
+
51
+ async function appendHistoricalEvents(): Promise<void> {
52
+ await append(stack.db, {
53
+ aggregateId: AGGREGATE_ID,
54
+ aggregateType: "removed-note",
55
+ tenantId: admin.tenantId,
56
+ expectedVersion: 0,
57
+ type: "removed-note.created",
58
+ payload: { title: "old", legacyNote: "legacy" },
59
+ metadata: { userId: admin.id },
60
+ });
61
+ await append(stack.db, {
62
+ aggregateId: AGGREGATE_ID,
63
+ aggregateType: "removed-note",
64
+ tenantId: admin.tenantId,
65
+ expectedVersion: 1,
66
+ type: "removed-note.updated",
67
+ payload: {
68
+ changes: { title: "new", legacyNote: "gone" },
69
+ previous: { title: "old", legacyNote: "legacy" },
70
+ },
71
+ metadata: { userId: admin.id },
72
+ });
73
+ }
74
+
75
+ async function readRow(): Promise<{ title: string; version: number } | undefined> {
76
+ const rows = (await asRawClient(stack.db).unsafe(
77
+ `SELECT title, version FROM read_removed_column_notes WHERE id = $1::uuid`,
78
+ [AGGREGATE_ID],
79
+ )) as ReadonlyArray<{ title: string; version: number }>;
80
+ return rows[0];
81
+ }
82
+
83
+ describe("applyEntityEvent — event field no longer on the entity", () => {
84
+ test("rebuild replays created + updated events that carry a removed field", async () => {
85
+ await appendHistoricalEvents();
86
+
87
+ const result = await rebuildProjection(PROJECTION, { db: stack.db, registry: stack.registry });
88
+
89
+ expect(result.eventsProcessed).toBe(2);
90
+ expect(await readRow()).toMatchObject({ title: "new", version: 2 });
91
+ });
92
+
93
+ test("live update after the rebuild still works", async () => {
94
+ await appendHistoricalEvents();
95
+ await rebuildProjection(PROJECTION, { db: stack.db, registry: stack.registry });
96
+
97
+ const executor = createEventStoreExecutor(noteTable, noteEntity, {
98
+ entityName: "removed-note",
99
+ });
100
+ const updated = await executor.update(
101
+ { id: AGGREGATE_ID, version: 2, changes: { title: "live" } },
102
+ admin,
103
+ tdb,
104
+ );
105
+
106
+ expect(updated.isSuccess).toBe(true);
107
+ expect(await readRow()).toMatchObject({ title: "live", version: 3 });
108
+ });
109
+ });
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { describeWorkflowStepError } from "../steps/describe-workflow-step-error";
3
+
4
+ describe("describeWorkflowStepError", () => {
5
+ test("uses the error class name and never the message", () => {
6
+ const text = describeWorkflowStepError(new TypeError("send to jane.doe@example.com failed"));
7
+ expect(text).toBe("workflow step failed (TypeError)");
8
+ expect(text).not.toContain("jane.doe@example.com");
9
+ });
10
+
11
+ test("non-Error throwables are described as unknown and not stringified", () => {
12
+ expect(describeWorkflowStepError("jane.doe@example.com")).toBe(
13
+ "workflow step failed (unknown error)",
14
+ );
15
+ expect(describeWorkflowStepError(undefined)).toBe("workflow step failed (unknown error)");
16
+ });
17
+ });
@@ -84,8 +84,9 @@ describe("mail.send run", () => {
84
84
  expect(eventArg.aggregateType).toBe(STEP_DISPATCH_AGGREGATE_TYPE);
85
85
  expect(eventArg.type).toBe(STEP_DISPATCH_REQUESTED_TYPE);
86
86
  expect(eventArg.payload.stepKind).toBe("mail.send");
87
- expect(eventArg.payload.spec).toMatchObject({
88
- to: "user@example.com",
87
+ expect(eventArg.payload).toEqual({
88
+ stepKind: "mail.send",
89
+ to: JSON.stringify("user@example.com"),
89
90
  subject: "Test",
90
91
  body: "Body text",
91
92
  });
@@ -119,10 +120,10 @@ describe("mail.send run", () => {
119
120
  );
120
121
 
121
122
  const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
122
- expect(eventArg.payload.spec.from).toBe("system@example.com");
123
+ expect(eventArg.payload.from).toBe("system@example.com");
123
124
  });
124
125
 
125
- it("omits from from the spec when not provided", async () => {
126
+ it("omits from from the payload when not provided", async () => {
126
127
  const stepDef = getStep("mail.send");
127
128
 
128
129
  await stepDef!.run(
@@ -131,6 +132,6 @@ describe("mail.send run", () => {
131
132
  );
132
133
 
133
134
  const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
134
- expect(eventArg.payload.spec.from).toBeUndefined();
135
+ expect(eventArg.payload.from).toBeUndefined();
135
136
  });
136
137
  });
@@ -73,8 +73,9 @@ describe("webhook.send run", () => {
73
73
  expect(eventArg.aggregateType).toBe(STEP_DISPATCH_AGGREGATE_TYPE);
74
74
  expect(eventArg.type).toBe(STEP_DISPATCH_REQUESTED_TYPE);
75
75
  expect(eventArg.payload.stepKind).toBe("webhook.send");
76
- expect(eventArg.payload.spec.url).toBe("https://hooks.example/test");
77
- expect(eventArg.payload.spec.body).toEqual({ event: "incident-opened", id: "abc" });
76
+ expect(eventArg.payload.url).toBe("https://hooks.example/test");
77
+ expect(JSON.parse(eventArg.payload.bodyJson)).toEqual({ event: "incident-opened", id: "abc" });
78
+ expect(eventArg.payload.headersJson).toBe("{}");
78
79
  });
79
80
 
80
81
  it("resolves function-based url and body resolvers", async () => {
@@ -96,10 +97,8 @@ describe("webhook.send run", () => {
96
97
  expect(mockUnsafeAppendEvent).toHaveBeenCalledWith(
97
98
  expect.objectContaining({
98
99
  payload: expect.objectContaining({
99
- spec: expect.objectContaining({
100
- url: "https://hooks.example/dynamic",
101
- body: { key: "value" },
102
- }),
100
+ url: "https://hooks.example/dynamic",
101
+ bodyJson: JSON.stringify({ key: "value" }),
103
102
  }),
104
103
  }),
105
104
  );
@@ -111,7 +110,7 @@ describe("webhook.send run", () => {
111
110
  await stepDef!.run({ url: "https://hooks.example/test", mode: "deferred" }, mockCtx);
112
111
 
113
112
  const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
114
- expect(eventArg.payload.spec.method).toBe("POST");
113
+ expect(eventArg.payload.method).toBe("POST");
115
114
  });
116
115
 
117
116
  it("defaults retry to 3x exponential when not specified", async () => {
@@ -123,6 +122,15 @@ describe("webhook.send run", () => {
123
122
  expect(eventArg.payload.retry).toEqual({ times: 3, backoff: "exponential" });
124
123
  });
125
124
 
125
+ it("omits bodyJson when no body is given", async () => {
126
+ const stepDef = getStep("webhook.send");
127
+
128
+ await stepDef!.run({ url: "https://hooks.example/test", mode: "deferred" }, mockCtx);
129
+
130
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
131
+ expect("bodyJson" in eventArg.payload).toBe(false);
132
+ });
133
+
126
134
  it("passes auth config through when provided", async () => {
127
135
  const stepDef = getStep("webhook.send");
128
136
  const auth = { kind: "bearer" as const, secret: "WEBHOOK_TOKEN" };
@@ -130,6 +138,6 @@ describe("webhook.send run", () => {
130
138
  await stepDef!.run({ url: "https://hooks.example/secured", mode: "deferred", auth }, mockCtx);
131
139
 
132
140
  const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
133
- expect(eventArg.payload.spec.auth).toEqual(auth);
141
+ expect(eventArg.payload.auth).toEqual(auth);
134
142
  });
135
143
  });
@@ -160,6 +160,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
160
160
  contentCollections: state.contentCollections,
161
161
  workspaces: state.workspaces,
162
162
  httpRoutes: state.httpRoutes,
163
+ webSocketRoutes: state.webSocketRoutes,
163
164
  storeTables: state.storeTables,
164
165
  ...(state.treeActions !== undefined && { treeActions: state.treeActions }),
165
166
  ...(state.envSchema !== undefined && { envSchema: state.envSchema }),
@@ -1,3 +1,4 @@
1
+ import type { WebSocketRouteDefinition } from "@cosmicdrift/kumiko-types/websocket-route";
1
2
  import type * as z from "zod";
2
3
  import type { ZodType } from "zod";
3
4
  import { LifecycleHookTypes } from "./constants";
@@ -99,6 +100,7 @@ export type FeatureBuilderState = {
99
100
  contentCollections: Record<string, ContentCollectionDefinition>;
100
101
  workspaces: Record<string, WorkspaceDefinition>;
101
102
  httpRoutes: Record<string, HttpRouteDefinition>;
103
+ webSocketRoutes: Record<string, WebSocketRouteDefinition>;
102
104
  translations: TranslationKeys;
103
105
  isSystemScoped: boolean;
104
106
  toggleableDefault: boolean | undefined;
@@ -160,6 +162,7 @@ export function createInitialFeatureBuilderState(): FeatureBuilderState {
160
162
  contentCollections: {},
161
163
  workspaces: {},
162
164
  httpRoutes: {},
165
+ webSocketRoutes: {},
163
166
  translations: {},
164
167
  isSystemScoped: false,
165
168
  toggleableDefault: undefined,
@@ -1,3 +1,9 @@
1
+ import {
2
+ WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT,
3
+ WEBSOCKET_MAX_PAYLOAD_BYTES,
4
+ WEBSOCKET_ROUTE_PATH_PREFIX,
5
+ type WebSocketRouteDefinition,
6
+ } from "@cosmicdrift/kumiko-types/websocket-route";
1
7
  import type { EntityTableMeta } from "../db/entity-table-meta";
2
8
  import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
3
9
  import { LifecycleHookTypes } from "./constants";
@@ -514,6 +520,52 @@ export function buildUiExtensionsMethods<TName extends string>(
514
520
  }
515
521
  state.httpRoutes[key] = definition;
516
522
  },
523
+ webSocketRoute(definition: WebSocketRouteDefinition): void {
524
+ if (!definition.path.startsWith(WEBSOCKET_ROUTE_PATH_PREFIX)) {
525
+ throw new Error(
526
+ `[Feature ${name}] webSocketRoute path "${definition.path}" must start with ` +
527
+ `"${WEBSOCKET_ROUTE_PATH_PREFIX}" — only that namespace rides the /api/* auth chain ` +
528
+ "the upgrade relies on.",
529
+ );
530
+ }
531
+ if (definition.path.includes("*")) {
532
+ throw new Error(
533
+ `[Feature ${name}] webSocketRoute path "${definition.path}" must not contain "*" — ` +
534
+ "wildcards would let one route swallow unrelated /api/ws/* paths.",
535
+ );
536
+ }
537
+ const { maxMessageBytes } = definition;
538
+ if (
539
+ maxMessageBytes !== undefined &&
540
+ (!Number.isInteger(maxMessageBytes) ||
541
+ maxMessageBytes < 1 ||
542
+ maxMessageBytes > WEBSOCKET_MAX_PAYLOAD_BYTES)
543
+ ) {
544
+ throw new Error(
545
+ `[Feature ${name}] webSocketRoute "${definition.path}" maxMessageBytes must be an ` +
546
+ `integer between 1 and ${WEBSOCKET_MAX_PAYLOAD_BYTES}, got ${maxMessageBytes}.`,
547
+ );
548
+ }
549
+ const { maxConnectionsPerUser } = definition;
550
+ if (
551
+ maxConnectionsPerUser !== undefined &&
552
+ (!Number.isInteger(maxConnectionsPerUser) ||
553
+ maxConnectionsPerUser < 1 ||
554
+ maxConnectionsPerUser > WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT)
555
+ ) {
556
+ throw new Error(
557
+ `[Feature ${name}] webSocketRoute "${definition.path}" maxConnectionsPerUser must be an ` +
558
+ `integer between 1 and ${WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT}, got ${maxConnectionsPerUser}.`,
559
+ );
560
+ }
561
+ if (state.webSocketRoutes[definition.path]) {
562
+ throw new Error(
563
+ `[Feature ${name}] WebSocket route "${definition.path}" already registered. ` +
564
+ "path must be unique per feature.",
565
+ );
566
+ }
567
+ state.webSocketRoutes[definition.path] = definition;
568
+ },
517
569
  storeTable(meta: EntityTableMeta, options: StoreTableOptions): void {
518
570
  // Name comes from the meta itself — apps already give the table a
519
571
  // name when calling defineUnmanagedTable, no need to repeat it.
@@ -298,6 +298,10 @@ export type { TransitionGraph } from "./state-machine";
298
298
  export { defineTransitions, guardTransition } from "./state-machine";
299
299
  export { evaluateEventMatch } from "./steps/_event-match";
300
300
  export {
301
+ STEP_DISPATCH_AGGREGATE_TYPE,
302
+ STEP_DISPATCH_FAILED_TYPE,
303
+ STEP_DISPATCH_REQUESTED_TYPE,
304
+ STEP_DISPATCHED_TYPE,
301
305
  SUSPEND_SENTINEL,
302
306
  WORKFLOW_AGGREGATE_TYPE,
303
307
  WORKFLOW_RESUMED_TYPE,
@@ -308,6 +312,7 @@ export {
308
312
  WORKFLOW_WAITING_FOR_EVENT_TYPE,
309
313
  WORKFLOW_WAITING_TYPE,
310
314
  } from "./steps/_step-dispatch-constants";
315
+ export { describeWorkflowStepError } from "./steps/describe-workflow-step-error";
311
316
  export {
312
317
  ANONYMOUS_ROLE,
313
318
  ANONYMOUS_USER_ID,
@@ -0,0 +1,6 @@
1
+ // Workflow events (run-failed, retry.scheduled) outlive an Art. 17 erase, and a
2
+ // raw error message can echo recipients or payload fragments. Events carry only
3
+ // this generic text; the raw error goes to the log.
4
+ export function describeWorkflowStepError(error: unknown): string {
5
+ return `workflow step failed (${error instanceof Error ? error.name : "unknown error"})`;
6
+ }
@@ -33,7 +33,11 @@ defineStep<MailSendArgs, void>({
33
33
  type: STEP_DISPATCH_REQUESTED_TYPE,
34
34
  payload: {
35
35
  stepKind: "mail.send",
36
- spec: { to, subject, body, ...(from && { from }) },
36
+ // Flat string fields: event PII encryption only handles top-level strings.
37
+ to: JSON.stringify(to),
38
+ subject,
39
+ body,
40
+ ...(from && { from }),
37
41
  },
38
42
  });
39
43
  },
@@ -7,6 +7,7 @@
7
7
  // after the backoff duration and re-enters the step at the same index.
8
8
  // After all attempts exhausted, the original error propagates.
9
9
 
10
+ import { createFallbackLogger } from "../../logging";
10
11
  import { defineStep } from "../define-step";
11
12
  import { runStepList } from "../run-pipeline";
12
13
  import type { PipelineCtx, StepInstance } from "../types/step";
@@ -15,6 +16,9 @@ import {
15
16
  WORKFLOW_AGGREGATE_TYPE,
16
17
  WORKFLOW_RETRY_SCHEDULED_TYPE,
17
18
  } from "./_step-dispatch-constants";
19
+ import { describeWorkflowStepError } from "./describe-workflow-step-error";
20
+
21
+ const log = createFallbackLogger("workflow-retry");
18
22
 
19
23
  type RetryStepArgs = {
20
24
  readonly times: number;
@@ -47,6 +51,14 @@ defineStep<RetryStepArgs, undefined | typeof SUSPEND_SENTINEL>({
47
51
  throw error;
48
52
  }
49
53
 
54
+ log.warn("workflow step failed, retry scheduled", {
55
+ runId: ctx.workflow.runId,
56
+ workflowName: ctx.workflow.workflowName,
57
+ stepIndex: ctx.workflow.stepIndex,
58
+ attempt,
59
+ error: String(error),
60
+ });
61
+
50
62
  const backoffMs = calculateBackoff(attempt, args.backoff);
51
63
  const wakeAt = Temporal.Now.instant().add({ milliseconds: backoffMs }).toString();
52
64
 
@@ -60,7 +72,7 @@ defineStep<RetryStepArgs, undefined | typeof SUSPEND_SENTINEL>({
60
72
  maxAttempts,
61
73
  wakeAt,
62
74
  workflowName: ctx.workflow.workflowName,
63
- error: String(error),
75
+ error: describeWorkflowStepError(error),
64
76
  triggerEventType: ctx.event.type,
65
77
  triggerPayload: ctx.event.payload,
66
78
  ...(ctx.workflow.definitionFingerprint && {
@@ -14,15 +14,6 @@ import {
14
14
  STEP_DISPATCH_REQUESTED_TYPE,
15
15
  } from "./_step-dispatch-constants";
16
16
 
17
- // Re-export for back-compat callers (bundled step-dispatcher imports
18
- // these). The canonical home is _step-dispatch-constants.ts.
19
- export {
20
- STEP_DISPATCH_AGGREGATE_TYPE,
21
- STEP_DISPATCH_FAILED_TYPE,
22
- STEP_DISPATCH_REQUESTED_TYPE,
23
- STEP_DISPATCHED_TYPE,
24
- } from "./_step-dispatch-constants";
25
-
26
17
  type WebhookHttpMethod = "POST" | "PUT" | "PATCH";
27
18
 
28
19
  // Name inside the tenant-owned secrets namespace
@@ -55,13 +46,12 @@ defineStep<WebhookSendArgs, void>({
55
46
  type: STEP_DISPATCH_REQUESTED_TYPE,
56
47
  payload: {
57
48
  stepKind: "webhook.send",
58
- spec: {
59
- url,
60
- method: args.method ?? "POST",
61
- headers,
62
- body,
63
- ...(args.auth && { auth: args.auth }),
64
- },
49
+ // Flat string fields: event PII encryption only handles top-level strings.
50
+ url,
51
+ method: args.method ?? "POST",
52
+ headersJson: JSON.stringify(headers),
53
+ ...(body !== undefined && { bodyJson: JSON.stringify(body) }),
54
+ ...(args.auth && { auth: args.auth }),
65
55
  retry: args.retry ?? { times: 3, backoff: "exponential" },
66
56
  },
67
57
  });
@@ -367,6 +367,13 @@ export type {
367
367
  TreeNode,
368
368
  TreeNodeState,
369
369
  } from "@cosmicdrift/kumiko-types/tree-node";
370
+ export type {
371
+ WebSocketConnection,
372
+ WebSocketMessageData,
373
+ WebSocketRouteConnectDeps,
374
+ WebSocketRouteDefinition,
375
+ WebSocketSessionHandlers,
376
+ } from "@cosmicdrift/kumiko-types/websocket-route";
370
377
  export type { WorkspaceDefinition } from "@cosmicdrift/kumiko-types/workspace";
371
378
  // Re-export types that were duplicated in types.ts but are canonical in constants.ts
372
379
  export type {
@@ -1,8 +1,8 @@
1
+ import { SYSTEM_EVENT_PREFIX } from "../crypto/system-event-pii";
1
2
  import { runInSavepointIfSupported } from "../db/query";
2
3
  import { type TenantDb, unsafeRawForDeclaredStep, withUnsafeRawGrant } from "../db/tenant-db";
3
4
  import type { TenantId } from "../engine/types";
4
5
  import { AccessDeniedError, InternalError } from "../errors";
5
- import { SYSTEM_EVENT_PREFIX } from "../pipeline/append-event-core";
6
6
  import { append, type EventMetadata, getStreamVersion } from "./event-store";
7
7
 
8
8
  // Fixed by the framework, not the caller — the point of this entry point is