@cosmicdrift/kumiko-framework 0.362.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.
- package/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- package/src/changes.json +251 -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
|
+
```
|