@cosmicdrift/kumiko-samples 0.344.0 → 0.346.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/changes.json +12 -0
  4. package/packages/bundled-features/src/audit/constants.ts +5 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/handlers/details.query.ts +14 -3
  7. package/packages/bundled-features/src/audit/handlers/list.query.ts +11 -2
  8. package/packages/bundled-features/src/audit/handlers/resolve-audit-tenant.ts +22 -0
  9. package/packages/bundled-features/src/audit/i18n.ts +3 -0
  10. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +31 -12
  11. package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
  12. package/packages/bundled-features/src/auth-email-password/constants.ts +1 -0
  13. package/packages/bundled-features/src/auth-email-password/feature.ts +9 -2
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +33 -10
  15. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +32 -15
  16. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +60 -0
  17. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -1
  18. package/packages/bundled-features/src/auth-email-password/i18n.ts +2 -0
  19. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +20 -7
  20. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -1
  21. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  22. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +31 -11
  23. package/packages/bundled-features/src/step-dispatcher/changes.json +31 -0
  24. package/packages/bundled-features/src/step-dispatcher/dispatch-payload.ts +144 -0
  25. package/packages/bundled-features/src/step-dispatcher/feature.ts +32 -152
  26. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +30 -3
  27. package/packages/bundled-features/src/tier-engine/constants.ts +7 -0
  28. package/packages/bundled-features/src/tier-engine/entity.ts +2 -4
  29. package/packages/bundled-features/src/tier-engine/feature.ts +7 -2
  30. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +10 -4
  31. package/packages/bundled-features/src/tier-engine/index.ts +6 -1
  32. package/samples/apps/use-all-bundled/feature-manifest.json +3 -2
  33. package/samples/recipes/i18n/README.md +1 -1
  34. package/samples/recipes/rate-limiting/README.md +1 -1
  35. package/samples/recipes/webhook-step/README.md +9 -2
  36. package/samples/recipes/webhook-step/package.json +1 -1
