@cosmicdrift/kumiko-bundled-features 0.100.0 → 0.102.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/auth-mailer.ts +28 -12
  3. package/src/auth-email-password/index.ts +3 -0
  4. package/src/channel-email/__tests__/smtp-transport-from-env.test.ts +35 -0
  5. package/src/channel-email/email-channel.ts +32 -20
  6. package/src/channel-email/feature.ts +2 -0
  7. package/src/channel-email/index.ts +6 -1
  8. package/src/channel-email/smtp-transport.ts +37 -0
  9. package/src/channel-in-app/feature.ts +1 -0
  10. package/src/channel-in-app/in-app-channel.ts +1 -0
  11. package/src/channel-push/feature.ts +1 -0
  12. package/src/channel-push/push-channel.ts +1 -0
  13. package/src/delivery/__tests__/delivery.integration.test.ts +247 -1
  14. package/src/delivery/attempt-log.ts +54 -0
  15. package/src/delivery/channel-context.ts +17 -0
  16. package/src/delivery/constants.ts +35 -1
  17. package/src/delivery/delivery-service.ts +118 -47
  18. package/src/delivery/events.ts +7 -1
  19. package/src/delivery/feature.ts +38 -14
  20. package/src/delivery/index.ts +3 -0
  21. package/src/delivery/jobs.ts +153 -0
  22. package/src/delivery/tables.ts +5 -1
  23. package/src/delivery/types.ts +25 -2
  24. package/src/presets/__tests__/dsgvo-self-service.test.ts +35 -0
  25. package/src/presets/dsgvo-self-service.ts +30 -0
  26. package/src/presets/index.ts +1 -0
  27. package/src/secrets/feature.ts +3 -1
  28. package/src/secrets/index.ts +1 -0
  29. package/src/text-content/__tests__/seed-legal-content.integration.test.ts +61 -0
  30. package/src/text-content/seeding.ts +35 -1
  31. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +7 -7
  32. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +7 -13
