@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.
- package/package.json +4 -4
- package/src/api/__tests__/auth-middleware-transport.test.ts +23 -1
- package/src/api/__tests__/client-ip.test.ts +20 -0
- package/src/api/__tests__/origin-middleware.test.ts +65 -0
- package/src/api/__tests__/websocket-route.test.ts +552 -0
- package/src/api/auth-middleware.ts +29 -7
- package/src/api/client-ip.ts +15 -2
- package/src/api/index.ts +24 -1
- package/src/api/jwt.ts +5 -0
- package/src/api/origin-middleware.ts +27 -1
- package/src/api/server.ts +161 -2
- package/src/api/websocket-route.ts +327 -0
- package/src/bun-db/query.ts +7 -5
- package/src/changes.json +18 -0
- package/src/crypto/__tests__/system-event-pii.test.ts +77 -0
- package/src/crypto/event-pii.ts +20 -4
- package/src/crypto/system-event-pii.ts +57 -0
- package/src/db/__tests__/apply-entity-event-removed-column.integration.test.ts +109 -0
- package/src/engine/__tests__/describe-workflow-step-error.test.ts +17 -0
- package/src/engine/__tests__/steps-mail-send.test.ts +6 -5
- package/src/engine/__tests__/steps-webhook-send.test.ts +16 -8
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-ui-extensions.ts +52 -0
- package/src/engine/index.ts +5 -0
- package/src/engine/steps/describe-workflow-step-error.ts +6 -0
- package/src/engine/steps/mail-send.ts +5 -1
- package/src/engine/steps/retry.ts +13 -1
- package/src/engine/steps/webhook-send.ts +6 -16
- package/src/engine/types/index.ts +7 -0
- package/src/event-store/provenance-append.ts +1 -1
- package/src/event-store/transfer.ts +2 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +31 -0
- package/src/jobs/job-runner.ts +18 -0
- package/src/pipeline/append-event-core.ts +1 -8
- 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
|
+
});
|
package/src/crypto/event-pii.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
88
|
-
|
|
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.
|
|
123
|
+
expect(eventArg.payload.from).toBe("system@example.com");
|
|
123
124
|
});
|
|
124
125
|
|
|
125
|
-
it("omits from from the
|
|
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.
|
|
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.
|
|
77
|
-
expect(eventArg.payload.
|
|
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
|
-
|
|
100
|
-
|
|
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.
|
|
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.
|
|
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.
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|