@@ -57,6 +57,7 @@ export function SignupCompleteScreen({
57
57
  const [submitting, setSubmitting] = useState(false);
58
58
  const [error, setError] = useState<string | null>(null);
59
59
  const [continueHref, setContinueHref] = useState<string | null>(null);
60
+ const [mfaPending, setMfaPending] = useState(false);
60
61
 
61
62
  const doSubmit = async (): Promise<void> => {
62
63
  setError(null);
@@ -73,6 +74,11 @@ export function SignupCompleteScreen({
73
74
  const res = await confirmSignup(token, password);
74
75
  setSubmitting(false);
75
76
  if (res.ok) {
77
+ if (res.data.kind === "mfa-pending") {
78
+ setMfaPending(true);
79
+ setContinueHref(loginHref);
80
+ return;
81
+ }
76
82
  // Cookies are already set (auto-login). Show a confirmation with an
77
83
  // explicit continue button instead of navigating away silently —
78
84
  // the user otherwise gets no signal that activation worked.
@@ -124,7 +130,11 @@ export function SignupCompleteScreen({
124
130
  return (
125
131
  <AuthCard title={effectiveTitle}>
126
132
  <p className="text-sm text-muted-foreground" role="status">
127
- {t("auth.signupComplete.activated")}
133
+ {t(
134
+ mfaPending
135
+ ? "auth.signupComplete.activatedMfaPending"
136
+ : "auth.signupComplete.activated",
137
+ )}
128
138
  </p>
129
139
  <Link href={continueHref} variant="button">
130
140
  {t("auth.signupComplete.continue")}
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.346.0",
4
+ "type": "fix",
5
+ "title": "Subscription webhooks no longer overwrite manual tier grants",
6
+ "detail": "Subscription tier sync keeps manual tier grants\nThe billing webhook sync now skips tier assignments with `source: \"manual\"`, so a `set-tenant-tier` grant is no longer overwritten by Stripe created/canceled events. Rows the sync writes are marked `source: \"billing\"`. `TierAssignmentSources` is exported from tier-engine."
7
+ },
2
8
  {
3
9
  "version": "0.344.0",
4
10
  "type": "breaking",
@@ -4,6 +4,7 @@
4
4
  // so it is now a factory parameter.
5
5
 
6
6
  import {
7
+ TierAssignmentSources,
7
8
  TierEngineHandlers,
8
9
  TierEngineQueries,
9
10
  tierAssignmentAggregateId,
@@ -61,6 +62,24 @@ function asRows(result: unknown): ReadonlyArray<Record<string, unknown>> {
61
62
  throw new Error("expected a { rows: [...] } list-query result");
62
63
  }
63
64
 
65
+ type ExistingTierAssignment = {
66
+ readonly id: string;
67
+ readonly version: number;
68
+ readonly tier: unknown;
69
+ readonly source: unknown;
70
+ };
71
+
72
+ function findTierAssignment(
73
+ rows: ReadonlyArray<Record<string, unknown>>,
74
+ tenantId: TenantId,
75
+ ): ExistingTierAssignment | undefined {
76
+ const row = rows.find((candidate) => candidate["tenantId"] === tenantId);
77
+ if (!row || typeof row["id"] !== "string" || typeof row["version"] !== "number") {
78
+ return undefined;
79
+ }
80
+ return { id: row["id"], version: row["version"], tier: row["tier"], source: row["source"] };
81
+ }
82
+
64
83
  export function createSubscriptionTierSync<TTier extends string>(
65
84
  deps: SubscriptionTierSyncDeps<TTier>,
66
85
  ) {
@@ -94,15 +113,15 @@ export function createSubscriptionTierSync<TTier extends string>(
94
113
  tenantId,
95
114
  }),
96
115
  );
97
- const assignment = tierAssignmentRows.find((row) => row["tenantId"] === tenantId);
98
- if (
99
- !assignment ||
100
- typeof assignment["id"] !== "string" ||
101
- typeof assignment["version"] !== "number"
102
- ) {
116
+ const assignment = findTierAssignment(tierAssignmentRows, tenantId);
117
+ if (!assignment) {
103
118
  const created = await routeDeps.dispatchSystemWrite({
104
119
  handlerQn: TierEngineHandlers.create,
105
- payload: { id: tierAssignmentAggregateId(tenantId), tier: effective },
120
+ payload: {
121
+ id: tierAssignmentAggregateId(tenantId),
122
+ tier: effective,
123
+ source: TierAssignmentSources.billing,
124
+ },
106
125
  tenantId,
107
126
  });
108
127
  if (!created.isSuccess) {
@@ -113,14 +132,15 @@ export function createSubscriptionTierSync<TTier extends string>(
113
132
  }
114
133
  return null;
115
134
  }
116
- if (assignment["tier"] === effective) return null;
135
+ if (assignment.source === TierAssignmentSources.manual) return null;
136
+ if (assignment.tier === effective) return null;
117
137
 
118
138
  const result = await routeDeps.dispatchSystemWrite({
119
139
  handlerQn: TierEngineHandlers.update,
120
140
  payload: {
121
- id: assignment["id"],
122
- version: assignment["version"],
123
- changes: { tier: effective },
141
+ id: assignment.id,
142
+ version: assignment.version,
143
+ changes: { tier: effective, source: TierAssignmentSources.billing },
124
144
  },
125
145
  tenantId,
126
146
  });
@@ -1,4 +1,35 @@
1
1
  [
2
+ {
3
+ "version": "0.346.0",
4
+ "type": "fix",
5
+ "title": "A failed key erase no longer causes a second delivery of the same dispatch request",
6
+ "detail": "step-dispatcher no longer re-sends after a failed key erase\nWhen erasing the per-dispatch key failed after the outcome was recorded, the redelivered request was sent again and produced a second `step.dispatched`. A redelivery now only repeats the erase."
7
+ },
8
+ {
9
+ "version": "0.346.0",
10
+ "type": "fix",
11
+ "title": "Webhook caller headers no longer merge with the default Content-Type or the auth header",
12
+ "detail": "Webhook headers merge case-insensitively\nA caller `Content-Type` replaces the default `application/json` instead of being joined with it. When a caller header collides with the auth header (any casing), the resolved secret value wins."
13
+ },
14
+ {
15
+ "version": "0.346.0",
16
+ "type": "fix",
17
+ "title": "Webhook requests time out after 10 seconds instead of blocking the dispatcher",
18
+ "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."
19
+ },
20
+ {
21
+ "version": "0.346.0",
22
+ "type": "breaking",
23
+ "title": "r.step.webhook.send drops the unused retry option",
24
+ "detail": "`r.step.webhook.send` no longer accepts `retry`\nThe option was never applied: every dispatch request is delivered once. Passing it is now a type error, and the dispatch-requested payload no longer carries it. Stored events that still contain `retry` are parsed and delivered as before.",
25
+ "migration": "Remove `retry` from `r.step.webhook.send` calls; it was never applied. Each dispatch request is delivered once; a delivery error ends as step.dispatch-failed."
26
+ },
27
+ {
28
+ "version": "0.345.0",
29
+ "type": "improvement",
30
+ "title": "Webhook sends carry an Idempotency-Key header that is stable across redeliveries",
31
+ "detail": "Webhook sends carry an Idempotency-Key header\nEvery `r.step.webhook.send` request now has `Idempotency-Key: <dispatch stream id>`. The value stays the same when the same dispatch request is delivered again, so receivers can deduplicate. An explicit `Idempotency-Key` in `headers` (any casing) takes precedence."
32
+ },
2
33
  {
3
34
  "version": "0.335.0",
4
35
  "type": "fix",
@@ -0,0 +1,144 @@
1
+ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
+ import {
3
+ configuredPiiSubjectKms,
4
+ decryptPiiValueForSubject,
5
+ isPiiCiphertext,
6
+ PII_ERASED_SENTINEL,
7
+ } from "@cosmicdrift/kumiko-framework/crypto";
8
+ import * as z from "zod";
9
+ import { type MailSpec, mailSpecSchema } from "./mail-runner.js";
10
+ import { type WebhookSpec, webhookSpecSchema } from "./webhook-runner.js";
11
+
12
+ // PII fields of the flat payload are ciphertext under the per-dispatch
13
+ // record key (system-event-pii.ts). `to`/`headersJson`/`bodyJson` are JSON
14
+ // strings because event PII encryption only handles top-level strings.
15
+ // Runtime-validated instead of cast — `event.payload` is `unknown` at the
16
+ // MSP-apply boundary, so a payload in another shape must end as
17
+ // dispatch-failed, never reach the runners.
18
+ export const dispatchRequestedPayloadSchema = z.discriminatedUnion("stepKind", [
19
+ z.object({
20
+ stepKind: z.literal("webhook.send"),
21
+ url: z.string(),
22
+ method: webhookSpecSchema.shape.method,
23
+ headersJson: z.string(),
24
+ bodyJson: z.string().optional(),
25
+ auth: webhookSpecSchema.shape.auth,
26
+ }),
27
+ z.object({
28
+ stepKind: z.literal("mail.send"),
29
+ to: z.string(),
30
+ subject: z.string(),
31
+ body: z.string(),
32
+ from: z.string().optional(),
33
+ }),
34
+ ]);
35
+
36
+ type DispatchRequestedPayload = z.infer<typeof dispatchRequestedPayloadSchema>;
37
+
38
+ const rawStepKindSchema = z.object({ stepKind: z.string() });
39
+
40
+ export function rawStepKindOf(payload: unknown): string {
41
+ const parsed = rawStepKindSchema.safeParse(payload);
42
+ return parsed.success ? parsed.data.stepKind : "unknown";
43
+ }
44
+
45
+ const jsonStringSchema = z.string().transform((raw, refinementCtx) => {
46
+ try {
47
+ const parsed: unknown = JSON.parse(raw);
48
+ return parsed;
49
+ } catch {
50
+ refinementCtx.addIssue({ code: "custom", message: "invalid json" });
51
+ return z.NEVER;
52
+ }
53
+ });
54
+
55
+ const headersJsonSchema = jsonStringSchema.pipe(z.record(z.string(), z.string()));
56
+ const mailToJsonSchema = jsonStringSchema.pipe(mailSpecSchema.shape.to);
57
+
58
+ type PayloadFieldName = "to" | "subject" | "body" | "from" | "url" | "headersJson" | "bodyJson";
59
+
60
+ function piiFieldsOf(payload: DispatchRequestedPayload): readonly PayloadFieldName[] {
61
+ return payload.stepKind === "mail.send"
62
+ ? ["to", "subject", "body", "from"]
63
+ : ["url", "headersJson", "bodyJson"];
64
+ }
65
+
66
+ function payloadFieldValue(
67
+ payload: DispatchRequestedPayload,
68
+ field: PayloadFieldName,
69
+ ): string | undefined {
70
+ const values: Readonly<Partial<Record<PayloadFieldName, string>>> =
71
+ payload.stepKind === "mail.send"
72
+ ? { to: payload.to, subject: payload.subject, body: payload.body, from: payload.from }
73
+ : { url: payload.url, headersJson: payload.headersJson, bodyJson: payload.bodyJson };
74
+ return values[field];
75
+ }
76
+
77
+ type ReadPayloadResult =
78
+ | { readonly kind: "ready"; readonly fields: Readonly<Partial<Record<PayloadFieldName, string>>> }
79
+ | { readonly kind: "erased" }
80
+ | { readonly kind: "unreadable" };
81
+
82
+ export async function readPayloadFields(
83
+ payload: DispatchRequestedPayload,
84
+ ): Promise<ReadPayloadResult> {
85
+ const kms = configuredPiiSubjectKms();
86
+ const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
87
+ const fields: Partial<Record<PayloadFieldName, string>> = {};
88
+ for (const field of piiFieldsOf(payload)) {
89
+ const value = payloadFieldValue(payload, field);
90
+ if (value === undefined) continue;
91
+ if (!isPiiCiphertext(value)) {
92
+ fields[field] = value;
93
+ continue;
94
+ }
95
+ if (!kms) return { kind: "unreadable" };
96
+ const plain = await decryptPiiValueForSubject(kms, value, { requestId }, field);
97
+ if (plain === PII_ERASED_SENTINEL) return { kind: "erased" };
98
+ fields[field] = plain;
99
+ }
100
+ return { kind: "ready", fields };
101
+ }
102
+
103
+ type DispatchSpec =
104
+ | { readonly stepKind: "mail.send"; readonly spec: MailSpec }
105
+ | { readonly stepKind: "webhook.send"; readonly spec: WebhookSpec };
106
+
107
+ // Parse failures return null — the caller records a generic error, never the
108
+ // (decrypted) values.
109
+ export function buildDispatchSpec(
110
+ payload: DispatchRequestedPayload,
111
+ fields: Readonly<Partial<Record<PayloadFieldName, string>>>,
112
+ ): DispatchSpec | null {
113
+ if (payload.stepKind === "mail.send") {
114
+ const to = mailToJsonSchema.safeParse(fields.to);
115
+ if (!to.success || fields.subject === undefined || fields.body === undefined) return null;
116
+ return {
117
+ stepKind: "mail.send",
118
+ spec: {
119
+ to: to.data,
120
+ subject: fields.subject,
121
+ body: fields.body,
122
+ ...(fields.from !== undefined && { from: fields.from }),
123
+ },
124
+ };
125
+ }
126
+ const headers = headersJsonSchema.safeParse(fields.headersJson);
127
+ if (!headers.success || fields.url === undefined) return null;
128
+ let body: unknown;
129
+ if (fields.bodyJson !== undefined) {
130
+ const parsedBody = jsonStringSchema.safeParse(fields.bodyJson);
131
+ if (!parsedBody.success) return null;
132
+ body = parsedBody.data;
133
+ }
134
+ return {
135
+ stepKind: "webhook.send",
136
+ spec: {
137
+ url: fields.url,
138
+ method: payload.method,
139
+ headers: headers.data,
140
+ ...(body !== undefined && { body }),
141
+ ...(payload.auth && { auth: payload.auth }),
142
+ },
143
+ };
144
+ }
@@ -8,12 +8,7 @@
8
8
  // the audit trail lives in the event log only — no separate status table.
9
9
 
10
10
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
11
- import {
12
- configuredPiiSubjectKms,
13
- decryptPiiValueForSubject,
14
- isPiiCiphertext,
15
- PII_ERASED_SENTINEL,
16
- } from "@cosmicdrift/kumiko-framework/crypto";
11
+ import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
17
12
  import {
18
13
  defineFeature,
19
14
  type FeatureDefinition,
@@ -26,13 +21,17 @@ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
26
21
  import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
27
22
  import * as z from "zod";
28
23
  import { redactEmailAddresses } from "../shared/index.js";
29
- import { type MailSpec, mailSpecSchema, performMailDispatch } from "./mail-runner.js";
24
+ import {
25
+ buildDispatchSpec,
26
+ dispatchRequestedPayloadSchema,
27
+ rawStepKindOf,
28
+ readPayloadFields,
29
+ } from "./dispatch-payload.js";
30
+ import { performMailDispatch } from "./mail-runner.js";
30
31
  import {
31
32
  performWebhookDispatch,
32
33
  WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
33
34
  WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS,
34
- type WebhookSpec,
35
- webhookSpecSchema,
36
35
  } from "./webhook-runner.js";
37
36
 
38
37
  const log = createFallbackLogger("step-dispatcher");
@@ -48,33 +47,6 @@ export const stepDispatcherEnvSchema = z.object({
48
47
 
49
48
  export { STEP_DISPATCH_AGGREGATE_TYPE };
50
49
 
51
- // PII fields of the flat payload are ciphertext under the per-dispatch
52
- // record key (system-event-pii.ts). `to`/`headersJson`/`bodyJson` are JSON
53
- // strings because event PII encryption only handles top-level strings.
54
- // Runtime-validated instead of cast — `event.payload` is `unknown` at the
55
- // MSP-apply boundary, so a payload in another shape must end as
56
- // dispatch-failed, never reach the runners.
57
- const dispatchRequestedPayloadSchema = z.discriminatedUnion("stepKind", [
58
- z.object({
59
- stepKind: z.literal("webhook.send"),
60
- url: z.string(),
61
- method: webhookSpecSchema.shape.method,
62
- headersJson: z.string(),
63
- bodyJson: z.string().optional(),
64
- auth: webhookSpecSchema.shape.auth,
65
- retry: z.object({ times: z.number(), backoff: z.enum(["exponential", "linear"]) }).optional(),
66
- }),
67
- z.object({
68
- stepKind: z.literal("mail.send"),
69
- to: z.string(),
70
- subject: z.string(),
71
- body: z.string(),
72
- from: z.string().optional(),
73
- }),
74
- ]);
75
-
76
- type DispatchRequestedPayload = z.infer<typeof dispatchRequestedPayloadSchema>;
77
-
78
50
  // zod issue messages can echo the invalid value (e.g. a rejected url) back
79
51
  // into the tenant-visible dispatch-failed event — keep this generic.
80
52
  const INVALID_DISPATCH_PAYLOAD_ERROR = "invalid dispatch payload";
@@ -84,116 +56,14 @@ const PAYLOAD_UNREADABLE_ERROR = "dispatch payload is not readable";
84
56
  const MAIL_DELIVERY_FAILED_ERROR = "mail delivery failed";
85
57
  const PAYLOAD_ERASED_ERROR = "dispatch payload erased before an outcome was recorded";
86
58
 
87
- const rawStepKindSchema = z.object({ stepKind: z.string() });
88
-
89
- function rawStepKindOf(payload: unknown): string {
90
- const parsed = rawStepKindSchema.safeParse(payload);
91
- return parsed.success ? parsed.data.stepKind : "unknown";
92
- }
93
-
94
- const jsonStringSchema = z.string().transform((raw, refinementCtx) => {
95
- try {
96
- const parsed: unknown = JSON.parse(raw);
97
- return parsed;
98
- } catch {
99
- refinementCtx.addIssue({ code: "custom", message: "invalid json" });
100
- return z.NEVER;
101
- }
102
- });
103
-
104
- const headersJsonSchema = jsonStringSchema.pipe(z.record(z.string(), z.string()));
105
- const mailToJsonSchema = jsonStringSchema.pipe(mailSpecSchema.shape.to);
106
-
107
- type PayloadFieldName = "to" | "subject" | "body" | "from" | "url" | "headersJson" | "bodyJson";
108
-
109
- function piiFieldsOf(payload: DispatchRequestedPayload): readonly PayloadFieldName[] {
110
- return payload.stepKind === "mail.send"
111
- ? ["to", "subject", "body", "from"]
112
- : ["url", "headersJson", "bodyJson"];
113
- }
114
-
115
- function payloadFieldValue(
116
- payload: DispatchRequestedPayload,
117
- field: PayloadFieldName,
118
- ): string | undefined {
119
- const values: Readonly<Partial<Record<PayloadFieldName, string>>> =
120
- payload.stepKind === "mail.send"
121
- ? { to: payload.to, subject: payload.subject, body: payload.body, from: payload.from }
122
- : { url: payload.url, headersJson: payload.headersJson, bodyJson: payload.bodyJson };
123
- return values[field];
124
- }
125
-
126
- type ReadPayloadResult =
127
- | { readonly kind: "ready"; readonly fields: Readonly<Partial<Record<PayloadFieldName, string>>> }
128
- | { readonly kind: "erased" }
129
- | { readonly kind: "unreadable" };
130
-
131
- async function readPayloadFields(payload: DispatchRequestedPayload): Promise<ReadPayloadResult> {
132
- const kms = configuredPiiSubjectKms();
133
- const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
134
- const fields: Partial<Record<PayloadFieldName, string>> = {};
135
- for (const field of piiFieldsOf(payload)) {
136
- const value = payloadFieldValue(payload, field);
137
- if (value === undefined) continue;
138
- if (!isPiiCiphertext(value)) {
139
- fields[field] = value;
140
- continue;
141
- }
142
- if (!kms) return { kind: "unreadable" };
143
- const plain = await decryptPiiValueForSubject(kms, value, { requestId }, field);
144
- if (plain === PII_ERASED_SENTINEL) return { kind: "erased" };
145
- fields[field] = plain;
146
- }
147
- return { kind: "ready", fields };
148
- }
149
-
150
- type DispatchSpec =
151
- | { readonly stepKind: "mail.send"; readonly spec: MailSpec }
152
- | { readonly stepKind: "webhook.send"; readonly spec: WebhookSpec };
153
-
154
- // Parse failures return null — the caller records a generic error, never the
155
- // (decrypted) values.
156
- function buildDispatchSpec(
157
- payload: DispatchRequestedPayload,
158
- fields: Readonly<Partial<Record<PayloadFieldName, string>>>,
159
- ): DispatchSpec | null {
160
- if (payload.stepKind === "mail.send") {
161
- const to = mailToJsonSchema.safeParse(fields.to);
162
- if (!to.success || fields.subject === undefined || fields.body === undefined) return null;
163
- return {
164
- stepKind: "mail.send",
165
- spec: {
166
- to: to.data,
167
- subject: fields.subject,
168
- body: fields.body,
169
- ...(fields.from !== undefined && { from: fields.from }),
170
- },
171
- };
172
- }
173
- const headers = headersJsonSchema.safeParse(fields.headersJson);
174
- if (!headers.success || fields.url === undefined) return null;
175
- let body: unknown;
176
- if (fields.bodyJson !== undefined) {
177
- const parsedBody = jsonStringSchema.safeParse(fields.bodyJson);
178
- if (!parsedBody.success) return null;
179
- body = parsedBody.data;
180
- }
181
- return {
182
- stepKind: "webhook.send",
183
- spec: {
184
- url: fields.url,
185
- method: payload.method,
186
- headers: headers.data,
187
- ...(body !== undefined && { body }),
188
- ...(payload.auth && { auth: payload.auth }),
189
- },
190
- };
59
+ function isDispatchOutcome(e: { readonly type: string }): boolean {
60
+ return e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE;
191
61
  }
192
62
 
193
63
  export function createStepDispatcherFeature(): FeatureDefinition {
194
64
  return defineFeature("step-dispatcher", (r) => {
195
65
  r.describe(
196
- "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>`.",
66
+ "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.",
197
67
  );
198
68
  r.secretNamespace("webhook-auth", WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS);
199
69
  r.uiHints({
@@ -211,9 +81,20 @@ export function createStepDispatcherFeature(): FeatureDefinition {
211
81
  const kms = configuredPiiSubjectKms();
212
82
  const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
213
83
 
84
+ const eraseDispatchKey = async (): Promise<void> => {
85
+ await kms?.eraseKey(
86
+ { kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: event.aggregateId },
87
+ { requestId, eraseReason: "step-dispatch-outcome-recorded" },
88
+ );
89
+ };
90
+
214
91
  // Outcome events are plaintext and generic; the request payload's
215
92
  // per-dispatch key is erased right after, so the PII dies with the
216
- // dispatch instead of living in the event log.
93
+ // dispatch instead of living in the event log. The outcome is
94
+ // appended before the erase on purpose: if the erase throws, the
95
+ // outcome survives and a redelivery only repeats the erase. A crash
96
+ // between the send and the outcome append still re-sends under the
97
+ // same Idempotency-Key — the one remaining double-send case.
217
98
  const recordOutcome = async (
218
99
  type: typeof STEP_DISPATCHED_TYPE | typeof STEP_DISPATCH_FAILED_TYPE,
219
100
  payload: Record<string, unknown>,
@@ -224,14 +105,18 @@ export function createStepDispatcherFeature(): FeatureDefinition {
224
105
  type,
225
106
  payload,
226
107
  });
227
- await kms?.eraseKey(
228
- { kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: event.aggregateId },
229
- { requestId, eraseReason: "step-dispatch-outcome-recorded" },
230
- );
108
+ await eraseDispatchKey();
231
109
  };
232
110
  const recordFailure = (stepKind: string, error: string) =>
233
111
  recordOutcome(STEP_DISPATCH_FAILED_TYPE, { stepKind, error, attempt: 1 });
234
112
 
113
+ const stream = await ctx.loadAggregate(event.aggregateId);
114
+ if (stream.some(isDispatchOutcome)) {
115
+ await eraseDispatchKey();
116
+ // skip: redelivery after an outcome was recorded — only the erase is repeated
117
+ return;
118
+ }
119
+
235
120
  const parsed = dispatchRequestedPayloadSchema.safeParse(event.payload);
236
121
  if (!parsed.success) {
237
122
  await recordFailure(rawStepKindOf(event.payload), INVALID_DISPATCH_PAYLOAD_ERROR);
@@ -247,12 +132,6 @@ export function createStepDispatcherFeature(): FeatureDefinition {
247
132
  return;
248
133
  }
249
134
  if (read.kind === "erased") {
250
- const stream = await ctx.loadAggregate(event.aggregateId);
251
- const hasOutcome = stream.some(
252
- (e) => e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE,
253
- );
254
- // skip: redelivery after the key was erased — the outcome is already recorded
255
- if (hasOutcome) return;
256
135
  await recordFailure(payload.stepKind, PAYLOAD_ERASED_ERROR);
257
136
  // skip: erased payload recorded via step.dispatch-failed above
258
137
  return;
@@ -270,6 +149,7 @@ export function createStepDispatcherFeature(): FeatureDefinition {
270
149
  tenantId: event.tenantId,
271
150
  userId: event.metadata.userId || SYSTEM_USER_ID,
272
151
  secrets: ctx.secrets,
152
+ idempotencyKey: event.aggregateId,
273
153
  })
274
154
  : await performMailDispatch(dispatchSpec.spec);
275
155
  if (result.ok) {
@@ -114,18 +114,44 @@ export type WebhookDispatchDeps = {
114
114
  readonly tenantId: TenantId;
115
115
  readonly userId: string;
116
116
  readonly secrets: SecretsContext | undefined;
117
+ readonly idempotencyKey: string;
118
+ readonly requestTimeoutMs?: number;
117
119
  };
118
120
 
121
+ // One hanging tenant-controlled receiver must not stall the shared
122
+ // step-dispatcher consumer for every tenant.
123
+ export const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000;
124
+
119
125
  // Never includes the secret name or value — spec.auth.secret is a
120
126
  // tenant-chosen name, but the error still reaches the tenant via the
121
127
  // dispatch-failed event, so it stays generic.
122
128
  const WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR = "webhook auth secret is not available";
123
129
 
130
+ export const WEBHOOK_IDEMPOTENCY_KEY_HEADER = "idempotency-key";
131
+
132
+ function hasHeader(headers: Readonly<Record<string, string>>, lowerCaseName: string): boolean {
133
+ return Object.keys(headers).some((name) => name.toLowerCase() === lowerCaseName);
134
+ }
135
+
136
+ // Header names are case-insensitive: two keys differing only in case would be
137
+ // comma-joined by `Headers`, so a replacement must drop every spelling first.
138
+ function setHeader(headers: Record<string, string>, name: string, value: string): void {
139
+ const lowerCaseName = name.toLowerCase();
140
+ for (const existing of Object.keys(headers)) {
141
+ if (existing.toLowerCase() === lowerCaseName) delete headers[existing];
142
+ }
143
+ headers[name] = value;
144
+ }
145
+
124
146
  async function buildWebhookHeaders(
125
147
  spec: WebhookSpec,
126
148
  deps: WebhookDispatchDeps,
127
149
  ): Promise<{ ok: true; headers: Record<string, string> } | { ok: false; error: string }> {
128
- const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
150
+ const headers: Record<string, string> = { ...spec.headers };
151
+ if (!hasHeader(headers, "content-type")) headers["content-type"] = "application/json";
152
+ if (!hasHeader(headers, WEBHOOK_IDEMPOTENCY_KEY_HEADER)) {
153
+ headers[WEBHOOK_IDEMPOTENCY_KEY_HEADER] = deps.idempotencyKey;
154
+ }
129
155
  if (!spec.auth) return { ok: true, headers };
130
156
  if (!deps.secrets) return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
131
157
  // A throwing get (corrupt envelope, rotated key, KMS error) must become a
@@ -144,9 +170,9 @@ async function buildWebhookHeaders(
144
170
  return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
145
171
  }
146
172
  if (spec.auth.kind === "bearer") {
147
- headers["authorization"] = `Bearer ${secret}`;
173
+ setHeader(headers, "authorization", `Bearer ${secret}`);
148
174
  } else {
149
- headers[spec.auth.name] = secret;
175
+ setHeader(headers, spec.auth.name, secret);
150
176
  }
151
177
  return { ok: true, headers };
152
178
  }
@@ -205,6 +231,7 @@ export async function performWebhookDispatch(
205
231
  ...target.requestInit,
206
232
  method: spec.method,
207
233
  redirect: "manual",
234
+ signal: AbortSignal.timeout(deps.requestTimeoutMs ?? WEBHOOK_REQUEST_TIMEOUT_MS),
208
235
  body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
209
236
  });
210
237
  if (!res.ok) {
@@ -15,6 +15,13 @@ export const TierEngineHandlers = {
15
15
  setTenantTier: "tier-engine:write:set-tenant-tier",
16
16
  } as const;
17
17
 
18
+ // Origin of a tier-assignment row; the billing sync must not touch "manual" rows.
19
+ export const TierAssignmentSources = {
20
+ manual: "manual",
21
+ billing: "billing",
22
+ default: "default",
23
+ } as const;
24
+
18
25
  // Qualified query handler names.
19
26
  export const TierEngineQueries = {
20
27
  list: "tier-engine:query:tier-assignment:list",
@@ -33,10 +33,8 @@ export const tierAssignmentEntity = createEntity({
33
33
  personal: false,
34
34
  reason: "catalog_label",
35
35
  }),
36
- // Woher das Assignment stammt: "manual" (Admin-Grant via tier-admin-Screen),
37
- // "stripe" (future Billing-Sync), "default" (auto-default-on-signup-Hook).
38
- // Optional für Back-Compat zu bestehenden Rows ohne source. Schützt manuelle
39
- // Grants davor, von einem späteren Stripe→Tier-Sync geplättet zu werden.
36
+ // Origin of the assignment, see TierAssignmentSources. Optional for back-compat with
37
+ // rows without source. Protects manual grants from being overwritten by the billing sync.
40
38
  source: createTextField({
41
39
  required: false,
42
40
  maxLength: 20,
@@ -75,7 +75,12 @@ import * as z from "zod";
75
75
  import { tenantTable } from "../tenant/index.js";
76
76
  import { tierAssignmentAggregateId } from "./aggregate-id.js";
77
77
  import type { TierMap } from "./compose-app.js";
78
- import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE, TierEngineHandlers } from "./constants.js";
78
+ import {
79
+ TIER_ADMIN_SCREEN_ID,
80
+ TIER_ENGINE_FEATURE,
81
+ TierAssignmentSources,
82
+ TierEngineHandlers,
83
+ } from "./constants.js";
79
84
  import { tierAssignmentEntity } from "./entity.js";
80
85
  import { getActiveTierQuery } from "./handlers/active-tier.query.js";
81
86
  import { getTenantTierQuery } from "./handlers/get-tenant-tier.query.js";
@@ -450,7 +455,7 @@ export function createTierEngineFeature<
450
455
  const tdb = createTenantDb(rawDb, newTenantId, "system");
451
456
 
452
457
  await tierAssignmentExecutor.create(
453
- { id: aggregateId, tier: defaultTier, source: "default" },
458
+ { id: aggregateId, tier: defaultTier, source: TierAssignmentSources.default },
454
459
  systemUser,
455
460
  tdb,
456
461
  );