@cosmicdrift/kumiko-framework 0.363.0 → 0.364.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 (189) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +7 -0
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/routes.js +46 -17
  80. package/dist/api/server.d.ts +2 -1
  81. package/dist/api/server.js +18 -6
  82. package/dist/api/session-cookies.d.ts +1 -0
  83. package/dist/api/session-cookies.js +20 -5
  84. package/dist/api/sse-route.js +16 -1
  85. package/dist/bun-db/index.d.ts +1 -1
  86. package/dist/bun-db/index.js +1 -1
  87. package/dist/bun-db/query.d.ts +8 -3
  88. package/dist/bun-db/query.js +27 -7
  89. package/dist/db/collect-table-metas.js +11 -7
  90. package/dist/db/eagerload.js +2 -2
  91. package/dist/db/event-store-executor-context.d.ts +5 -6
  92. package/dist/db/event-store-executor-context.js +24 -22
  93. package/dist/db/event-store-executor-read.js +31 -16
  94. package/dist/db/event-store-executor-write.js +18 -20
  95. package/dist/db/index.d.ts +1 -1
  96. package/dist/db/index.js +1 -1
  97. package/dist/db/lock-guard.d.ts +4 -0
  98. package/dist/db/lock-guard.js +14 -0
  99. package/dist/db/migrate-generator.js +1 -1
  100. package/dist/db/migrate-runner.js +9 -8
  101. package/dist/db/queries/event-consumer.d.ts +6 -6
  102. package/dist/db/queries/event-store.d.ts +1 -1
  103. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  104. package/dist/db/tenant-db-runner.d.ts +4 -0
  105. package/dist/db/tenant-db-runner.js +17 -0
  106. package/dist/db/tenant-db.d.ts +2 -0
  107. package/dist/db/tenant-db.js +30 -4
  108. package/dist/derivatives/derivatives-context.js +1 -1
  109. package/dist/engine/boot-validator/access-declarations.js +2 -2
  110. package/dist/engine/boot-validator/api-ext.js +3 -3
  111. package/dist/engine/boot-validator/boot-check.js +1 -1
  112. package/dist/engine/boot-validator/config-deps.js +13 -13
  113. package/dist/engine/boot-validator/detail-screens.js +1 -1
  114. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  115. package/dist/engine/boot-validator/entity-handler.js +84 -35
  116. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  117. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  118. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  119. package/dist/engine/boot-validator/nav.js +1 -1
  120. package/dist/engine/boot-validator/pii-retention.js +8 -8
  121. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  122. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  123. package/dist/engine/boot-validator/screens.d.ts +2 -1
  124. package/dist/engine/boot-validator/screens.js +153 -95
  125. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  126. package/dist/engine/define-handler.d.ts +2 -2
  127. package/dist/engine/define-handler.js +8 -1
  128. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  129. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  130. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  131. package/dist/engine/feature-ast/render.js +10 -2
  132. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  133. package/dist/engine/feature-entity-handlers.js +12 -3
  134. package/dist/engine/handler-field-references.d.ts +21 -2
  135. package/dist/engine/handler-field-references.js +55 -17
  136. package/dist/engine/index.d.ts +2 -2
  137. package/dist/engine/schema-builder.js +2 -1
  138. package/dist/engine/soft-delete-cleanup.js +10 -3
  139. package/dist/engine/steps/retry.js +2 -1
  140. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  141. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  142. package/dist/engine/template-variables.d.ts +3 -0
  143. package/dist/engine/template-variables.js +3 -0
  144. package/dist/engine/types/index.d.ts +1 -1
  145. package/dist/env/_zod-introspect.d.ts +1 -0
  146. package/dist/errors/classes.js +1 -0
  147. package/dist/errors/kumiko-error.d.ts +2 -0
  148. package/dist/errors/kumiko-error.js +2 -0
  149. package/dist/errors/write-error-info.js +2 -0
  150. package/dist/event-store/tenant-db-append.js +2 -2
  151. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  152. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  153. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  154. package/dist/files/bind-pending-file-refs.js +3 -3
  155. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  156. package/dist/files/bind-referenced-files.write.js +22 -13
  157. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  158. package/dist/files/create-file-ref-from-bytes.js +67 -4
  159. package/dist/files/create-file-ref.d.ts +22 -0
  160. package/dist/files/create-file-ref.js +53 -25
  161. package/dist/files/file-handle.d.ts +1 -1
  162. package/dist/files/file-handle.js +8 -0
  163. package/dist/files/file-routes.js +1 -1
  164. package/dist/files/index.d.ts +2 -2
  165. package/dist/files/index.js +1 -1
  166. package/dist/files/load-save-file-refs.d.ts +10 -0
  167. package/dist/files/load-save-file-refs.js +47 -0
  168. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  169. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  170. package/dist/jobs/index.d.ts +1 -1
  171. package/dist/jobs/job-runner.d.ts +4 -1
  172. package/dist/jobs/job-runner.js +8 -4
  173. package/dist/observability/escape-hatch-report.d.ts +1 -1
  174. package/dist/observability/escape-hatch-report.js +17 -5
  175. package/dist/pipeline/cascade-handler.js +5 -2
  176. package/dist/pipeline/dispatch-batch.js +79 -31
  177. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  178. package/dist/pipeline/dispatch-shared.js +99 -22
  179. package/dist/pipeline/dispatch-write.js +34 -22
  180. package/dist/pipeline/index.d.ts +1 -0
  181. package/dist/pipeline/index.js +1 -0
  182. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  183. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  184. package/dist/rate-limit/bucket.d.ts +3 -3
  185. package/dist/rate-limit/bucket.js +14 -4
  186. package/dist/redis/cache-sync-bus.js +9 -3
  187. package/dist/search/meilisearch-adapter.js +36 -9
  188. package/package.json +7 -5
  189. package/src/changes.json +207 -8
