@cosmicdrift/kumiko-bundled-features 0.251.0 → 0.252.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.
@@ -0,0 +1,117 @@
1
+ // process-payment-event — programmatic write-handler that the webhook-
2
+ // handler calls AFTER a plugin has verified a one-off-payment webhook and
3
+ // normalized it into a PaymentEvent.
4
+ //
5
+ // **ES-Pattern (mirrors process-event.write.ts):**
6
+ // 1. Idempotency-check: loads the payment-stream and scans for an
7
+ // already-seen `metadata.providerEventId`. A provider-replay with
8
+ // the same event-id → duplicate=true, no second append.
9
+ // 2. ctx.unsafeAppendEvent — inline-projection materializes a new
10
+ // `read_payments`-row (INSERT-once, not UPSERT — every payment is
11
+ // its own fact, not a state-update).
12
+
13
+ import {
14
+ configuredPiiSubjectKms,
15
+ encryptPiiFieldValues,
16
+ } from "@cosmicdrift/kumiko-framework/crypto";
17
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
18
+ import { z } from "zod";
19
+ import { paymentAggregateId } from "../aggregate-id";
20
+ import { PAYMENT_PII_FIELDS, paymentEntity } from "../entities";
21
+ import {
22
+ PAYMENT_AGGREGATE_TYPE,
23
+ PAYMENT_RECEIVED_EVENT_QN,
24
+ type PaymentEventHeaders,
25
+ type PaymentEventPayload,
26
+ } from "../events";
27
+
28
+ export const processPaymentEventSchema = z.object({
29
+ providerEventId: z.string().min(1).max(200),
30
+ providerName: z.string().min(1).max(50),
31
+ providerCustomerId: z.string().min(1).max(200),
32
+ priceId: z.string().min(1).max(200),
33
+ rawPayload: z.string().min(1),
34
+ });
35
+ type ProcessPaymentEventPayload = z.infer<typeof processPaymentEventSchema>;
36
+
37
+ // SystemAdmin-only: this handler is called exclusively by the programmatic
38
+ // webhook-handler (with an internal SystemUser), never directly by the
39
+ // tenant-admin.
40
+ export const processPaymentEventHandler: WriteHandlerDef = {
41
+ name: "process-payment-event",
42
+ agent: { expose: false },
43
+ schema: processPaymentEventSchema,
44
+ access: { roles: ["SystemAdmin"] },
45
+ handler: async (event, ctx) => {
46
+ // @cast-boundary engine-payload — dispatcher-zod-validated payload
47
+ const payload = event.payload as ProcessPaymentEventPayload;
48
+ const tenantId = event.user.tenantId;
49
+ const aggId = paymentAggregateId(tenantId);
50
+
51
+ // ---------------------------------------------------------------
52
+ // 1. Idempotency: same scan pattern as process-event.write.ts — see
53
+ // that file's comment for the O(N)-per-stream performance caveat
54
+ // (a tenant with hundreds of purchases scans hundreds of events;
55
+ // optimize via a per-tenant providerEventId-index if that bites).
56
+ // ---------------------------------------------------------------
57
+ const existingEvents = await ctx.loadAggregate(aggId);
58
+ const alreadySeen = existingEvents.some((e) => {
59
+ const headers = e.metadata.headers ?? {};
60
+ return (
61
+ headers["providerEventId"] === payload.providerEventId &&
62
+ headers["providerName"] === payload.providerName
63
+ );
64
+ });
65
+ if (alreadySeen) {
66
+ return {
67
+ isSuccess: true as const,
68
+ data: { duplicate: true as const, paymentAggregateId: aggId },
69
+ };
70
+ }
71
+
72
+ // ---------------------------------------------------------------
73
+ // 2. Encrypt the provider-subject PII field before it touches storage
74
+ // — same rationale as process-event.write.ts's step 3.
75
+ // ---------------------------------------------------------------
76
+ const piiKms = configuredPiiSubjectKms();
77
+ const encryptedFields = piiKms
78
+ ? await encryptPiiFieldValues(
79
+ { tenantId, providerCustomerId: payload.providerCustomerId },
80
+ paymentEntity,
81
+ PAYMENT_PII_FIELDS,
82
+ piiKms,
83
+ {
84
+ requestId: `billing-foundation:process-payment-event:${payload.providerEventId}`,
85
+ tenantId,
86
+ },
87
+ )
88
+ : { providerCustomerId: payload.providerCustomerId };
89
+
90
+ // ---------------------------------------------------------------
91
+ // 3. Append event on the payment-stream. Inline-projection
92
+ // materializes a new read_payments-row in the same TX.
93
+ // ---------------------------------------------------------------
94
+ const eventPayload: PaymentEventPayload = {
95
+ providerName: payload.providerName,
96
+ providerCustomerId: encryptedFields["providerCustomerId"] as string,
97
+ priceId: payload.priceId,
98
+ };
99
+ const headers: PaymentEventHeaders = {
100
+ providerEventId: payload.providerEventId,
101
+ providerName: payload.providerName,
102
+ rawPayload: payload.rawPayload,
103
+ };
104
+ await ctx.unsafeAppendEvent({
105
+ aggregateId: aggId,
106
+ aggregateType: PAYMENT_AGGREGATE_TYPE,
107
+ type: PAYMENT_RECEIVED_EVENT_QN,
108
+ payload: eventPayload,
109
+ headers,
110
+ });
111
+
112
+ return {
113
+ isSuccess: true as const,
114
+ data: { duplicate: false as const, paymentAggregateId: aggId },
115
+ };
116
+ },
117
+ };
@@ -1,8 +1,10 @@
1
1
  // Public API of the subscription-foundation bundled-feature.
