@cosmicdrift/kumiko-bundled-features 0.365.0 → 0.365.1

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.
@@ -0,0 +1,10 @@
1
+ export declare const STEP_DISPATCHER_FEATURE = "step-dispatcher";
2
+ export declare const StepDispatcherJobNames: {
3
+ readonly deliver: "deliver";
4
+ };
5
+ export declare const StepDispatcherJobs: {
6
+ readonly deliver: string;
7
+ };
8
+ export declare function deliverJobId(aggregateId: string): string;
9
+ export declare const DELIVER_MAX_PER_TENANT = 2;
10
+ export declare const DELIVER_RETRIES = 3;
@@ -0,0 +1,17 @@
1
+ import { QnTypes, qn } from "@cosmicdrift/kumiko-framework/engine";
2
+ export const STEP_DISPATCHER_FEATURE = "step-dispatcher";
3
+ export const StepDispatcherJobNames = {
4
+ deliver: "deliver",
5
+ };
6
+ export const StepDispatcherJobs = {
7
+ deliver: qn(STEP_DISPATCHER_FEATURE, QnTypes.job, StepDispatcherJobNames.deliver),
8
+ };
9
+ // Stable per dispatch stream: BullMQ ignores a second add() with the same id,
10
+ // so an MSP redelivery cannot enqueue a second delivery.
11
+ export function deliverJobId(aggregateId) {
12
+ return `step-dispatch-${aggregateId}`;
13
+ }
14
+ // Per-tenant cap on simultaneous deliveries, so one tenant's slow receivers
15
+ // cannot occupy every worker slot.
16
+ export const DELIVER_MAX_PER_TENANT = 2;
17
+ export const DELIVER_RETRIES = 3;
@@ -0,0 +1,14 @@
1
+ import type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-framework/pipeline";
2
+ import { type TenantId } from "@cosmicdrift/kumiko-types/identifiers";
3
+ export declare function isDispatchOutcome(e: {
4
+ readonly type: string;
5
+ }): boolean;
6
+ export declare function eraseDispatchKey(aggregateId: string): Promise<void>;
7
+ export type DeliverDispatchRequestArgs = {
8
+ readonly aggregateId: string;
9
+ readonly tenantId: TenantId;
10
+ readonly attempt: number;
11
+ readonly finalAttempt: boolean;
12
+ readonly applyContextFor: (userId: string) => MultiStreamApplyContext;
13
+ };
14
+ export declare function deliverDispatchRequest(args: DeliverDispatchRequestArgs): Promise<void>;
@@ -0,0 +1,163 @@
1
+ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
+ import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
3
+ import { STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE, STEP_DISPATCH_REQUESTED_TYPE, STEP_DISPATCHED_TYPE, } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
5
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
6
+ import { redactEmailAddresses } from "../shared/index.js";
7
+ import { buildDispatchSpec, dispatchRequestedPayloadSchema, rawStepKindOf, readPayloadFields, } from "./dispatch-payload.js";
8
+ import { performMailDispatch } from "./mail-runner.js";
9
+ import { performWebhookDispatch } from "./webhook-runner.js";
10
+ const log = createFallbackLogger("step-dispatcher");
11
+ // zod issue messages can echo the invalid value (e.g. a rejected url) back
12
+ // into the tenant-visible dispatch-failed event — keep this generic.
13
+ const INVALID_DISPATCH_PAYLOAD_ERROR = "invalid dispatch payload";
14
+ const PAYLOAD_UNREADABLE_ERROR = "dispatch payload is not readable";
15
+ // Adapter errors (e.g. SMTP 550) usually echo the recipient address; the
16
+ // dispatch-failed text outlives the payload erase, so only this is persisted.
17
+ const MAIL_DELIVERY_FAILED_ERROR = "mail delivery failed";
18
+ const PAYLOAD_ERASED_ERROR = "dispatch payload erased before an outcome was recorded";
19
+ // Thrown messages land in BullMQ's failedReason and the run log, so this never
20
+ // carries the receiver's error text or the target url.
21
+ const DELIVERY_RETRY_ERROR = "step delivery failed, retrying";
22
+ const DISPATCH_THREW_ERROR = "delivery attempt threw unexpectedly";
23
+ const FINAL_ATTEMPT_ABORTED_ERROR = "delivery aborted on the final attempt";
24
+ const FINAL_ATTEMPT_UNRECORDED_ERROR = "step delivery failed and its outcome could not be recorded";
25
+ export function isDispatchOutcome(e) {
26
+ return e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE;
27
+ }
28
+ export async function eraseDispatchKey(aggregateId) {
29
+ await configuredPiiSubjectKms()?.eraseKey({ kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: aggregateId }, {
30
+ requestId: requestContext.get()?.requestId ?? "step-dispatcher",
31
+ eraseReason: "step-dispatch-outcome-recorded",
32
+ });
33
+ }
34
+ // Mail adapter failures carry no status to classify, so every one is retried.
35
+ function mailResultAsDelivery(result) {
36
+ return result.ok ? result : { ...result, retryable: true };
37
+ }
38
+ // Outcome events are plaintext and generic; the request payload's per-dispatch
39
+ // key is erased right after, so the PII dies with the dispatch instead of
40
+ // living in the event log. The outcome is appended before the erase on
41
+ // purpose: if the erase throws, the outcome survives and a redelivery only
42
+ // repeats the erase. A crash between the send and the outcome append still
43
+ // re-sends under the same Idempotency-Key — the one remaining double-send case.
44
+ // A transient failure before the final attempt throws without an outcome or
45
+ // erase: the key must survive until the outcome is final.
46
+ export async function deliverDispatchRequest(args) {
47
+ const { aggregateId, finalAttempt } = args;
48
+ const stream = await args.applyContextFor(SYSTEM_USER_ID).loadAggregate(aggregateId);
49
+ if (stream.some(isDispatchOutcome)) {
50
+ await eraseDispatchKey(aggregateId);
51
+ // skip: redelivery after an outcome was recorded — only the erase is repeated
52
+ return;
53
+ }
54
+ const requested = stream.find((e) => e.type === STEP_DISPATCH_REQUESTED_TYPE);
55
+ if (!requested) {
56
+ // The key ref carries no tenant, so a job triggered with a foreign or
57
+ // unknown aggregateId must not erase anything or append to that stream.
58
+ log.warn("step dispatch request not found, nothing delivered", { aggregateId });
59
+ return;
60
+ }
61
+ const ctx = args.applyContextFor(requested.metadata.userId || SYSTEM_USER_ID);
62
+ const recorder = createOutcomeRecorder(ctx, aggregateId, args.attempt);
63
+ try {
64
+ await deliverRequested(args, requested, ctx, recorder);
65
+ }
66
+ catch (err) {
67
+ if (!finalAttempt)
68
+ throw err;
69
+ await abortOnFinalAttempt(err, ctx, recorder, aggregateId, requested.payload);
70
+ }
71
+ }
72
+ function createOutcomeRecorder(ctx, aggregateId, attempt) {
73
+ const record = async (type, payload) => {
74
+ await ctx.unsafeAppendEvent({
75
+ aggregateId,
76
+ aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
77
+ type,
78
+ payload,
79
+ });
80
+ await eraseDispatchKey(aggregateId);
81
+ };
82
+ return {
83
+ record,
84
+ recordFailure: (stepKind, error) => record(STEP_DISPATCH_FAILED_TYPE, { stepKind, error, attempt }),
85
+ };
86
+ }
87
+ async function deliverRequested(args, requested, ctx, recorder) {
88
+ const { aggregateId, tenantId, attempt, finalAttempt } = args;
89
+ const { record, recordFailure } = recorder;
90
+ const parsed = dispatchRequestedPayloadSchema.safeParse(requested.payload);
91
+ if (!parsed.success) {
92
+ return recordFailure(rawStepKindOf(requested.payload), INVALID_DISPATCH_PAYLOAD_ERROR);
93
+ }
94
+ const payload = parsed.data;
95
+ const read = await readPayloadFields(payload);
96
+ if (read.kind === "unreadable")
97
+ return recordFailure(payload.stepKind, PAYLOAD_UNREADABLE_ERROR);
98
+ if (read.kind === "erased")
99
+ return recordFailure(payload.stepKind, PAYLOAD_ERASED_ERROR);
100
+ const dispatchSpec = buildDispatchSpec(payload, read.fields);
101
+ if (!dispatchSpec)
102
+ return recordFailure(payload.stepKind, INVALID_DISPATCH_PAYLOAD_ERROR);
103
+ const result = await sendDispatch(dispatchSpec, {
104
+ aggregateId,
105
+ tenantId,
106
+ userId: requested.metadata.userId || SYSTEM_USER_ID,
107
+ secrets: ctx.secrets,
108
+ });
109
+ if (result.ok) {
110
+ return record(STEP_DISPATCHED_TYPE, { stepKind: payload.stepKind, status: result.status });
111
+ }
112
+ const loggedReason = payload.stepKind === "mail.send" ? redactEmailAddresses(result.error) : result.error;
113
+ if (result.retryable && !finalAttempt) {
114
+ log.warn("step delivery failed, will retry", { aggregateId, attempt, reason: loggedReason });
115
+ throw new Error(DELIVERY_RETRY_ERROR);
116
+ }
117
+ if (payload.stepKind === "mail.send") {
118
+ log.warn("mail dispatch failed", { aggregateId, reason: loggedReason });
119
+ return recordFailure(payload.stepKind, MAIL_DELIVERY_FAILED_ERROR);
120
+ }
121
+ return recordFailure(payload.stepKind, result.error);
122
+ }
123
+ async function sendDispatch(dispatchSpec, target) {
124
+ try {
125
+ return dispatchSpec.stepKind === "webhook.send"
126
+ ? await performWebhookDispatch(dispatchSpec.spec, {
127
+ tenantId: target.tenantId,
128
+ userId: target.userId,
129
+ secrets: target.secrets,
130
+ idempotencyKey: target.aggregateId,
131
+ })
132
+ : await performMailDispatch(dispatchSpec.spec).then(mailResultAsDelivery);
133
+ }
134
+ catch (err) {
135
+ // err.message can echo the url or an address; only the class name is logged.
136
+ log.warn("step delivery threw", {
137
+ aggregateId: target.aggregateId,
138
+ errorName: err instanceof Error ? err.name : "unknown",
139
+ });
140
+ return { ok: false, error: DISPATCH_THREW_ERROR, retryable: true };
141
+ }
142
+ }
143
+ async function abortOnFinalAttempt(err, ctx, recorder, aggregateId, requestedPayload) {
144
+ // err.message can echo the url or an address; only the class name is logged.
145
+ log.warn("step delivery aborted on the final attempt", {
146
+ aggregateId,
147
+ errorName: err instanceof Error ? err.name : "unknown",
148
+ });
149
+ try {
150
+ // An outcome may already be appended when only the erase threw: repeat the
151
+ // erase, never a second outcome.
152
+ const current = await ctx.loadAggregate(aggregateId);
153
+ if (current.some(isDispatchOutcome)) {
154
+ await eraseDispatchKey(aggregateId);
155
+ }
156
+ else {
157
+ await recorder.recordFailure(rawStepKindOf(requestedPayload), FINAL_ATTEMPT_ABORTED_ERROR);
158
+ }
159
+ }
160
+ catch {
161
+ throw new Error(FINAL_ATTEMPT_UNRECORDED_ERROR);
162
+ }
163
+ }
@@ -1,22 +1,19 @@
1
1
  // step-dispatcher — bundled-feature that drains deferred Tier-2 step
