@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.
- package/package.json +7 -6
- package/src/auth-email-password/auth-mailer.ts +28 -12
- package/src/auth-email-password/index.ts +3 -0
- package/src/channel-email/__tests__/smtp-transport-from-env.test.ts +35 -0
- package/src/channel-email/email-channel.ts +32 -20
- package/src/channel-email/feature.ts +2 -0
- package/src/channel-email/index.ts +6 -1
- package/src/channel-email/smtp-transport.ts +37 -0
- package/src/channel-in-app/feature.ts +1 -0
- package/src/channel-in-app/in-app-channel.ts +1 -0
- package/src/channel-push/feature.ts +1 -0
- package/src/channel-push/push-channel.ts +1 -0
- package/src/delivery/__tests__/delivery.integration.test.ts +247 -1
- package/src/delivery/attempt-log.ts +54 -0
- package/src/delivery/channel-context.ts +17 -0
- package/src/delivery/constants.ts +35 -1
- package/src/delivery/delivery-service.ts +118 -47
- package/src/delivery/events.ts +7 -1
- package/src/delivery/feature.ts +38 -14
- package/src/delivery/index.ts +3 -0
- package/src/delivery/jobs.ts +153 -0
- package/src/delivery/tables.ts +5 -1
- package/src/delivery/types.ts +25 -2
- package/src/presets/__tests__/dsgvo-self-service.test.ts +35 -0
- package/src/presets/dsgvo-self-service.ts +30 -0
- package/src/presets/index.ts +1 -0
- package/src/secrets/feature.ts +3 -1
- package/src/secrets/index.ts +1 -0
- package/src/text-content/__tests__/seed-legal-content.integration.test.ts +61 -0
- package/src/text-content/seeding.ts +35 -1
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +7 -7
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +7 -13
package/src/delivery/types.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|
package/src/secrets/feature.ts
CHANGED
|
@@ -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(
|
|
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
|
);
|
package/src/secrets/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
describe
|
|
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
|
|
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
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
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);
|