@cosmicdrift/kumiko-bundled-features 0.326.1 → 0.328.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 +9 -9
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +38 -2
- package/src/auth-email-password/web/index.ts +2 -0
- package/src/auth-email-password/web/tenant-menu-items.tsx +47 -0
- package/src/auth-email-password/web/user-menu.tsx +18 -6
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +61 -2
- package/src/jobs/handlers/projection-rebuild.job.ts +2 -2
- package/src/step-dispatcher/__tests__/step-dispatcher-pii.integration.test.ts +268 -0
- package/src/step-dispatcher/__tests__/webhook-runner.test.ts +15 -1
- package/src/step-dispatcher/changes.json +6 -0
- package/src/step-dispatcher/feature.ts +208 -33
- package/src/step-dispatcher/webhook-runner.ts +6 -2
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +4 -1
- package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +6 -3
- package/src/workflow-runner/changes.json +8 -1
- package/src/workflow-runner/event-trigger.ts +11 -1
- package/src/workflow-runner/handlers/resume-run.write.ts +6 -1
- package/src/workflow-runner/runner.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.328.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -134,12 +134,12 @@
|
|
|
134
134
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
135
135
|
},
|
|
136
136
|
"dependencies": {
|
|
137
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
142
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
137
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.328.0",
|
|
138
|
+
"@cosmicdrift/kumiko-framework": "0.328.0",
|
|
139
|
+
"@cosmicdrift/kumiko-headless": "0.328.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer": "0.328.0",
|
|
141
|
+
"@cosmicdrift/kumiko-renderer-web": "0.328.0",
|
|
142
|
+
"@cosmicdrift/kumiko-types": "0.328.0",
|
|
143
143
|
"@mollie/api-client": "^4.5.0",
|
|
144
144
|
"@node-rs/argon2": "^2.0.2",
|
|
145
145
|
"@types/mailparser": "^3.4.6",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
],
|
|
168
168
|
"devDependencies": {
|
|
169
169
|
"@testing-library/user-event": "^14.6.1",
|
|
170
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.328.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.328.0",
|
|
172
172
|
"jsqr": "^1.4.0"
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.328.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "User menu and tenant menu items fit the new shell",
|
|
6
|
+
"detail": "The web user menu and tenant menu items are adapted to the sidebar footer user block, which now hosts the tenant, language and theme controls."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.323.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { TokensProvider } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { SidebarProvider, ThemeMenuItem } from "@cosmicdrift/kumiko-renderer-web";
|
|
3
4
|
import { screen, within } from "@testing-library/react";
|
|
4
5
|
import userEvent from "@testing-library/user-event";
|
|
6
|
+
import { TenantMenuItems } from "../tenant-menu-items";
|
|
5
7
|
import { UserMenu } from "../user-menu";
|
|
6
8
|
import { makeSessionApi, renderWithProviders } from "./test-utils";
|
|
7
9
|
|
|
@@ -77,4 +79,38 @@ describe("UserMenu", () => {
|
|
|
77
79
|
await user.click(screen.getByRole("button", { name: /Alice Wonder/ }));
|
|
78
80
|
expect(screen.getByText("Sign out")).toBeTruthy();
|
|
79
81
|
});
|
|
82
|
+
|
|
83
|
+
test("sidebar variant: tenant and theme controls live inside the menu, not beside the row", async () => {
|
|
84
|
+
const user = userEvent.setup();
|
|
85
|
+
const switchTenant = mock(async () => {});
|
|
86
|
+
const session = makeSessionApi({
|
|
87
|
+
tenants: [
|
|
88
|
+
{ tenantId: "tenant-1", name: "Alpha", roles: ["Admin"] },
|
|
89
|
+
{ tenantId: "tenant-2", name: "Beta", roles: ["Admin"] },
|
|
90
|
+
],
|
|
91
|
+
switchTenant,
|
|
92
|
+
});
|
|
93
|
+
const tokens = {
|
|
94
|
+
tokens: {} as never,
|
|
95
|
+
mode: "light" as const,
|
|
96
|
+
setMode: () => {},
|
|
97
|
+
toggleMode: () => {},
|
|
98
|
+
};
|
|
99
|
+
renderWithProviders(
|
|
100
|
+
<TokensProvider value={tokens}>
|
|
101
|
+
<SidebarProvider>
|
|
102
|
+
<UserMenu variant="sidebar">
|
|
103
|
+
<TenantMenuItems />
|
|
104
|
+
<ThemeMenuItem />
|
|
105
|
+
</UserMenu>
|
|
106
|
+
</SidebarProvider>
|
|
107
|
+
</TokensProvider>,
|
|
108
|
+
{ session },
|
|
109
|
+
);
|
|
110
|
+
expect(screen.queryByText("Beta")).toBeNull();
|
|
111
|
+
await user.click(screen.getByRole("button", { name: /Test User/ }));
|
|
112
|
+
await user.click(screen.getByText("Beta"));
|
|
113
|
+
expect(switchTenant).toHaveBeenCalledWith("tenant-2");
|
|
114
|
+
expect(screen.getByText(/Modus/)).toBeTruthy();
|
|
115
|
+
});
|
|
80
116
|
});
|
|
@@ -63,6 +63,8 @@ export type { SignupCompleteScreenProps } from "./signup-complete-screen";
|
|
|
63
63
|
export { SignupCompleteScreen } from "./signup-complete-screen";
|
|
64
64
|
export type { SignupScreenProps } from "./signup-screen";
|
|
65
65
|
export { SignupScreen } from "./signup-screen";
|
|
66
|
+
export type { TenantMenuItemsProps } from "./tenant-menu-items";
|
|
67
|
+
export { TenantMenuItems } from "./tenant-menu-items";
|
|
66
68
|
export type { TenantSwitcherProps } from "./tenant-switcher";
|
|
67
69
|
export { TenantSwitcher } from "./tenant-switcher";
|
|
68
70
|
export type { ShellUser } from "./use-shell-user";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Tenant choices as dropdown entries — the TenantSwitcher's list for a user
|
|
3
|
+
// menu that carries the shell controls. Renders nothing for a single tenant.
|
|
4
|
+
|
|
5
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenuCheckboxItem,
|
|
8
|
+
DropdownMenuLabel,
|
|
9
|
+
DropdownMenuSeparator,
|
|
10
|
+
} from "@cosmicdrift/kumiko-renderer-web";
|
|
11
|
+
import type { ReactNode } from "react";
|
|
12
|
+
import { useSession } from "./session";
|
|
13
|
+
|
|
14
|
+
export type TenantMenuItemsProps = {
|
|
15
|
+
readonly tenantName?: (tenantId: string) => string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function TenantMenuItems({ tenantName }: TenantMenuItemsProps): ReactNode {
|
|
19
|
+
const t = useTranslation();
|
|
20
|
+
const { user, tenants, activeTenantId, switchTenant } = useSession();
|
|
21
|
+
if (user === null || tenants.length <= 1) return null;
|
|
22
|
+
|
|
23
|
+
const nameOf = (tenantId: string): string => {
|
|
24
|
+
if (tenantName !== undefined) return tenantName(tenantId);
|
|
25
|
+
const membership = tenants.find((m) => m.tenantId === tenantId);
|
|
26
|
+
return membership?.name || membership?.key || tenantId.slice(0, 8);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<DropdownMenuLabel>{t("auth.tenant.switcher.label")}</DropdownMenuLabel>
|
|
32
|
+
{tenants.map((membership) => (
|
|
33
|
+
<DropdownMenuCheckboxItem
|
|
34
|
+
key={membership.tenantId}
|
|
35
|
+
checked={membership.tenantId === activeTenantId}
|
|
36
|
+
onSelect={(event) => {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
if (membership.tenantId !== activeTenantId) void switchTenant(membership.tenantId);
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<span className="truncate">{nameOf(membership.tenantId)}</span>
|
|
42
|
+
</DropdownMenuCheckboxItem>
|
|
43
|
+
))}
|
|
44
|
+
<DropdownMenuSeparator />
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -63,13 +63,21 @@ export function UserMenu({ children, variant = "pill" }: UserMenuProps): ReactNo
|
|
|
63
63
|
const avatarText = initials(displayName);
|
|
64
64
|
|
|
65
65
|
const content = (
|
|
66
|
-
<DropdownMenuContent
|
|
66
|
+
<DropdownMenuContent
|
|
67
|
+
align={variant === "sidebar" ? "start" : "end"}
|
|
68
|
+
aria-label={t("auth.user.menu.label")}
|
|
69
|
+
>
|
|
67
70
|
<DropdownMenuLabel className="text-xs">
|
|
68
71
|
<div className="font-medium text-foreground truncate">{displayName}</div>
|
|
69
72
|
{hasName && <div className="truncate">{user.email}</div>}
|
|
70
73
|
</DropdownMenuLabel>
|
|
71
74
|
<DropdownMenuSeparator />
|
|
72
|
-
{children
|
|
75
|
+
{children !== undefined && (
|
|
76
|
+
<>
|
|
77
|
+
{children}
|
|
78
|
+
<DropdownMenuSeparator />
|
|
79
|
+
</>
|
|
80
|
+
)}
|
|
73
81
|
<DropdownMenuItem onSelect={() => void logout()}>
|
|
74
82
|
<LogOut className="h-4 w-4" />
|
|
75
83
|
<span>{t("auth.user.menu.logout")}</span>
|
|
@@ -91,15 +99,19 @@ export function UserMenu({ children, variant = "pill" }: UserMenuProps): ReactNo
|
|
|
91
99
|
>
|
|
92
100
|
<span
|
|
93
101
|
aria-hidden="true"
|
|
94
|
-
className="flex
|
|
102
|
+
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-sidebar-accent text-xs font-medium text-sidebar-accent-foreground"
|
|
95
103
|
>
|
|
96
104
|
{avatarText}
|
|
97
105
|
</span>
|
|
98
106
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
99
|
-
<span className="truncate font-
|
|
100
|
-
|
|
107
|
+
<span className="truncate font-medium text-sidebar-foreground">
|
|
108
|
+
{displayName}
|
|
109
|
+
</span>
|
|
110
|
+
{hasName && (
|
|
111
|
+
<span className="truncate text-xs text-sidebar-muted">{user.email}</span>
|
|
112
|
+
)}
|
|
101
113
|
</div>
|
|
102
|
-
<ChevronsUpDown className="ml-auto size-4" />
|
|
114
|
+
<ChevronsUpDown className="ml-auto size-4 text-sidebar-muted" />
|
|
103
115
|
</SidebarMenuButton>
|
|
104
116
|
</DropdownMenuTrigger>
|
|
105
117
|
{content}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
table as pgTable,
|
|
16
16
|
selectMany,
|
|
17
17
|
type TenantDb,
|
|
18
|
+
text,
|
|
18
19
|
uuid,
|
|
19
20
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
20
21
|
import {
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
createTextField,
|
|
24
25
|
defineApply,
|
|
25
26
|
defineFeature,
|
|
27
|
+
defineMspApply,
|
|
26
28
|
type ProjectionDefinition,
|
|
27
29
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
30
|
import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
@@ -30,7 +32,10 @@ import {
|
|
|
30
32
|
enqueueProjectionRebuild,
|
|
31
33
|
PROJECTION_REBUILD_JOB,
|
|
32
34
|
} from "@cosmicdrift/kumiko-framework/migrations";
|
|
33
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
createEventConsumerStateTable,
|
|
37
|
+
createProjectionStateTable,
|
|
38
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
34
39
|
import {
|
|
35
40
|
createTestDb,
|
|
36
41
|
createTestRedis,
|
|
@@ -79,12 +84,32 @@ const countsProjection: ProjectionDefinition = {
|
|
|
79
84
|
},
|
|
80
85
|
};
|
|
81
86
|
|
|
87
|
+
const itemNamesTable = pgTable("read_rebuild_msp_names", {
|
|
88
|
+
id: uuid("id").primaryKey(),
|
|
89
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
90
|
+
name: text("name").notNull(),
|
|
91
|
+
});
|
|
92
|
+
|
|
82
93
|
const PROJECTION = "rebuildtest:projection:rebuild-counts";
|
|
94
|
+
const MSP_PROJECTION = "rebuildtest:projection:rebuild-item-names";
|
|
83
95
|
const GROUP = "00000000-0000-4000-8000-000000000001";
|
|
84
96
|
|
|
85
97
|
const appFeature = defineFeature("rebuildtest", (r) => {
|
|
86
98
|
r.entity("rebuild-item", itemEntity);
|
|
87
99
|
r.projection(countsProjection);
|
|
100
|
+
r.multiStreamProjection({
|
|
101
|
+
name: "rebuild-item-names",
|
|
102
|
+
table: itemNamesTable,
|
|
103
|
+
apply: {
|
|
104
|
+
"rebuild-item.created": defineMspApply<{ name: string }>(async (event, tx) => {
|
|
105
|
+
await asRawClient(tx).unsafe(
|
|
106
|
+
`INSERT INTO "read_rebuild_msp_names" (id, tenant_id, name) VALUES ($1::uuid, $2::uuid, $3)
|
|
107
|
+
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`,
|
|
108
|
+
[event.aggregateId, event.tenantId, event.payload.name],
|
|
109
|
+
);
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
});
|
|
88
113
|
});
|
|
89
114
|
|
|
90
115
|
const admin = TestUsers.admin;
|
|
@@ -103,7 +128,13 @@ beforeAll(async () => {
|
|
|
103
128
|
|
|
104
129
|
await unsafeCreateEntityTable(db, itemEntity, "rebuild-item");
|
|
105
130
|
await createProjectionStateTable(db);
|
|
106
|
-
await
|
|
131
|
+
await createEventConsumerStateTable(db);
|
|
132
|
+
await unsafePushTables(db, {
|
|
133
|
+
readRebuildCounts: countsTable,
|
|
134
|
+
readRebuildMspNames: itemNamesTable,
|
|
135
|
+
jobRunsTable,
|
|
136
|
+
jobRunLogsTable,
|
|
137
|
+
});
|
|
107
138
|
tdb = createTenantDb(db, admin.tenantId);
|
|
108
139
|
|
|
109
140
|
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
@@ -168,4 +199,32 @@ describe("projection-rebuild job (jobs feature composed)", () => {
|
|
|
168
199
|
expect(runs.length).toBeGreaterThanOrEqual(1);
|
|
169
200
|
expect(runs.some((r) => r.status === "completed")).toBe(true);
|
|
170
201
|
}, 30000);
|
|
202
|
+
|
|
203
|
+
test("enqueueProjectionRebuild refills a multi-stream projection through the job", async () => {
|
|
204
|
+
await executor.create({ groupId: GROUP, name: "msp-a" }, admin, tdb);
|
|
205
|
+
await asRawClient(db).unsafe('DELETE FROM "read_rebuild_msp_names"');
|
|
206
|
+
expect(await selectMany(db, itemNamesTable)).toHaveLength(0);
|
|
207
|
+
|
|
208
|
+
const outcome = await enqueueProjectionRebuild(MSP_PROJECTION, { db, registry, jobRunner });
|
|
209
|
+
if (outcome.mode !== "dispatched") throw new Error(`expected dispatch, got ${outcome.mode}`);
|
|
210
|
+
|
|
211
|
+
await waitFor(async () => (await selectMany(db, itemNamesTable)).length > 0, {
|
|
212
|
+
delays: Array(40).fill(200),
|
|
213
|
+
});
|
|
214
|
+
const names = (await selectMany<{ name: string }>(db, itemNamesTable)).map((r) => r.name);
|
|
215
|
+
expect(names).toContain("msp-a");
|
|
216
|
+
|
|
217
|
+
let status: string | undefined;
|
|
218
|
+
await waitFor(
|
|
219
|
+
async () => {
|
|
220
|
+
const [run] = await selectMany<{ status: string }>(db, jobRunsTable, {
|
|
221
|
+
bullJobId: outcome.bullJobId,
|
|
222
|
+
});
|
|
223
|
+
status = run?.status;
|
|
224
|
+
return status === "completed" || status === "failed";
|
|
225
|
+
},
|
|
226
|
+
{ delays: Array(40).fill(200) },
|
|
227
|
+
);
|
|
228
|
+
expect(status).toBe("completed");
|
|
229
|
+
}, 30000);
|
|
171
230
|
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
|
-
import {
|
|
6
|
+
import { rebuildProjectionOrMultiStream } from "@cosmicdrift/kumiko-framework/migrations";
|
|
7
7
|
import * as z from "zod";
|
|
8
8
|
|
|
9
9
|
export const projectionRebuildPayloadSchema = z.object({
|
|
@@ -31,7 +31,7 @@ export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promi
|
|
|
31
31
|
// documents that instead of the previous implicit ctx.db cast.
|
|
32
32
|
ctx.systemDb.acknowledgeCrossTenant("global projection rebuild");
|
|
33
33
|
const db = ctx.systemDb.unsafeRaw("projection rebuild spans every tenant's rows") as DbConnection; // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
|
|
34
|
-
const result = await
|
|
34
|
+
const result = await rebuildProjectionOrMultiStream(projection, {
|
|
35
35
|
db,
|
|
36
36
|
registry: ctx.registry,
|
|
37
37
|
...(skipApplyErrors === true && { errorPolicy: { skipApplyErrors: true } }),
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// step-dispatcher crypto-shredding (fw#2057): the dispatch-requested payload
|
|
2
|
+
// carries recipient/subject/body/url/headers as ciphertext under a
|
|
3
|
+
// per-dispatch record key; the dispatcher decrypts to send, then erases the
|
|
4
|
+
// key once the outcome is recorded — redelivery after that must be a no-op.
|
|
5
|
+
|
|
6
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
7
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import {
|
|
9
|
+
configurePiiSubjectKms,
|
|
10
|
+
decryptPiiValueForSubject,
|
|
11
|
+
InMemoryKmsAdapter,
|
|
12
|
+
isPiiCiphertext,
|
|
13
|
+
KeyErasedError,
|
|
14
|
+
PII_CIPHERTEXT_PREFIX,
|
|
15
|
+
PII_ERASED_SENTINEL,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
17
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
18
|
+
import {
|
|
19
|
+
defineFeature,
|
|
20
|
+
defineWriteHandler,
|
|
21
|
+
stepsPipeline,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
24
|
+
import {
|
|
25
|
+
createTestUser,
|
|
26
|
+
resetEventStore,
|
|
27
|
+
setupTestStack,
|
|
28
|
+
type TestStack,
|
|
29
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
30
|
+
import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
|
|
31
|
+
import * as z from "zod";
|
|
32
|
+
import { createSecretsFeature } from "../../secrets";
|
|
33
|
+
import {
|
|
34
|
+
createStepDispatcherFeature,
|
|
35
|
+
type MailDispatchResult,
|
|
36
|
+
type MailSpec,
|
|
37
|
+
setMailRunner,
|
|
38
|
+
setWebhookFetch,
|
|
39
|
+
WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
40
|
+
} from "../index";
|
|
41
|
+
|
|
42
|
+
const DISPATCH_REQUESTED = "kumiko:system:step.dispatch-requested";
|
|
43
|
+
const DISPATCHED = "kumiko:system:step.dispatched";
|
|
44
|
+
const DISPATCH_FAILED = "kumiko:system:step.dispatch-failed";
|
|
45
|
+
|
|
46
|
+
const probeFeature = defineFeature("step-pii-probe", (r) => {
|
|
47
|
+
r.requires.step("mail.send");
|
|
48
|
+
r.requires.step("webhook.send");
|
|
49
|
+
|
|
50
|
+
r.writeHandler(
|
|
51
|
+
defineWriteHandler({
|
|
52
|
+
name: "notify-mail",
|
|
53
|
+
schema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
|
|
54
|
+
access: { roles: ["Admin"] },
|
|
55
|
+
perform: stepsPipeline<{ to: string; subject: string; body: string }, { ok: true }>(
|
|
56
|
+
({ event, r }) => [
|
|
57
|
+
r.step.mail.send({
|
|
58
|
+
to: () => event.payload.to,
|
|
59
|
+
subject: () => event.payload.subject,
|
|
60
|
+
body: () => event.payload.body,
|
|
61
|
+
mode: "deferred",
|
|
62
|
+
}),
|
|
63
|
+
r.step.return(() => ({ isSuccess: true as const, data: { ok: true as const } })),
|
|
64
|
+
],
|
|
65
|
+
),
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
r.writeHandler(
|
|
70
|
+
defineWriteHandler({
|
|
71
|
+
name: "notify-webhook",
|
|
72
|
+
schema: z.object({ url: z.string(), token: z.string() }),
|
|
73
|
+
access: { roles: ["Admin"] },
|
|
74
|
+
perform: stepsPipeline<{ url: string; token: string }, { ok: true }>(({ event, r }) => [
|
|
75
|
+
r.step.webhook.send({
|
|
76
|
+
url: () => event.payload.url,
|
|
77
|
+
headers: () => ({ "x-probe-token": event.payload.token }),
|
|
78
|
+
body: () => ({ secretNote: "webhook-body-secret" }),
|
|
79
|
+
mode: "deferred",
|
|
80
|
+
}),
|
|
81
|
+
r.step.return(() => ({ isSuccess: true as const, data: { ok: true as const } })),
|
|
82
|
+
]),
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const admin = createTestUser({ roles: ["Admin"] });
|
|
88
|
+
const fetchMock = mock<typeof fetch>();
|
|
89
|
+
const mailMock = mock<(spec: MailSpec) => Promise<MailDispatchResult>>();
|
|
90
|
+
const originalAllowedPrivateHostsEnv = process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
91
|
+
|
|
92
|
+
let stack: TestStack;
|
|
93
|
+
let kms: InMemoryKmsAdapter;
|
|
94
|
+
|
|
95
|
+
type EventRow = { aggregateId: string; payload: Record<string, unknown> };
|
|
96
|
+
|
|
97
|
+
async function eventsOfType(type: string): Promise<EventRow[]> {
|
|
98
|
+
return (await selectMany(stack.db, eventsTable, { type })) as unknown as EventRow[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function drain(): Promise<void> {
|
|
102
|
+
await stack.eventDispatcher?.runOnce();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function requestedRow(): Promise<EventRow> {
|
|
106
|
+
const rows = await eventsOfType(DISPATCH_REQUESTED);
|
|
107
|
+
expect(rows).toHaveLength(1);
|
|
108
|
+
return rows[0]!;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function expectKeyErased(aggregateId: string): Promise<void> {
|
|
112
|
+
await expect(
|
|
113
|
+
kms.getKey({ kind: "record", entity: "step-dispatch", id: aggregateId }),
|
|
114
|
+
).rejects.toBeInstanceOf(KeyErasedError);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
beforeAll(async () => {
|
|
118
|
+
// hooks.example is a placeholder host — operator allowlist instead of DNS.
|
|
119
|
+
process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "hooks.example";
|
|
120
|
+
setWebhookFetch(fetchMock as unknown as typeof fetch);
|
|
121
|
+
setMailRunner(async (spec: MailSpec) => mailMock(spec));
|
|
122
|
+
stack = await setupTestStack({
|
|
123
|
+
features: [createStepDispatcherFeature(), createSecretsFeature(), probeFeature],
|
|
124
|
+
systemHooks: [],
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterAll(async () => {
|
|
129
|
+
if (originalAllowedPrivateHostsEnv === undefined) {
|
|
130
|
+
delete process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
131
|
+
} else {
|
|
132
|
+
process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = originalAllowedPrivateHostsEnv;
|
|
133
|
+
}
|
|
134
|
+
await stack.cleanup();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
beforeEach(async () => {
|
|
138
|
+
fetchMock.mockReset();
|
|
139
|
+
mailMock.mockReset();
|
|
140
|
+
mailMock.mockResolvedValue({ ok: true, status: 202 });
|
|
141
|
+
kms = new InMemoryKmsAdapter();
|
|
142
|
+
configurePiiSubjectKms(kms);
|
|
143
|
+
await resetEventStore(stack, []);
|
|
144
|
+
await stack.redis.flushNamespace();
|
|
145
|
+
await stack.eventDispatcher?.ensureRegistered();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
afterEach(() => {
|
|
149
|
+
resetPiiSubjectKmsForTests();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const MAIL_INPUT = { to: "ops@example.com", subject: "Server down", body: "Disk is full" };
|
|
153
|
+
|
|
154
|
+
describe("step-dispatcher payload crypto-shredding", () => {
|
|
155
|
+
test("mail.send: ciphertext at rest, plaintext to the runner, key erased after dispatched", async () => {
|
|
156
|
+
await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
|
|
157
|
+
|
|
158
|
+
const requested = await requestedRow();
|
|
159
|
+
const keyPrefix = `${PII_CIPHERTEXT_PREFIX}record:step-dispatch:${requested.aggregateId}:`;
|
|
160
|
+
for (const field of ["to", "subject", "body"]) {
|
|
161
|
+
const stored = String(requested.payload[field]);
|
|
162
|
+
expect(stored.startsWith(keyPrefix)).toBe(true);
|
|
163
|
+
}
|
|
164
|
+
expect(JSON.stringify(requested.payload)).not.toContain("ops@example.com");
|
|
165
|
+
expect(JSON.stringify(requested.payload)).not.toContain("Disk is full");
|
|
166
|
+
|
|
167
|
+
await drain();
|
|
168
|
+
|
|
169
|
+
expect(mailMock).toHaveBeenCalledTimes(1);
|
|
170
|
+
expect(mailMock).toHaveBeenCalledWith({
|
|
171
|
+
to: "ops@example.com",
|
|
172
|
+
subject: "Server down",
|
|
173
|
+
body: "Disk is full",
|
|
174
|
+
});
|
|
175
|
+
expect(await eventsOfType(DISPATCHED)).toHaveLength(1);
|
|
176
|
+
await expectKeyErased(requested.aggregateId);
|
|
177
|
+
const afterErase = await decryptPiiValueForSubject(
|
|
178
|
+
kms,
|
|
179
|
+
String(requested.payload["to"]),
|
|
180
|
+
{ requestId: "test" },
|
|
181
|
+
"to",
|
|
182
|
+
);
|
|
183
|
+
expect(afterErase).toBe(PII_ERASED_SENTINEL);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("failed delivery erases the key too", async () => {
|
|
187
|
+
mailMock.mockResolvedValue({ ok: false, error: "550 <ops@example.com> rejected" });
|
|
188
|
+
await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
|
|
189
|
+
const requested = await requestedRow();
|
|
190
|
+
|
|
191
|
+
await drain();
|
|
192
|
+
|
|
193
|
+
const failed = await eventsOfType(DISPATCH_FAILED);
|
|
194
|
+
expect(failed).toHaveLength(1);
|
|
195
|
+
expect(failed[0]?.payload["error"]).toBe("mail delivery failed");
|
|
196
|
+
expect(JSON.stringify(failed[0]?.payload)).not.toContain("ops@example.com");
|
|
197
|
+
await expectKeyErased(requested.aggregateId);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("redelivery after the erase neither re-sends nor throws nor records a second outcome", async () => {
|
|
201
|
+
await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
|
|
202
|
+
await drain();
|
|
203
|
+
expect(mailMock).toHaveBeenCalledTimes(1);
|
|
204
|
+
|
|
205
|
+
await asRawClient(stack.db).unsafe(
|
|
206
|
+
`UPDATE kumiko_event_consumers SET last_processed_event_id = 0 WHERE name LIKE '%step-dispatcher%'`,
|
|
207
|
+
);
|
|
208
|
+
await drain();
|
|
209
|
+
|
|
210
|
+
expect(mailMock).toHaveBeenCalledTimes(1);
|
|
211
|
+
expect(await eventsOfType(DISPATCHED)).toHaveLength(1);
|
|
212
|
+
expect(await eventsOfType(DISPATCH_FAILED)).toHaveLength(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a key erased before any outcome ends as a generic dispatch-failed, without sending", async () => {
|
|
216
|
+
await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
|
|
217
|
+
const requested = await requestedRow();
|
|
218
|
+
await kms.eraseKey({ kind: "record", entity: "step-dispatch", id: requested.aggregateId });
|
|
219
|
+
|
|
220
|
+
await drain();
|
|
221
|
+
|
|
222
|
+
expect(mailMock).not.toHaveBeenCalled();
|
|
223
|
+
const failed = await eventsOfType(DISPATCH_FAILED);
|
|
224
|
+
expect(failed).toHaveLength(1);
|
|
225
|
+
expect(failed[0]?.payload["error"]).toBe(
|
|
226
|
+
"dispatch payload erased before an outcome was recorded",
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("ciphertext without a configured KMS ends as generic dispatch-failed", async () => {
|
|
231
|
+
await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
|
|
232
|
+
resetPiiSubjectKmsForTests();
|
|
233
|
+
|
|
234
|
+
await drain();
|
|
235
|
+
|
|
236
|
+
expect(mailMock).not.toHaveBeenCalled();
|
|
237
|
+
const failed = await eventsOfType(DISPATCH_FAILED);
|
|
238
|
+
expect(failed).toHaveLength(1);
|
|
239
|
+
expect(JSON.stringify(failed[0]?.payload)).not.toContain("ops@example.com");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("webhook.send: url, headers and body are ciphertext at rest and plaintext to fetch", async () => {
|
|
243
|
+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
244
|
+
await stack.http.writeOk(
|
|
245
|
+
"step-pii-probe:write:notify-webhook",
|
|
246
|
+
{ url: "https://hooks.example/secret-path", token: "probe-token" },
|
|
247
|
+
admin,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const requested = await requestedRow();
|
|
251
|
+
for (const field of ["url", "headersJson", "bodyJson"]) {
|
|
252
|
+
expect(isPiiCiphertext(requested.payload[field])).toBe(true);
|
|
253
|
+
}
|
|
254
|
+
expect(JSON.stringify(requested.payload)).not.toContain("secret-path");
|
|
255
|
+
expect(JSON.stringify(requested.payload)).not.toContain("probe-token");
|
|
256
|
+
expect(JSON.stringify(requested.payload)).not.toContain("webhook-body-secret");
|
|
257
|
+
|
|
258
|
+
await drain();
|
|
259
|
+
|
|
260
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
261
|
+
const [calledUrl, init] = fetchMock.mock.calls[0]!;
|
|
262
|
+
expect(calledUrl).toBe("https://hooks.example/secret-path");
|
|
263
|
+
expect(new Headers(init?.headers).get("x-probe-token")).toBe("probe-token");
|
|
264
|
+
expect(JSON.parse(init?.body as string)).toEqual({ secretNote: "webhook-body-secret" });
|
|
265
|
+
expect(await eventsOfType(DISPATCHED)).toHaveLength(1);
|
|
266
|
+
await expectKeyErased(requested.aggregateId);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -276,8 +276,22 @@ describe("performWebhookDispatch — auth.secret resolution", () => {
|
|
|
276
276
|
{ tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets },
|
|
277
277
|
);
|
|
278
278
|
|
|
279
|
-
expect(result
|
|
279
|
+
expect(result).toEqual({ ok: false, error: "invalid url" });
|
|
280
280
|
expect(get).not.toHaveBeenCalled();
|
|
281
281
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
282
282
|
});
|
|
283
|
+
|
|
284
|
+
test("a failing request never echoes the request url into the delivery error", async () => {
|
|
285
|
+
setWebhookHostLookup(fakeLookupFor({ "secret-host.example": "203.0.113.9" }));
|
|
286
|
+
setWebhookFetch((async () => {
|
|
287
|
+
throw new TypeError("connect ECONNREFUSED https://secret-host.example/hook?token=abc");
|
|
288
|
+
}) as unknown as typeof fetch);
|
|
289
|
+
|
|
290
|
+
const result = await performWebhookDispatch(
|
|
291
|
+
{ url: "https://secret-host.example/hook?token=abc", method: "POST", headers: {} },
|
|
292
|
+
{ tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets: undefined },
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
expect(result).toEqual({ ok: false, error: "webhook request failed (TypeError)" });
|
|
296
|
+
});
|
|
283
297
|
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.327.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "step.dispatch-requested payloads are flat and crypto-shredded; kumiko:system:* events have a declared PII stance",
|
|
6
|
+
"migration": "No code change is needed for r.step.mail.send / r.step.webhook.send callers. The step.dispatch-requested payload changed from a nested `spec` object to flat fields (mail.send: to as JSON string, subject, body, from; webhook.send: url, method, headersJson, bodyJson, auth, retry). With a subject KMS configured, those fields are encrypted under a per-dispatch record key (record:step-dispatch:<aggregateId>) that the step-dispatcher erases once the outcome is recorded (Art. 17 via crypto-shredding). There is no legacy branch: a dispatch-requested event still in flight at upgrade (old nested shape) ends as step.dispatch-failed with error \"invalid dispatch payload\", so drain the queue before deploying or accept the one-time failed event. Webhook delivery errors no longer echo the URL or the raw request error. mail.send delivery errors are recorded as a generic \"mail delivery failed\"; the adapter's raw error goes to the step-dispatcher log. Code reading dispatch-requested payloads directly must switch to the flat shape. A new kumiko:system:* event type must be added to SYSTEM_EVENT_PII_STANCES (crypto/system-event-pii.ts); encryptEventPayloadPii throws for an undeclared system type. SYSTEM_EVENT_PREFIX and AGGREGATE_TRANSFERRED_EVENT_TYPE now live in crypto/system-event-pii.ts, and the STEP_DISPATCH_* constants are exported from @cosmicdrift/kumiko-framework/engine instead of engine/steps/webhook-send."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.321.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -2,21 +2,39 @@
|
|
|
2
2
|
// requests (webhook.send, mail.send, ...) after their TX commits.
|
|
3
3
|
//
|
|
4
4
|
// Listens on the `kumiko:system:step.dispatch-requested` system event
|
|
5
|
-
// (registry-bypassed, see
|
|
5
|
+
// (registry-bypassed, see SYSTEM_EVENT_PREFIX).
|
|
6
6
|
// Performs the side-effect and emits `kumiko:system:step.dispatched`
|
|
7
7
|
// or `kumiko:system:step.dispatch-failed` back onto the same stream so
|
|
8
8
|
// the audit trail lives in the event log only — no separate status table.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
11
|
+
import {
|
|
12
|
+
configuredPiiSubjectKms,
|
|
13
|
+
decryptPiiValueForSubject,
|
|
14
|
+
isPiiCiphertext,
|
|
15
|
+
PII_ERASED_SENTINEL,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
17
|
+
import {
|
|
18
|
+
defineFeature,
|
|
19
|
+
type FeatureDefinition,
|
|
20
|
+
STEP_DISPATCH_AGGREGATE_TYPE,
|
|
21
|
+
STEP_DISPATCH_FAILED_TYPE,
|
|
22
|
+
STEP_DISPATCH_REQUESTED_TYPE,
|
|
23
|
+
STEP_DISPATCHED_TYPE,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
11
26
|
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
12
27
|
import * as z from "zod";
|
|
13
|
-
import { mailSpecSchema, performMailDispatch } from "./mail-runner";
|
|
28
|
+
import { type MailSpec, mailSpecSchema, performMailDispatch } from "./mail-runner";
|
|
14
29
|
import {
|
|
15
30
|
performWebhookDispatch,
|
|
16
31
|
WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
32
|
+
type WebhookSpec,
|
|
17
33
|
webhookSpecSchema,
|
|
18
34
|
} from "./webhook-runner";
|
|
19
35
|
|
|
36
|
+
const log = createFallbackLogger("step-dispatcher");
|
|
37
|
+
|
|
20
38
|
export const stepDispatcherEnvSchema = z.object({
|
|
21
39
|
[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
|
|
22
40
|
.string()
|
|
@@ -26,26 +44,43 @@ export const stepDispatcherEnvSchema = z.object({
|
|
|
26
44
|
),
|
|
27
45
|
});
|
|
28
46
|
|
|
29
|
-
export
|
|
30
|
-
export const STEP_DISPATCH_REQUESTED_TYPE = "kumiko:system:step.dispatch-requested";
|
|
31
|
-
export const STEP_DISPATCHED_TYPE = "kumiko:system:step.dispatched";
|
|
32
|
-
export const STEP_DISPATCH_FAILED_TYPE = "kumiko:system:step.dispatch-failed";
|
|
47
|
+
export { STEP_DISPATCH_AGGREGATE_TYPE };
|
|
33
48
|
|
|
49
|
+
// PII fields of the flat payload are ciphertext under the per-dispatch
|
|
50
|
+
// record key (system-event-pii.ts). `to`/`headersJson`/`bodyJson` are JSON
|
|
51
|
+
// strings because event PII encryption only handles top-level strings.
|
|
34
52
|
// Runtime-validated instead of cast — `event.payload` is `unknown` at the
|
|
35
|
-
// MSP-apply boundary
|
|
36
|
-
//
|
|
53
|
+
// MSP-apply boundary, so a payload in another shape must end as
|
|
54
|
+
// dispatch-failed, never reach the runners.
|
|
37
55
|
const dispatchRequestedPayloadSchema = z.discriminatedUnion("stepKind", [
|
|
38
56
|
z.object({
|
|
39
57
|
stepKind: z.literal("webhook.send"),
|
|
40
|
-
|
|
58
|
+
url: z.string(),
|
|
59
|
+
method: webhookSpecSchema.shape.method,
|
|
60
|
+
headersJson: z.string(),
|
|
61
|
+
bodyJson: z.string().optional(),
|
|
62
|
+
auth: webhookSpecSchema.shape.auth,
|
|
41
63
|
retry: z.object({ times: z.number(), backoff: z.enum(["exponential", "linear"]) }).optional(),
|
|
42
64
|
}),
|
|
43
|
-
z.object({
|
|
65
|
+
z.object({
|
|
66
|
+
stepKind: z.literal("mail.send"),
|
|
67
|
+
to: z.string(),
|
|
68
|
+
subject: z.string(),
|
|
69
|
+
body: z.string(),
|
|
70
|
+
from: z.string().optional(),
|
|
71
|
+
}),
|
|
44
72
|
]);
|
|
45
73
|
|
|
74
|
+
type DispatchRequestedPayload = z.infer<typeof dispatchRequestedPayloadSchema>;
|
|
75
|
+
|
|
46
76
|
// zod issue messages can echo the invalid value (e.g. a rejected url) back
|
|
47
77
|
// into the tenant-visible dispatch-failed event — keep this generic.
|
|
48
78
|
const INVALID_DISPATCH_PAYLOAD_ERROR = "invalid dispatch payload";
|
|
79
|
+
const PAYLOAD_UNREADABLE_ERROR = "dispatch payload is not readable";
|
|
80
|
+
// Adapter errors (e.g. SMTP 550) usually echo the recipient address; the
|
|
81
|
+
// dispatch-failed text outlives the payload erase, so only this is persisted.
|
|
82
|
+
const MAIL_DELIVERY_FAILED_ERROR = "mail delivery failed";
|
|
83
|
+
const PAYLOAD_ERASED_ERROR = "dispatch payload erased before an outcome was recorded";
|
|
49
84
|
|
|
50
85
|
const rawStepKindSchema = z.object({ stepKind: z.string() });
|
|
51
86
|
|
|
@@ -54,6 +89,105 @@ function rawStepKindOf(payload: unknown): string {
|
|
|
54
89
|
return parsed.success ? parsed.data.stepKind : "unknown";
|
|
55
90
|
}
|
|
56
91
|
|
|
92
|
+
const jsonStringSchema = z.string().transform((raw, refinementCtx) => {
|
|
93
|
+
try {
|
|
94
|
+
const parsed: unknown = JSON.parse(raw);
|
|
95
|
+
return parsed;
|
|
96
|
+
} catch {
|
|
97
|
+
refinementCtx.addIssue({ code: "custom", message: "invalid json" });
|
|
98
|
+
return z.NEVER;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const headersJsonSchema = jsonStringSchema.pipe(z.record(z.string(), z.string()));
|
|
103
|
+
const mailToJsonSchema = jsonStringSchema.pipe(mailSpecSchema.shape.to);
|
|
104
|
+
|
|
105
|
+
type PayloadFieldName = "to" | "subject" | "body" | "from" | "url" | "headersJson" | "bodyJson";
|
|
106
|
+
|
|
107
|
+
function piiFieldsOf(payload: DispatchRequestedPayload): readonly PayloadFieldName[] {
|
|
108
|
+
return payload.stepKind === "mail.send"
|
|
109
|
+
? ["to", "subject", "body", "from"]
|
|
110
|
+
: ["url", "headersJson", "bodyJson"];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function payloadFieldValue(
|
|
114
|
+
payload: DispatchRequestedPayload,
|
|
115
|
+
field: PayloadFieldName,
|
|
116
|
+
): string | undefined {
|
|
117
|
+
const values: Readonly<Partial<Record<PayloadFieldName, string>>> =
|
|
118
|
+
payload.stepKind === "mail.send"
|
|
119
|
+
? { to: payload.to, subject: payload.subject, body: payload.body, from: payload.from }
|
|
120
|
+
: { url: payload.url, headersJson: payload.headersJson, bodyJson: payload.bodyJson };
|
|
121
|
+
return values[field];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type ReadPayloadResult =
|
|
125
|
+
| { readonly kind: "ready"; readonly fields: Readonly<Partial<Record<PayloadFieldName, string>>> }
|
|
126
|
+
| { readonly kind: "erased" }
|
|
127
|
+
| { readonly kind: "unreadable" };
|
|
128
|
+
|
|
129
|
+
async function readPayloadFields(payload: DispatchRequestedPayload): Promise<ReadPayloadResult> {
|
|
130
|
+
const kms = configuredPiiSubjectKms();
|
|
131
|
+
const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
|
|
132
|
+
const fields: Partial<Record<PayloadFieldName, string>> = {};
|
|
133
|
+
for (const field of piiFieldsOf(payload)) {
|
|
134
|
+
const value = payloadFieldValue(payload, field);
|
|
135
|
+
if (value === undefined) continue;
|
|
136
|
+
if (!isPiiCiphertext(value)) {
|
|
137
|
+
fields[field] = value;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (!kms) return { kind: "unreadable" };
|
|
141
|
+
const plain = await decryptPiiValueForSubject(kms, value, { requestId }, field);
|
|
142
|
+
if (plain === PII_ERASED_SENTINEL) return { kind: "erased" };
|
|
143
|
+
fields[field] = plain;
|
|
144
|
+
}
|
|
145
|
+
return { kind: "ready", fields };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type DispatchSpec =
|
|
149
|
+
| { readonly stepKind: "mail.send"; readonly spec: MailSpec }
|
|
150
|
+
| { readonly stepKind: "webhook.send"; readonly spec: WebhookSpec };
|
|
151
|
+
|
|
152
|
+
// Parse failures return null — the caller records a generic error, never the
|
|
153
|
+
// (decrypted) values.
|
|
154
|
+
function buildDispatchSpec(
|
|
155
|
+
payload: DispatchRequestedPayload,
|
|
156
|
+
fields: Readonly<Partial<Record<PayloadFieldName, string>>>,
|
|
157
|
+
): DispatchSpec | null {
|
|
158
|
+
if (payload.stepKind === "mail.send") {
|
|
159
|
+
const to = mailToJsonSchema.safeParse(fields.to);
|
|
160
|
+
if (!to.success || fields.subject === undefined || fields.body === undefined) return null;
|
|
161
|
+
return {
|
|
162
|
+
stepKind: "mail.send",
|
|
163
|
+
spec: {
|
|
164
|
+
to: to.data,
|
|
165
|
+
subject: fields.subject,
|
|
166
|
+
body: fields.body,
|
|
167
|
+
...(fields.from !== undefined && { from: fields.from }),
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const headers = headersJsonSchema.safeParse(fields.headersJson);
|
|
172
|
+
if (!headers.success || fields.url === undefined) return null;
|
|
173
|
+
let body: unknown;
|
|
174
|
+
if (fields.bodyJson !== undefined) {
|
|
175
|
+
const parsedBody = jsonStringSchema.safeParse(fields.bodyJson);
|
|
176
|
+
if (!parsedBody.success) return null;
|
|
177
|
+
body = parsedBody.data;
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
stepKind: "webhook.send",
|
|
181
|
+
spec: {
|
|
182
|
+
url: fields.url,
|
|
183
|
+
method: payload.method,
|
|
184
|
+
headers: headers.data,
|
|
185
|
+
...(body !== undefined && { body }),
|
|
186
|
+
...(payload.auth && { auth: payload.auth }),
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
57
191
|
export function createStepDispatcherFeature(): FeatureDefinition {
|
|
58
192
|
return defineFeature("step-dispatcher", (r) => {
|
|
59
193
|
r.describe(
|
|
@@ -71,44 +205,85 @@ export function createStepDispatcherFeature(): FeatureDefinition {
|
|
|
71
205
|
name: "step-dispatcher",
|
|
72
206
|
apply: {
|
|
73
207
|
[STEP_DISPATCH_REQUESTED_TYPE]: async (event, _tx, ctx) => {
|
|
74
|
-
const
|
|
75
|
-
|
|
208
|
+
const kms = configuredPiiSubjectKms();
|
|
209
|
+
const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
|
|
210
|
+
|
|
211
|
+
// Outcome events are plaintext and generic; the request payload's
|
|
212
|
+
// per-dispatch key is erased right after, so the PII dies with the
|
|
213
|
+
// dispatch instead of living in the event log.
|
|
214
|
+
const recordOutcome = async (
|
|
215
|
+
type: typeof STEP_DISPATCHED_TYPE | typeof STEP_DISPATCH_FAILED_TYPE,
|
|
216
|
+
payload: Record<string, unknown>,
|
|
217
|
+
): Promise<void> => {
|
|
76
218
|
await ctx.unsafeAppendEvent({
|
|
77
219
|
aggregateId: event.aggregateId,
|
|
78
220
|
aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
|
|
79
|
-
type
|
|
80
|
-
payload
|
|
81
|
-
stepKind: rawStepKindOf(event.payload),
|
|
82
|
-
error: INVALID_DISPATCH_PAYLOAD_ERROR,
|
|
83
|
-
attempt: 1,
|
|
84
|
-
},
|
|
221
|
+
type,
|
|
222
|
+
payload,
|
|
85
223
|
});
|
|
224
|
+
await kms?.eraseKey(
|
|
225
|
+
{ kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: event.aggregateId },
|
|
226
|
+
{ requestId, eraseReason: "step-dispatch-outcome-recorded" },
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
const recordFailure = (stepKind: string, error: string) =>
|
|
230
|
+
recordOutcome(STEP_DISPATCH_FAILED_TYPE, { stepKind, error, attempt: 1 });
|
|
231
|
+
|
|
232
|
+
const parsed = dispatchRequestedPayloadSchema.safeParse(event.payload);
|
|
233
|
+
if (!parsed.success) {
|
|
234
|
+
await recordFailure(rawStepKindOf(event.payload), INVALID_DISPATCH_PAYLOAD_ERROR);
|
|
86
235
|
// skip: invalid payload already recorded via step.dispatch-failed above, nothing left to dispatch
|
|
87
236
|
return;
|
|
88
237
|
}
|
|
89
238
|
const payload = parsed.data;
|
|
239
|
+
|
|
240
|
+
const read = await readPayloadFields(payload);
|
|
241
|
+
if (read.kind === "unreadable") {
|
|
242
|
+
await recordFailure(payload.stepKind, PAYLOAD_UNREADABLE_ERROR);
|
|
243
|
+
// skip: unreadable payload already recorded via step.dispatch-failed above
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (read.kind === "erased") {
|
|
247
|
+
const stream = await ctx.loadAggregate(event.aggregateId);
|
|
248
|
+
const hasOutcome = stream.some(
|
|
249
|
+
(e) => e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE,
|
|
250
|
+
);
|
|
251
|
+
// skip: redelivery after the key was erased — the outcome is already recorded
|
|
252
|
+
if (hasOutcome) return;
|
|
253
|
+
await recordFailure(payload.stepKind, PAYLOAD_ERASED_ERROR);
|
|
254
|
+
// skip: erased payload recorded via step.dispatch-failed above
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const dispatchSpec = buildDispatchSpec(payload, read.fields);
|
|
259
|
+
if (!dispatchSpec) {
|
|
260
|
+
await recordFailure(payload.stepKind, INVALID_DISPATCH_PAYLOAD_ERROR);
|
|
261
|
+
// skip: unparseable payload already recorded via step.dispatch-failed above
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
90
264
|
const result =
|
|
91
|
-
|
|
92
|
-
? await performWebhookDispatch(
|
|
265
|
+
dispatchSpec.stepKind === "webhook.send"
|
|
266
|
+
? await performWebhookDispatch(dispatchSpec.spec, {
|
|
93
267
|
tenantId: event.tenantId,
|
|
94
268
|
userId: event.metadata.userId || SYSTEM_USER_ID,
|
|
95
269
|
secrets: ctx.secrets,
|
|
96
270
|
})
|
|
97
|
-
: await performMailDispatch(
|
|
271
|
+
: await performMailDispatch(dispatchSpec.spec);
|
|
98
272
|
if (result.ok) {
|
|
99
|
-
await
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
type: STEP_DISPATCHED_TYPE,
|
|
103
|
-
payload: { stepKind: payload.stepKind, status: result.status },
|
|
273
|
+
await recordOutcome(STEP_DISPATCHED_TYPE, {
|
|
274
|
+
stepKind: payload.stepKind,
|
|
275
|
+
status: result.status,
|
|
104
276
|
});
|
|
105
277
|
} else {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
278
|
+
if (payload.stepKind === "mail.send") {
|
|
279
|
+
log.warn("mail dispatch failed", {
|
|
280
|
+
aggregateId: event.aggregateId,
|
|
281
|
+
reason: result.error,
|
|
282
|
+
});
|
|
283
|
+
await recordFailure(payload.stepKind, MAIL_DELIVERY_FAILED_ERROR);
|
|
284
|
+
} else {
|
|
285
|
+
await recordFailure(payload.stepKind, result.error);
|
|
286
|
+
}
|
|
112
287
|
}
|
|
113
288
|
},
|
|
114
289
|
},
|
|
@@ -179,7 +179,7 @@ export async function performWebhookDispatch(
|
|
|
179
179
|
try {
|
|
180
180
|
url = new URL(spec.url);
|
|
181
181
|
} catch {
|
|
182
|
-
return { ok: false, error:
|
|
182
|
+
return { ok: false, error: "invalid url" };
|
|
183
183
|
}
|
|
184
184
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
185
185
|
return { ok: false, error: `unsupported url scheme "${url.protocol}"` };
|
|
@@ -203,6 +203,10 @@ export async function performWebhookDispatch(
|
|
|
203
203
|
}
|
|
204
204
|
return { ok: true, status: res.status };
|
|
205
205
|
} catch (err) {
|
|
206
|
-
|
|
206
|
+
// err.message can echo the request URL; this text outlives the payload erase.
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
error: `webhook request failed (${err instanceof Error ? err.name : "unknown error"})`,
|
|
210
|
+
};
|
|
207
211
|
}
|
|
208
212
|
}
|
|
@@ -79,7 +79,7 @@ const retryWorkflow: WorkflowDefinition = defineWorkflow({
|
|
|
79
79
|
do: [
|
|
80
80
|
r.step.compute("gate", (ctx) => {
|
|
81
81
|
const attempt = ctx.workflow?.retryAttempt ?? 1;
|
|
82
|
-
if (attempt === 1) throw new Error("
|
|
82
|
+
if (attempt === 1) throw new Error("send to jane.doe@example.com failed");
|
|
83
83
|
return attempt;
|
|
84
84
|
}),
|
|
85
85
|
],
|
|
@@ -289,6 +289,9 @@ describe("workflow-runner resume loop", () => {
|
|
|
289
289
|
WORKFLOW_RETRY_SCHEDULED_TYPE,
|
|
290
290
|
]);
|
|
291
291
|
expect(rowsAfterSuspend[1]!["payload"]).toMatchObject({ stepIndex: 0, attempt: 1 });
|
|
292
|
+
// The event outlives an erase, so it carries the error class only.
|
|
293
|
+
expect(rowsAfterSuspend[1]!["payload"]["error"]).toBe("workflow step failed (Error)");
|
|
294
|
+
expect(JSON.stringify(rowsAfterSuspend[1]!["payload"])).not.toContain("jane.doe@example.com");
|
|
292
295
|
|
|
293
296
|
await tamperWakeAtToPast(runId, 0);
|
|
294
297
|
await runResumeDueRunsJob();
|
|
@@ -52,7 +52,7 @@ const failingWorkflow: WorkflowDefinition = defineWorkflow({
|
|
|
52
52
|
idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
|
|
53
53
|
steps: stepsPipeline(({ r }) => [
|
|
54
54
|
r.step.compute("boom", () => {
|
|
55
|
-
throw new Error("boom-explicit-failure");
|
|
55
|
+
throw new Error("boom-explicit-failure for jane.doe@example.com");
|
|
56
56
|
}),
|
|
57
57
|
r.step.return({ isSuccess: true, data: undefined }),
|
|
58
58
|
]),
|
|
@@ -186,7 +186,7 @@ describe("workflow-runner event-trigger", () => {
|
|
|
186
186
|
});
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
test("error path: a throwing step writes run-failed with
|
|
189
|
+
test("error path: a throwing step writes run-failed with a generic error text, no run-completed", async () => {
|
|
190
190
|
const runKey = crypto.randomUUID();
|
|
191
191
|
const runId = workflowRunAggregateId(failingWorkflow.name, runKey);
|
|
192
192
|
// The registrar namespaces every MSP as `<feature>:projection:<name>`.
|
|
@@ -223,7 +223,10 @@ describe("workflow-runner event-trigger", () => {
|
|
|
223
223
|
WORKFLOW_RUN_FAILED_TYPE,
|
|
224
224
|
]);
|
|
225
225
|
expect(rows[1]!["payload"]).toMatchObject({ workflowName: failingWorkflow.name, stepIndex: 0 });
|
|
226
|
-
|
|
226
|
+
// The event outlives an erase, so it carries the error class only.
|
|
227
|
+
expect(rows[1]!["payload"]["error"]).toBe("workflow step failed (Error)");
|
|
228
|
+
expect(JSON.stringify(rows[1]!["payload"])).not.toContain("boom-explicit-failure");
|
|
229
|
+
expect(JSON.stringify(rows[1]!["payload"])).not.toContain("jane.doe@example.com");
|
|
227
230
|
|
|
228
231
|
// Second pass has nothing left to redeliver — the same trigger event
|
|
229
232
|
// never spawns a second run-started for this runId. `processed` counts
|
|
@@ -1 +1,8 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.327.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Workflow run-failed and retry.scheduled events store a generic error text instead of the raw error",
|
|
6
|
+
"migration": "The `error` field of workflow.run-failed and workflow.retry.scheduled events is now generic, \"workflow step failed (<error class>)\" (or \"(unknown error)\" for non-Error throwables), because the raw text can contain recipients or payload fragments and would survive an erase. The raw error goes to the log (namespace workflow-runner, and the framework logger for retry). Code that parses the `error` text must switch to `reason` (machine-readable, set for definition changes) or to the logs. Already stored events keep their old text. The texts for an unregistered workflow and a definition-fingerprint mismatch are unchanged. describeWorkflowStepError is exported from @cosmicdrift/kumiko-framework/engine."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -16,14 +16,18 @@ import type {
|
|
|
16
16
|
WriteEvent,
|
|
17
17
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
18
|
import {
|
|
19
|
+
describeWorkflowStepError,
|
|
19
20
|
WORKFLOW_AGGREGATE_TYPE,
|
|
20
21
|
WORKFLOW_RUN_FAILED_TYPE,
|
|
21
22
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
22
24
|
import { workflowRunAggregateId } from "./aggregate-id";
|
|
23
25
|
import { registerEventWakeup } from "./event-subscriber";
|
|
24
26
|
import { startAndRunWorkflow, type WorkflowRunFailedPayload } from "./runner";
|
|
25
27
|
import { registerWorkflow } from "./workflow-registry";
|
|
26
28
|
|
|
29
|
+
const log = createFallbackLogger("workflow-runner");
|
|
30
|
+
|
|
27
31
|
export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
|
|
28
32
|
// Populate the workflow-registry unconditionally, before the event-trigger
|
|
29
33
|
// guard below — resume-run (framework#2513 Phase 2) looks workflows up by
|
|
@@ -89,10 +93,16 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
|
|
|
89
93
|
handlerCtx: ctx as never,
|
|
90
94
|
});
|
|
91
95
|
} catch (error) {
|
|
92
|
-
|
|
96
|
+
log.warn("workflow run failed", {
|
|
97
|
+
runId,
|
|
93
98
|
workflowName: workflow.name,
|
|
94
99
|
stepIndex: 0,
|
|
95
100
|
error: String(error),
|
|
101
|
+
});
|
|
102
|
+
const failedPayload: WorkflowRunFailedPayload = {
|
|
103
|
+
workflowName: workflow.name,
|
|
104
|
+
stepIndex: 0,
|
|
105
|
+
error: describeWorkflowStepError(error),
|
|
96
106
|
};
|
|
97
107
|
await ctx.unsafeAppendEvent({
|
|
98
108
|
aggregateId: runId,
|
|
@@ -31,6 +31,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
31
31
|
import {
|
|
32
32
|
buildPipelineSteps,
|
|
33
33
|
computeDefinitionFingerprint,
|
|
34
|
+
describeWorkflowStepError,
|
|
34
35
|
getStep,
|
|
35
36
|
type HandlerContext,
|
|
36
37
|
runStepList,
|
|
@@ -48,6 +49,7 @@ import {
|
|
|
48
49
|
type WriteHandlerDef,
|
|
49
50
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
50
51
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
52
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
51
53
|
import * as z from "zod";
|
|
52
54
|
import {
|
|
53
55
|
isResumableSuspension,
|
|
@@ -59,6 +61,8 @@ import {
|
|
|
59
61
|
import { workflowRunPendingTable } from "../tables";
|
|
60
62
|
import { getWorkflow } from "../workflow-registry";
|
|
61
63
|
|
|
64
|
+
const log = createFallbackLogger("workflow-runner");
|
|
65
|
+
|
|
62
66
|
const resumeRunSchema = z.object({
|
|
63
67
|
runId: z.string().min(1),
|
|
64
68
|
stepIndex: z.number().int().nonnegative(),
|
|
@@ -338,10 +342,11 @@ export const resumeRunHandler: WriteHandlerDef = {
|
|
|
338
342
|
});
|
|
339
343
|
return { isSuccess: true, data: { outcome: "completed" as const } };
|
|
340
344
|
} catch (error) {
|
|
345
|
+
log.warn("workflow run failed", { runId, workflowName, stepIndex, error: String(error) });
|
|
341
346
|
const failedPayload: WorkflowRunFailedPayload = {
|
|
342
347
|
workflowName,
|
|
343
348
|
stepIndex,
|
|
344
|
-
error:
|
|
349
|
+
error: describeWorkflowStepError(error),
|
|
345
350
|
};
|
|
346
351
|
await appendRunFailed(ctx, runId, failedPayload);
|
|
347
352
|
return { isSuccess: true, data: { outcome: "failed" as const } };
|
|
@@ -57,10 +57,12 @@ export type WorkflowRunCompletedPayload = {
|
|
|
57
57
|
export type WorkflowRunFailedPayload = {
|
|
58
58
|
readonly workflowName: string;
|
|
59
59
|
readonly stepIndex: number;
|
|
60
|
+
// Generic text (class name only), never the raw error message: the event
|
|
61
|
+
// survives an erase. The raw error goes to the log.
|
|
60
62
|
readonly error: string;
|
|
61
63
|
// Machine-readable failure category — set by resume-run for a Q7
|
|
62
64
|
// fingerprint mismatch ("workflow_definition_changed"); absent for a
|
|
63
|
-
// plain pipeline-step failure (the `error` string is
|
|
65
|
+
// plain pipeline-step failure (the `error` string is generic).
|
|
64
66
|
readonly reason?: string;
|
|
65
67
|
};
|
|
66
68
|
|