@cosmicdrift/kumiko-bundled-features 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/dist/audit/constants.d.ts +7 -0
- package/dist/audit/constants.js +3 -0
- package/dist/audit/feature.js +1 -1
- package/dist/audit/handlers/details.query.d.ts +4 -0
- package/dist/audit/handlers/details.query.js +14 -3
- package/dist/audit/handlers/list.query.d.ts +4 -0
- package/dist/audit/handlers/list.query.js +11 -2
- package/dist/audit/handlers/resolve-audit-tenant.d.ts +7 -0
- package/dist/audit/handlers/resolve-audit-tenant.js +14 -0
- package/dist/audit/i18n.js +3 -0
- package/dist/audit/run-escape-hatch-retention.js +25 -12
- package/dist/auth-email-password/constants.d.ts +1 -0
- package/dist/auth-email-password/constants.js +1 -0
- package/dist/auth-email-password/feature.js +6 -2
- package/dist/auth-email-password/handlers/invite-signup-complete.write.d.ts +6 -2
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +14 -4
- package/dist/auth-email-password/handlers/signup-confirm.write.d.ts +6 -2
- package/dist/auth-email-password/handlers/signup-confirm.write.js +8 -1
- package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.d.ts +9 -0
- package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.js +39 -0
- package/dist/auth-email-password/handlers/token-request-handler.js +7 -1
- package/dist/auth-email-password/i18n.js +1 -0
- package/dist/auth-email-password/web/auth-client.d.ts +6 -1
- package/dist/auth-email-password/web/auth-client.js +6 -2
- package/dist/auth-email-password/web/signup-complete-screen.js +9 -1
- package/dist/billing-foundation/subscription-tier-sync.js +21 -10
- package/dist/step-dispatcher/dispatch-payload.d.ts +49 -0
- package/dist/step-dispatcher/dispatch-payload.js +115 -0
- package/dist/step-dispatcher/feature.js +23 -121
- package/dist/step-dispatcher/webhook-runner.d.ts +4 -0
- package/dist/step-dispatcher/webhook-runner.js +26 -3
- package/dist/tier-engine/constants.d.ts +5 -0
- package/dist/tier-engine/constants.js +6 -0
- package/dist/tier-engine/entity.js +2 -4
- package/dist/tier-engine/feature.js +2 -2
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +10 -4
- package/dist/tier-engine/index.d.ts +1 -1
- package/dist/tier-engine/index.js +1 -1
- package/package.json +9 -9
- package/src/audit/changes.json +12 -0
- package/src/auth-email-password/changes.json +24 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/step-dispatcher/changes.json +31 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// near-identical webhook-route.ts app copies in show-pony and publicstatus
|
|
3
3
|
// (infra#446) — the only per-app variables were the TierName union/default,
|
|
4
4
|
// so it is now a factory parameter.
|
|
5
|
-
import { TierEngineHandlers, TierEngineQueries, tierAssignmentAggregateId, } from "@cosmicdrift/kumiko-bundled-features/tier-engine";
|
|
5
|
+
import { TierAssignmentSources, TierEngineHandlers, TierEngineQueries, tierAssignmentAggregateId, } from "@cosmicdrift/kumiko-bundled-features/tier-engine";
|
|
6
6
|
import { subscriptionAggregateId } from "./aggregate-id.js";
|
|
7
7
|
import { SubscriptionFoundationQueries, SubscriptionStatuses } from "./constants.js";
|
|
8
8
|
import { createSubscriptionWebhookRoute } from "./webhook-handler.js";
|
|
@@ -25,6 +25,13 @@ function asRows(result) {
|
|
|
25
25
|
}
|
|
26
26
|
throw new Error("expected a { rows: [...] } list-query result");
|
|
27
27
|
}
|
|
28
|
+
function findTierAssignment(rows, tenantId) {
|
|
29
|
+
const row = rows.find((candidate) => candidate["tenantId"] === tenantId);
|
|
30
|
+
if (!row || typeof row["id"] !== "string" || typeof row["version"] !== "number") {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return { id: row["id"], version: row["version"], tier: row["tier"], source: row["source"] };
|
|
34
|
+
}
|
|
28
35
|
export function createSubscriptionTierSync(deps) {
|
|
29
36
|
async function syncTierFromSubscription(tenantId, routeDeps) {
|
|
30
37
|
try {
|
|
@@ -43,13 +50,15 @@ export function createSubscriptionTierSync(deps) {
|
|
|
43
50
|
payload: {},
|
|
44
51
|
tenantId,
|
|
45
52
|
}));
|
|
46
|
-
const assignment = tierAssignmentRows
|
|
47
|
-
if (!assignment
|
|
48
|
-
typeof assignment["id"] !== "string" ||
|
|
49
|
-
typeof assignment["version"] !== "number") {
|
|
53
|
+
const assignment = findTierAssignment(tierAssignmentRows, tenantId);
|
|
54
|
+
if (!assignment) {
|
|
50
55
|
const created = await routeDeps.dispatchSystemWrite({
|
|
51
56
|
handlerQn: TierEngineHandlers.create,
|
|
52
|
-
payload: {
|
|
57
|
+
payload: {
|
|
58
|
+
id: tierAssignmentAggregateId(tenantId),
|
|
59
|
+
tier: effective,
|
|
60
|
+
source: TierAssignmentSources.billing,
|
|
61
|
+
},
|
|
53
62
|
tenantId,
|
|
54
63
|
});
|
|
55
64
|
if (!created.isSuccess) {
|
|
@@ -60,14 +69,16 @@ export function createSubscriptionTierSync(deps) {
|
|
|
60
69
|
}
|
|
61
70
|
return null;
|
|
62
71
|
}
|
|
63
|
-
if (assignment
|
|
72
|
+
if (assignment.source === TierAssignmentSources.manual)
|
|
73
|
+
return null;
|
|
74
|
+
if (assignment.tier === effective)
|
|
64
75
|
return null;
|
|
65
76
|
const result = await routeDeps.dispatchSystemWrite({
|
|
66
77
|
handlerQn: TierEngineHandlers.update,
|
|
67
78
|
payload: {
|
|
68
|
-
id: assignment
|
|
69
|
-
version: assignment
|
|
70
|
-
changes: { tier: effective },
|
|
79
|
+
id: assignment.id,
|
|
80
|
+
version: assignment.version,
|
|
81
|
+
changes: { tier: effective, source: TierAssignmentSources.billing },
|
|
71
82
|
},
|
|
72
83
|
tenantId,
|
|
73
84
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { type MailSpec } from "./mail-runner.js";
|
|
3
|
+
import { type WebhookSpec } from "./webhook-runner.js";
|
|
4
|
+
export declare const dispatchRequestedPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
5
|
+
stepKind: z.ZodLiteral<"webhook.send">;
|
|
6
|
+
url: z.ZodString;
|
|
7
|
+
method: z.ZodEnum<{
|
|
8
|
+
PATCH: "PATCH";
|
|
9
|
+
POST: "POST";
|
|
10
|
+
PUT: "PUT";
|
|
11
|
+
}>;
|
|
12
|
+
headersJson: z.ZodString;
|
|
13
|
+
bodyJson: z.ZodOptional<z.ZodString>;
|
|
14
|
+
auth: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
15
|
+
kind: z.ZodLiteral<"bearer">;
|
|
16
|
+
secret: z.ZodString;
|
|
17
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
18
|
+
kind: z.ZodLiteral<"header">;
|
|
19
|
+
name: z.ZodString;
|
|
20
|
+
secret: z.ZodString;
|
|
21
|
+
}, z.core.$strip>]>>;
|
|
22
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
23
|
+
stepKind: z.ZodLiteral<"mail.send">;
|
|
24
|
+
to: z.ZodString;
|
|
25
|
+
subject: z.ZodString;
|
|
26
|
+
body: z.ZodString;
|
|
27
|
+
from: z.ZodOptional<z.ZodString>;
|
|
28
|
+
}, z.core.$strip>], "stepKind">;
|
|
29
|
+
type DispatchRequestedPayload = z.infer<typeof dispatchRequestedPayloadSchema>;
|
|
30
|
+
export declare function rawStepKindOf(payload: unknown): string;
|
|
31
|
+
type PayloadFieldName = "to" | "subject" | "body" | "from" | "url" | "headersJson" | "bodyJson";
|
|
32
|
+
type ReadPayloadResult = {
|
|
33
|
+
readonly kind: "ready";
|
|
34
|
+
readonly fields: Readonly<Partial<Record<PayloadFieldName, string>>>;
|
|
35
|
+
} | {
|
|
36
|
+
readonly kind: "erased";
|
|
37
|
+
} | {
|
|
38
|
+
readonly kind: "unreadable";
|
|
39
|
+
};
|
|
40
|
+
export declare function readPayloadFields(payload: DispatchRequestedPayload): Promise<ReadPayloadResult>;
|
|
41
|
+
type DispatchSpec = {
|
|
42
|
+
readonly stepKind: "mail.send";
|
|
43
|
+
readonly spec: MailSpec;
|
|
44
|
+
} | {
|
|
45
|
+
readonly stepKind: "webhook.send";
|
|
46
|
+
readonly spec: WebhookSpec;
|
|
47
|
+
};
|
|
48
|
+
export declare function buildDispatchSpec(payload: DispatchRequestedPayload, fields: Readonly<Partial<Record<PayloadFieldName, string>>>): DispatchSpec | null;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { configuredPiiSubjectKms, decryptPiiValueForSubject, isPiiCiphertext, PII_ERASED_SENTINEL, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { mailSpecSchema } from "./mail-runner.js";
|
|
5
|
+
import { webhookSpecSchema } from "./webhook-runner.js";
|
|
6
|
+
// PII fields of the flat payload are ciphertext under the per-dispatch
|
|
7
|
+
// record key (system-event-pii.ts). `to`/`headersJson`/`bodyJson` are JSON
|
|
8
|
+
// strings because event PII encryption only handles top-level strings.
|
|
9
|
+
// Runtime-validated instead of cast — `event.payload` is `unknown` at the
|
|
10
|
+
// MSP-apply boundary, so a payload in another shape must end as
|
|
11
|
+
// dispatch-failed, never reach the runners.
|
|
12
|
+
export const dispatchRequestedPayloadSchema = z.discriminatedUnion("stepKind", [
|
|
13
|
+
z.object({
|
|
14
|
+
stepKind: z.literal("webhook.send"),
|
|
15
|
+
url: z.string(),
|
|
16
|
+
method: webhookSpecSchema.shape.method,
|
|
17
|
+
headersJson: z.string(),
|
|
18
|
+
bodyJson: z.string().optional(),
|
|
19
|
+
auth: webhookSpecSchema.shape.auth,
|
|
20
|
+
}),
|
|
21
|
+
z.object({
|
|
22
|
+
stepKind: z.literal("mail.send"),
|
|
23
|
+
to: z.string(),
|
|
24
|
+
subject: z.string(),
|
|
25
|
+
body: z.string(),
|
|
26
|
+
from: z.string().optional(),
|
|
27
|
+
}),
|
|
28
|
+
]);
|
|
29
|
+
const rawStepKindSchema = z.object({ stepKind: z.string() });
|
|
30
|
+
export function rawStepKindOf(payload) {
|
|
31
|
+
const parsed = rawStepKindSchema.safeParse(payload);
|
|
32
|
+
return parsed.success ? parsed.data.stepKind : "unknown";
|
|
33
|
+
}
|
|
34
|
+
const jsonStringSchema = z.string().transform((raw, refinementCtx) => {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
refinementCtx.addIssue({ code: "custom", message: "invalid json" });
|
|
41
|
+
return z.NEVER;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
const headersJsonSchema = jsonStringSchema.pipe(z.record(z.string(), z.string()));
|
|
45
|
+
const mailToJsonSchema = jsonStringSchema.pipe(mailSpecSchema.shape.to);
|
|
46
|
+
function piiFieldsOf(payload) {
|
|
47
|
+
return payload.stepKind === "mail.send"
|
|
48
|
+
? ["to", "subject", "body", "from"]
|
|
49
|
+
: ["url", "headersJson", "bodyJson"];
|
|
50
|
+
}
|
|
51
|
+
function payloadFieldValue(payload, field) {
|
|
52
|
+
const values = payload.stepKind === "mail.send"
|
|
53
|
+
? { to: payload.to, subject: payload.subject, body: payload.body, from: payload.from }
|
|
54
|
+
: { url: payload.url, headersJson: payload.headersJson, bodyJson: payload.bodyJson };
|
|
55
|
+
return values[field];
|
|
56
|
+
}
|
|
57
|
+
export async function readPayloadFields(payload) {
|
|
58
|
+
const kms = configuredPiiSubjectKms();
|
|
59
|
+
const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
|
|
60
|
+
const fields = {};
|
|
61
|
+
for (const field of piiFieldsOf(payload)) {
|
|
62
|
+
const value = payloadFieldValue(payload, field);
|
|
63
|
+
if (value === undefined)
|
|
64
|
+
continue;
|
|
65
|
+
if (!isPiiCiphertext(value)) {
|
|
66
|
+
fields[field] = value;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!kms)
|
|
70
|
+
return { kind: "unreadable" };
|
|
71
|
+
const plain = await decryptPiiValueForSubject(kms, value, { requestId }, field);
|
|
72
|
+
if (plain === PII_ERASED_SENTINEL)
|
|
73
|
+
return { kind: "erased" };
|
|
74
|
+
fields[field] = plain;
|
|
75
|
+
}
|
|
76
|
+
return { kind: "ready", fields };
|
|
77
|
+
}
|
|
78
|
+
// Parse failures return null — the caller records a generic error, never the
|
|
79
|
+
// (decrypted) values.
|
|
80
|
+
export function buildDispatchSpec(payload, fields) {
|
|
81
|
+
if (payload.stepKind === "mail.send") {
|
|
82
|
+
const to = mailToJsonSchema.safeParse(fields.to);
|
|
83
|
+
if (!to.success || fields.subject === undefined || fields.body === undefined)
|
|
84
|
+
return null;
|
|
85
|
+
return {
|
|
86
|
+
stepKind: "mail.send",
|
|
87
|
+
spec: {
|
|
88
|
+
to: to.data,
|
|
89
|
+
subject: fields.subject,
|
|
90
|
+
body: fields.body,
|
|
91
|
+
...(fields.from !== undefined && { from: fields.from }),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const headers = headersJsonSchema.safeParse(fields.headersJson);
|
|
96
|
+
if (!headers.success || fields.url === undefined)
|
|
97
|
+
return null;
|
|
98
|
+
let body;
|
|
99
|
+
if (fields.bodyJson !== undefined) {
|
|
100
|
+
const parsedBody = jsonStringSchema.safeParse(fields.bodyJson);
|
|
101
|
+
if (!parsedBody.success)
|
|
102
|
+
return null;
|
|
103
|
+
body = parsedBody.data;
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
stepKind: "webhook.send",
|
|
107
|
+
spec: {
|
|
108
|
+
url: fields.url,
|
|
109
|
+
method: payload.method,
|
|
110
|
+
headers: headers.data,
|
|
111
|
+
...(body !== undefined && { body }),
|
|
112
|
+
...(payload.auth && { auth: payload.auth }),
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
// or `kumiko:system:step.dispatch-failed` back onto the same stream so
|
|
8
8
|
// the audit trail lives in the event log only — no separate status table.
|
|
9
9
|
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
10
|
-
import { configuredPiiSubjectKms
|
|
10
|
+
import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
|
|
11
11
|
import { defineFeature, STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE, STEP_DISPATCH_REQUESTED_TYPE, STEP_DISPATCHED_TYPE, } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
12
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
13
13
|
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
14
14
|
import * as z from "zod";
|
|
15
15
|
import { redactEmailAddresses } from "../shared/index.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
16
|
+
import { buildDispatchSpec, dispatchRequestedPayloadSchema, rawStepKindOf, readPayloadFields, } from "./dispatch-payload.js";
|
|
17
|
+
import { performMailDispatch } from "./mail-runner.js";
|
|
18
|
+
import { performWebhookDispatch, WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR, WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS, } from "./webhook-runner.js";
|
|
18
19
|
const log = createFallbackLogger("step-dispatcher");
|
|
19
20
|
export const stepDispatcherEnvSchema = z.object({
|
|
20
21
|
[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
|
|
@@ -23,30 +24,6 @@ export const stepDispatcherEnvSchema = z.object({
|
|
|
23
24
|
.describe("Comma-separated operator allowlist of private/internal hosts (e.g. a local webhook-receiver for dev/test) that bypass the public-address check for webhook.send targets. Never a tenant-config value."),
|
|
24
25
|
});
|
|
25
26
|
export { STEP_DISPATCH_AGGREGATE_TYPE };
|
|
26
|
-
// PII fields of the flat payload are ciphertext under the per-dispatch
|
|
27
|
-
// record key (system-event-pii.ts). `to`/`headersJson`/`bodyJson` are JSON
|
|
28
|
-
// strings because event PII encryption only handles top-level strings.
|
|
29
|
-
// Runtime-validated instead of cast — `event.payload` is `unknown` at the
|
|
30
|
-
// MSP-apply boundary, so a payload in another shape must end as
|
|
31
|
-
// dispatch-failed, never reach the runners.
|
|
32
|
-
const dispatchRequestedPayloadSchema = z.discriminatedUnion("stepKind", [
|
|
33
|
-
z.object({
|
|
34
|
-
stepKind: z.literal("webhook.send"),
|
|
35
|
-
url: z.string(),
|
|
36
|
-
method: webhookSpecSchema.shape.method,
|
|
37
|
-
headersJson: z.string(),
|
|
38
|
-
bodyJson: z.string().optional(),
|
|
39
|
-
auth: webhookSpecSchema.shape.auth,
|
|
40
|
-
retry: z.object({ times: z.number(), backoff: z.enum(["exponential", "linear"]) }).optional(),
|
|
41
|
-
}),
|
|
42
|
-
z.object({
|
|
43
|
-
stepKind: z.literal("mail.send"),
|
|
44
|
-
to: z.string(),
|
|
45
|
-
subject: z.string(),
|
|
46
|
-
body: z.string(),
|
|
47
|
-
from: z.string().optional(),
|
|
48
|
-
}),
|
|
49
|
-
]);
|
|
50
27
|
// zod issue messages can echo the invalid value (e.g. a rejected url) back
|
|
51
28
|
// into the tenant-visible dispatch-failed event — keep this generic.
|
|
52
29
|
const INVALID_DISPATCH_PAYLOAD_ERROR = "invalid dispatch payload";
|
|
@@ -55,96 +32,12 @@ const PAYLOAD_UNREADABLE_ERROR = "dispatch payload is not readable";
|
|
|
55
32
|
// dispatch-failed text outlives the payload erase, so only this is persisted.
|
|
56
33
|
const MAIL_DELIVERY_FAILED_ERROR = "mail delivery failed";
|
|
57
34
|
const PAYLOAD_ERASED_ERROR = "dispatch payload erased before an outcome was recorded";
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const parsed = rawStepKindSchema.safeParse(payload);
|
|
61
|
-
return parsed.success ? parsed.data.stepKind : "unknown";
|
|
62
|
-
}
|
|
63
|
-
const jsonStringSchema = z.string().transform((raw, refinementCtx) => {
|
|
64
|
-
try {
|
|
65
|
-
const parsed = JSON.parse(raw);
|
|
66
|
-
return parsed;
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
refinementCtx.addIssue({ code: "custom", message: "invalid json" });
|
|
70
|
-
return z.NEVER;
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
const headersJsonSchema = jsonStringSchema.pipe(z.record(z.string(), z.string()));
|
|
74
|
-
const mailToJsonSchema = jsonStringSchema.pipe(mailSpecSchema.shape.to);
|
|
75
|
-
function piiFieldsOf(payload) {
|
|
76
|
-
return payload.stepKind === "mail.send"
|
|
77
|
-
? ["to", "subject", "body", "from"]
|
|
78
|
-
: ["url", "headersJson", "bodyJson"];
|
|
79
|
-
}
|
|
80
|
-
function payloadFieldValue(payload, field) {
|
|
81
|
-
const values = payload.stepKind === "mail.send"
|
|
82
|
-
? { to: payload.to, subject: payload.subject, body: payload.body, from: payload.from }
|
|
83
|
-
: { url: payload.url, headersJson: payload.headersJson, bodyJson: payload.bodyJson };
|
|
84
|
-
return values[field];
|
|
85
|
-
}
|
|
86
|
-
async function readPayloadFields(payload) {
|
|
87
|
-
const kms = configuredPiiSubjectKms();
|
|
88
|
-
const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
|
|
89
|
-
const fields = {};
|
|
90
|
-
for (const field of piiFieldsOf(payload)) {
|
|
91
|
-
const value = payloadFieldValue(payload, field);
|
|
92
|
-
if (value === undefined)
|
|
93
|
-
continue;
|
|
94
|
-
if (!isPiiCiphertext(value)) {
|
|
95
|
-
fields[field] = value;
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (!kms)
|
|
99
|
-
return { kind: "unreadable" };
|
|
100
|
-
const plain = await decryptPiiValueForSubject(kms, value, { requestId }, field);
|
|
101
|
-
if (plain === PII_ERASED_SENTINEL)
|
|
102
|
-
return { kind: "erased" };
|
|
103
|
-
fields[field] = plain;
|
|
104
|
-
}
|
|
105
|
-
return { kind: "ready", fields };
|
|
106
|
-
}
|
|
107
|
-
// Parse failures return null — the caller records a generic error, never the
|
|
108
|
-
// (decrypted) values.
|
|
109
|
-
function buildDispatchSpec(payload, fields) {
|
|
110
|
-
if (payload.stepKind === "mail.send") {
|
|
111
|
-
const to = mailToJsonSchema.safeParse(fields.to);
|
|
112
|
-
if (!to.success || fields.subject === undefined || fields.body === undefined)
|
|
113
|
-
return null;
|
|
114
|
-
return {
|
|
115
|
-
stepKind: "mail.send",
|
|
116
|
-
spec: {
|
|
117
|
-
to: to.data,
|
|
118
|
-
subject: fields.subject,
|
|
119
|
-
body: fields.body,
|
|
120
|
-
...(fields.from !== undefined && { from: fields.from }),
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
const headers = headersJsonSchema.safeParse(fields.headersJson);
|
|
125
|
-
if (!headers.success || fields.url === undefined)
|
|
126
|
-
return null;
|
|
127
|
-
let body;
|
|
128
|
-
if (fields.bodyJson !== undefined) {
|
|
129
|
-
const parsedBody = jsonStringSchema.safeParse(fields.bodyJson);
|
|
130
|
-
if (!parsedBody.success)
|
|
131
|
-
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
|
-
};
|
|
35
|
+
function isDispatchOutcome(e) {
|
|
36
|
+
return e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE;
|
|
144
37
|
}
|
|
145
38
|
export function createStepDispatcherFeature() {
|
|
146
39
|
return defineFeature("step-dispatcher", (r) => {
|
|
147
|
-
r.describe("Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`.");
|
|
40
|
+
r.describe("Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`. Every `webhook.send` request carries `Idempotency-Key: <dispatch stream id>`, stable across redeliveries of the same dispatch request; an explicit `Idempotency-Key` in `headers` takes precedence.");
|
|
148
41
|
r.secretNamespace("webhook-auth", WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS);
|
|
149
42
|
r.uiHints({
|
|
150
43
|
displayLabel: "Step Dispatcher · Deferred Side-Effects",
|
|
@@ -159,9 +52,16 @@ export function createStepDispatcherFeature() {
|
|
|
159
52
|
[STEP_DISPATCH_REQUESTED_TYPE]: async (event, _tx, ctx) => {
|
|
160
53
|
const kms = configuredPiiSubjectKms();
|
|
161
54
|
const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
|
|
55
|
+
const eraseDispatchKey = async () => {
|
|
56
|
+
await kms?.eraseKey({ kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: event.aggregateId }, { requestId, eraseReason: "step-dispatch-outcome-recorded" });
|
|
57
|
+
};
|
|
162
58
|
// Outcome events are plaintext and generic; the request payload's
|
|
163
59
|
// per-dispatch key is erased right after, so the PII dies with the
|
|
164
|
-
// dispatch instead of living in the event log.
|
|
60
|
+
// dispatch instead of living in the event log. The outcome is
|
|
61
|
+
// appended before the erase on purpose: if the erase throws, the
|
|
62
|
+
// outcome survives and a redelivery only repeats the erase. A crash
|
|
63
|
+
// between the send and the outcome append still re-sends under the
|
|
64
|
+
// same Idempotency-Key — the one remaining double-send case.
|
|
165
65
|
const recordOutcome = async (type, payload) => {
|
|
166
66
|
await ctx.unsafeAppendEvent({
|
|
167
67
|
aggregateId: event.aggregateId,
|
|
@@ -169,9 +69,15 @@ export function createStepDispatcherFeature() {
|
|
|
169
69
|
type,
|
|
170
70
|
payload,
|
|
171
71
|
});
|
|
172
|
-
await
|
|
72
|
+
await eraseDispatchKey();
|
|
173
73
|
};
|
|
174
74
|
const recordFailure = (stepKind, error) => recordOutcome(STEP_DISPATCH_FAILED_TYPE, { stepKind, error, attempt: 1 });
|
|
75
|
+
const stream = await ctx.loadAggregate(event.aggregateId);
|
|
76
|
+
if (stream.some(isDispatchOutcome)) {
|
|
77
|
+
await eraseDispatchKey();
|
|
78
|
+
// skip: redelivery after an outcome was recorded — only the erase is repeated
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
175
81
|
const parsed = dispatchRequestedPayloadSchema.safeParse(event.payload);
|
|
176
82
|
if (!parsed.success) {
|
|
177
83
|
await recordFailure(rawStepKindOf(event.payload), INVALID_DISPATCH_PAYLOAD_ERROR);
|
|
@@ -186,11 +92,6 @@ export function createStepDispatcherFeature() {
|
|
|
186
92
|
return;
|
|
187
93
|
}
|
|
188
94
|
if (read.kind === "erased") {
|
|
189
|
-
const stream = await ctx.loadAggregate(event.aggregateId);
|
|
190
|
-
const hasOutcome = stream.some((e) => e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE);
|
|
191
|
-
// skip: redelivery after the key was erased — the outcome is already recorded
|
|
192
|
-
if (hasOutcome)
|
|
193
|
-
return;
|
|
194
95
|
await recordFailure(payload.stepKind, PAYLOAD_ERASED_ERROR);
|
|
195
96
|
// skip: erased payload recorded via step.dispatch-failed above
|
|
196
97
|
return;
|
|
@@ -206,6 +107,7 @@ export function createStepDispatcherFeature() {
|
|
|
206
107
|
tenantId: event.tenantId,
|
|
207
108
|
userId: event.metadata.userId || SYSTEM_USER_ID,
|
|
208
109
|
secrets: ctx.secrets,
|
|
110
|
+
idempotencyKey: event.aggregateId,
|
|
209
111
|
})
|
|
210
112
|
: await performMailDispatch(dispatchSpec.spec);
|
|
211
113
|
if (result.ok) {
|
|
@@ -45,5 +45,9 @@ export type WebhookDispatchDeps = {
|
|
|
45
45
|
readonly tenantId: TenantId;
|
|
46
46
|
readonly userId: string;
|
|
47
47
|
readonly secrets: SecretsContext | undefined;
|
|
48
|
+
readonly idempotencyKey: string;
|
|
49
|
+
readonly requestTimeoutMs?: number;
|
|
48
50
|
};
|
|
51
|
+
export declare const WEBHOOK_REQUEST_TIMEOUT_MS = 10000;
|
|
52
|
+
export declare const WEBHOOK_IDEMPOTENCY_KEY_HEADER = "idempotency-key";
|
|
49
53
|
export declare function performWebhookDispatch(spec: WebhookSpec, deps: WebhookDispatchDeps): Promise<WebhookDispatchResult>;
|
|
@@ -79,12 +79,34 @@ let fetchImpl = globalThis.fetch.bind(globalThis);
|
|
|
79
79
|
export function setWebhookFetch(fn) {
|
|
80
80
|
fetchImpl = fn;
|
|
81
81
|
}
|
|
82
|
+
// One hanging tenant-controlled receiver must not stall the shared
|
|
83
|
+
// step-dispatcher consumer for every tenant.
|
|
84
|
+
export const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000;
|
|
82
85
|
// Never includes the secret name or value — spec.auth.secret is a
|
|
83
86
|
// tenant-chosen name, but the error still reaches the tenant via the
|
|
84
87
|
// dispatch-failed event, so it stays generic.
|
|
85
88
|
const WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR = "webhook auth secret is not available";
|
|
89
|
+
export const WEBHOOK_IDEMPOTENCY_KEY_HEADER = "idempotency-key";
|
|
90
|
+
function hasHeader(headers, lowerCaseName) {
|
|
91
|
+
return Object.keys(headers).some((name) => name.toLowerCase() === lowerCaseName);
|
|
92
|
+
}
|
|
93
|
+
// Header names are case-insensitive: two keys differing only in case would be
|
|
94
|
+
// comma-joined by `Headers`, so a replacement must drop every spelling first.
|
|
95
|
+
function setHeader(headers, name, value) {
|
|
96
|
+
const lowerCaseName = name.toLowerCase();
|
|
97
|
+
for (const existing of Object.keys(headers)) {
|
|
98
|
+
if (existing.toLowerCase() === lowerCaseName)
|
|
99
|
+
delete headers[existing];
|
|
100
|
+
}
|
|
101
|
+
headers[name] = value;
|
|
102
|
+
}
|
|
86
103
|
async function buildWebhookHeaders(spec, deps) {
|
|
87
|
-
const headers = {
|
|
104
|
+
const headers = { ...spec.headers };
|
|
105
|
+
if (!hasHeader(headers, "content-type"))
|
|
106
|
+
headers["content-type"] = "application/json";
|
|
107
|
+
if (!hasHeader(headers, WEBHOOK_IDEMPOTENCY_KEY_HEADER)) {
|
|
108
|
+
headers[WEBHOOK_IDEMPOTENCY_KEY_HEADER] = deps.idempotencyKey;
|
|
109
|
+
}
|
|
88
110
|
if (!spec.auth)
|
|
89
111
|
return { ok: true, headers };
|
|
90
112
|
if (!deps.secrets)
|
|
@@ -107,10 +129,10 @@ async function buildWebhookHeaders(spec, deps) {
|
|
|
107
129
|
return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
|
|
108
130
|
}
|
|
109
131
|
if (spec.auth.kind === "bearer") {
|
|
110
|
-
headers
|
|
132
|
+
setHeader(headers, "authorization", `Bearer ${secret}`);
|
|
111
133
|
}
|
|
112
134
|
else {
|
|
113
|
-
headers
|
|
135
|
+
setHeader(headers, spec.auth.name, secret);
|
|
114
136
|
}
|
|
115
137
|
return { ok: true, headers };
|
|
116
138
|
}
|
|
@@ -159,6 +181,7 @@ export async function performWebhookDispatch(spec, deps) {
|
|
|
159
181
|
...target.requestInit,
|
|
160
182
|
method: spec.method,
|
|
161
183
|
redirect: "manual",
|
|
184
|
+
signal: AbortSignal.timeout(deps.requestTimeoutMs ?? WEBHOOK_REQUEST_TIMEOUT_MS),
|
|
162
185
|
body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
|
|
163
186
|
});
|
|
164
187
|
if (!res.ok) {
|
|
@@ -4,6 +4,11 @@ export declare const TierEngineHandlers: {
|
|
|
4
4
|
readonly update: "tier-engine:write:tier-assignment:update";
|
|
5
5
|
readonly setTenantTier: "tier-engine:write:set-tenant-tier";
|
|
6
6
|
};
|
|
7
|
+
export declare const TierAssignmentSources: {
|
|
8
|
+
readonly manual: "manual";
|
|
9
|
+
readonly billing: "billing";
|
|
10
|
+
readonly default: "default";
|
|
11
|
+
};
|
|
7
12
|
export declare const TierEngineQueries: {
|
|
8
13
|
readonly list: "tier-engine:query:tier-assignment:list";
|
|
9
14
|
readonly getActiveTier: "tier-engine:query:get-active-tier";
|
|
@@ -13,6 +13,12 @@ export const TierEngineHandlers = {
|
|
|
13
13
|
update: "tier-engine:write:tier-assignment:update",
|
|
14
14
|
setTenantTier: "tier-engine:write:set-tenant-tier",
|
|
15
15
|
};
|
|
16
|
+
// Origin of a tier-assignment row; the billing sync must not touch "manual" rows.
|
|
17
|
+
export const TierAssignmentSources = {
|
|
18
|
+
manual: "manual",
|
|
19
|
+
billing: "billing",
|
|
20
|
+
default: "default",
|
|
21
|
+
};
|
|
16
22
|
// Qualified query handler names.
|
|
17
23
|
export const TierEngineQueries = {
|
|
18
24
|
list: "tier-engine:query:tier-assignment:list",
|
|
@@ -31,10 +31,8 @@ export const tierAssignmentEntity = createEntity({
|
|
|
31
31
|
personal: false,
|
|
32
32
|
reason: "catalog_label",
|
|
33
33
|
}),
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// Optional für Back-Compat zu bestehenden Rows ohne source. Schützt manuelle
|
|
37
|
-
// Grants davor, von einem späteren Stripe→Tier-Sync geplättet zu werden.
|
|
34
|
+
// Origin of the assignment, see TierAssignmentSources. Optional for back-compat with
|
|
35
|
+
// rows without source. Protects manual grants from being overwritten by the billing sync.
|
|
38
36
|
source: createTextField({
|
|
39
37
|
required: false,
|
|
40
38
|
maxLength: 20,
|
|
@@ -50,7 +50,7 @@ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
|
50
50
|
import * as z from "zod";
|
|
51
51
|
import { tenantTable } from "../tenant/index.js";
|
|
52
52
|
import { tierAssignmentAggregateId } from "./aggregate-id.js";
|
|
53
|
-
import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE, TierEngineHandlers } from "./constants.js";
|
|
53
|
+
import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE, TierAssignmentSources, TierEngineHandlers, } from "./constants.js";
|
|
54
54
|
import { tierAssignmentEntity } from "./entity.js";
|
|
55
55
|
import { getActiveTierQuery } from "./handlers/active-tier.query.js";
|
|
56
56
|
import { getTenantTierQuery } from "./handlers/get-tenant-tier.query.js";
|
|
@@ -347,7 +347,7 @@ export function createTierEngineFeature(opts = {}) {
|
|
|
347
347
|
roles: ["SystemAdmin"],
|
|
348
348
|
};
|
|
349
349
|
const tdb = createTenantDb(rawDb, newTenantId, "system");
|
|
350
|
-
await tierAssignmentExecutor.create({ id: aggregateId, tier: defaultTier, source:
|
|
350
|
+
await tierAssignmentExecutor.create({ id: aggregateId, tier: defaultTier, source: TierAssignmentSources.default }, systemUser, tdb);
|
|
351
351
|
}, { phase: HookPhases.inTransaction, escapeHatch: { reason: autoDefaultTierHookReason } });
|
|
352
352
|
}
|
|
353
353
|
// Extension-point declaration + self-registration. Pattern analog
|
|
@@ -3,6 +3,7 @@ import { buildEntityTable, createEventStoreExecutor, createTenantDb, } from "@co
|
|
|
3
3
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { tierAssignmentAggregateId } from "../aggregate-id.js";
|
|
6
|
+
import { TierAssignmentSources } from "../constants.js";
|
|
6
7
|
import { tierAssignmentEntity } from "../entity.js";
|
|
7
8
|
// SystemAdmin setzt das Tier eines BELIEBIGEN Tenants — manueller Grant ohne
|
|
8
9
|
// Billing. Cross-tenant, daher SystemAdmin-only (kein TenantAdmin: sonst
|
|
@@ -16,8 +17,8 @@ import { tierAssignmentEntity } from "../entity.js";
|
|
|
16
17
|
// funktioniert nur für SYSTEM_TENANT_ID (immer im IN-Filter). Dies ist das
|
|
17
18
|
// auto-default-Hook-Muster (feature.ts), generalisiert auf einen Request-Handler.
|
|
18
19
|
//
|
|
19
|
-
// `source:
|
|
20
|
-
//
|
|
20
|
+
// `source: TierAssignmentSources.manual` marks the grant so the billing sync skips it.
|
|
21
|
+
// Upsert: one aggregate per tenant (deterministic aggregate id).
|
|
21
22
|
//
|
|
22
23
|
// Effective-set invalidation: the executor write does not fire the
|
|
23
24
|
// `tier-assignment:postSave` entity hook, and a per-handler postSave would not
|
|
@@ -67,14 +68,19 @@ export function createSetTenantTierWrite(opts = {}) {
|
|
|
67
68
|
const result = await executor.update({
|
|
68
69
|
id: existing.id,
|
|
69
70
|
version: existing.version,
|
|
70
|
-
changes: { tier, source:
|
|
71
|
+
changes: { tier, source: TierAssignmentSources.manual },
|
|
71
72
|
}, systemUser, tdb);
|
|
72
73
|
if (!result.isSuccess)
|
|
73
74
|
return result;
|
|
74
75
|
afterCommit(tenantId, tier);
|
|
75
76
|
return { isSuccess: true, data: { tenantId, tier, isNew: false } };
|
|
76
77
|
}
|
|
77
|
-
const result = await executor.create({
|
|
78
|
+
const result = await executor.create({
|
|
79
|
+
id: tierAssignmentAggregateId(tenantId),
|
|
80
|
+
tier,
|
|
81
|
+
source: TierAssignmentSources.manual,
|
|
82
|
+
tenantId,
|
|
83
|
+
}, systemUser, tdb);
|
|
78
84
|
if (!result.isSuccess)
|
|
79
85
|
return result;
|
|
80
86
|
afterCommit(tenantId, tier);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { tierAssignmentAggregateId } from "./aggregate-id.js";
|
|
2
2
|
export { type AddOnDefinition, type AddOnMap, type ComposeAppInput, type ComposedApp, composeApp, type TierDefinition, type TierMap, } from "./compose-app.js";
|
|
3
|
-
export { TIER_ENGINE_FEATURE, TierEngineHandlers, TierEngineQueries } from "./constants.js";
|
|
3
|
+
export { TIER_ENGINE_FEATURE, TierAssignmentSources, TierEngineHandlers, TierEngineQueries, } from "./constants.js";
|
|
4
4
|
export { tierAssignmentEntity } from "./entity.js";
|
|
5
5
|
export { type CreateTierEngineOptions, createTierEngineFeature, tierEngineFeature, } from "./feature.js";
|
|
6
6
|
export { createTierResolver, type TierResolver, type TierResolverDeps, } from "./tier-resolver.js";
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// - `TierEngineHandlers` / `TierEngineQueries` — qualified handler names
|
|
9
9
|
export { tierAssignmentAggregateId } from "./aggregate-id.js";
|
|
10
10
|
export { composeApp, } from "./compose-app.js";
|
|
11
|
-
export { TIER_ENGINE_FEATURE, TierEngineHandlers, TierEngineQueries } from "./constants.js";
|
|
11
|
+
export { TIER_ENGINE_FEATURE, TierAssignmentSources, TierEngineHandlers, TierEngineQueries, } from "./constants.js";
|
|
12
12
|
export { tierAssignmentEntity } from "./entity.js";
|
|
13
13
|
export { createTierEngineFeature, tierEngineFeature, } from "./feature.js";
|
|
14
14
|
export { createTierResolver, } from "./tier-resolver.js";
|