2
2
  // requests (webhook.send, mail.send, ...) after their TX commits.
3
3
  //
4
- // Listens on the `kumiko:system:step.dispatch-requested` system event
5
- // (registry-bypassed, see SYSTEM_EVENT_PREFIX).
6
- // Performs the side-effect and emits `kumiko:system:step.dispatched`
7
- // or `kumiko:system:step.dispatch-failed` back onto the same stream so
8
- // the audit trail lives in the event log only — no separate status table.
9
- import { requestContext } from "@cosmicdrift/kumiko-framework/api";
10
- import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
11
- import { defineFeature, STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE, STEP_DISPATCH_REQUESTED_TYPE, STEP_DISPATCHED_TYPE, } from "@cosmicdrift/kumiko-framework/engine";
12
- import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
13
- import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
4
+ // The `kumiko:system:step.dispatch-requested` system event (registry-bypassed,
5
+ // see SYSTEM_EVENT_PREFIX) is picked up by an MSP that only enqueues a
6
+ // per-dispatch job. The job performs the side-effect and emits
7
+ // `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back
8
+ // onto the same stream so the audit trail lives in the event log only — no
9
+ // separate status table. The job's Redis data carries only the stream id and
10
+ // tenant, never the (encrypted) request payload.
11
+ import { defineFeature, STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_REQUESTED_TYPE, } from "@cosmicdrift/kumiko-framework/engine";
12
+ import { createMultiStreamApplyContext } from "@cosmicdrift/kumiko-framework/pipeline";
14
13
  import * as z from "zod";
