@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/constants.ts +5 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/handlers/details.query.ts +14 -3
- package/packages/bundled-features/src/audit/handlers/list.query.ts +11 -2
- package/packages/bundled-features/src/audit/handlers/resolve-audit-tenant.ts +22 -0
- package/packages/bundled-features/src/audit/i18n.ts +3 -0
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +31 -12
- package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +9 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +33 -10
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +32 -15
- package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +60 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -1
- package/packages/bundled-features/src/auth-email-password/i18n.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +20 -7
- package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -1
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +31 -11
- package/packages/bundled-features/src/step-dispatcher/changes.json +31 -0
- package/packages/bundled-features/src/step-dispatcher/dispatch-payload.ts +144 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +32 -152
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +30 -3
- package/packages/bundled-features/src/tier-engine/constants.ts +7 -0
- package/packages/bundled-features/src/tier-engine/entity.ts +2 -4
- package/packages/bundled-features/src/tier-engine/feature.ts +7 -2
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +10 -4
- package/packages/bundled-features/src/tier-engine/index.ts +6 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +3 -2
- package/samples/recipes/i18n/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +1 -1
- package/samples/recipes/webhook-step/README.md +9 -2
- 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(
|
|
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
|
|
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: {
|
|
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
|
|
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
|
|
122
|
-
version: assignment
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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> = {
|
|
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
|
|
173
|
+
setHeader(headers, "authorization", `Bearer ${secret}`);
|
|
148
174
|
} else {
|
|
149
|
-
headers
|
|
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
|
-
//
|
|
37
|
-
//
|
|
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 {
|
|
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:
|
|
458
|
+
{ id: aggregateId, tier: defaultTier, source: TierAssignmentSources.default },
|
|
454
459
|
systemUser,
|
|
455
460
|
tdb,
|
|
456
461
|
);
|