2
2
 
3
- export { subscriptionAggregateId } from "./aggregate-id";
3
+ export { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id";
4
4
  export {
5
5
  BILLING_FOUNDATION_FEATURE,
6
+ type BillingEventKind,
7
+ BillingEventKinds,
6
8
  SUBSCRIPTION_PROVIDER_EXTENSION,
7
9
  type SubscriptionEventType,
8
10
  SubscriptionEventTypes,
@@ -11,12 +13,18 @@ export {
11
13
  type SubscriptionStatus,
12
14
  SubscriptionStatuses,
13
15
  } from "./constants";
14
- export { subscriptionEntity } from "./entities";
16
+ export { paymentEntity, subscriptionEntity } from "./entities";
15
17
  export {
16
18
  INVOICE_PAID_EVENT_QN,
17
19
  INVOICE_PAID_EVENT_SHORT,
18
20
  INVOICE_PAYMENT_FAILED_EVENT_QN,
19
21
  INVOICE_PAYMENT_FAILED_EVENT_SHORT,
22
+ PAYMENT_AGGREGATE_TYPE,
23
+ PAYMENT_RECEIVED_EVENT_QN,
24
+ PAYMENT_RECEIVED_EVENT_SHORT,
25
+ type PaymentEventHeaders,
26
+ type PaymentEventPayload,
27
+ paymentEventPayloadSchema,
20
28
  SUBSCRIPTION_AGGREGATE_TYPE,
21
29
  SUBSCRIPTION_CANCELED_EVENT_QN,
22
30
  SUBSCRIPTION_CANCELED_EVENT_SHORT,
@@ -30,7 +38,7 @@ export {
30
38
  } from "./events";
31
39
  export { billingFoundationFeature } from "./feature";
32
40
  export { getSubscriptionForTenant, type SubscriptionView } from "./get-subscription-for-tenant";
33
- export { subscriptionsProjectionTable } from "./projection";
41
+ export { paymentsProjectionTable, subscriptionsProjectionTable } from "./projection";
34
42
  export {
35
43
  createSubscriptionTierSync,
36
44
  effectiveTierFromSubscription,
@@ -39,6 +47,7 @@ export {
39
47
  type SystemWriteResult,
40
48
  } from "./subscription-tier-sync";
41
49
  export type {
50
+ PaymentEvent,
42
51
  SubscriptionEvent,
43
52
  SubscriptionProviderPlugin,
44
53
  } from "./types";
@@ -16,15 +16,22 @@
16
16
 
17
17
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
18
18
  import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { paymentRowId } from "./aggregate-id";
20
+ import { insertPaymentProjectionRow } from "./db/queries/payment-projection";
19
21
  import { upsertSubscriptionProjectionRow } from "./db/queries/subscription-projection";
20
- import { subscriptionEntity } from "./entities";
21
- import type { SubscriptionEventPayload } from "./events";
22
+ import { paymentEntity, subscriptionEntity } from "./entities";
23
+ import type { PaymentEventPayload, SubscriptionEventPayload } from "./events";
22
24
 
23
25
  // Drizzle-table-instance aus dem entity-shape. Wird sowohl von der
24
26
  // projection-apply als auch von list-query / get-helper genutzt damit
25
27
  // alle drei Stellen denselben column-namespace teilen.
26
28
  export const subscriptionsProjectionTable = buildEntityTable("subscription", subscriptionEntity);
27
29
 
30
+ // Same production-deployment caveat as subscriptionsProjectionTable above:
31
+ // raw drizzle-pgTable, not an r.entity — apps mounting billing-foundation in
32
+ // production must add read_payments to their own drizzle/generate.ts too.
33
+ export const paymentsProjectionTable = buildEntityTable("payment", paymentEntity);
34
+
28
35
  // =============================================================================
29
36
  // Shared helpers
30
37
  // =============================================================================
@@ -152,3 +159,34 @@ export const applyInvoicePaymentFailed = defineApply<SubscriptionEventPayload>(
152
159
  await upsert(tx, event, { status: p.status, tier: p.tier }, p);
153
160
  },
154
161
  );
162
+
163
+ // =============================================================================
164
+ // payment-received apply — one row per event, no UPSERT
165
+ // =============================================================================
166
+
167
+ /** payment-received → INSERT-once row keyed by a deterministic uuid derived
168
+ * from (tenantId, providerName, providerEventId) — NOT `event.id`, which is
169
+ * the event-store's global bigserial sequence (not a UUID, can't back this
170
+ * uuid-typed PK) — and not aggregateId, which is shared by every payment on
171
+ * the same tenant's payment-stream. tenantId is part of the key because the
172
+ * idempotency scan is per-tenant: without it, two tenants sharing the same
173
+ * providerEventId would collide on ON CONFLICT DO NOTHING and silently lose
174
+ * the second tenant's row. ON CONFLICT DO NOTHING also makes a projection
175
+ * rebuild idempotent without mutating an existing row. */
176
+ export const applyPaymentReceived = defineApply<PaymentEventPayload>(async (event, tx) => {
177
+ const tableName = (paymentsProjectionTable as { tableName: string }).tableName;
178
+ const providerEventId = event.metadata.headers?.["providerEventId"];
179
+ const providerName = event.metadata.headers?.["providerName"];
180
+ if (typeof providerEventId !== "string" || typeof providerName !== "string") {
181
+ throw new Error(
182
+ "applyPaymentReceived: event.metadata.headers is missing providerEventId/providerName — process-payment-event.write.ts always sets both",
183
+ );
184
+ }
185
+ await insertPaymentProjectionRow(tx, tableName, {
186
+ id: paymentRowId(event.tenantId, providerName, providerEventId),
187
+ tenant_id: event.tenantId,
188
+ provider_name: event.payload.providerName,
189
+ provider_customer_id: event.payload.providerCustomerId,
190
+ price_id: event.payload.priceId,
191
+ });
192
+ });
@@ -2,9 +2,9 @@ import { deleteMany, type EntityTableMeta, updateMany } from "@cosmicdrift/kumik
2
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
4
4
  import { resolveProfileForTenant } from "../compliance-profiles";
5
- import { subscriptionAggregateId } from "./aggregate-id";
6
- import { SUBSCRIPTION_AGGREGATE_TYPE } from "./events";
7
- import { subscriptionsProjectionTable } from "./projection";
5
+ import { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id";
6
+ import { PAYMENT_AGGREGATE_TYPE, SUBSCRIPTION_AGGREGATE_TYPE } from "./events";
7
+ import { paymentsProjectionTable, subscriptionsProjectionTable } from "./projection";
8
8
 
9
9
  // providerCustomerId/providerSubscriptionId are `personal: "tenant"` on the
10
10
  // entity (envelope-encrypted with the tenant subject key, see entities.ts) —
@@ -44,3 +44,38 @@ export async function subscriptionTenantDestroyHook(ctx: {
44
44
  reason: "tenant_destroy",
45
45
  });
46
46
  }
47
+
48
+ /** Tenant-destroy hook for payment PII (#800). Unlike subscriptionTenantDestroyHook
49
+ * (one row per tenant, targeted by `id`), a tenant can have many payment
50
+ * rows — the predicate here is `tenantId`, not `id`. The payment-stream
51
+ * itself is still one stream per tenant (paymentAggregateId), so
52
+ * archiveStream targets that single aggregateId as usual. */
53
+ export async function paymentTenantDestroyHook(ctx: {
54
+ readonly db: import("@cosmicdrift/kumiko-framework/db").DbRunner;
55
+ readonly tenantId: TenantId;
56
+ }): Promise<void> {
57
+ const { profile } = await resolveProfileForTenant({
58
+ db: ctx.db,
59
+ tenantId: ctx.tenantId,
60
+ });
61
+ const aggregateId = paymentAggregateId(ctx.tenantId);
62
+ if (profile.key === "de-hr-dsgvo-hgb") {
63
+ await updateMany(
64
+ ctx.db,
65
+ paymentsProjectionTable as EntityTableMeta,
66
+ { providerCustomerId: REDACTED_PII_VALUE },
67
+ { tenantId: ctx.tenantId },
68
+ );
69
+ } else {
70
+ await deleteMany(ctx.db, paymentsProjectionTable as EntityTableMeta, {
71
+ tenantId: ctx.tenantId,
72
+ });
73
+ }
74
+ await archiveStream(ctx.db, {
75
+ tenantId: ctx.tenantId,
76
+ aggregateId,
77
+ aggregateType: PAYMENT_AGGREGATE_TYPE,
78
+ archivedBy: "tenant-lifecycle:destroy",
79
+ reason: "tenant_destroy",
80
+ });
81
+ }
@@ -26,7 +26,7 @@
26
26
 
27
27
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
28
28
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
29
- import type { SubscriptionEventType, SubscriptionStatus } from "./constants";
29
+ import type { BillingEventKinds, SubscriptionEventType, SubscriptionStatus } from "./constants";
30
30
 
31
31
  // =============================================================================
32
32
  // Normalisierter Webhook-Event
@@ -37,6 +37,12 @@ import type { SubscriptionEventType, SubscriptionStatus } from "./constants";
37
37
  // Plugin abstrahiert.
38
38
 
39
39
  export type SubscriptionEvent = {
40
+ /** Discriminator against PaymentEvent. Optional (instead of required) so
41
+ * existing plugins like subscription-mollie, which don't set this field,
42
+ * stay backward-compatible without changes — TS still narrows
43
+ * `parsed.kind === BillingEventKinds.payment` correctly, because only
44
+ * PaymentEvent["kind"] can take that value. */
45
+ readonly kind?: typeof BillingEventKinds.subscription;
40
46
  /** Provider-eigene Event-ID — UNIQUE-key für Idempotency.
41
47
  * Stripe: "evt_..."; Mollie: payment-id oder subscription-id. */
42
48
  readonly providerEventId: string;
@@ -68,6 +74,40 @@ export type SubscriptionEvent = {
68
74
  readonly rawPayload: string;
69
75
  };
70
76
 
77
+ // =============================================================================
78
+ // Normalized One-off-Payment-Event
79
+ // =============================================================================
80
+ //
81
+ // Separate type instead of a sixth SubscriptionEventTypes value: a one-off-
82
+ // payment is not a subscription-state transition (no status/tier/
83
+ // currentPeriodEnd — a purchase either happened or it didn't) and
84
+ // materializes into its own `read_payments`-row instead of overwriting the
85
+ // subscription-row. Provider-plugins that don't support one-off-payments
86
+ // (e.g. Mollie-recurring-only) never deliver this type — verifyAndParseWebhook
87
+ // still returns just SubscriptionEvent | null for them, unchanged.
88
+
89
+ export type PaymentEvent = {
90
+ /** Required — distinguishes this union member at runtime from
91
+ * SubscriptionEvent (whose `kind` is optional and never "payment"). */
92
+ readonly kind: typeof BillingEventKinds.payment;
93
+ /** Provider's own event-ID — UNIQUE key for idempotency. */
94
+ readonly providerEventId: string;
95
+ /** Discriminator — which plugin delivered this event. */
96
+ readonly providerName: string;
97
+ /** Platform-tenant-ID. **Must** come from provider-verified metadata
98
+ * (e.g. the PaymentIntent metadata the app itself set at checkout-
99
+ * create time) — never from a freely-choosable payload field, or an
100
+ * attacker could attribute payments to someone else's tenant. */
101
+ readonly tenantId: string;
102
+ /** Provider's own customer-id. */
103
+ readonly providerCustomerId: string;
104
+ /** Provider's own price/plan-ID of the purchased item. */
105
+ readonly priceId: string;
106
+ /** Raw provider-payload — archived 1:1 into payment-event.rawPayload.
107
+ * Plugin delivers this as a JSON-stringified string. */
108
+ readonly rawPayload: string;
109
+ };
110
+
71
111
  // =============================================================================
72
112
  // Plugin-Contract
73
113
  // =============================================================================
@@ -75,31 +115,37 @@ export type SubscriptionEvent = {
75
115
  export type SubscriptionProviderPlugin = {
76
116
  /**
77
117
  * Verify webhook signature + parse provider-event into normalized
78
- * form. **Pre-tenant-resolution** — kein HandlerContext, weil zum
79
- * Zeitpunkt des sig-verify der Tenant noch nicht aufgelöst ist
80
- * (Plugin macht die Tenant-Resolution selbst aus dem provider-
81
- * payload metadata).
118
+ * form. **Pre-tenant-resolution** — no HandlerContext, because at
119
+ * sig-verify time the tenant hasn't been resolved yet (the plugin
120
+ * does the tenant-resolution itself from the provider-payload
121
+ * metadata).
82
122
  *
83
- * App-wide-secret = App-Owner's eigener Provider-Account. Der Plugin
84
- * liest ihn entweder aus einem mount-time-Closure ODER — zur Laufzeit
85
- * rotierbar — aus dem optionalen `systemSecrets`-Arg (system-scoped
86
- * SecretsContext, vom webhook-handler durchgereicht). `systemSecrets`
87
- * ist undefined, wenn der App-Owner keinen wired; Plugins müssen dann
88
- * auf ihren Closure-Fallback zurückfallen.
123
+ * App-wide-secret = the App-Owner's own provider account. The plugin
124
+ * reads it either from a mount-time closure OR — rotatable at
125
+ * runtime — from the optional `systemSecrets` arg (system-scoped
126
+ * SecretsContext, passed through by the webhook-handler).
127
+ * `systemSecrets` is undefined when the App-Owner hasn't wired one;
128
+ * plugins then fall back to their closure fallback.
89
129
  *
90
- * Returns null für events die der Plugin nicht versteht oder die
91
- * foundation nicht braucht (= filter out, foundation returnt 200
130
+ * Returns null for events the plugin doesn't understand or that the
131
+ * foundation doesn't need (= filter out, foundation returns 200
92
132
  * "ignored").
93
133
  *
94
- * **Throws** bei sig-mismatch — der webhook-handler mapped das auf
95
- * 401 damit der Provider keine retries macht (sig-fail = config-bug,
96
- * nicht transient).
134
+ * **Throws** on sig-mismatch — the webhook-handler maps that to 401
135
+ * so the provider doesn't retry (sig-fail = config-bug, not
136
+ * transient).
137
+ *
138
+ * Returns `PaymentEvent` for one-off-payment-webhooks (checkout mode
139
+ * "payment"). Plugins without one-off-payment support never deliver this
140
+ * union member — the return-type extension is covariant, so a
141
+ * `Promise<SubscriptionEvent | null>` implementor stays assignable
142
+ * without any change.
97
143
  */
98
144
  readonly verifyAndParseWebhook: (
99
145
  rawBody: string,
100
146
  headers: Record<string, string>,
101
147
  systemSecrets?: SecretsContext,
102
- ) => Promise<SubscriptionEvent | null>;
148
+ ) => Promise<SubscriptionEvent | PaymentEvent | null>;
103
149
 
104
150
  /**
105
151
  * **Post-tenant-resolution** — wird aus dem
@@ -45,6 +45,7 @@ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
45
45
  import type { Context, Hono } from "hono";
46
46
  import {
47
47
  BILLING_FOUNDATION_FEATURE,
48
+ BillingEventKinds,
48
49
  SUBSCRIPTION_PROVIDER_EXTENSION,
49
50
  SubscriptionFoundationHandlers,
50
51
  } from "./constants";
@@ -153,9 +154,31 @@ export function createSubscriptionWebhookHandler(deps: SubscriptionWebhookDeps)
153
154
  return c.json({ ignored: true }, 200);
154
155
  }
155
156
 
156
- // 6. Dispatch process-event-handler durch den Standard-Dispatcher.
157
- // Idempotency macht der handler intern via deterministic
158
- // aggregate-id + UNIQUE-constraint.
157
+ // 6. Dispatch to the type-matching write-handler. Payment-events (own
158
+ // aggregate, own `read_payments`-row) go to process-payment-event;
159
+ // everything else (kind is undefined or "subscription") keeps going
160
+ // through process-event, unchanged. Every handler handles idempotency
161
+ // internally via deterministic aggregate-id + stream-scan.
162
+ if (parsed.kind === BillingEventKinds.payment) {
163
+ const dispatched = await deps.dispatchWrite({
164
+ handlerQn: SubscriptionFoundationHandlers.processPaymentEvent,
165
+ tenantId: parsed.tenantId,
166
+ payload: {
167
+ providerEventId: parsed.providerEventId,
168
+ providerName: parsed.providerName,
169
+ providerCustomerId: parsed.providerCustomerId,
170
+ priceId: parsed.priceId,
171
+ rawPayload: parsed.rawPayload,
172
+ },
173
+ });
174
+ return respondFromDispatch(
175
+ c,
176
+ dispatched,
177
+ "subscription_payment_webhook_processing_failed",
178
+ "Internal error processing payment event",
179
+ );
180
+ }
181
+
159
182
  const dispatched = await deps.dispatchWrite({
160
183
  handlerQn: SubscriptionFoundationHandlers.processEvent,
161
184
  tenantId: parsed.tenantId,
@@ -171,26 +194,32 @@ export function createSubscriptionWebhookHandler(deps: SubscriptionWebhookDeps)
171
194
  rawPayload: parsed.rawPayload,
172
195
  },
173
196
  });
174
-
175
- if (!dispatched.isSuccess) {
176
- // Internal error — Provider soll retry'n. 500 statt 401/404 weil
177
- // das transient ist (DB down etc.) nicht config-bug.
178
- return c.json(
179
- {
180
- error: {
181
- code: "subscription_webhook_processing_failed",
182
- message: "Internal error processing subscription event",
183
- details: dispatched.error,
184
- },
185
- },
186
- 500,
187
- );
188
- }
189
-
190
- return c.json({ processed: true, ...((dispatched.data as object) ?? {}) }, 200); // @cast-boundary engine-bridge
197
+ return respondFromDispatch(
198
+ c,
199
+ dispatched,
200
+ "subscription_webhook_processing_failed",
201
+ "Internal error processing subscription event",
202
+ );
191
203
  };
192
204
  }
193
205
 
206
+ /** Shared 500/200-mapping for both dispatch branches above. Internal error →
207
+ * provider should retry, hence 500 instead of 401/404 (transient, not a config-bug). */
208
+ function respondFromDispatch(
209
+ c: Context,
210
+ dispatched: Awaited<ReturnType<SubscriptionWebhookDeps["dispatchWrite"]>>,
211
+ errorCode: string,
212
+ errorMessage: string,
213
+ ): Response {
214
+ if (!dispatched.isSuccess) {
215
+ return c.json(
216
+ { error: { code: errorCode, message: errorMessage, details: dispatched.error } },
217
+ 500,
218
+ );
219
+ }
220
+ return c.json({ processed: true, ...((dispatched.data as object) ?? {}) }, 200); // @cast-boundary engine-bridge
221
+ }
222
+
194
223
  /**
195
224
  * Convenience für TypeScript-IDE: `Hono.post(...)`-Call-Type damit der
196
225
  * App-Owner-Code typed bleibt ohne Hono-types zu importieren.