15
- import { redactEmailAddresses } from "../shared/index.js";
16
- import { buildDispatchSpec, dispatchRequestedPayloadSchema, rawStepKindOf, readPayloadFields, } from "./dispatch-payload.js";
17
- import { performMailDispatch } from "./mail-runner.js";
18
- import { performWebhookDispatch, WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR, WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS, } from "./webhook-runner.js";
19
- const log = createFallbackLogger("step-dispatcher");
14
+ import { DELIVER_MAX_PER_TENANT, DELIVER_RETRIES, deliverJobId, STEP_DISPATCHER_FEATURE, StepDispatcherJobs, } from "./constants.js";
15
+ import { deliverDispatchRequest, eraseDispatchKey, isDispatchOutcome, } from "./deliver-dispatch-request.js";
16
+ import { WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR, WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS, } from "./webhook-runner.js";
20
17
  export const stepDispatcherEnvSchema = z.object({
21
18
  [WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
22
19
  .string()
@@ -24,20 +21,12 @@ export const stepDispatcherEnvSchema = z.object({
24
21
  .describe("Comma-separated operator allowlist of private/internal hosts (e.g. a local webhook-receiver for dev/test) that bypass the public-address check for webhook.send targets. Never a tenant-config value."),
25
22
  });
26
23
  export { STEP_DISPATCH_AGGREGATE_TYPE };
27
- // zod issue messages can echo the invalid value (e.g. a rejected url) back
28
- // into the tenant-visible dispatch-failed event — keep this generic.
29
- const INVALID_DISPATCH_PAYLOAD_ERROR = "invalid dispatch payload";
30
- const PAYLOAD_UNREADABLE_ERROR = "dispatch payload is not readable";
31
- // Adapter errors (e.g. SMTP 550) usually echo the recipient address; the
32
- // dispatch-failed text outlives the payload erase, so only this is persisted.
33
- const MAIL_DELIVERY_FAILED_ERROR = "mail delivery failed";
34
- const PAYLOAD_ERASED_ERROR = "dispatch payload erased before an outcome was recorded";
35
- function isDispatchOutcome(e) {
36
- return e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE;
37
- }
24
+ const NEEDS_JOB_RUNNER_ERROR = "step-dispatcher needs a job runner (Redis) to deliver";
25
+ const DELIVER_ESCAPE_HATCH_REASON = "reads the dispatch stream and appends its outcome event, which needs a raw connection";
26
+ const deliverJobPayloadSchema = z.object({ aggregateId: z.string() });
38
27
  export function createStepDispatcherFeature() {
39
28
  return defineFeature("step-dispatcher", (r) => {
40
- r.describe("Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`. Every `webhook.send` request carries `Idempotency-Key: <dispatch stream id>`, stable across redeliveries of the same dispatch request; an explicit `Idempotency-Key` in `headers` takes precedence.");
29
+ r.describe("Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. An `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event enqueues one `step-dispatcher:job:deliver` BullMQ job per dispatch (Redis carries only the stream id and tenant, never the request payload). The job performs the actual HTTP or mail delivery and appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream, so the outcome is recorded in the event log without a separate status table. Transient failures (network error, timeout, 5xx, 408, 429, any mail adapter failure) are retried 3 times with exponential backoff; the outcome is recorded after the final attempt and the payload key is erased only then. Permanent failures (invalid url, blocked host, other 4xx, unavailable auth secret) are recorded immediately. At most 2 deliveries per tenant run at once (`maxPerTenant` with `onLimit: \"defer\"`), so a slow receiver of one tenant does not delay other tenants. Requires a job runner (Redis) and a worker consuming the job's default `worker` lane; without a job runner the projection fails loudly. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`. Every `webhook.send` request carries `Idempotency-Key: <dispatch stream id>`, stable across retries and redeliveries of the same dispatch request; an explicit `Idempotency-Key` in `headers` takes precedence.");
41
30
  r.secretNamespace("webhook-auth", WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS);
42
31
  r.uiHints({
43
32
  displayLabel: "Step Dispatcher · Deferred Side-Effects",
@@ -46,88 +35,46 @@ export function createStepDispatcherFeature() {
46
35
  });
47
36
  r.envSchema(stepDispatcherEnvSchema);
48
37
  r.requires("secrets");
38
+ // Literal name: the few-shot corpus parser only reads string-literal job names.
39
+ // StepDispatcherJobNames.deliver mirrors it; feature.boot.test.ts pins the two together.
40
+ r.job("deliver", {
41
+ trigger: { manual: true },
42
+ retries: DELIVER_RETRIES,
43
+ backoff: "exponential",
44
+ maxPerTenant: { limit: DELIVER_MAX_PER_TENANT, onLimit: "defer" },
45
+ escapeHatch: { reason: DELIVER_ESCAPE_HATCH_REASON },
46
+ }, async (payload, ctx) => {
47
+ const { aggregateId } = deliverJobPayloadSchema.parse(payload);
48
+ const db = ctx.db.unsafeRaw(); // @cast-boundary db-operator — jobs never run inside a DbTx
49
+ const tenantId = ctx.systemUser.tenantId;
50
+ await deliverDispatchRequest({
51
+ aggregateId,
52
+ tenantId,
53
+ attempt: ctx.attempt,
54
+ finalAttempt: ctx.finalAttempt,
55
+ applyContextFor: (userId) => createMultiStreamApplyContext({
56
+ registry: ctx.registry,
57
+ db,
58
+ tenantId,
59
+ userId,
60
+ callerFeature: STEP_DISPATCHER_FEATURE,
61
+ ...(ctx.secrets && { secrets: ctx.secrets }),
62
+ }),
63
+ });
64
+ });
49
65
  r.multiStreamProjection({
50
66
  name: "step-dispatcher",
51
67
  apply: {
52
68
  [STEP_DISPATCH_REQUESTED_TYPE]: async (event, _tx, ctx) => {
53
- const kms = configuredPiiSubjectKms();
54
- const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
55
- const eraseDispatchKey = async () => {
56
- await kms?.eraseKey({ kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: event.aggregateId }, { requestId, eraseReason: "step-dispatch-outcome-recorded" });
57
- };
58
- // Outcome events are plaintext and generic; the request payload's
59
- // per-dispatch key is erased right after, so the PII dies with the
60
- // dispatch instead of living in the event log. The outcome is
61
- // appended before the erase on purpose: if the erase throws, the
62
- // outcome survives and a redelivery only repeats the erase. A crash
63
- // between the send and the outcome append still re-sends under the
64
- // same Idempotency-Key — the one remaining double-send case.
65
- const recordOutcome = async (type, payload) => {
66
- await ctx.unsafeAppendEvent({
67
- aggregateId: event.aggregateId,
68
- aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
69
- type,
70
- payload,
71
- });
72
- await eraseDispatchKey();
73
- };
74
- const recordFailure = (stepKind, error) => recordOutcome(STEP_DISPATCH_FAILED_TYPE, { stepKind, error, attempt: 1 });
75
69
  const stream = await ctx.loadAggregate(event.aggregateId);
76
70
  if (stream.some(isDispatchOutcome)) {
77
- await eraseDispatchKey();
71
+ await eraseDispatchKey(event.aggregateId);
78
72
  // skip: redelivery after an outcome was recorded — only the erase is repeated
79
73
  return;
80
74
  }
81
- const parsed = dispatchRequestedPayloadSchema.safeParse(event.payload);
82
- if (!parsed.success) {
83
- await recordFailure(rawStepKindOf(event.payload), INVALID_DISPATCH_PAYLOAD_ERROR);
84
- // skip: invalid payload already recorded via step.dispatch-failed above, nothing left to dispatch
85
- return;
86
- }
87
- const payload = parsed.data;
88
- const read = await readPayloadFields(payload);
89
- if (read.kind === "unreadable") {
90
- await recordFailure(payload.stepKind, PAYLOAD_UNREADABLE_ERROR);
91
- // skip: unreadable payload already recorded via step.dispatch-failed above
92
- return;
93
- }
94
- if (read.kind === "erased") {
95
- await recordFailure(payload.stepKind, PAYLOAD_ERASED_ERROR);
96
- // skip: erased payload recorded via step.dispatch-failed above
97
- return;
98
- }
99
- const dispatchSpec = buildDispatchSpec(payload, read.fields);
100
- if (!dispatchSpec) {
101
- await recordFailure(payload.stepKind, INVALID_DISPATCH_PAYLOAD_ERROR);
102
- // skip: unparseable payload already recorded via step.dispatch-failed above
103
- return;
104
- }
105
- const result = dispatchSpec.stepKind === "webhook.send"
106
- ? await performWebhookDispatch(dispatchSpec.spec, {
107
- tenantId: event.tenantId,
108
- userId: event.metadata.userId || SYSTEM_USER_ID,
109
- secrets: ctx.secrets,
110
- idempotencyKey: event.aggregateId,
111
- })
112
- : await performMailDispatch(dispatchSpec.spec);
113
- if (result.ok) {
114
- await recordOutcome(STEP_DISPATCHED_TYPE, {
115
- stepKind: payload.stepKind,
116
- status: result.status,
117
- });
118
- }
119
- else {
120
- if (payload.stepKind === "mail.send") {
121
- log.warn("mail dispatch failed", {
122
- aggregateId: event.aggregateId,
123
- reason: redactEmailAddresses(result.error),
124
- });
125
- await recordFailure(payload.stepKind, MAIL_DELIVERY_FAILED_ERROR);
126
- }
127
- else {
128
- await recordFailure(payload.stepKind, result.error);
129
- }
130
- }
75
+ if (!ctx.jobs)
76
+ throw new Error(NEEDS_JOB_RUNNER_ERROR);
77
+ await ctx.jobs.dispatch(StepDispatcherJobs.deliver, { aggregateId: event.aggregateId, _tenantId: event.tenantId }, { jobId: deliverJobId(event.aggregateId) });
131
78
  },
132
79
  },
133
80
  });
@@ -33,13 +33,15 @@ export declare const webhookSpecSchema: z.ZodObject<{
33
33
  }, z.core.$strip>]>>;
34
34
  }, z.core.$strip>;
35
35
  export type WebhookSpec = z.infer<typeof webhookSpecSchema>;
36
- export type WebhookDispatchResult = {
37
- readonly ok: true;
38
- readonly status: number;
39
- } | {
36
+ type WebhookDispatchFailure = {
40
37
  readonly ok: false;
41
38
  readonly error: string;
39
+ readonly retryable: boolean;
42
40
  };
41
+ export type WebhookDispatchResult = {
42
+ readonly ok: true;
43
+ readonly status: number;
44
+ } | WebhookDispatchFailure;
43
45
  export declare function setWebhookFetch(fn: typeof fetch): void;
44
46
  export type WebhookDispatchDeps = {
45
47
  readonly tenantId: TenantId;
@@ -51,3 +53,4 @@ export type WebhookDispatchDeps = {
51
53
  export declare const WEBHOOK_REQUEST_TIMEOUT_MS = 10000;
52
54
  export declare const WEBHOOK_IDEMPOTENCY_KEY_HEADER = "idempotency-key";
53
55
  export declare function performWebhookDispatch(spec: WebhookSpec, deps: WebhookDispatchDeps): Promise<WebhookDispatchResult>;
56
+ export {};
@@ -75,12 +75,14 @@ export const webhookSpecSchema = z.object({
75
75
  ])
76
76
  .optional(),
77
77
  });
78
+ function isRetryableHttpStatus(status) {
79
+ return status >= 500 || status === 408 || status === 429;
80
+ }
78
81
  let fetchImpl = globalThis.fetch.bind(globalThis);
79
82
  export function setWebhookFetch(fn) {
80
83
  fetchImpl = fn;
81
84
  }
82
- // Bounds a single hanging tenant-controlled receiver to this long. The consumer
83
- // is sequential and shared, so N hanging requests still delay other tenants by N times this.
85
+ // Bounds a single hanging tenant-controlled receiver to this long per attempt.
84
86
  export const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000;
85
87
  // Never includes the secret name or value — spec.auth.secret is a
86
88
  // tenant-chosen name, but the error still reaches the tenant via the
@@ -110,10 +112,10 @@ async function buildWebhookHeaders(spec, deps) {
110
112
  if (!spec.auth)
111
113
  return { ok: true, headers };
112
114
  if (!deps.secrets)
113
- return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
115
+ return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR, retryable: false };
114
116
  // A throwing get (corrupt envelope, rotated key, KMS error) must become a
115
- // dispatch-failed event; rethrowing would retry and dead-letter the whole
116
- // step-dispatcher consumer for every tenant.
117
+ // dispatch-failed event; rethrowing would burn the job's retries on a
118
+ // failure a retry cannot fix.
117
119
  let secret;
118
120
  try {
119
121
  const revealed = await deps.secrets.get(deps.tenantId, webhookAuthSecretKey(spec.auth.secret), {
@@ -121,12 +123,12 @@ async function buildWebhookHeaders(spec, deps) {
121
123
  handlerName: "step-dispatcher:webhook.send",
122
124
  });
123
125
  if (!revealed)
124
- return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
126
+ return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR, retryable: false };
125
127
  secret = revealed.reveal();
126
128
  }
127
129
  catch {
128
130
  log.warn("webhook auth secret lookup failed", { tenantId: deps.tenantId });
129
- return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
131
+ return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR, retryable: false };
130
132
  }
131
133
  if (spec.auth.kind === "bearer") {
132
134
  setHeader(headers, "authorization", `Bearer ${secret}`);
@@ -148,7 +150,11 @@ async function resolveWebhookFetchTarget(rawUrl, url, headers) {
148
150
  const reason = err instanceof Error ? err.message : String(err);
149
151
  if (err instanceof BlockedHostError || err instanceof HostResolutionError) {
150
152
  log.warn("webhook host unreachable", { host: url.hostname, reason });
151
- return { ok: false, error: "webhook host is not reachable or not allowed" };
153
+ return {
154
+ ok: false,
155
+ error: "webhook host is not reachable or not allowed",
156
+ retryable: false,
157
+ };
152
158
  }
153
159
  throw err;
154
160
  }
@@ -165,10 +171,14 @@ export async function performWebhookDispatch(spec, deps) {
165
171
  url = new URL(spec.url);
166
172
  }
167
173
  catch {
168
- return { ok: false, error: "invalid url" };
174
+ return { ok: false, error: "invalid url", retryable: false };
169
175
  }
170
176
  if (url.protocol !== "http:" && url.protocol !== "https:") {
171
- return { ok: false, error: `unsupported url scheme "${url.protocol}"` };
177
+ return {
178
+ ok: false,
179
+ error: `unsupported url scheme "${url.protocol}"`,
180
+ retryable: false,
181
+ };
172
182
  }
173
183
  const headers = await buildWebhookHeaders(spec, deps);
174
184
  if (!headers.ok)
@@ -187,7 +197,11 @@ export async function performWebhookDispatch(spec, deps) {
187
197
  // Only the status is used; an unread body would pin the socket until the timeout fires.
188
198
  await res.body?.cancel().catch(() => { });
189
199
  if (!res.ok) {
190
- return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` };
200
+ return {
201
+ ok: false,
202
+ error: `HTTP ${res.status}: ${res.statusText}`,
203
+ retryable: isRetryableHttpStatus(res.status),
204
+ };
191
205
  }
192
206
  return { ok: true, status: res.status };
193
207
  }
@@ -196,6 +210,7 @@ export async function performWebhookDispatch(spec, deps) {
196
210
  return {
197
211
  ok: false,
198
212
  error: `webhook request failed (${err instanceof Error ? err.name : "unknown error"})`,
213
+ retryable: true,
199
214
  };
200
215
  }
201
216
  }
@@ -126,7 +126,7 @@ async function tombstoneTenantRow(ctx) {
126
126
  throw new Error(`tenant-lifecycle: failed to forget membership ${membership.id} for tenant ${ctx.tenantId}: ${result.error.message}`);
127
127
  }
128
128
  }
129
- await tenantCrud.update({
129
+ const tombstone = await tenantCrud.update({
130
130
  id: ctx.tenantId,
131
131
  changes: {
132
132
  status: "destroyed",
@@ -134,6 +134,9 @@ async function tombstoneTenantRow(ctx) {
134
134
  isEnabled: false,
135
135
  },
136
136
  }, user, db, { skipOptimisticLock: true, writePurpose: WritePurposes.erasure });
137
+ if (!tombstone.isSuccess) {
138
+ throw new Error(`tenant-lifecycle: failed to mark tenant ${ctx.tenantId} destroyed: ${tombstone.error.message}`);
139
+ }
137
140
  invalidateTenantLifecycleGate(ctx.tenantId);
138
141
  return STAGE_DONE;
139
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.365.0",
3
+ "version": "0.365.1",
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>",
@@ -524,12 +524,12 @@
524
524
  }
525
525
  },
526
526
  "dependencies": {
527
- "@cosmicdrift/kumiko-dispatcher-live": "0.365.0",
528
- "@cosmicdrift/kumiko-framework": "0.365.0",
529
- "@cosmicdrift/kumiko-headless": "0.365.0",
530
- "@cosmicdrift/kumiko-renderer": "0.365.0",
531
- "@cosmicdrift/kumiko-renderer-web": "0.365.0",
532
- "@cosmicdrift/kumiko-types": "0.365.0",
527
+ "@cosmicdrift/kumiko-dispatcher-live": "0.365.1",
528
+ "@cosmicdrift/kumiko-framework": "0.365.1",
529
+ "@cosmicdrift/kumiko-headless": "0.365.1",
530
+ "@cosmicdrift/kumiko-renderer": "0.365.1",
531
+ "@cosmicdrift/kumiko-renderer-web": "0.365.1",
532
+ "@cosmicdrift/kumiko-types": "0.365.1",
533
533
  "@mollie/api-client": "^4.5.0",
534
534
  "@node-rs/argon2": "^2.0.2",
535
535
  "@tiptap/core": "3.31.3",
@@ -1078,9 +1078,9 @@
1078
1078
  ],
1079
1079
  "devDependencies": {
1080
1080
  "@testing-library/user-event": "^14.6.1",
1081
- "@cosmicdrift/kumiko-locale-de": "0.365.0",
1082
- "@cosmicdrift/kumiko-locale-es": "0.365.0",
1083
- "@cosmicdrift/kumiko-renderer-mail-html": "0.365.0",
1081
+ "@cosmicdrift/kumiko-locale-de": "0.365.1",
1082
+ "@cosmicdrift/kumiko-locale-es": "0.365.1",
1083
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.365.1",
1084
1084
  "jsqr": "^1.4.0",
1085
1085
  "unpdf": "^1.8.1"
1086
1086
  }
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.365.1",
4
+ "type": "fix",
5
+ "title": "eraseKey leaves a tombstone for subjects that never had a key",
6
+ "detail": "eraseKey now records an erased entry even when the subject has no key (pre-KMS plaintext). A later createKey or write for that subject fails with subject_erased instead of minting a fresh key and re-encrypting the plaintext. Erase flows must write with writePurpose erasure after eraseKey. kumiko_subject_keys now gets one tombstone row per forgotten record subject and per step dispatch. The tenant destruction stage tenant-row now fails loudly when marking the tenant destroyed does not succeed.",
7
+ "migration": "Run backfill-pii before erasing Pre-KMS data, otherwise untouched plaintext stays readable after eraseKey."
8
+ },
2
9
  {
3
10
  "version": "0.364.0",
4
11
  "type": "fix",
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.365.1",
4
+ "type": "fix",
5
+ "title": "step-dispatcher delivers each dispatch as a retried job, so a slow receiver no longer delays other tenants",
6
+ "detail": "The step-dispatcher projection now only enqueues one `step-dispatcher:job:deliver` job per dispatch request, and the job performs the webhook or mail delivery. Redis holds only the dispatch stream id and the tenant, never the request payload. Transient failures (network error, timeout, 5xx, 408, 429, any mail adapter failure) are retried 3 times with exponential backoff; the outcome event and the payload key erase follow after the final attempt. Permanent failures (invalid url, blocked host, other 4xx, unavailable auth secret) are recorded at once. At most 2 deliveries per tenant run at the same time, so one tenant's slow receivers cannot hold up the others. This corrects the 0.346.0 note that a hanging receiver no longer stalls the dispatcher for every tenant: the shared consumer still delivered one request at a time.",
7
+ "migration": "step-dispatcher now needs a job runner (Redis) and a worker consuming the default worker lane. Without a job runner the projection throws instead of delivering. Test stacks must pass `jobs` to setupTestStack and call `drainJobs()` after the event dispatcher ran. `r.job` accepts `maxPerTenant: { limit, onLimit: \"defer\", deferMs? }`; a plain number still drops excess enqueues. A `webhook.send` request that gets a retryable status (5xx, 408, 429) is now sent up to 4 times under the same Idempotency-Key. Worker pods must know the deliver job before the projection enqueues it, so deploy worker and API together or the worker first. A dev server only consumes jobs when the app mounts `createJobsFeature()`."
8
+ },
2
9
  {
3
10
  "version": "0.347.0",
4
11
  "type": "fix",
@@ -21,7 +28,7 @@
21
28
  "version": "0.346.0",
22
29
  "type": "fix",
23
30
  "title": "Webhook requests time out after 10 seconds instead of blocking the dispatcher",
24
- "detail": "Webhook requests time out after 10 seconds\nA hanging receiver now ends as `step.dispatch-failed` and no longer stalls the step-dispatcher for every tenant."
31
+ "detail": "Webhook requests time out after 10 seconds\nA hanging receiver now ends as `step.dispatch-failed` after the timeout instead of waiting indefinitely. Until the step-dispatcher delivered through jobs, the shared consumer still processed requests one at a time, so a slow receiver of one tenant delayed the others."
25
32
  },
26
33
  {
27
34
  "version": "0.346.0",