@cosmicdrift/kumiko-bundled-features 0.145.1 → 0.146.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 (37) hide show
  1. package/package.json +7 -6
  2. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +15 -55
  3. package/src/custom-fields/__tests__/parse-serialized-field.test.ts +11 -3
  4. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +24 -79
  5. package/src/custom-fields/db/queries/user-data-rights.ts +0 -20
  6. package/src/custom-fields/events.ts +4 -9
  7. package/src/custom-fields/feature.ts +8 -0
  8. package/src/custom-fields/handlers/set-custom-field.write.ts +3 -44
  9. package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -27
  10. package/src/custom-fields/lib/parse-serialized-field.ts +12 -4
  11. package/src/custom-fields/lib/value-schema.ts +1 -1
  12. package/src/custom-fields/schemas.ts +9 -9
  13. package/src/custom-fields/wire-for-entity.ts +8 -15
  14. package/src/custom-fields/wire-user-data-rights.ts +5 -54
  15. package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +199 -0
  16. package/src/inbound-mail-foundation/feature.ts +58 -0
  17. package/src/inbound-mail-foundation/retention-sweep.ts +155 -0
  18. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +26 -0
  19. package/src/managed-pages/feature.ts +3 -1
  20. package/src/managed-pages/handlers/by-tenant-published.query.ts +52 -0
  21. package/src/page-render/__tests__/layout.test.ts +53 -0
  22. package/src/page-render/index.ts +1 -1
  23. package/src/page-render/layout.ts +15 -1
  24. package/src/seo/__tests__/llms-txt.test.ts +38 -0
  25. package/src/seo/__tests__/robots-txt.test.ts +18 -0
  26. package/src/seo/__tests__/schema-builders.test.ts +57 -0
  27. package/src/seo/__tests__/seo.integration.test.ts +258 -0
  28. package/src/seo/__tests__/sitemap.test.ts +38 -0
  29. package/src/seo/constants.ts +60 -0
  30. package/src/seo/feature.ts +313 -0
  31. package/src/seo/handlers/seo-config.query.ts +35 -0
  32. package/src/seo/index.ts +19 -0
  33. package/src/seo/llms-txt.ts +33 -0
  34. package/src/seo/robots-txt.ts +13 -0
  35. package/src/seo/schema-builders.ts +57 -0
  36. package/src/seo/sitemap.ts +30 -0
  37. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +39 -62
@@ -1,18 +1,9 @@
1
1
  // T1.5c — user-data-rights wiring for custom-fields.
2
2
 
3
3
  import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