@@ -0,0 +1,597 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/webhook-step. Do not edit. -->
2
+
3
+ # webhook-step
4
+
5
+ Tier-2 r.step.webhook.send showcase: deferred webhook dispatch via step-dispatcher subscription, no outbox table.
6
+
7
+ ## README
8
+
9
+ # Webhook Step
10
+
11
+ Workflow / pipeline step that calls an HTTP webhook.
12
+
13
+ ## What it shows
14
+
15
+ - Webhook step registration in a workflow
16
+ - Failure surface for outbound HTTP (one delivery attempt per request)
17
+ - Authenticated webhooks via a tenant-owned secret (`incident:open-authenticated`)
18
+
19
+ ## Auth secret
20
+
21
+ `r.step.webhook.send`'s `auth.secret` is a name inside the tenant-owned
22
+ `step-dispatcher:webhook-auth.<name>` namespace of the `secrets` feature —
23
+ never a process-wide env var. The `secrets` feature must be mounted
24
+ (`createSecretsFeature()` + a `MasterKeyProvider`) alongside
25
+ `step-dispatcher`. Set the secret per tenant before dispatching:
26
+
27
+ ```ts illustration
28
+ await stack.http.writeOk(
29
+ "secrets:write:set",
30
+ { key: "step-dispatcher:webhook-auth.incident-hook", value: "<token>" },
31
+ tenantAdmin,
32
+ );
33
+ ```
34
+
35
+ Each tenant's secret is isolated — a tenant with no matching secret (or one
36
+ stored without the `step-dispatcher:webhook-auth.` prefix) gets a generic
37
+ `webhook auth secret is not available` `step.dispatch-failed` event, never
38
+ another tenant's credential.
39
+
40
+ Never combine `auth.secret` with a caller-controlled `url`: the caller would
41
+ receive the secret as the `Authorization` header. `incident:open-authenticated`
42
+ therefore posts to a fixed URL.
43
+
44
+ ## Idempotency-Key
45
+
46
+ Every webhook request carries `Idempotency-Key: <dispatch stream id>`. The
47
+ value is the same when the same dispatch request is delivered again, so a
48
+ receiver can deduplicate redelivered calls. An `Idempotency-Key` set explicitly in
49
+ `headers` (any casing) wins.
50
+
51
+ ## Source
52
+
53
+ Feature entry point: `src/feature.ts`.
54
+
55
+ ## Tests
56
+
57
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
58
+
59
+ ```bash
60
+ cd samples/recipes/webhook-step
61
+ bun test
62
+ ```
63
+
64
+ ## Source files
65
+
66
+ ### src/__tests__/webhook.integration.test.ts
67
+
68
+ ```ts
69
+ // webhook-step integration test — drives r.step.webhook.send end-to-end:
70
+ // handler appends step.dispatch-requested in TX, dispatcher subscription
71
+ // drains after COMMIT and calls the (stubbed) fetch, follow-up
72
+ // step.dispatched / step.dispatch-failed events land on the same stream.
73
+
74
+ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
75
+ import { randomBytes } from "node:crypto";
76
+ import {
77
+ createSecretsContext,
78
+ createSecretsFeature,
79
+ tenantSecretsTable,
80
+ } from "@cosmicdrift/kumiko-bundled-features/secrets";
81
+ import {
82
+ createStepDispatcherFeature,
83
+ type MailSpec,
84
+ setMailRunner,
85
+ setWebhookFetch,
86
+ WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
87
+ } from "@cosmicdrift/kumiko-bundled-features/step-dispatcher";
88
+ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
89
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
90
+ import {
91
+ createEnvMasterKeyProvider,
92
+ type MasterKeyProvider,
93
+ } from "@cosmicdrift/kumiko-framework/secrets";
94
+ import {
95
+ createTestUser,
96
+ resetEventStore,
97
+ setupTestStack,
98
+ type TestStack,
99
+ testTenantId,
100
+ unsafeCreateEntityTable,
101
+ unsafePushTables,
102
+ } from "@cosmicdrift/kumiko-framework/stack";
103
+
104
+ import { incidentEntity, incidentTable, webhookDemoFeature } from "../feature";
105
+
106
+ let stack: TestStack;
107
+ let masterKeyProvider: MasterKeyProvider;
108
+ const admin = createTestUser({ roles: ["Admin"] });
109
+
110
+ const TENANT_B = testTenantId(101);
111
+ const TENANT_A = testTenantId(102);
112
+ const TENANT_C = testTenantId(103);
113
+ const adminB = createTestUser({ id: 101, tenantId: TENANT_B, roles: ["Admin", "TenantAdmin"] });
114
+ const adminA = createTestUser({ id: 102, tenantId: TENANT_A, roles: ["Admin", "TenantAdmin"] });
115
+ const adminC = createTestUser({ id: 103, tenantId: TENANT_C, roles: ["Admin", "TenantAdmin"] });
116
+
117
+ const fetchMock = mock<typeof fetch>();
118
+ const mailMock =
119
+ mock<
120
+ (spec: { to: string | readonly string[]; subject: string; body: string }) => Promise<{
121
+ ok: true;
122
+ status: number;
123
+ }>
124
+ >();
125
+
126
+ const originalAllowedPrivateHostsEnv = process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
127
+
128
+ beforeAll(async () => {
129
+ // hooks.example is a placeholder, not a real resolvable host — goes
130
+ // through the operator escape hatch instead of a real DNS lookup.
131
+ process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "hooks.example";
132
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
133
+ setMailRunner(async (spec: MailSpec) => mailMock(spec));
134
+ masterKeyProvider = createEnvMasterKeyProvider({
135
+ env: {
136
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
137
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
138
+ },
139
+ });
140
+ stack = await setupTestStack({
141
+ features: [createStepDispatcherFeature(), createSecretsFeature(), webhookDemoFeature],
142
+ systemHooks: [],
143
+ extraContext: ({ db, registry }) => ({
144
+ secrets: createSecretsContext({ db, masterKeyProvider, registry }),
145
+ }),
146
+ });
147
+ await unsafeCreateEntityTable(stack.db, incidentEntity, "incident");
148
+ await unsafePushTables(stack.db, { tenantSecretsTable });
149
+ });
150
+
151
+ afterAll(async () => {
152
+ if (originalAllowedPrivateHostsEnv === undefined) {
153
+ delete process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
154
+ } else {
155
+ process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = originalAllowedPrivateHostsEnv;
156
+ }
157
+ await stack.cleanup();
158
+ });
159
+
160
+ beforeEach(async () => {
161
+ fetchMock.mockReset();
162
+ mailMock.mockReset();
163
+ mailMock.mockResolvedValue({ ok: true, status: 202 });
164
+ await resetEventStore(stack, ["read_webhook_demo_incidents", tenantSecretsTable]);
165
+ await stack.redis.flushNamespace();
166
+ await stack.eventDispatcher?.ensureRegistered();
167
+ });
168
+
169
+ describe("webhook-step Sample", () => {
170
+ test("incident:open writes aggregate AND fires webhook after COMMIT", async () => {
171
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
172
+
173
+ const { id } = await stack.http.writeOk<{ id: string }>(
174
+ "webhook-demo:write:incident:open",
175
+ { title: "DB outage", severity: "high", webhookUrl: "https://hooks.example/incident" },
176
+ admin,
177
+ );
178
+
179
+ expect(id).toMatch(/^[0-9a-f-]{36}$/);
180
+
181
+ // Aggregate landed
182
+ const [row] = await selectMany(stack.db, incidentTable, { id });
183
+ expect(row).toMatchObject({ title: "DB outage", severity: "high" });
184
+
185
+ // Drain dispatcher (MSP runs async via runOnce in test mode)
186
+ await stack.eventDispatcher?.runOnce();
187
+
188
+ expect(fetchMock).toHaveBeenCalledTimes(1);
189
+ const [calledUrl, init] = fetchMock.mock.calls[0]!;
190
+ expect(calledUrl).toBe("https://hooks.example/incident");
191
+ expect(init?.method).toBe("POST");
192
+ const body = JSON.parse(init?.body as string);
193
+ expect(body).toMatchObject({ event: "incident-opened", id, severity: "high" });
194
+ });
195
+
196
+ test("incident:notify-via-mail dispatches mail.send through the same MSP", async () => {
197
+ const { id } = await stack.http.writeOk<{ id: string }>(
198
+ "webhook-demo:write:incident:notify-via-mail",
199
+ { to: "ops@example.com", title: "DB outage", severity: "high" },
200
+ admin,
201
+ );
202
+ expect(id).toMatch(/^[0-9a-f-]{36}$/);
203
+
204
+ await stack.eventDispatcher?.runOnce();
205
+
206
+ expect(mailMock).toHaveBeenCalledTimes(1);
207
+ expect(mailMock).toHaveBeenCalledWith(
208
+ expect.objectContaining({
209
+ to: "ops@example.com",
210
+ subject: "Incident: DB outage",
211
+ body: "Severity high",
212
+ }),
213
+ );
214
+ // webhook didn't fire — different stepKind
215
+ expect(fetchMock).not.toHaveBeenCalled();
216
+ });
217
+
218
+ test("incident:open-via-call invokes incident:open via callFeature and threads the result", async () => {
219
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
220
+
221
+ const { id } = await stack.http.writeOk<{ id: string }>(
222
+ "webhook-demo:write:incident:open-via-call",
223
+ { title: "Network hiccup", severity: "low" },
224
+ admin,
225
+ );
226
+ expect(id).toMatch(/^[0-9a-f-]{36}$/);
227
+
228
+ // The inner incident:open ran and committed an aggregate
229
+ const [row] = await selectMany(stack.db, incidentTable, { id });
230
+ expect(row).toMatchObject({ title: "Network hiccup", severity: "low" });
231
+
232
+ // And the inner handler's webhook fired
233
+ await stack.eventDispatcher?.runOnce();
234
+ expect(fetchMock).toHaveBeenCalledTimes(1);
235
+ });
236
+
237
+ test("rollback: webhook does NOT fire when a later step throws", async () => {
238
+ const res = await stack.http.write(
239
+ "webhook-demo:write:incident:open-then-fail",
240
+ { title: "should-rollback", webhookUrl: "https://hooks.example/never" },
241
+ admin,
242
+ );
243
+ expect(res.status).toBe(500);
244
+
245
+ await stack.eventDispatcher?.runOnce();
246
+
247
+ expect(fetchMock).not.toHaveBeenCalled();
248
+ const rows = await selectMany(stack.db, incidentTable);
249
+ expect(rows).toHaveLength(0);
250
+ });
251
+ });
252
+
253
+ describe("access-denied — role-restricted handlers", () => {
254
+ test("incident:notify-via-mail rejects a caller without the Admin role", async () => {
255
+ const viewer = createTestUser({ roles: ["User"] });
256
+
257
+ const error = await stack.http.writeErr(
258
+ "webhook-demo:write:incident:notify-via-mail",
259
+ { to: "ops@example.com", title: "DB outage", severity: "high" },
260
+ viewer,
261
+ );
262
+ expect(error.code).toBe("access_denied");
263
+ await stack.eventDispatcher?.runOnce();
264
+ expect(mailMock).not.toHaveBeenCalled();
265
+ });
266
+
267
+ test("incident:open-via-call rejects a caller without the Admin role", async () => {
268
+ const viewer = createTestUser({ roles: ["User"] });
269
+
270
+ const error = await stack.http.writeErr(
271
+ "webhook-demo:write:incident:open-via-call",
272
+ { title: "Network hiccup", severity: "low" },
273
+ viewer,
274
+ );
275
+ expect(error.code).toBe("access_denied");
276
+ const rows = await selectMany(stack.db, incidentTable, { title: "Network hiccup" });
277
+ expect(rows).toHaveLength(0);
278
+ });
279
+
280
+ test("incident:open-then-fail rejects a caller without the Admin role", async () => {
281
+ const viewer = createTestUser({ roles: ["User"] });
282
+
283
+ const error = await stack.http.writeErr(
284
+ "webhook-demo:write:incident:open-then-fail",
285
+ { title: "should-not-run", webhookUrl: "https://hooks.example/never" },
286
+ viewer,
287
+ );
288
+ expect(error.code).toBe("access_denied");
289
+ await stack.eventDispatcher?.runOnce();
290
+ expect(fetchMock).not.toHaveBeenCalled();
291
+ });
292
+ });
293
+
294
+ describe("incident:open-authenticated — tenant-owned webhook auth secret", () => {
295
+ test("a caller without the Admin role is rejected", async () => {
296
+ const viewer = createTestUser({ roles: ["User"] });
297
+
298
+ const error = await stack.http.writeErr(
299
+ "webhook-demo:write:incident:open-authenticated",
300
+ { title: "DB outage", severity: "high" },
301
+ viewer,
302
+ );
303
+ expect(error.code).toBe("access_denied");
304
+ });
305
+
306
+ test("tenant B's own secret authenticates its own webhook", async () => {
307
+ await stack.http.writeOk(
308
+ "secrets:write:set",
309
+ { key: "step-dispatcher:webhook-auth.incident-hook", value: "b-token-secret" },
310
+ adminB,
311
+ );
312
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
313
+
314
+ await stack.http.writeOk<{ id: string }>(
315
+ "webhook-demo:write:incident:open-authenticated",
316
+ { title: "DB outage", severity: "high" },
317
+ adminB,
318
+ );
319
+ await stack.eventDispatcher?.runOnce();
320
+
321
+ expect(fetchMock).toHaveBeenCalledTimes(1);
322
+ const [, init] = fetchMock.mock.calls[0]!;
323
+ expect(new Headers(init?.headers).get("authorization")).toBe("Bearer b-token-secret");
324
+
325
+ const dispatchedEvents = await selectMany(stack.db, eventsTable, {
326
+ type: "kumiko:system:step.dispatched",
327
+ });
328
+ expect(dispatchedEvents).toHaveLength(1);
329
+ const failedEvents = await selectMany(stack.db, eventsTable, {
330
+ type: "kumiko:system:step.dispatch-failed",
331
+ });
332
+ expect(failedEvents).toHaveLength(0);
333
+ });
334
+
335
+ test("another tenant without a matching secret cannot ride tenant B's credential", async () => {
336
+ await stack.http.writeOk(
337
+ "secrets:write:set",
338
+ { key: "step-dispatcher:webhook-auth.incident-hook", value: "b-token-secret" },
339
+ adminB,
340
+ );
341
+
342
+ await stack.http.writeOk<{ id: string }>(
343
+ "webhook-demo:write:incident:open-authenticated",
344
+ {
345
+ title: "Tenant A incident",
346
+ severity: "high",
347
+ },
348
+ adminA,
349
+ );
350
+ await stack.eventDispatcher?.runOnce();
351
+
352
+ expect(fetchMock).not.toHaveBeenCalled();
353
+
354
+ const failedEvents = await selectMany(stack.db, eventsTable, {
355
+ type: "kumiko:system:step.dispatch-failed",
356
+ });
357
+ expect(failedEvents).toHaveLength(1);
358
+ const errorMessage = (failedEvents[0]?.payload as { error?: string } | null)?.error;
359
+ expect(errorMessage).toBe("webhook auth secret is not available");
360
+ expect(errorMessage).not.toContain("incident-hook");
361
+ expect(errorMessage).not.toContain("b-token-secret");
362
+ });
363
+
364
+ test("a secret outside the webhook-auth namespace cannot be stored and does not authenticate", async () => {
365
+ const rejected = await stack.http.writeErr(
366
+ "secrets:write:set",
367
+ { key: "incident-hook", value: "c-raw-token" },
368
+ adminC,
369
+ );
370
+ expect(rejected.i18nKey).toBe("secrets.errors.unknownKey");
371
+
372
+ await stack.http.writeOk<{ id: string }>(
373
+ "webhook-demo:write:incident:open-authenticated",
374
+ {
375
+ title: "Tenant C incident",
376
+ severity: "high",
377
+ },
378
+ adminC,
379
+ );
380
+ await stack.eventDispatcher?.runOnce();
381
+
382
+ expect(fetchMock).not.toHaveBeenCalled();
383
+ const failedEvents = await selectMany(stack.db, eventsTable, {
384
+ type: "kumiko:system:step.dispatch-failed",
385
+ });
386
+ expect(failedEvents).toHaveLength(1);
387
+ });
388
+ });
389
+ ```
390
+
391
+ ### src/feature.ts
392
+
393
+ ```ts
394
+ // webhook-step Sample — Tier-2 r.step.webhook.send showcase.
395
+ //
396
+ // A minimal "incident-open" handler that opens an incident aggregate and
397
+ // dispatches a Zapier-style webhook in deferred mode. The webhook fires
398
+ // only after the TX commits — if the aggregate.create rolls back, the
399
+ // webhook does NOT go out (the step.dispatch-requested event vanishes
400
+ // with the rollback).
401
+
402
+ import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
403
+ import {
404
+ createEntity,
405
+ createTextField,
406
+ defineFeature,
407
+ defineWriteHandler,
408
+ type PipelineCtx,
409
+ stepsPipeline,
410
+ } from "@cosmicdrift/kumiko-framework/engine";
411
+ import { z } from "zod";
412
+
413
+ export const incidentEntity = createEntity({
414
+ table: "read_webhook_demo_incidents",
415
+ fields: {
416
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
417
+ severity: createTextField({ personal: false, reason: "is_business_data", required: true }),
418
+ },
419
+ });
420
+
421
+ export const incidentTable = buildEntityTable("incident", incidentEntity);
422
+ const incidentExecutor = createEventStoreExecutor(incidentTable, incidentEntity, {
423
+ entityName: "incident",
424
+ });
425
+
426
+ const INCIDENT_HOOK_URL = "https://hooks.example/incident";
427
+
428
+ export const webhookDemoFeature = defineFeature("webhook-demo", (r) => {
429
+ r.entity("incident", incidentEntity);
430
+ r.requires.step("webhook.send");
431
+ r.requires.step("mail.send");
432
+ r.requires.step("callFeature");
433
+
434
+ r.writeHandler(
435
+ defineWriteHandler({
436
+ name: "incident:open",
437
+ schema: z.object({
438
+ title: z.string().min(1),
439
+ severity: z.enum(["low", "medium", "high"]),
440
+ webhookUrl: z.string(),
441
+ }),
442
+ access: { roles: ["Admin"] },
443
+ perform: stepsPipeline<
444
+ { title: string; severity: "low" | "medium" | "high"; webhookUrl: string },
445
+ { id: string }
446
+ >(({ event, r }) => [
447
+ r.step.aggregate.create("incident", {
448
+ executor: incidentExecutor,
449
+ data: () => ({ title: event.payload.title, severity: event.payload.severity }),
450
+ }),
451
+ r.step.webhook.send({
452
+ url: () => event.payload.webhookUrl,
453
+ mode: "deferred",
454
+ body: ({ steps }: PipelineCtx) => ({
455
+ event: "incident-opened",
456
+ id: (steps["incident"] as { id: string }).id,
457
+ title: event.payload.title,
458
+ severity: event.payload.severity,
459
+ }),
460
+ }),
461
+ r.step.return(({ steps }) => ({
462
+ isSuccess: true as const,
463
+ data: { id: (steps["incident"] as { id: string }).id },
464
+ })),
465
+ ]),
466
+ }),
467
+ );
468
+
469
+ // auth variant — the URL is fixed server-side: a caller-supplied URL would
470
+ // receive the tenant's bearer secret. The webhook secret resolves per-tenant
471
+ // via the secrets feature under `step-dispatcher:webhook-auth.incident-hook`, never a
472
+ // process-wide env var. See README for how to set it via
473
+ // `secrets:write:set`.
474
+ r.writeHandler(
475
+ defineWriteHandler({
476
+ name: "incident:open-authenticated",
477
+ schema: z.object({
478
+ title: z.string().min(1),
479
+ severity: z.enum(["low", "medium", "high"]),
480
+ }),
481
+ access: { roles: ["Admin"] },
482
+ perform: stepsPipeline<
483
+ { title: string; severity: "low" | "medium" | "high" },
484
+ { id: string }
485
+ >(({ event, r }) => [
486
+ r.step.aggregate.create("incident", {
487
+ executor: incidentExecutor,
488
+ data: () => ({ title: event.payload.title, severity: event.payload.severity }),
489
+ }),
490
+ r.step.webhook.send({
491
+ url: () => INCIDENT_HOOK_URL,
492
+ mode: "deferred",
493
+ auth: { kind: "bearer", secret: "incident-hook" },
494
+ body: ({ steps }: PipelineCtx) => ({
495
+ event: "incident-opened",
496
+ id: (steps["incident"] as { id: string }).id,
497
+ title: event.payload.title,
498
+ severity: event.payload.severity,
499
+ }),
500
+ }),
501
+ r.step.return(({ steps }) => ({
502
+ isSuccess: true as const,
503
+ data: { id: (steps["incident"] as { id: string }).id },
504
+ })),
505
+ ]),
506
+ }),
507
+ );
508
+
509
+ // mail.send variant — same deferred shape, different stepKind.
510
+ // Step-dispatcher MSP routes to performMailDispatch.
511
+ r.writeHandler(
512
+ defineWriteHandler({
513
+ name: "incident:notify-via-mail",
514
+ schema: z.object({
515
+ to: z.string(),
516
+ title: z.string(),
517
+ severity: z.enum(["low", "medium", "high"]),
518
+ }),
519
+ access: { roles: ["Admin"] },
520
+ perform: stepsPipeline<
521
+ { to: string; title: string; severity: "low" | "medium" | "high" },
522
+ { id: string }
523
+ >(({ event, r }) => [
524
+ r.step.aggregate.create("incident", {
525
+ executor: incidentExecutor,
526
+ data: () => ({ title: event.payload.title, severity: event.payload.severity }),
527
+ }),
528
+ r.step.mail.send({
529
+ to: () => event.payload.to,
530
+ subject: () => `Incident: ${event.payload.title}`,
531
+ body: () => `Severity ${event.payload.severity}`,
532
+ mode: "deferred",
533
+ }),
534
+ r.step.return(({ steps }) => ({
535
+ isSuccess: true as const,
536
+ data: { id: (steps["incident"] as { id: string }).id },
537
+ })),
538
+ ]),
539
+ }),
540
+ );
541
+
542
+ // callFeature variant — sync sub-command on the same feature's
543
+ // incident:open. Threads the result of the callFeature step into
544
+ // the wrapper's response.
545
+ r.writeHandler(
546
+ defineWriteHandler({
547
+ name: "incident:open-via-call",
548
+ schema: z.object({ title: z.string(), severity: z.enum(["low", "medium", "high"]) }),
549
+ access: { roles: ["Admin"] },
550
+ perform: stepsPipeline<
551
+ { title: string; severity: "low" | "medium" | "high" },
552
+ { id: string }
553
+ >(({ event, r }) => [
554
+ r.step.callFeature("inner", {
555
+ handler: "webhook-demo:write:incident:open",
556
+ payload: () => ({
557
+ title: event.payload.title,
558
+ severity: event.payload.severity,
559
+ webhookUrl: "https://hooks.example/from-callFeature",
560
+ }),
561
+ }),
562
+ r.step.return(({ steps }) => ({
563
+ isSuccess: true as const,
564
+ data: { id: (steps["inner"] as { id: string }).id },
565
+ })),
566
+ ]),
567
+ }),
568
+ );
569
+
570
+ // Negative test handler: aggregate.create succeeds, then a compute step
571
+ // throws. Proves the webhook event is rolled back too — no fetch should
572
+ // ever fire because the step.dispatch-requested event vanishes with
573
+ // the TX rollback.
574
+ r.writeHandler(
575
+ defineWriteHandler({
576
+ name: "incident:open-then-fail",
577
+ schema: z.object({ title: z.string().min(1), webhookUrl: z.string() }),
578
+ access: { roles: ["Admin"] },
579
+ perform: stepsPipeline<{ title: string; webhookUrl: string }, never>(({ event, r }) => [
580
+ r.step.aggregate.create("incident", {
581
+ executor: incidentExecutor,
582
+ data: () => ({ title: event.payload.title, severity: "high" }),
583
+ }),
584
+ r.step.webhook.send({
585
+ url: () => event.payload.webhookUrl,
586
+ mode: "deferred",
587
+ body: () => ({ event: "incident-opened-but-rolled-back" }),
588
+ }),
589
+ r.step.compute("explode", () => {
590
+ throw new Error("rollback-test: throwing AFTER webhook.send");
591
+ }),
592
+ r.step.return({ isSuccess: true as const, data: undefined as never }),
593
+ ]),
594
+ }),
595
+ );
596
+ });
597
+ ```