@@ -2,6 +2,7 @@ import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type {
4
4
  NotifyOptions,
5
+ NotifyPriority,
5
6
  Registry,
6
7
  SessionUser,
7
8
  TenantId,
@@ -29,10 +30,31 @@ export type ChannelResult = {
29
30
  readonly address?: string;
30
31
  };
31
32
 
33
+ // Output of a channel's render step, passed into send(). Only channels that
34
+ // declare render() (email) produce one; inline channels (inApp) and channels
35
+ // without an expensive render step (push) receive `undefined`.
36
+ export type RenderedMessage = {
37
+ readonly html: string;
38
+ readonly subject: string;
39
+ };
40
+
41
+ // `mode` decides how the delivery-service dispatches a channel:
42
+ // inline — sent synchronously inside notify() (inApp: DB insert + SSE).
43
+ // queued — sent asynchronously via the delivery.send job; channels with a
44
+ // render() additionally run through delivery.render first.
45
+ export type DeliveryChannelMode = "inline" | "queued";
46
+
32
47
  export type DeliveryChannel = {
33
48
  readonly name: string;
49
+ readonly mode: DeliveryChannelMode;
34
50
  resolve(userId: string, ctx: ChannelContext): Promise<string | null>;
35
- send(address: string, message: ChannelMessage, ctx: ChannelContext): Promise<ChannelResult>;
51
+ render?(message: ChannelMessage, ctx: ChannelContext): Promise<RenderedMessage>;
52
+ send(
53
+ address: string,
54
+ message: ChannelMessage,
55
+ ctx: ChannelContext,
56
+ rendered?: RenderedMessage,
57
+ ): Promise<ChannelResult>;
36
58
  };
37
59
 
38
60
  // --- Notification Renderer ---
@@ -55,8 +77,9 @@ export type DeliveryLogEntry = {
55
77
  readonly channel: string;
56
78
  readonly recipientId: string | null;
57
79
  readonly recipientAddress: string | null;
58
- readonly status: "sent" | "failed" | "skipped";
80
+ readonly status: "queued" | "sent" | "failed" | "skipped";
59
81
  readonly error: string | null;
82
+ readonly priority: NotifyPriority;
60
83
  };
61
84
 
62
85
  // --- Delivery Service ---
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { dsgvoSelfServiceFeatures } from "../dsgvo-self-service";
3
+
4
+ // Pins the DSGVO/self-service bundle: the five features in dependency order
5
+ // (user-data-rights requires data-retention + compliance-profiles + sessions;
6
+ // user-profile requires user-data-rights). Order is load-bearing, so it is
7
+ // asserted explicitly.
8
+
9
+ describe("dsgvoSelfServiceFeatures", () => {
10
+ test("returns the five features in require-order", () => {
11
+ const names = dsgvoSelfServiceFeatures().map((f) => f.name);
12
+ expect(names).toEqual([
13
+ "data-retention",
14
+ "compliance-profiles",
15
+ "sessions",
16
+ "user-data-rights",
17
+ "user-profile",
18
+ ]);
19
+ });
20
+
21
+ test("data-retention/compliance-profiles precede user-data-rights (dependency order holds)", () => {
22
+ const names = dsgvoSelfServiceFeatures().map((f) => f.name);
23
+ const udr = names.indexOf("user-data-rights");
24
+ expect(names.indexOf("data-retention")).toBeLessThan(udr);
25
+ expect(names.indexOf("compliance-profiles")).toBeLessThan(udr);
26
+ expect(names.indexOf("sessions")).toBeLessThan(udr);
27
+ expect(names.indexOf("user-profile")).toBeGreaterThan(udr);
28
+ });
29
+
30
+ test("each call yields fresh feature instances (no shared mutable state)", () => {
31
+ const a = dsgvoSelfServiceFeatures();
32
+ const b = dsgvoSelfServiceFeatures();
33
+ expect(a[0]).not.toBe(b[0]);
34
+ });
35
+ });
@@ -0,0 +1,30 @@
1
+ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { createComplianceProfilesFeature } from "../compliance-profiles";
3
+ import { createDataRetentionFeature } from "../data-retention";
4
+ import { createSessionsFeature } from "../sessions";
5
+ import { createUserDataRightsFeature, type UserDataRightsOptions } from "../user-data-rights";
6
+ import { createUserProfileFeature } from "../user-profile";
7
+
8
+ export type DsgvoSelfServiceOptions = {
9
+ /** Durchgereicht an createUserDataRightsFeature — Export-/Deletion-Mail-
10
+ * Callbacks + Apex-Deletion-HMAC. Default {} (no-op Mail-Side). */
11
+ readonly userDataRights?: UserDataRightsOptions;
12
+ };
13
+
14
+ // DSGVO- + Account-Self-Service-Kette, die jede Kumiko-SaaS-App mountet
15
+ // (Privacy-Center, Account-Löschung Art. 17, Export Art. 20, Sessions).
16
+ // Die Reihenfolge IST load-bearing (Require-Order): user-data-rights braucht
17
+ // data-retention + compliance-profiles + sessions, user-profile braucht
18
+ // user-data-rights. Genau diese Order stand bisher in jeder App handkopiert
19
+ // mit Erklär-Kommentar. text-content + legal-pages bleiben bewusst draußen —
20
+ // legal-pages hat ein app-spezifisches wrapLayout, text-content ist
21
+ // standalone Foundation; beide spreaded die App selbst dazu.
22
+ export function dsgvoSelfServiceFeatures(opts: DsgvoSelfServiceOptions = {}): FeatureDefinition[] {
23
+ return [
24
+ createDataRetentionFeature(),
25
+ createComplianceProfilesFeature(),
26
+ createSessionsFeature(),
27
+ createUserDataRightsFeature(opts.userDataRights ?? {}),
28
+ createUserProfileFeature(),
29
+ ];
30
+ }
@@ -0,0 +1 @@
1
+ export { type DsgvoSelfServiceOptions, dsgvoSelfServiceFeatures } from "./dsgvo-self-service";
@@ -20,6 +20,8 @@ import { tenantSecretEntity, tenantSecretsTable } from "./table";
20
20
  * and picks the highest as the active KEK unless
21
21
  * `KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION` pins one explicitly.
22
22
  */
23
+ export const SECRETS_FEATURE_NAME = "secrets";
24
+
23
25
  export const secretsEnvSchema = z.object({
24
26
  KUMIKO_SECRETS_MASTER_KEY_V1: z
25
27
  .string()
@@ -83,7 +85,7 @@ export function requireSecretsContext(
83
85
  }
84
86
 
85
87
  export function createSecretsFeature(): FeatureDefinition {
86
- return defineFeature("secrets", (r) => {
88
+ return defineFeature(SECRETS_FEATURE_NAME, (r) => {
87
89
  r.describe(
88
90
  "Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump.",
89
91
  );
@@ -2,6 +2,7 @@ export {
2
2
  createSecretsContext,
3
3
  createSecretsFeature,
4
4
  requireSecretsContext,
5
+ SECRETS_FEATURE_NAME,
5
6
  type SecretsContext,
6
7
  type SecretsContextOptions,
7
8
  type StoredEnvelope,
@@ -0,0 +1,61 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { type DbConnection, fetchOne } from "@cosmicdrift/kumiko-framework/db";
3
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
5
+ import {
6
+ setupTestStack,
7
+ type TestStack,
8
+ unsafeCreateEntityTable,
9
+ } from "@cosmicdrift/kumiko-framework/stack";
10
+ import { createTextContentFeature } from "../feature";
11
+ import { type LegalContentBlock, seedLegalContentFromJson } from "../seeding";
12
+ import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../table";
13
+
14
+ // Pins seedLegalContentFromJson: seeds into SYSTEM_TENANT_ID by default and
15
+ // re-seeds with ifExists:"update" so a changed template body lands on an
16
+ // already-seeded block (legal-drift guard — the load-bearing behaviour).
17
+
18
+ let stack: TestStack;
19
+ let db: DbConnection;
20
+
21
+ beforeAll(async () => {
22
+ stack = await setupTestStack({ features: [createTextContentFeature()] });
23
+ db = stack.db;
24
+ await unsafeCreateEntityTable(db, textBlockEntity);
25
+ await createEventsTable(db);
26
+ });
27
+
28
+ afterAll(async () => {
29
+ await stack.cleanup();
30
+ });
31
+
32
+ function read(slug: string, lang: string) {
33
+ return fetchOne<TextBlockRow>(db, textBlocksTable, { tenantId: SYSTEM_TENANT_ID, slug, lang });
34
+ }
35
+
36
+ describe("seedLegalContentFromJson", () => {
37
+ test("seeds all blocks into SYSTEM_TENANT_ID by default", async () => {
38
+ const blocks: LegalContentBlock[] = [
39
+ { slug: "imprint", lang: "de", title: "Impressum", body: "Marc Frost" },
40
+ { slug: "imprint", lang: "en", title: "Imprint", body: "Marc Frost" },
41
+ ];
42
+ await seedLegalContentFromJson(db, blocks);
43
+
44
+ expect(await read("imprint", "de")).toMatchObject({ title: "Impressum", body: "Marc Frost" });
45
+ expect(await read("imprint", "en")).toMatchObject({ title: "Imprint", body: "Marc Frost" });
46
+ });
47
+
48
+ test("re-seed lifts an existing block to the new template state (ifExists:update)", async () => {
49
+ const v1: LegalContentBlock[] = [
50
+ { slug: "privacy", lang: "de", title: "Datenschutz", body: "v1" },
51
+ ];
52
+ await seedLegalContentFromJson(db, v1);
53
+ expect(await read("privacy", "de")).toMatchObject({ body: "v1" });
54
+
55
+ const v2: LegalContentBlock[] = [
56
+ { slug: "privacy", lang: "de", title: "Datenschutz", body: "v2 + Sub-Processor-Tabelle" },
57
+ ];
58
+ await seedLegalContentFromJson(db, v2);
59
+ expect(await read("privacy", "de")).toMatchObject({ body: "v2 + Sub-Processor-Tabelle" });
60
+ });
61
+ });
@@ -9,7 +9,11 @@ import {
9
9
  createTenantDb,
10
10
  type DbConnection,
11
11
  } from "@cosmicdrift/kumiko-framework/db";
12
- import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
+ import {
13
+ type SessionUser,
14
+ SYSTEM_TENANT_ID,
15
+ type TenantId,
16
+ } from "@cosmicdrift/kumiko-framework/engine";
13
17
  import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
14
18
  import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
15
19
  import { type TextBlockRow, textBlockEntity, textBlocksTable } from "./table";
@@ -101,3 +105,33 @@ export async function seedTextBlock(
101
105
  },
102
106
  });
103
107
  }
108
+
109
+ export type LegalContentBlock = {
110
+ readonly slug: string;
111
+ readonly lang: string;
112
+ readonly title: string;
113
+ readonly body: string;
114
+ };
115
+
116
+ // Boot-Seed für Legal-/Marketing-Texte aus einer JSON-Vorlage. `ifExists:
117
+ // "update"` ist load-bearing: die Vorlage ist autoritativ, jeder Boot hebt
118
+ // bestehende Blöcke auf den kompilierten Stand — sonst erreichen gesetzlich
119
+ // vorgeschriebene Zusätze (z.B. Sub-Processor-Tabelle) bereits geseedete
120
+ // Prod-Records nie. Genau diese Entscheidung soll keine App selbst treffen.
121
+ export async function seedLegalContentFromJson(
122
+ db: DbConnection,
123
+ blocks: readonly LegalContentBlock[],
124
+ opts: { readonly tenantId?: TenantId } = {},
125
+ ): Promise<void> {
126
+ const tenantId = opts.tenantId ?? SYSTEM_TENANT_ID;
127
+ for (const block of blocks) {
128
+ await seedTextBlock(db, {
129
+ tenantId,
130
+ slug: block.slug,
131
+ lang: block.lang,
132
+ title: block.title,
133
+ body: block.body,
134
+ ifExists: "update",
135
+ });
136
+ }
137
+ }
@@ -51,12 +51,12 @@ function renderWith(ui: ReactElement, dispatcher: Dispatcher): ReturnType<typeof
51
51
  return within(container);
52
52
  }
53
53
 
54
- // SKIPPED CI-only flake (#457): on the shared single-process happy-dom runner
55
- // the click never reaches React's submit handler after ~30 prior DOM test files
56
- // have mounted/unmounted (write is never invoked, calls stays empty). Green
57
- // locally and on main; not a timing issue. Un-skip once the global afterEach
58
- // teardown / per-file DOM isolation fix lands.
59
- describe.skip("RequestAccountDeletionScreen", () => {
54
+ // CI runs this file in its own `bun test` process (own ci.yml step), NOT in the
55
+ // shared `kumiko check` run see bunfig.ci.toml pathIgnorePatterns. The shared
56
+ // single-process happy-dom corrupts React event delegation after ~30 prior DOM
57
+ // test files mount/unmount, so the click never reached the submit handler here
58
+ // (#457). A fresh process has no such accumulation.
59
+ describe("RequestAccountDeletionScreen", () => {
60
60
  test("Submit → write(request-deletion-by-email) + enumeration-safe Success", async () => {
61
61
  const calls: WriteCall[] = [];
62
62
  const ui = renderWith(<RequestAccountDeletionScreen />, makeDispatcher(true, calls));
@@ -78,7 +78,7 @@ describe.skip("RequestAccountDeletionScreen", () => {
78
78
  });
79
79
  });
80
80
 
81
- describe.skip("ConfirmAccountDeletionScreen", () => {
81
+ describe("ConfirmAccountDeletionScreen", () => {
82
82
  test("ohne ?token → missingToken, kein Confirm-Button", () => {
83
83
  window.history.replaceState({}, "", "/delete-account/confirm");
84
84
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(true, []));
@@ -114,19 +114,13 @@ async function waitForMount(view: ReturnType<typeof render>): Promise<void> {
114
114
  });
115
115
  }
116
116
 
117
- // QUARANTINED (#457-Klasse): diese Render-Tests laufen lokal grün (13/13 isoliert,
118
- // 82/88 mit allen bundled-features-web-Tests parallel), failen aber auf CI-Linux
119
- // unter bun-`concurrency=8`. Diagnose aus dem CI-Log: parallele Test-FILES teilen
120
- // sich das eine globale happy-dom `document`; der globale `afterEach` aus
121
- // `test-setup/dom.preload.ts` (`cleanup()` + `document.body.replaceChildren()`)
122
- // eines parallel laufenden Tests wischt die in-flight gerenderte DOM eines anderen
123
- // weg die `await`-Assertions hier finden den Screen-Stand eines Nachbar-Tests
124
- // (sichtbar: alle Fails zeigen denselben active-state Screen statt der eigenen
125
- // Test-Daten). Nicht aus diesem File fixbar — gleiche Architektur-Flake, wegen der
126
- // `deletion-screens.test.tsx` im selben Verzeichnis quarantäniert ist. Un-skip,
127
- // sobald das framework-weite Test-Isolation-Problem (#457) gelöst ist. Die
128
- // QN-Drift-Pins + formatDate unten decken die CI-stabile Verdrahtungs-Korrektheit ab.
129
- describe.skip("PrivacyCenterScreen", () => {
117
+ // CI runs this file in its own `bun test` process (own ci.yml step), NOT in the
118
+ // shared `kumiko check` run see bunfig.ci.toml pathIgnorePatterns. In the shared
119
+ // single-process happy-dom, the global `afterEach` from `test-setup/dom.preload.ts`
120
+ // plus accumulated global DOM/event state across ~30 prior DOM test files corrupts
121
+ // these in-flight renders (#457-class). A fresh process has no such accumulation.
122
+ // The QN-Drift-Pins + formatDate describes below are pure-logic and CI-stable.
123
+ describe("PrivacyCenterScreen", () => {
130
124
  test("aktiver User: Export/Einschränken/Löschen-Sektionen, Texte übersetzt (keine rohen Keys)", async () => {
131
125
  const { view } = renderCenter({ me: activeMe });
132
126
  await waitForMount(view);