4
- import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
5
- import {
6
- EXT_USER_DATA,
7
- EXT_USER_DATA_ORDER,
8
- type FeatureRegistrar,
9
- } from "@cosmicdrift/kumiko-framework/engine";
10
- import {
11
- selectCustomFieldsHostRows,
12
- selectFieldDefinitionsForEntity,
13
- stripSensitiveCustomFieldKeys,
14
- } from "./db/queries/user-data-rights";
15
- import { isFieldDefinitionRow, parseSerializedField } from "./lib/parse-serialized-field";
4
+ import type { UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { EXT_USER_DATA, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { selectCustomFieldsHostRows } from "./db/queries/user-data-rights";
16
7
 
17
8
  export interface WireCustomFieldsUserDataRightsOptions {
18
9
  readonly entityName: string;
@@ -36,14 +27,8 @@ function asCustomFieldsHostRow(value: unknown): CustomFieldsHostRow | null {
36
27
  return { id: value.id, customFields: Object.fromEntries(Object.entries(cf)) };
37
28
  }
38
29
 
39
- // The anonymize strip filters rows by `WHERE userIdColumn = userId`. A host
40
- // anonymize hook on the SAME entity that nulls that column (e.g. inserted_by_id
41
- // = NULL) would, if it ran first, leave the strip matching 0 rows → sensitive
42
- // jsonb PII silently retained (DSGVO Art. 17 violation). A negative order makes
43
- // runForgetCleanup run this strip before any default-order (0) owner-nulling
44
- // hook, independent of feature registration order.
45
- const ORDER_REDACT_BEFORE_OWNER_MUTATION = EXT_USER_DATA_ORDER.REDACT_BEFORE_OWNER;
46
-
30
+ // Export-only wiring: custom fields hold supplemental business data, not PII
31
+ // (#972) there is nothing to redact on user-forget, only Art. 20 export.
47
32
  export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<string>>(
48
33
  r: TReg,
49
34
  opts: WireCustomFieldsUserDataRightsOptions,
@@ -71,42 +56,8 @@ export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<
71
56
  return { entity: `${opts.entityName}.customFields`, rows: snippetRows };
72
57
  };
73
58
 
74
- const deleteHook: UserDataDeleteHook = async (ctx, strategy) => {
75
- // skip: delete strategy removes rows wholesale — custom-field redaction N/A.
76
- if (strategy === "delete") return;
77
- const sensitiveKeys = await loadSensitiveFieldKeys(ctx.db, ctx.tenantId, opts.entityName);
78
- // skip: no sensitive custom fields configured for this entity.
79
- if (sensitiveKeys.length === 0) return;
80
-
81
- await stripSensitiveCustomFieldKeys(
82
- ctx.db,
83
- tableName,
84
- opts.userIdColumn,
85
- sensitiveKeys,
86
- ctx.userId,
87
- ctx.tenantId,
88
- );
89
- };
90
-
91
59
  // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar API, not a React hook.
92
60
  r.useExtension(EXT_USER_DATA, opts.entityName, {
93
61
  export: exportHook,
94
- delete: deleteHook,
95
- order: ORDER_REDACT_BEFORE_OWNER_MUTATION,
96
62
  });
97
63
  }
98
-
99
- async function loadSensitiveFieldKeys(
100
- db: Parameters<UserDataExportHook>[0]["db"],
101
- tenantId: string,
102
- entityName: string,
103
- ): Promise<string[]> {
104
- const rows = await selectFieldDefinitionsForEntity(db, entityName, tenantId);
105
- const keys: string[] = [];
106
- for (const raw of rows) {
107
- if (!isFieldDefinitionRow(raw)) continue;
108
- const parsed = parseSerializedField(raw.serialized_field);
109
- if (parsed?.sensitive === true) keys.push(raw.field_key);
110
- }
111
- return keys;
112
- }
@@ -0,0 +1,199 @@
1
+ // Integration-test für inbound-mail data-retention (#957). Treibt echte
2
+ // ingests durch den Dispatcher + DB (setupTestStack), dann runInboundMailRetention
3
+ // mit injiziertem `now`. Verifiziert die Plan-Pflicht-Szenarien:
4
+ // - Message älter als Frist → Row weg, Stream archiviert, jüngere bleibt
5
+ // - Rebuild resurrectet die gepurgte Row NICHT (archiveStream-Rationale)
6
+ // - Seen-Anchor-Cleanup: past-cutoff weg, in-window bleibt (Dedup intakt)
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import {
11
+ configurePiiSubjectKms,
12
+ InMemoryKmsAdapter,
13
+ resetPiiSubjectKmsForTests,
14
+ } from "@cosmicdrift/kumiko-framework/crypto";
15
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
16
+ import { createEventsTable, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
18
+ import {
19
+ createTestUser,
20
+ setupTestStack,
21
+ type TestStack,
22
+ testTenantId,
23
+ unsafeCreateEntityTable,
24
+ } from "@cosmicdrift/kumiko-framework/stack";
25
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
26
+ import {
27
+ createComplianceProfilesFeature,
28
+ tenantComplianceProfileEntity,
29
+ } from "../../compliance-profiles";
30
+ import { createConfigFeature } from "../../config";
31
+ import { inboundProviderInMemoryFeature } from "../../inbound-provider-inmemory";
32
+ import { createTenantFeature } from "../../tenant/feature";
33
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
34
+ import { inboundMessageAggregateId } from "../aggregate-id";
35
+ import { InboundMailFoundationHandlers } from "../constants";
36
+ import { seenMessageEntity, seenMessageTable, syncCursorEntity } from "../entities";
37
+ import { inboundMailFoundationFeature } from "../feature";
38
+ import { inboundMessagesProjectionTable } from "../projection";
39
+ import { runInboundMailRetention } from "../retention-sweep";
40
+
41
+ let stack: TestStack;
42
+ let db: DbConnection;
43
+
44
+ beforeAll(async () => {
45
+ stack = await setupTestStack({
46
+ features: [
47
+ createConfigFeature(),
48
+ createTenantFeature(),
49
+ createComplianceProfilesFeature(),
50
+ createTenantLifecycleFeature(),
51
+ inboundMailFoundationFeature,
52
+ inboundProviderInMemoryFeature,
53
+ ],
54
+ });
55
+ db = stack.db;
56
+ await createEventsTable(db);
57
+ await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
58
+ await unsafeCreateEntityTable(db, syncCursorEntity);
59
+ await unsafeCreateEntityTable(db, seenMessageEntity);
60
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
61
+ });
62
+
63
+ afterAll(async () => {
64
+ await stack.cleanup();
65
+ resetPiiSubjectKmsForTests();
66
+ });
67
+
68
+ function adminFor(tenantNumber: number) {
69
+ return createTestUser({
70
+ id: tenantNumber,
71
+ tenantId: testTenantId(tenantNumber),
72
+ roles: ["TenantAdmin", "SystemAdmin"],
73
+ });
74
+ }
75
+
76
+ async function connectSharedAccount(admin: ReturnType<typeof adminFor>): Promise<string> {
77
+ const result = (await stack.http.writeOk(
78
+ InboundMailFoundationHandlers.connectAccount,
79
+ {
80
+ provider: "inmemory",
81
+ authMethod: "password",
82
+ displayName: "Team-Inbox",
83
+ address: "inbox@tenant.example",
84
+ scope: "shared",
85
+ },
86
+ admin,
87
+ )) as { accountId: string };
88
+ return result.accountId;
89
+ }
90
+
91
+ async function ingest(
92
+ accountId: string,
93
+ admin: ReturnType<typeof adminFor>,
94
+ providerMessageId: string,
95
+ receivedAtIso: string,
96
+ ): Promise<void> {
97
+ await stack.http.writeOk(
98
+ InboundMailFoundationHandlers.ingestMessage,
99
+ {
100
+ accountId,
101
+ ownerUserId: null,
102
+ providerName: "inmemory",
103
+ providerMessageId,
104
+ messageIdHeader: `${providerMessageId}@example.com`,
105
+ providerThreadId: null,
106
+ references: [],
107
+ from: "sender@example.com",
108
+ to: ["inbox@tenant.example"],
109
+ cc: [],
110
+ subject: "Hello",
111
+ snippet: "Hello world …",
112
+ receivedAtIso,
113
+ bodyRef: "",
114
+ scope: "inbox",
115
+ providerCursor: '{"offset":1}',
116
+ },
117
+ admin,
118
+ );
119
+ }
120
+
121
+ describe("retention: inbound messages", () => {
122
+ test("expired message purged + stream archived; younger stays; rebuild does not resurrect", async () => {
123
+ const admin = adminFor(5001);
124
+ const accountId = await connectSharedAccount(admin);
125
+ await ingest(accountId, admin, "uid-old", "2024-01-01T10:00:00Z");
126
+ await ingest(accountId, admin, "uid-young", "2026-07-01T10:00:00Z");
127
+
128
+ const oldId = inboundMessageAggregateId(accountId, "uid-old");
129
+ const youngId = inboundMessageAggregateId(accountId, "uid-young");
130
+ expect(await selectMany(db, inboundMessagesProjectionTable, { accountId })).toHaveLength(2);
131
+
132
+ // now=2026-07-13, default 365d → cutoff 2025-07-13: old(2024) expired, young(2026-07) stays.
133
+ // seen weit hochgesetzt, damit dieser Fall nur die Messages misst.
134
+ const now = getTemporal().Instant.from("2026-07-13T00:00:00Z");
135
+ const report = await runInboundMailRetention({
136
+ db,
137
+ tenantId: admin.tenantId,
138
+ now,
139
+ seenRetentionDays: 100000,
140
+ });
141
+ expect(report.messagesPurged).toBe(1);
142
+ expect(report.seenPurged).toBe(0);
143
+
144
+ const rows = await selectMany(db, inboundMessagesProjectionTable, { accountId });
145
+ expect(rows).toHaveLength(1);
146
+ expect(rows[0]?.["id"]).toBe(youngId);
147
+
148
+ // Archivierter Stream → loadAggregate leer; junger Stream intakt.
149
+ expect(await loadAggregate(db, oldId, admin.tenantId)).toHaveLength(0);
150
+ expect((await loadAggregate(db, youngId, admin.tenantId)).length).toBeGreaterThanOrEqual(1);
151
+
152
+ // Rebuild replayt received NICHT für den archivierten Stream → keine Resurrection.
153
+ await rebuildProjection("inbound-mail-foundation:projection:inbound-message", {
154
+ db,
155
+ registry: stack.registry,
156
+ });
157
+ const afterRebuild = await selectMany(db, inboundMessagesProjectionTable, { accountId });
158
+ expect(afterRebuild).toHaveLength(1);
159
+ expect(afterRebuild[0]?.["id"]).toBe(youngId);
160
+ });
161
+ });
162
+
163
+ describe("retention: seen anchors", () => {
164
+ test("past-cutoff anchor purged, in-window anchor stays (dedup window intact)", async () => {
165
+ const admin = adminFor(5002);
166
+ const accountId = await connectSharedAccount(admin);
167
+ await ingest(accountId, admin, "uid-seen", "2026-07-01T10:00:00Z");
168
+
169
+ const seenRows = await selectMany(db, seenMessageTable, { tenantId: admin.tenantId });
170
+ expect(seenRows.length).toBeGreaterThanOrEqual(1);
171
+ const seenAt = getTemporal().Instant.from(String(seenRows[0]?.["seenAt"]));
172
+
173
+ // now = seenAt + 1 Tag → cutoff (now-90d) liegt vor seenAt → nichts fällig.
174
+ const nowInWindow = seenAt.add({ hours: 24 });
175
+ const inWindow = await runInboundMailRetention({
176
+ db,
177
+ tenantId: admin.tenantId,
178
+ now: nowInWindow,
179
+ seenRetentionDays: 90,
180
+ messageRetentionDays: 100000,
181
+ });
182
+ expect(inWindow.seenPurged).toBe(0);
183
+ expect(await selectMany(db, seenMessageTable, { tenantId: admin.tenantId })).toHaveLength(
184
+ seenRows.length,
185
+ );
186
+
187
+ // now = seenAt + 91 Tage → cutoff (now-90d) liegt nach seenAt → fällig.
188
+ const nowExpired = seenAt.add({ hours: 91 * 24 });
189
+ const expired = await runInboundMailRetention({
190
+ db,
191
+ tenantId: admin.tenantId,
192
+ now: nowExpired,
193
+ seenRetentionDays: 90,
194
+ messageRetentionDays: 100000,
195
+ });
196
+ expect(expired.seenPurged).toBeGreaterThanOrEqual(1);
197
+ expect(await selectMany(db, seenMessageTable, { tenantId: admin.tenantId })).toHaveLength(0);
198
+ });
199
+ });
@@ -41,7 +41,12 @@
41
41
  // (crypto-shredding, Muster billing-foundation #800). Der Destroy-Hook
42
42
  // archiviert zusätzlich alle Streams + löscht die Rows.
43
43
 
44
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
44
45
  import { defineFeature, EXT_TENANT_DATA } from "@cosmicdrift/kumiko-framework/engine";
46
+ import {
47
+ createFileProviderForTenant,
48
+ type FileStorageProvider,
49
+ } from "@cosmicdrift/kumiko-framework/files";
45
50
  import { INBOUND_MAIL_FOUNDATION_FEATURE, INBOUND_MAIL_PROVIDER_EXTENSION } from "./constants";
46
51
  import {
47
52
  inboundMessageEntity,
@@ -84,6 +89,7 @@ import {
84
89
  mailAccountsProjectionTable,
85
90
  mailThreadsProjectionTable,
86
91
  } from "./projection";
92
+ import { runInboundMailRetention } from "./retention-sweep";
87
93
  import {
88
94
  inboundMessageTenantDestroyHook,
89
95
  mailAccountTenantDestroyHook,
@@ -182,4 +188,56 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
182
188
  r.useExtension(EXT_TENANT_DATA, "mail-thread", {
183
189
  destroy: mailThreadTenantDestroyHook,
184
190
  });
191
+
192
+ // Data-Retention (#957) — perTenant-Fan-out (ein Run pro Tenant, wie
193
+ // data-retention:retention-cleanup): die job-DB ist NICHT tenant-scoped,
194
+ // der Sweep scoped jeden Delete explizit per tenantId. Ohne diesen Cron
195
+ // wachsen read_inbound_messages + die Bodies unbegrenzt (DSGVO Art. 5).
196
+ r.job(
197
+ "inbound-mail-retention",
198
+ { trigger: { cron: "0 3 * * *" }, perTenant: true, concurrency: "skip" },
199
+ async (_payload, ctx) => {
200
+ if (!ctx.db || !ctx.registry) {
201
+ throw new Error(
202
+ "inbound-mail-retention: ctx.db + ctx.registry required (JobContext incomplete)",
203
+ );
204
+ }
205
+ const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
206
+ if (tenantId === undefined) {
207
+ // skip: cron fired without a perTenant fan-out tenant — nothing scoped
208
+ return;
209
+ }
210
+ const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
211
+ const retentionDb = ctx.db as DbConnection; // @cast-boundary db-operator
212
+
213
+ // Body-Objekte liegen in file-foundation. Provider lazy + memoized —
214
+ // heute schreibt der Ingest kein bodyRef (storeBody-Hook ungebunden),
215
+ // der Provider wird also nie gebaut. Sobald storeBody wired ist, raeumt
216
+ // die Retention die Bodies mit ab.
217
+ let providerPromise: Promise<FileStorageProvider> | null = null;
218
+ const deleteBodyObject = async (bodyRef: string): Promise<void> => {
219
+ if (!providerPromise) {
220
+ providerPromise = createFileProviderForTenant(
221
+ { config: ctx.config, registry: ctx.registry },
222
+ tenantId,
223
+ );
224
+ }
225
+ const provider = await providerPromise;
226
+ await provider.delete(bodyRef);
227
+ };
228
+
229
+ const report = await runInboundMailRetention({
230
+ db: retentionDb,
231
+ tenantId,
232
+ now: T.Now.instant(),
233
+ deleteBodyObject,
234
+ });
235
+ if (report.bodyObjectErrors > 0) {
236
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for failed body deletes
237
+ console.warn(
238
+ `[inbound-mail-foundation:inbound-mail-retention] tenant=${tenantId} bodyObjectErrors=${report.bodyObjectErrors}`,
239
+ );
240
+ }
241
+ },
242
+ );
185
243
  });
@@ -0,0 +1,155 @@
1
+ // Inbound-mail data-retention (#957). DSGVO Art. 5 (Datenminimierung):
2
+ // ohne Retention wachsen read_inbound_messages + die File-Storage-Bodies
3
+ // unbegrenzt. Zwei Targets, zwei Mechanismen — der Unterschied ist
4
+ // event-sourced vs. unmanaged:
5
+ //
6
+ // read_inbound_messages (event-sourced, EXPLIZITE r.projection):
7
+ // pro Row aelter als messageRetentionDays → archiveStream + Row-Delete
8
+ // (+ bodyRef-Objekt loeschen). archiveStream statt executor.forget, weil
9
+ // die Projection nur das received-Domain-Event mappt, nicht den
10
+ // forgotten-Auto-Verb (den registriert nur ImplicitProjection). Ein
11
+ // forget-Event wuerde beim Rebuild ignoriert und die Row aus dem
12
+ // received-Event resurrecten (#648-Klasse). Der archivierte Stream macht
13
+ // loadAggregate leer → received wird gar nicht erst repliziert. Gleiches
14
+ // Muster wie tenant-destroy-hook, nur per-Row-cutoff statt ganzer Tenant.
15
+ //
16
+ // read_mail_seen_messages (unmanaged, direct-write, nicht event-sourced):
17
+ // Dedup-Anker braucht nur das Backfill-/Replay-Fenster → seenAt-cutoff
18
+ // via plain deleteMany, kein Stream/Archiv. Gescoped auf die accountIds
19
+ // dieses Tenants (die Tabelle traegt keine tenantId-Spalte).
20
+ //
21
+ // GRENZE (#957 Teil 2, Plan §3.4): die PII in den Message-Events bleibt
22
+ // unter dem TENANT-Subject-Key entschluesselbar — per-Row-Retention
23
+ // shreddet keinen Key. Echte Art.-17-Erasure der Event-Payloads passiert
24
+ // erst bei tenant-destroy (Key-Erase). Read-Row + Body-File sind nach dem
25
+ // Sweep weg; das Event-Log ist append-only by design.
26
+
27
+ import {
28
+ type DbRunner,
29
+ deleteMany,
30
+ type EntityTableMeta,
31
+ selectMany,
32
+ } from "@cosmicdrift/kumiko-framework/db";
33
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
34
+ import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
35
+ import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
36
+ import { seenMessageTable } from "./entities";
37
+ import { INBOUND_MESSAGE_AGGREGATE_TYPE } from "./events";
38
+ import { inboundMessagesProjectionTable } from "./projection";
39
+
40
+ // Temporal.Instant ohne den Caller zwingen es aus globalThis zu ziehen
41
+ // (Muster data-retention/keep-for.ts).
42
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
43
+
44
+ export const MESSAGE_RETENTION_DAYS = 365;
45
+ export const SEEN_RETENTION_DAYS = 90;
46
+ const BATCH_LIMIT = 1000;
47
+ const ARCHIVED_BY = "inbound-mail:retention";
48
+ const REASON = "retention_expired";
49
+
50
+ export interface InboundRetentionArgs {
51
+ readonly db: DbRunner;
52
+ readonly tenantId: TenantId;
53
+ /** Now-Injection — Tests pinnen den Wert ohne Date-Mock (Pattern keep-for.ts). */
54
+ readonly now: Instant;
55
+ readonly messageRetentionDays?: number;
56
+ readonly seenRetentionDays?: number;
57
+ /** Loescht ein file-storage-Body-Objekt per bodyRef-key. Vom Job lazy
58
+ * verdrahtet (createFileProviderForTenant). Fehlt = kein Body-Delete. */
59
+ readonly deleteBodyObject?: (bodyRef: string) => Promise<void>;
60
+ }
61
+
62
+ export interface InboundRetentionReport {
63
+ readonly messagesPurged: number;
64
+ readonly bodyObjectsDeleted: number;
65
+ readonly bodyObjectErrors: number;
66
+ readonly seenPurged: number;
67
+ }
68
+
69
+ export async function runInboundMailRetention(
70
+ args: InboundRetentionArgs,
71
+ ): Promise<InboundRetentionReport> {
72
+ const messageDays = args.messageRetentionDays ?? MESSAGE_RETENTION_DAYS;
73
+ const seenDays = args.seenRetentionDays ?? SEEN_RETENTION_DAYS;
74
+ const messageCutoff = args.now.subtract({ hours: messageDays * 24 });
75
+ const seenCutoff = args.now.subtract({ hours: seenDays * 24 });
76
+
77
+ const messages = await purgeExpiredMessages(args, messageCutoff);
78
+ const seenPurged = await purgeExpiredSeenAnchors(args.db, args.tenantId, seenCutoff);
79
+
80
+ return { ...messages, seenPurged };
81
+ }
82
+
83
+ type ExpiredMessageRow = { readonly id: string; readonly bodyRef: string | null };
84
+
85
+ async function purgeExpiredMessages(
86
+ args: InboundRetentionArgs,
87
+ cutoff: Instant,
88
+ ): Promise<Omit<InboundRetentionReport, "seenPurged">> {
89
+ let messagesPurged = 0;
90
+ let bodyObjectsDeleted = 0;
91
+ let bodyObjectErrors = 0;
92
+
93
+ // Drain-Loop: jede Page loescht die Rows die sie liest, die naechste ist
94
+ // damit frisch. archiveStream VOR dem Delete (crash dazwischen → naechster
95
+ // Lauf re-archiviert idempotent + loescht). Delete strikt nach den
96
+ // archivierten ids, nie per cutoff-WHERE — sonst koennte eine zwischen
97
+ // select und delete eingegangene Alt-Mail unarchiviert geloescht werden
98
+ // und beim Rebuild resurrecten.
99
+ for (;;) {
100
+ const rows = await selectMany<ExpiredMessageRow>(
101
+ args.db,
102
+ inboundMessagesProjectionTable,
103
+ { receivedAt: { lt: cutoff }, tenantId: args.tenantId },
104
+ { limit: BATCH_LIMIT },
105
+ );
106
+ if (rows.length === 0) break;
107
+
108
+ const ids: string[] = [];
109
+ for (const row of rows) {
110
+ await archiveStream(args.db, {
111
+ tenantId: args.tenantId,
112
+ aggregateId: row.id,
113
+ aggregateType: INBOUND_MESSAGE_AGGREGATE_TYPE,
114
+ archivedBy: ARCHIVED_BY,
115
+ reason: REASON,
116
+ });
117
+ if (row.bodyRef && args.deleteBodyObject) {
118
+ try {
119
+ await args.deleteBodyObject(row.bodyRef);
120
+ bodyObjectsDeleted++;
121
+ } catch {
122
+ bodyObjectErrors++;
123
+ }
124
+ }
125
+ ids.push(row.id);
126
+ }
127
+ await deleteMany(args.db, inboundMessagesProjectionTable as EntityTableMeta, {
128
+ id: { in: ids },
129
+ });
130
+ messagesPurged += ids.length;
131
+
132
+ if (rows.length < BATCH_LIMIT) break;
133
+ }
134
+
135
+ return { messagesPurged, bodyObjectsDeleted, bodyObjectErrors };
136
+ }
137
+
138
+ async function purgeExpiredSeenAnchors(
139
+ db: DbRunner,
140
+ tenantId: TenantId,
141
+ cutoff: Instant,
142
+ ): Promise<number> {
143
+ // Seen-Anker erben tenantId (table-builder: jede Entity hat tenant_id) →
144
+ // direkt tenant-scoped, kein Account-Join. Trifft auch Anker, deren Account
145
+ // schon disconnected/geloescht ist.
146
+ const where = { tenantId, seenAt: { lt: cutoff } };
147
+ const expired = await selectMany<{ accountId: string }>(
148
+ db,
149
+ seenMessageTable as EntityTableMeta,
150
+ where,
151
+ );
152
+ if (expired.length === 0) return 0;
153
+ await deleteMany(db, seenMessageTable as EntityTableMeta, where);
154
+ return expired.length;
155
+ }
@@ -398,6 +398,32 @@ describe("managed-pages :: set with tenantIdOverride (cross-tenant, SystemAdmin)
398
398
  });
399
399
  });
