@cosmicdrift/kumiko-bundled-features 0.2.3 → 0.4.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/CHANGELOG.md +109 -0
- package/package.json +19 -14
- package/src/auth-email-password/handlers/change-password.write.ts +1 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
- package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
- package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
- package/src/auth-email-password/handlers/login.write.ts +1 -1
- package/src/auth-email-password/handlers/logout.write.ts +2 -2
- package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/src/auth-email-password/web/auth-client.ts +1 -1
- package/src/billing-foundation/events.ts +1 -1
- package/src/billing-foundation/feature.ts +44 -47
- package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +3 -3
- package/src/billing-foundation/projection.ts +1 -1
- package/src/billing-foundation/webhook-handler.ts +1 -1
- package/src/cap-counter/constants.ts +1 -1
- package/src/cap-counter/enforce-cap.ts +1 -1
- package/src/cap-counter/feature.ts +3 -7
- package/src/cap-counter/handlers/get-counter.query.ts +1 -1
- package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
- package/src/cap-counter/handlers/increment.write.ts +3 -3
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
- package/src/channel-email/email-channel.ts +1 -1
- package/src/channel-email/types.ts +1 -1
- package/src/compliance-profiles/handlers/for-tenant.query.ts +7 -6
- package/src/compliance-profiles/handlers/needs-profile.query.ts +1 -1
- package/src/compliance-profiles/handlers/set-profile.write.ts +6 -8
- package/src/compliance-profiles/resolve-for-tenant.ts +7 -5
- package/src/compliance-profiles/seeding.ts +1 -1
- package/src/config/resolver.ts +1 -1
- package/src/data-retention/_internal/parse-override.ts +3 -2
- package/src/data-retention/handlers/policy-for.query.ts +1 -1
- package/src/data-retention/keep-for.ts +1 -1
- package/src/data-retention/presets.ts +1 -1
- package/src/data-retention/resolve-for-tenant.ts +1 -1
- package/src/delivery/__tests__/delivery.integration.ts +6 -0
- package/src/delivery/delivery-service.ts +4 -12
- package/src/delivery/feature.ts +7 -5
- package/src/delivery/index.ts +0 -1
- package/src/delivery/testing.ts +1 -2
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/feature-toggles/feature.ts +1 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +9 -2
- package/src/feature-toggles/handlers/set.write.ts +3 -3
- package/src/file-foundation/feature.ts +1 -1
- package/src/file-provider-s3/feature.ts +2 -2
- package/src/files-provider-s3/s3-provider.ts +2 -2
- package/src/jobs/handlers/list.query.ts +3 -3
- package/src/jobs/handlers/trigger.write.ts +1 -1
- package/src/legal-pages/constants.ts +1 -0
- package/src/legal-pages/web/client-plugin.ts +82 -0
- package/src/legal-pages/web/index.ts +4 -0
- package/src/mail-foundation/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +2 -2
- package/src/renderer-foundation/README.md +86 -0
- package/src/renderer-foundation/__tests__/api.test.ts +188 -0
- package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
- package/src/renderer-foundation/api.ts +106 -0
- package/src/renderer-foundation/constants.ts +21 -0
- package/src/renderer-foundation/feature.ts +47 -0
- package/src/renderer-foundation/index.ts +25 -0
- package/src/renderer-foundation/types.ts +109 -0
- package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
- package/src/renderer-simple/feature.ts +28 -3
- package/src/renderer-simple/simple-renderer.ts +1 -1
- package/src/secrets/handlers/rotate.job.ts +2 -2
- package/src/sessions/handlers/cleanup.job.ts +2 -2
- package/src/step-dispatcher/feature.ts +62 -0
- package/src/step-dispatcher/index.ts +16 -0
- package/src/step-dispatcher/mail-runner.ts +32 -0
- package/src/step-dispatcher/webhook-runner.ts +67 -0
- package/src/subscription-mollie/plugin-methods.ts +1 -1
- package/src/subscription-mollie/verify-webhook.ts +9 -5
- package/src/subscription-stripe/verify-webhook.ts +3 -3
- package/src/template-resolver/README.md +89 -0
- package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
- package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
- package/src/template-resolver/api.ts +189 -0
- package/src/template-resolver/constants.ts +28 -0
- package/src/template-resolver/feature.ts +36 -0
- package/src/template-resolver/handlers/archive.write.ts +42 -0
- package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
- package/src/template-resolver/handlers/list.query.ts +69 -0
- package/src/template-resolver/handlers/publish.write.ts +45 -0
- package/src/template-resolver/handlers/shared.ts +41 -0
- package/src/template-resolver/handlers/upsert-system.write.ts +75 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +98 -0
- package/src/template-resolver/index.ts +28 -0
- package/src/template-resolver/qualified-names.ts +24 -0
- package/src/template-resolver/table.ts +67 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
- package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
- package/src/tenant/handlers/remove-member.write.ts +1 -1
- package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
- package/src/tenant/handlers/update-member-roles.write.ts +3 -3
- package/src/text-content/__tests__/text-content.integration.ts +54 -0
- package/src/text-content/constants.ts +2 -0
- package/src/text-content/feature.ts +20 -4
- package/src/text-content/handlers/by-slug.query.ts +1 -0
- package/src/text-content/handlers/by-tenant.query.ts +58 -0
- package/src/text-content/handlers/set.write.ts +24 -1
- package/src/text-content/seeding.ts +9 -1
- package/src/text-content/table.ts +6 -0
- package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
- package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
- package/src/text-content/web/client-plugin.tsx +378 -0
- package/src/text-content/web/index.ts +8 -0
- package/src/tier-engine/feature.ts +8 -8
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +2 -2
- package/src/user-data-rights/feature.ts +4 -3
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +8 -11
- package/src/user-data-rights/handlers/download-by-token.query.ts +14 -16
- package/src/user-data-rights/handlers/export-status.query.ts +1 -1
- package/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
- package/src/user-data-rights/handlers/request-export.write.ts +2 -2
- package/src/user-data-rights/run-export-jobs.ts +2 -2
- package/src/user-data-rights/run-forget-cleanup.ts +27 -28
- package/src/user-data-rights/run-user-export.ts +1 -1
- package/src/user-data-rights/token-helpers.ts +2 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +1 -1
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +1 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { ContentFormat, RenderKind } from "./constants";
|
|
4
|
+
|
|
5
|
+
// RenderRequest — Plugins erhalten das. Resource-Mode wählt der Caller
|
|
6
|
+
// (renderer-foundation api) je nach kind oder explizit. Plugin selbst
|
|
7
|
+
// entscheidet nicht — es bekommt eine schon aufgelöste Form.
|
|
8
|
+
export type RenderRequest =
|
|
9
|
+
| { kind: "notification"; payload: NotificationPayload }
|
|
10
|
+
| { kind: "mail-html"; payload: MailHtmlPayload }
|
|
11
|
+
| { kind: "document-pdf"; payload: DocumentPayload; options?: PdfOptions }
|
|
12
|
+
| { kind: "image-snapshot"; payload: DocumentPayload; options?: ImageOptions };
|
|
13
|
+
|
|
14
|
+
export type RenderResponse =
|
|
15
|
+
| { kind: "notification"; html: string }
|
|
16
|
+
| { kind: "mail-html"; html: string; text: string }
|
|
17
|
+
| { kind: "document-pdf"; pdfBytes: Uint8Array; pageCount: number; sizeBytes: number }
|
|
18
|
+
| {
|
|
19
|
+
kind: "image-snapshot";
|
|
20
|
+
imageBytes: Uint8Array;
|
|
21
|
+
format: "png" | "jpg";
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type NotificationPayload = {
|
|
27
|
+
readonly template?: string;
|
|
28
|
+
readonly content?: string;
|
|
29
|
+
readonly contentFormat?: ContentFormat;
|
|
30
|
+
readonly variables?: Readonly<Record<string, unknown>>;
|
|
31
|
+
readonly locale?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type MailHtmlPayload = {
|
|
35
|
+
readonly template?: string;
|
|
36
|
+
readonly content?: string;
|
|
37
|
+
readonly contentFormat?: ContentFormat;
|
|
38
|
+
readonly variables?: Readonly<Record<string, unknown>>;
|
|
39
|
+
readonly locale?: string;
|
|
40
|
+
readonly subject?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type DocumentPayload = {
|
|
44
|
+
readonly template?: string;
|
|
45
|
+
readonly content?: string;
|
|
46
|
+
readonly contentFormat?: ContentFormat;
|
|
47
|
+
readonly variables?: Readonly<Record<string, unknown>>;
|
|
48
|
+
readonly locale?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type PdfOptions = {
|
|
52
|
+
readonly format?: "A4" | "Letter";
|
|
53
|
+
readonly marginMm?: { top?: number; right?: number; bottom?: number; left?: number };
|
|
54
|
+
readonly headerTemplate?: string;
|
|
55
|
+
readonly footerTemplate?: string;
|
|
56
|
+
readonly printBackground?: boolean;
|
|
57
|
+
readonly displayHeaderFooter?: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type ImageOptions = {
|
|
61
|
+
readonly width?: number;
|
|
62
|
+
readonly height?: number;
|
|
63
|
+
readonly format?: "png" | "jpg";
|
|
64
|
+
readonly quality?: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// RendererContext — schmale Surface (Kumiko-Style, matcht FileProviderContext
|
|
68
|
+
// + ChannelContext). Plugins, die Service-Access brauchen, holen sich
|
|
69
|
+
// templateResolver/etc. via direkten Bundle-Import + ctx.db (cross-feature-
|
|
70
|
+
// public-API). KEIN extraContext-Pass-Through — Plugin importiert pure-
|
|
71
|
+
// Function-Factories statt App-ctx zu casten.
|
|
72
|
+
//
|
|
73
|
+
// **Beispiel renderer-mail-html:**
|
|
74
|
+
// import { createTemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
75
|
+
// const tplApi = createTemplateResolverApi(ctx.db);
|
|
76
|
+
// const layout = await tplApi.resolveTemplate({ tenantId: ctx.tenantId, slug, kind });
|
|
77
|
+
export type RendererContext = {
|
|
78
|
+
readonly db: DbConnection;
|
|
79
|
+
readonly registry: Registry;
|
|
80
|
+
readonly tenantId: TenantId;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// RendererPlugin-Contract — pro Plugin (renderer-simple, renderer-mail-html,
|
|
84
|
+
// renderer-puppeteer-client). Plugin deklariert welche kinds es bedienen
|
|
85
|
+
// kann; Foundation-Resolver wählt basierend auf Tenant-Config + kind.
|
|
86
|
+
//
|
|
87
|
+
// **Plugin-Invarianten:**
|
|
88
|
+
// - `kinds` muss min. 1 Element haben (sonst nie ausgewählt)
|
|
89
|
+
// - `render(req, ctx)`-Response.kind MUSS req.kind matchen
|
|
90
|
+
// - Plugin ist zustandslos: kein internal-state zwischen render-Calls
|
|
91
|
+
// - Plugin wirft `RendererError` für domain-Fehler (nicht bare Error)
|
|
92
|
+
// - `ctx` ist required. Plugins ohne Service-Deps (z.B. renderer-simple)
|
|
93
|
+
// ignorieren ihn einfach — TS function-arg variance erlaubt `(req) => ...`-
|
|
94
|
+
// Implementations weiterhin (Implementation-Args ≤ Contract-Args ist OK).
|
|
95
|
+
export type RendererPlugin = {
|
|
96
|
+
readonly name: string;
|
|
97
|
+
readonly kinds: ReadonlyArray<RenderKind>;
|
|
98
|
+
render(req: RenderRequest, ctx: RendererContext): Promise<RenderResponse>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export class RendererError extends Error {
|
|
102
|
+
constructor(
|
|
103
|
+
message: string,
|
|
104
|
+
public readonly code: "no_plugin_for_kind" | "invalid_payload" | "other",
|
|
105
|
+
) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.name = "RendererError";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { RendererError } from "../../renderer-foundation";
|
|
3
|
+
import { adaptToFoundation } from "../feature";
|
|
4
|
+
|
|
5
|
+
describe("renderer-simple :: adaptToFoundation", () => {
|
|
6
|
+
test("kind='notification' rendert via simpleRenderer und gibt RenderResponse zurück", async () => {
|
|
7
|
+
const res = await adaptToFoundation({
|
|
8
|
+
kind: "notification",
|
|
9
|
+
payload: {
|
|
10
|
+
template: "welcome",
|
|
11
|
+
variables: { title: "Welcome!", body: "Hello there" },
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
expect(res.kind).toBe("notification");
|
|
15
|
+
if (res.kind === "notification") {
|
|
16
|
+
// simpleRenderer baut HTML mit title als header + body als section
|
|
17
|
+
expect(res.html).toContain("Welcome!");
|
|
18
|
+
expect(res.html).toContain("Hello there");
|
|
19
|
+
expect(res.html).toContain("<!DOCTYPE html>");
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("leere variables → leerer body, kein crash", async () => {
|
|
24
|
+
const res = await adaptToFoundation({
|
|
25
|
+
kind: "notification",
|
|
26
|
+
payload: { template: "", variables: {} },
|
|
27
|
+
});
|
|
28
|
+
expect(res.kind).toBe("notification");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("non-notification kind → RendererError mit code 'invalid_payload'", async () => {
|
|
32
|
+
await expect(
|
|
33
|
+
adaptToFoundation({
|
|
34
|
+
kind: "mail-html",
|
|
35
|
+
payload: { content: "test", contentFormat: "markdown" },
|
|
36
|
+
}),
|
|
37
|
+
).rejects.toThrow(RendererError);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await adaptToFoundation({
|
|
41
|
+
kind: "document-pdf",
|
|
42
|
+
payload: { content: "test", contentFormat: "markdown" },
|
|
43
|
+
});
|
|
44
|
+
expect.fail("expected RendererError");
|
|
45
|
+
} catch (e) {
|
|
46
|
+
expect(e).toBeInstanceOf(RendererError);
|
|
47
|
+
expect((e as RendererError).code).toBe("invalid_payload");
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -1,12 +1,37 @@
|
|
|
1
1
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { RendererError, type RenderRequest, type RenderResponse } from "../renderer-foundation";
|
|
2
3
|
import { simpleRenderer } from "./simple-renderer";
|
|
3
4
|
|
|
5
|
+
// Adapter: simpleRenderer.render hat `Promise<string>`-Signatur (Legacy
|
|
6
|
+
// NotificationRenderer-Contract), renderer-foundation erwartet
|
|
7
|
+
// `Promise<RenderResponse>` mit discriminated union. Mapper bewahrt
|
|
8
|
+
// die simpleRenderer-Implementierung (Template-Strings → HTML mit
|
|
9
|
+
// Inline-CSS) und packt sie in den RendererPlugin-Contract.
|
|
10
|
+
//
|
|
11
|
+
// Exported damit der Adapter-Pfad direkt testbar ist (unit-test).
|
|
12
|
+
export async function adaptToFoundation(req: RenderRequest): Promise<RenderResponse> {
|
|
13
|
+
if (req.kind !== "notification") {
|
|
14
|
+
// Defensiver Guard — Foundation wählt Plugins nur für matching kinds,
|
|
15
|
+
// dieser Pfad sollte unter normalen Umständen nie erreicht werden.
|
|
16
|
+
throw new RendererError(
|
|
17
|
+
`renderer-simple supports only kind="notification", got "${req.kind}"`,
|
|
18
|
+
"invalid_payload",
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
const html = await simpleRenderer.render({
|
|
22
|
+
template: req.payload.template ?? "",
|
|
23
|
+
variables: req.payload.variables ?? {},
|
|
24
|
+
});
|
|
25
|
+
return { kind: "notification", html };
|
|
26
|
+
}
|
|
27
|
+
|
|
4
28
|
export function createRendererSimpleFeature(): FeatureDefinition {
|
|
5
29
|
return defineFeature("rendererSimple", (r) => {
|
|
6
|
-
r.requires("
|
|
30
|
+
r.requires("renderer-foundation");
|
|
7
31
|
|
|
8
|
-
r.useExtension("
|
|
9
|
-
|
|
32
|
+
r.useExtension("renderer", "simple", {
|
|
33
|
+
kinds: ["notification"] as const,
|
|
34
|
+
render: adaptToFoundation,
|
|
10
35
|
});
|
|
11
36
|
});
|
|
12
37
|
}
|
|
@@ -38,7 +38,7 @@ export const simpleRenderer: NotificationRenderer = {
|
|
|
38
38
|
name: "simple",
|
|
39
39
|
|
|
40
40
|
async render(input) {
|
|
41
|
-
const data = input.variables as EmailTemplateData;
|
|
41
|
+
const data = input.variables as EmailTemplateData; // @cast-boundary render-helper
|
|
42
42
|
|
|
43
43
|
// Fallback: if no structured fields, use title + body as header + single text section
|
|
44
44
|
const header = data.header ?? data.title;
|
|
@@ -51,7 +51,7 @@ export type RotateJobResult = {
|
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
54
|
-
const payload = rawPayload as RotateJobPayload;
|
|
54
|
+
const payload = rawPayload as RotateJobPayload; // @cast-boundary engine-payload
|
|
55
55
|
if (!ctx.masterKeyProvider) {
|
|
56
56
|
throw new InternalError({
|
|
57
57
|
message:
|
|
@@ -64,7 +64,7 @@ export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =>
|
|
|
64
64
|
message: "[secrets:rotate] ctx.db missing — job context requires a database connection.",
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
|
-
const db = ctx.db as DbConnection;
|
|
67
|
+
const db = ctx.db as DbConnection; // @cast-boundary db-operator
|
|
68
68
|
const batchSize = payload.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
69
69
|
const maxFailures = payload.maxFailures ?? DEFAULT_MAX_FAILURES;
|
|
70
70
|
const deadline = payload.maxDurationMs
|
|
@@ -39,13 +39,13 @@ export type SessionCleanupResult = {
|
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
export const cleanupJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
42
|
-
const payload = rawPayload as SessionCleanupPayload;
|
|
42
|
+
const payload = rawPayload as SessionCleanupPayload; // @cast-boundary engine-payload
|
|
43
43
|
if (!ctx.db) {
|
|
44
44
|
throw new InternalError({
|
|
45
45
|
message: "[sessions:cleanup] ctx.db missing — job context requires a database connection.",
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
|
-
const db = ctx.db as DbConnection;
|
|
48
|
+
const db = ctx.db as DbConnection; // @cast-boundary db-operator
|
|
49
49
|
|
|
50
50
|
// Coerce-and-validate: BullMQ payloads arrive as opaque JSON, so TS types
|
|
51
51
|
// don't survive. Guard before the value is interpolated into SQL.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// step-dispatcher — bundled-feature that drains deferred Tier-2 step
|
|
2
|
+
// requests (webhook.send, mail.send, ...) after their TX commits.
|
|
3
|
+
//
|
|
4
|
+
// Listens on the `kumiko:system:step.dispatch-requested` system event
|
|
5
|
+
// (registry-bypassed, see append-event-core.ts SYSTEM_EVENT_PREFIX).
|
|
6
|
+
// Performs the side-effect and emits `kumiko:system:step.dispatched`
|
|
7
|
+
// or `kumiko:system:step.dispatch-failed` back onto the same stream so
|
|
8
|
+
// the audit trail lives in the event log only — no separate status table.
|
|
9
|
+
|
|
10
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { type MailSpec, performMailDispatch } from "./mail-runner";
|
|
12
|
+
import { performWebhookDispatch, type WebhookSpec } from "./webhook-runner";
|
|
13
|
+
|
|
14
|
+
export const STEP_DISPATCH_AGGREGATE_TYPE = "step-dispatch";
|
|
15
|
+
export const STEP_DISPATCH_REQUESTED_TYPE = "kumiko:system:step.dispatch-requested";
|
|
16
|
+
export const STEP_DISPATCHED_TYPE = "kumiko:system:step.dispatched";
|
|
17
|
+
export const STEP_DISPATCH_FAILED_TYPE = "kumiko:system:step.dispatch-failed";
|
|
18
|
+
|
|
19
|
+
type DispatchRequestedPayload =
|
|
20
|
+
| {
|
|
21
|
+
readonly stepKind: "webhook.send";
|
|
22
|
+
readonly spec: WebhookSpec;
|
|
23
|
+
readonly retry?: { readonly times: number; readonly backoff: "exponential" | "linear" };
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
readonly stepKind: "mail.send";
|
|
27
|
+
readonly spec: MailSpec;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function createStepDispatcherFeature(): FeatureDefinition {
|
|
31
|
+
return defineFeature("step-dispatcher", (r) => {
|
|
32
|
+
r.systemScope();
|
|
33
|
+
|
|
34
|
+
r.multiStreamProjection({
|
|
35
|
+
name: "step-dispatcher",
|
|
36
|
+
apply: {
|
|
37
|
+
[STEP_DISPATCH_REQUESTED_TYPE]: async (event, _tx, ctx) => {
|
|
38
|
+
const payload = event.payload as DispatchRequestedPayload;
|
|
39
|
+
const result =
|
|
40
|
+
payload.stepKind === "webhook.send"
|
|
41
|
+
? await performWebhookDispatch(payload.spec)
|
|
42
|
+
: await performMailDispatch(payload.spec);
|
|
43
|
+
if (result.ok) {
|
|
44
|
+
await ctx.unsafeAppendEvent({
|
|
45
|
+
aggregateId: event.aggregateId,
|
|
46
|
+
aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
|
|
47
|
+
type: STEP_DISPATCHED_TYPE,
|
|
48
|
+
payload: { stepKind: payload.stepKind, status: result.status },
|
|
49
|
+
});
|
|
50
|
+
} else {
|
|
51
|
+
await ctx.unsafeAppendEvent({
|
|
52
|
+
aggregateId: event.aggregateId,
|
|
53
|
+
aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
|
|
54
|
+
type: STEP_DISPATCH_FAILED_TYPE,
|
|
55
|
+
payload: { stepKind: payload.stepKind, error: result.error, attempt: 1 },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { createStepDispatcherFeature, STEP_DISPATCH_AGGREGATE_TYPE } from "./feature";
|
|
2
|
+
export {
|
|
3
|
+
type MailDispatchResult,
|
|
4
|
+
type MailSpec,
|
|
5
|
+
mailSpecSchema,
|
|
6
|
+
performMailDispatch,
|
|
7
|
+
setMailRunner,
|
|
8
|
+
} from "./mail-runner";
|
|
9
|
+
export {
|
|
10
|
+
performWebhookDispatch,
|
|
11
|
+
setWebhookFetch,
|
|
12
|
+
setWebhookSecretResolver,
|
|
13
|
+
type WebhookDispatchResult,
|
|
14
|
+
type WebhookSpec,
|
|
15
|
+
webhookSpecSchema,
|
|
16
|
+
} from "./webhook-runner";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Mail execution logic — separated from feature.ts and tests-injectable.
|
|
2
|
+
// Production wiring (mail-foundation transport) is a follow-up; the
|
|
3
|
+
// default impl throws so a missing setMailRunner is loud, not silent.
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
export const mailSpecSchema = z.object({
|
|
8
|
+
to: z.union([z.string(), z.array(z.string())]),
|
|
9
|
+
subject: z.string(),
|
|
10
|
+
body: z.string(),
|
|
11
|
+
from: z.string().optional(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export type MailSpec = z.infer<typeof mailSpecSchema>;
|
|
15
|
+
|
|
16
|
+
export type MailDispatchResult =
|
|
17
|
+
| { readonly ok: true; readonly status: number }
|
|
18
|
+
| { readonly ok: false; readonly error: string };
|
|
19
|
+
|
|
20
|
+
let mailRunner: (spec: MailSpec) => Promise<MailDispatchResult> = async () => ({
|
|
21
|
+
ok: false,
|
|
22
|
+
error:
|
|
23
|
+
"no mail-runner configured — call setMailRunner() with a mail-foundation transport adapter",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export function setMailRunner(fn: (spec: MailSpec) => Promise<MailDispatchResult>): void {
|
|
27
|
+
mailRunner = fn;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function performMailDispatch(spec: MailSpec): Promise<MailDispatchResult> {
|
|
31
|
+
return mailRunner(spec);
|
|
32
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Webhook execution logic — separated from feature.ts so tests can stub
|
|
2
|
+
// the fetch without touching the MSP wiring.
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
export const webhookSpecSchema = z.object({
|
|
7
|
+
url: z.string(),
|
|
8
|
+
method: z.enum(["POST", "PUT", "PATCH"]),
|
|
9
|
+
headers: z.record(z.string(), z.string()),
|
|
10
|
+
body: z.unknown().optional(),
|
|
11
|
+
auth: z
|
|
12
|
+
.union([
|
|
13
|
+
z.object({ kind: z.literal("bearer"), secretRef: z.string() }),
|
|
14
|
+
z.object({ kind: z.literal("header"), name: z.string(), secretRef: z.string() }),
|
|
15
|
+
])
|
|
16
|
+
.optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type WebhookSpec = z.infer<typeof webhookSpecSchema>;
|
|
20
|
+
|
|
21
|
+
export type WebhookDispatchResult =
|
|
22
|
+
| { readonly ok: true; readonly status: number }
|
|
23
|
+
| { readonly ok: false; readonly error: string };
|
|
24
|
+
|
|
25
|
+
// Resolves a secretRef via the test-injectable secret-store. Default
|
|
26
|
+
// implementation reads from process.env at the prefix WEBHOOK_SECRET_.
|
|
27
|
+
// Tests pass a custom resolver via setWebhookSecretResolver.
|
|
28
|
+
let secretResolver: (ref: string) => string | undefined = (ref) =>
|
|
29
|
+
process.env[`WEBHOOK_SECRET_${ref}`];
|
|
30
|
+
|
|
31
|
+
export function setWebhookSecretResolver(fn: (ref: string) => string | undefined): void {
|
|
32
|
+
secretResolver = fn;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis);
|
|
36
|
+
|
|
37
|
+
export function setWebhookFetch(fn: typeof fetch): void {
|
|
38
|
+
fetchImpl = fn;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function performWebhookDispatch(spec: WebhookSpec): Promise<WebhookDispatchResult> {
|
|
42
|
+
const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
|
|
43
|
+
if (spec.auth) {
|
|
44
|
+
const secret = secretResolver(spec.auth.secretRef);
|
|
45
|
+
if (!secret) {
|
|
46
|
+
return { ok: false, error: `secret "${spec.auth.secretRef}" not configured` };
|
|
47
|
+
}
|
|
48
|
+
if (spec.auth.kind === "bearer") {
|
|
49
|
+
headers["authorization"] = `Bearer ${secret}`;
|
|
50
|
+
} else {
|
|
51
|
+
headers[spec.auth.name] = secret;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetchImpl(spec.url, {
|
|
56
|
+
method: spec.method,
|
|
57
|
+
headers,
|
|
58
|
+
body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` };
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, status: res.status };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -66,7 +66,7 @@ export function createMollieCheckoutSession(
|
|
|
66
66
|
tenantId: options.tenantId,
|
|
67
67
|
priceId: options.priceId,
|
|
68
68
|
},
|
|
69
|
-
}) as Promise<Payment>)) satisfies Payment;
|
|
69
|
+
}) as Promise<Payment>)) satisfies Payment; // @cast-boundary engine-bridge
|
|
70
70
|
|
|
71
71
|
const checkoutHref = payment.getCheckoutUrl();
|
|
72
72
|
if (!checkoutHref) {
|
|
@@ -102,7 +102,7 @@ export function verifyAndParseMollieWebhook(
|
|
|
102
102
|
return null;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
const metadata = (subscription.metadata as Record<string, string> | null) ?? {};
|
|
105
|
+
const metadata = (subscription.metadata as Record<string, string> | null) ?? {}; // @cast-boundary engine-bridge
|
|
106
106
|
const tenantId = metadata["tenantId"];
|
|
107
107
|
if (!tenantId || tenantId.length === 0) return null;
|
|
108
108
|
const priceId = metadata["priceId"];
|
|
@@ -153,7 +153,7 @@ async function ensureSubscriptionForMandate(
|
|
|
153
153
|
): Promise<MollieSubscription | null> {
|
|
154
154
|
const customerId = payment.customerId;
|
|
155
155
|
if (!customerId) return null;
|
|
156
|
-
const paymentMetadata = (payment.metadata as Record<string, string> | null) ?? {};
|
|
156
|
+
const paymentMetadata = (payment.metadata as Record<string, string> | null) ?? {}; // @cast-boundary engine-bridge
|
|
157
157
|
const tenantId = paymentMetadata["tenantId"];
|
|
158
158
|
const priceId = paymentMetadata["priceId"];
|
|
159
159
|
if (!tenantId || !priceId) return null;
|
|
@@ -163,7 +163,7 @@ async function ensureSubscriptionForMandate(
|
|
|
163
163
|
const existing = await client.customerSubscriptions.list(customerId);
|
|
164
164
|
const matchingExisting = existing.find(
|
|
165
165
|
(sub) =>
|
|
166
|
-
(sub.metadata as Record<string, string> | null)?.["priceId"] === priceId &&
|
|
166
|
+
(sub.metadata as Record<string, string> | null)?.["priceId"] === priceId && // @cast-boundary engine-bridge
|
|
167
167
|
(sub.status === "active" || sub.status === "pending"),
|
|
168
168
|
);
|
|
169
169
|
if (matchingExisting) return matchingExisting;
|
|
@@ -185,8 +185,12 @@ export function extractMollieId(rawBody: string, headers: Record<string, string>
|
|
|
185
185
|
const contentType = headers["content-type"] ?? "";
|
|
186
186
|
if (contentType.includes("application/json")) {
|
|
187
187
|
try {
|
|
188
|
-
const parsed = JSON.parse(rawBody)
|
|
189
|
-
|
|
188
|
+
const parsed: unknown = JSON.parse(rawBody);
|
|
189
|
+
const id =
|
|
190
|
+
typeof parsed === "object" && parsed !== null && "id" in parsed
|
|
191
|
+
? (parsed as Record<string, unknown>)["id"] // @cast-boundary engine-payload
|
|
192
|
+
: undefined;
|
|
193
|
+
return typeof id === "string" ? id : null;
|
|
190
194
|
} catch {
|
|
191
195
|
return null;
|
|
192
196
|
}
|
|
@@ -203,15 +203,15 @@ async function extractSubscriptionFromEvent(
|
|
|
203
203
|
case StripeEventTypes.customerSubscriptionCreated:
|
|
204
204
|
case StripeEventTypes.customerSubscriptionUpdated:
|
|
205
205
|
case StripeEventTypes.customerSubscriptionDeleted:
|
|
206
|
-
return event.data.object as Stripe.Subscription;
|
|
206
|
+
return event.data.object as Stripe.Subscription; // @cast-boundary engine-bridge
|
|
207
207
|
case StripeEventTypes.invoicePaid:
|
|
208
208
|
case StripeEventTypes.invoicePaymentFailed: {
|
|
209
209
|
// Lazy-fetch der subscription. invoice.subscription ist eine
|
|
210
210
|
// string-id (Stripe-Webhooks expanden nicht auto). Wir holen das
|
|
211
211
|
// full subscription-Object damit der downstream-mapping
|
|
212
212
|
// (status, tier via priceId, period-end) konsistent funktioniert.
|
|
213
|
-
const invoice = event.data.object as Stripe.Invoice;
|
|
214
|
-
const subRef = (invoice as { subscription?: string | Stripe.Subscription | null })
|
|
213
|
+
const invoice = event.data.object as Stripe.Invoice; // @cast-boundary engine-bridge
|
|
214
|
+
const subRef = (invoice as { subscription?: string | Stripe.Subscription | null }) // @cast-boundary engine-payload
|
|
215
215
|
.subscription;
|
|
216
216
|
if (!subRef) {
|
|
217
217
|
// Invoice ohne subscription-reference (= one-shot-invoice, nicht
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# template-resolver
|
|
2
|
+
|
|
3
|
+
Strukturierter Template-Storage mit Tenant-Override-Hierarchie, Locale-Fallback und Resource-Linking via `file-foundation`.
|
|
4
|
+
|
|
5
|
+
**Plan-Doc:** [`kumiko-platform/docs/plans/features/template-resolver.md`](../../../../../../kumiko-platform/docs/plans/features/template-resolver.md)
|
|
6
|
+
|
|
7
|
+
**Status (2026-05-19):** 45 Integration-Tests grün, typecheck grün, self+advisor-reviewed. Implementierungs-Erkenntnisse im Plan-Doc.
|
|
8
|
+
|
|
9
|
+
## Mount
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
// App-Bootstrap
|
|
13
|
+
import {
|
|
14
|
+
createTemplateResolverApi,
|
|
15
|
+
createTemplateResolverFeature,
|
|
16
|
+
} from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
17
|
+
|
|
18
|
+
const features = [
|
|
19
|
+
createTemplateResolverFeature(),
|
|
20
|
+
// ... weitere Features
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const app = createKumikoApp({
|
|
24
|
+
features,
|
|
25
|
+
extraContext: ({ db }) => ({
|
|
26
|
+
templateResolver: createTemplateResolverApi(db),
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Konsumtion (in Feature-Handlern)
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { requireTemplateResolver } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
35
|
+
|
|
36
|
+
async function someHandler(ctx) {
|
|
37
|
+
const templateResolver = requireTemplateResolver(ctx, "someHandler");
|
|
38
|
+
const template = await templateResolver.resolveTemplate({
|
|
39
|
+
tenantId: ctx.user.tenantId,
|
|
40
|
+
slug: "nka-versand",
|
|
41
|
+
kind: "mail-html",
|
|
42
|
+
locale: "de",
|
|
43
|
+
});
|
|
44
|
+
// template.content + template.variableSchema + template.linkedResources verwenden
|
|
45
|
+
// ...
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Resolver-Reihenfolge (4-Stufen-Fallback)
|
|
50
|
+
|
|
51
|
+
1. `tenantId` + requested locale
|
|
52
|
+
2. `SYSTEM_TENANT_ID` + requested locale
|
|
53
|
+
3. `tenantId` + `FALLBACK_LOCALE` (default "de")
|
|
54
|
+
4. `SYSTEM_TENANT_ID` + `FALLBACK_LOCALE`
|
|
55
|
+
|
|
56
|
+
Wenn nichts gefunden → `TemplateNotFoundError`.
|
|
57
|
+
|
|
58
|
+
## Admin-Workflows (Write-Handlers + Queries)
|
|
59
|
+
|
|
60
|
+
| Handler | QN | Wer | Was |
|
|
61
|
+
|---|---|---|---|
|
|
62
|
+
| `TemplateResolverHandlers.upsertSystem` | `template-resolver:write:upsert-system` | SystemAdmin | Erstellt/Updated System-Default-Templates (`SYSTEM_TENANT_ID`, scope='system', status='active') |
|
|
63
|
+
| `TemplateResolverHandlers.upsertTenant` | `template-resolver:write:upsert-tenant` | TenantAdmin (eigener Tenant) + SystemAdmin via `tenantIdOverride` | Erstellt/Updated Tenant-Overrides (scope='tenant'), default-status='draft' |
|
|
64
|
+
| `TemplateResolverHandlers.publish` | `template-resolver:write:publish` | TenantAdmin (eigener Tenant) | Setzt status='active' |
|
|
65
|
+
| `TemplateResolverHandlers.archive` | `template-resolver:write:archive` | TenantAdmin (eigener Tenant) | Setzt status='archived' (Resolver ignoriert es danach) |
|
|
66
|
+
| `TemplateResolverQueries.findById` | `template-resolver:query:find-by-id` | TenantAdmin + User (eigener Tenant + system-templates sichtbar) | Raw-Lookup für Edit-UI |
|
|
67
|
+
| `TemplateResolverQueries.list` | `template-resolver:query:list` | gleich | Filter nach kind/locale/status, optional includeSystem |
|
|
68
|
+
|
|
69
|
+
**SystemAdmin-Cross-Tenant für publish/archive/findById:** aktuell nicht implementiert. `ctx.db` ist tenant-scoped (createTenantDb in dispatcher), SystemAdmin sieht ohne explicit `tenantIdOverride` keine fremden Tenants. Wenn Admin-UI das fordert: Schema-Erweiterung in einer M2-Iteration.
|
|
70
|
+
|
|
71
|
+
## Status-Lifecycle
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
upsertSystem ──┐
|
|
75
|
+
├──► status: "active" (System-Default sofort aktiv)
|
|
76
|
+
upsertTenant ──┴──► status: "draft" (Default) | "active" (explizit)
|
|
77
|
+
|
|
78
|
+
publish ───────► status: "active"
|
|
79
|
+
archive ───────► status: "archived"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Resolver returnt **nur** Templates mit `status: "active"`. draft/archived werden ignoriert.
|
|
83
|
+
|
|
84
|
+
## Out-of-Scope
|
|
85
|
+
|
|
86
|
+
- Rendering (Markdown/MJML → HTML/PDF) — siehe `renderer-foundation`
|
|
87
|
+
- Resource-URL-Substitution (signed-URL vs. data-URI) — Caller-Verantwortung je nach kind
|
|
88
|
+
- Visual Template-Editor — `designer`-Bundle (geplant)
|
|
89
|
+
- A/B-Testing — eigenes Bundle wenn Bedarf real
|