@cosmicdrift/kumiko-bundled-features 0.145.1 → 0.146.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/package.json +7 -6
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +15 -55
- package/src/custom-fields/__tests__/parse-serialized-field.test.ts +11 -3
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +24 -79
- package/src/custom-fields/db/queries/user-data-rights.ts +0 -20
- package/src/custom-fields/events.ts +4 -9
- package/src/custom-fields/feature.ts +8 -0
- package/src/custom-fields/handlers/set-custom-field.write.ts +3 -44
- package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -27
- package/src/custom-fields/lib/parse-serialized-field.ts +12 -4
- package/src/custom-fields/lib/value-schema.ts +1 -1
- package/src/custom-fields/schemas.ts +9 -9
- package/src/custom-fields/wire-for-entity.ts +8 -15
- package/src/custom-fields/wire-user-data-rights.ts +5 -54
- package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +199 -0
- package/src/inbound-mail-foundation/feature.ts +58 -0
- package/src/inbound-mail-foundation/retention-sweep.ts +155 -0
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +26 -0
- package/src/managed-pages/feature.ts +3 -1
- package/src/managed-pages/handlers/by-tenant-published.query.ts +52 -0
- package/src/page-render/__tests__/layout.test.ts +53 -0
- package/src/page-render/index.ts +1 -1
- package/src/page-render/layout.ts +15 -1
- package/src/seo/__tests__/llms-txt.test.ts +38 -0
- package/src/seo/__tests__/robots-txt.test.ts +18 -0
- package/src/seo/__tests__/schema-builders.test.ts +57 -0
- package/src/seo/__tests__/seo.integration.test.ts +258 -0
- package/src/seo/__tests__/sitemap.test.ts +38 -0
- package/src/seo/constants.ts +60 -0
- package/src/seo/feature.ts +313 -0
- package/src/seo/handlers/seo-config.query.ts +35 -0
- package/src/seo/index.ts +19 -0
- package/src/seo/llms-txt.ts +33 -0
- package/src/seo/robots-txt.ts +13 -0
- package/src/seo/schema-builders.ts +57 -0
- package/src/seo/sitemap.ts +30 -0
- 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 {
|
|
5
|
-
import {
|
|
6
|
-
|
|
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
|
-
//
|
|
40
|
-
//
|
|
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
|
+
});
|