400
400
 
401
+ describe("managed-pages :: by-tenant-published (Discovery-Query)", () => {
402
+ // tenantAdmin/otherAdmin operate under the default test tenant + a third
403
+ // tenant (see file header) — neither is TENANT_A/TENANT_B, so this needs
404
+ // its own explicit-tenantId users (same pattern as adminA/adminB below).
405
+ const pageAdminA = createTestUser({ id: 14, roles: ["TenantAdmin"], tenantId: TENANT_A });
406
+ const pageAdminB = createTestUser({ id: 15, roles: ["TenantAdmin"], tenantId: TENANT_B });
407
+
408
+ test("listet nur published Pages des Tenants, SQL-gefiltert (Drafts fehlen)", async () => {
409
+ const res = await stack.http.queryOk<{
410
+ pages: Array<{ slug: string; lang: string; title: string }>;
411
+ }>("managed-pages:query:by-tenant-published", {}, pageAdminA);
412
+ const slugs = res.pages.map((p) => p.slug);
413
+ expect(slugs).toContain("about");
414
+ expect(slugs).not.toContain("secret");
415
+ });
416
+
417
+ test("Cross-Tenant-Isolation: TENANT_B sieht nur eigene Slugs, keine von TENANT_A", async () => {
418
+ const res = await stack.http.queryOk<{ pages: Array<{ slug: string; title: string }> }>(
419
+ "managed-pages:query:by-tenant-published",
420
+ {},
421
+ pageAdminB,
422
+ );
423
+ expect(res.pages).toEqual([expect.objectContaining({ slug: "about", title: "About B" })]);
424
+ });
425
+ });
426
+
401
427
  describe("managed-pages :: Branding (Config + Render)", () => {
402
428
  // config:write:set leitet tenantId aus user.tenantId ab → tenant-spezifische
403
429
  // Admins, damit das Branding auf TENANT_A bzw. TENANT_B landet (Host a.*/b.*).
@@ -18,6 +18,7 @@ import {
18
18
  import { BRANDING_KEYS, BRANDING_QUERY_QN, CUSTOM_CSS_KEY, coerceBranding } from "./branding";
19
19
  import { createBrandingQuery } from "./handlers/branding.query";
20
20
  import { bySlugQuery } from "./handlers/by-slug.query";
21
+ import { byTenantPublishedQuery } from "./handlers/by-tenant-published.query";
21
22
  import { setWrite } from "./handlers/set.write";
22
23
  import { MANAGED_PAGES_I18N } from "./i18n";
23
24
  import { createBrandingSettingsScreen } from "./screens/branding-screen";
@@ -161,7 +162,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
161
162
 
162
163
  return defineFeature("managed-pages", (r) => {
163
164
  r.describe(
164
- "Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
165
+ "Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
165
166
  );
166
167
  r.uiHints({
167
168
  displayLabel: "Managed Pages · Public CMS",
@@ -180,6 +181,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
180
181
  const handlers = { set: r.writeHandler(setWrite) };
181
182
  const queries = {
182
183
  bySlug: r.queryHandler(bySlugQuery),
184
+ byTenantPublished: r.queryHandler(byTenantPublishedQuery),
183
185
  branding: r.queryHandler(createBrandingQuery({ allowCustomCss })),
184
186
  };
185
187
 
@@ -0,0 +1,52 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ crossTenantOverrideDenied,
5
+ defineQueryHandler,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { z } from "zod";
8
+ import { type PageRow, pagesTable } from "../table";
9
+
10
+ // Public-Read aller published Pages eines Tenants — Discovery-Quelle für
11
+ // sitemap.xml/llms.txt (siehe seo-Feature). Anders als by-slug (single-row,
12
+ // nur der angefragte Slug) listet dies alle published Slugs auf einmal, SQL-
13
+ // seitig auf `published = true` gefiltert (Drafts nie im Ergebnis, kein
14
+ // Body-Content — Discovery-Zweck braucht nur Slug/Title/updatedAt).
15
+ // Anonymous: explizit in roles, symmetrisch zu text-content's by-tenant.
16
+ export type PublishedPageSummary = {
17
+ readonly slug: string;
18
+ readonly lang: string;
19
+ readonly title: string;
20
+ readonly updatedAt: Date;
21
+ };
22
+
23
+ export const byTenantPublishedQuery = defineQueryHandler({
24
+ name: "by-tenant-published",
25
+ schema: z.object({
26
+ /** Optional cross-tenant read — nur für SystemAdmin. Symmetrisch zur
27
+ * by-slug.query Override-Logik. */
28
+ tenantIdOverride: z.string().min(1).optional(),
29
+ }),
30
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
31
+ handler: async (query, ctx) => {
32
+ const override = query.payload.tenantIdOverride;
33
+ const overrideDenied = crossTenantOverrideDenied(
34
+ query.user,
35
+ override,
36
+ "managedPages.errors.tenantOverrideRequiresSystemAdmin",
37
+ );
38
+ if (overrideDenied) throw overrideDenied;
39
+ const tenantId = override ?? query.user.tenantId;
40
+ const rows = castTenantRows<PageRow>(
41
+ await selectMany(ctx.db, pagesTable, { tenantId: tenantId, published: true }),
42
+ );
43
+ return {
44
+ pages: rows.map((row) => ({
45
+ slug: row.slug,
46
+ lang: row.lang,
47
+ title: row.title,
48
+ updatedAt: row.updatedAt,
49
+ })),
50
+ };
51
+ },
52
+ });