@cosmicdrift/kumiko-bundled-features 0.146.4 → 0.147.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 +6 -6
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +1 -0
- package/src/admin-shell/overview-allowlist.ts +1 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +11 -13
- package/src/audit/__tests__/audit-screens.boot.test.ts +17 -8
- package/src/audit/handlers/details.query.ts +1 -1
- package/src/audit/web/audit-log-detail-screen.tsx +7 -9
- package/src/audit/web/audit-log-screen.tsx +16 -29
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +4 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -2
- package/src/auth-email-password/password-hashing.ts +4 -55
- package/src/auth-email-password/web/user-menu.tsx +1 -1
- package/src/auth-mfa/__tests__/totp.test.ts +122 -0
- package/src/auth-mfa/base32.ts +45 -0
- package/src/auth-mfa/otpauth-uri.ts +21 -0
- package/src/auth-mfa/totp.ts +47 -0
- package/src/billing-foundation/events.ts +4 -4
- package/src/billing-foundation/tenant-destroy-hook.ts +5 -6
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +8 -5
- package/src/cap-counter/enforce-cap.ts +15 -18
- package/src/cap-counter/with-cap-enforcement.ts +2 -2
- package/src/config/__tests__/deserialize-value.test.ts +2 -2
- package/src/config/resolver.ts +7 -2
- package/src/custom-fields/__tests__/field-access.integration.test.ts +25 -0
- package/src/custom-fields/__tests__/retention.integration.test.ts +40 -0
- package/src/custom-fields/lib/field-access.ts +8 -1
- package/src/custom-fields/run-retention.ts +9 -1
- package/src/data-retention/__tests__/parse-override.test.ts +1 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +81 -2
- package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +39 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +29 -8
- package/src/inbound-mail-foundation/retention-sweep.ts +25 -12
- package/src/inbound-provider-imap/feature.ts +3 -7
- package/src/jobs/web/job-run-detail-screen.tsx +7 -9
- package/src/jobs/web/job-runs-screen.tsx +3 -21
- package/src/page-render/__tests__/layout.test.ts +10 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +1 -1
- package/src/renderer-simple/__tests__/resolve-variables.test.ts +37 -0
- package/src/renderer-simple/resolve-variables.ts +3 -1
- package/src/seo/__tests__/seo.integration.test.ts +12 -0
- package/src/seo/feature.ts +16 -4
- package/src/seo/sitemap.ts +3 -0
- package/src/{auth-email-password/__tests__ → shared}/identity-v3-hash.test.ts +2 -2
- package/src/shared/index.ts +2 -0
- package/src/shared/password-hashing.ts +55 -0
- package/src/tags/web/__tests__/tag-section.test.tsx +47 -1
- package/src/tags/web/tag-section.tsx +6 -3
- package/src/tenant-lifecycle/lifecycle-gate.ts +17 -10
- package/src/text-content/web/client-plugin.tsx +1 -1
- package/src/tier-engine/__tests__/trial-config-validation.test.ts +30 -0
- package/src/tier-engine/feature.ts +5 -0
- package/src/user-data-rights/__tests__/email-templates.test.ts +15 -0
- package/src/user-data-rights/email-templates.ts +6 -6
- package/src/user-data-rights/web/privacy-center-screen.tsx +2 -0
- /package/src/{auth-email-password → shared}/identity-v3-hash.ts +0 -0
- /package/src/{auth-email-password → shared}/password-hashing.test.ts +0 -0
|
@@ -6,12 +6,11 @@ import { subscriptionAggregateId } from "./aggregate-id";
|
|
|
6
6
|
import { SUBSCRIPTION_AGGREGATE_TYPE } from "./events";
|
|
7
7
|
import { subscriptionsProjectionTable } from "./projection";
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// subject identifiers.
|
|
9
|
+
// providerCustomerId/providerSubscriptionId are `tenantOwned: true` on the
|
|
10
|
+
// entity (envelope-encrypted with the tenant subject key, see entities.ts) —
|
|
11
|
+
// eraseSubjectKeys crypto-shreds them for tenants that hard-delete. HGB
|
|
12
|
+
// can not: it needs the accounting facts (status/tier/period) to survive, so
|
|
13
|
+
// it redacts the projection row directly instead of relying on key erasure.
|
|
15
14
|
const REDACTED_PII_VALUE = "[erased]";
|
|
16
15
|
|
|
17
16
|
/** Tenant-destroy hook for billing PII (#800). HGB retains the accounting
|
|
@@ -170,10 +170,11 @@ describe("withCapEnforcement — calendar", () => {
|
|
|
170
170
|
const beforeBlocked = await readCounter(admin, "newsletter-cap", PERIOD);
|
|
171
171
|
expect(beforeBlocked!["value"]).toBe(6);
|
|
172
172
|
|
|
173
|
-
// 7. send: pre-call sieht value=6 ≥ hard=6 → CapExceededError
|
|
174
|
-
// dispatcher
|
|
173
|
+
// 7. send: pre-call sieht value=6 ≥ hard=6 → CapExceededError (extends
|
|
174
|
+
// KumikoError) → dispatcher mapped auto auf 429 + cap_exceeded.
|
|
175
175
|
const error = await stack.http.writeErr(NEWSLETTER_QN, { to: "blocked@x.de" }, admin);
|
|
176
|
-
expect(
|
|
176
|
+
expect(error.code).toBe("cap_exceeded");
|
|
177
|
+
expect(error.httpStatus).toBe(429);
|
|
177
178
|
|
|
178
179
|
// Drift-Pin: handler darf NICHT gelaufen sein (sendCallCount unverändert)
|
|
179
180
|
expect(sendCallCount).toBe(6);
|
|
@@ -231,7 +232,8 @@ describe("withRollingCapEnforcement — rolling", () => {
|
|
|
231
232
|
expect(sendCallCount).toBe(6);
|
|
232
233
|
|
|
233
234
|
const error = await stack.http.writeErr(NEWSLETTER_ROLLING_QN, { to: "blocked@x.de" }, admin);
|
|
234
|
-
expect(
|
|
235
|
+
expect(error.code).toBe("cap_exceeded");
|
|
236
|
+
expect(error.httpStatus).toBe(429);
|
|
235
237
|
expect(sendCallCount).toBe(6); // handler wurde NICHT erneut aufgerufen
|
|
236
238
|
});
|
|
237
239
|
|
|
@@ -270,7 +272,8 @@ describe("withRollingCapEnforcement — rolling", () => {
|
|
|
270
272
|
expect(sendCallCount).toBe(7);
|
|
271
273
|
|
|
272
274
|
const blocked = await stack.http.writeErr(NEWSLETTER_ROLLING_QN, { to: "blocked@x.de" }, admin);
|
|
273
|
-
expect(
|
|
275
|
+
expect(blocked.code).toBe("cap_exceeded");
|
|
276
|
+
expect(blocked.httpStatus).toBe(429);
|
|
274
277
|
expect(sendCallCount).toBe(7); // wrapper hat den blockierten handler NICHT gerufen
|
|
275
278
|
});
|
|
276
279
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createEntityExecutor, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
3
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
3
4
|
import { rollingCapAggregateId } from "./aggregate-id";
|
|
4
5
|
import {
|
|
@@ -81,8 +82,8 @@ export type EnforceCapResult =
|
|
|
81
82
|
*
|
|
82
83
|
* **Throws** `CapExceededError` when value ≥ hard-threshold. Pre-save
|
|
83
84
|
* hooks call this BEFORE the actual write — the throw rolls back the
|
|
84
|
-
* transaction
|
|
85
|
-
*
|
|
85
|
+
* transaction; `CapExceededError` extends `KumikoError` so the dispatcher
|
|
86
|
+
* auto-maps it to HTTP 429 (see CapExceededError below).
|
|
86
87
|
*
|
|
87
88
|
* **Sync read implication:** the counter reflects the state at this
|
|
88
89
|
* exact transaction. Two parallel writes can each see "value < hard"
|
|
@@ -222,30 +223,26 @@ export async function enforceRollingCap(
|
|
|
222
223
|
// =============================================================================
|
|
223
224
|
|
|
224
225
|
/**
|
|
225
|
-
* Thrown by enforceCap when value ≥ hard-threshold.
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* Caller-side mapping example (in your dispatcher error-handler):
|
|
230
|
-
* if (err instanceof CapExceededError) {
|
|
231
|
-
* return c.json(
|
|
232
|
-
* { error: { code: err.code, message: err.message, capName: err.capName, ... } },
|
|
233
|
-
* 429,
|
|
234
|
-
* );
|
|
235
|
-
* }
|
|
226
|
+
* Thrown by enforceCap/enforceRollingCap when value ≥ hard-threshold.
|
|
227
|
+
* Extends `KumikoError` — the dispatcher auto-maps this to HTTP 429
|
|
228
|
+
* with `code: "cap_exceeded"`, no caller-side catch needed.
|
|
236
229
|
*/
|
|
237
|
-
export class CapExceededError extends
|
|
230
|
+
export class CapExceededError extends KumikoError {
|
|
238
231
|
readonly code = "cap_exceeded" as const;
|
|
232
|
+
readonly httpStatus = 429;
|
|
233
|
+
|
|
239
234
|
constructor(
|
|
240
235
|
readonly capName: string,
|
|
241
236
|
readonly limit: number,
|
|
242
237
|
readonly currentValue: number,
|
|
243
238
|
readonly tolerance: CapToleranceProfile,
|
|
244
239
|
) {
|
|
245
|
-
super(
|
|
246
|
-
`Cap "${capName}" exceeded: current=${currentValue}, limit=${limit}, hard-threshold=${limit * tolerance.hard}. Upgrade tier or wait for next period reset.`,
|
|
247
|
-
|
|
248
|
-
|
|
240
|
+
super({
|
|
241
|
+
message: `Cap "${capName}" exceeded: current=${currentValue}, limit=${limit}, hard-threshold=${limit * tolerance.hard}. Upgrade tier or wait for next period reset.`,
|
|
242
|
+
i18nKey: "errors.cap.exceeded",
|
|
243
|
+
i18nParams: { capName, limit, currentValue },
|
|
244
|
+
details: { capName, limit, currentValue, tolerance },
|
|
245
|
+
});
|
|
249
246
|
}
|
|
250
247
|
}
|
|
251
248
|
|
|
@@ -87,8 +87,8 @@ export function withCapEnforcement(
|
|
|
87
87
|
handler: async (event, ctx) => {
|
|
88
88
|
const cap = await capResolver(event, ctx);
|
|
89
89
|
|
|
90
|
-
// Pre-enforce. Hard-hit throws CapExceededError
|
|
91
|
-
// maps
|
|
90
|
+
// Pre-enforce. Hard-hit throws CapExceededError (extends KumikoError,
|
|
91
|
+
// dispatcher auto-maps to HTTP 429 + cap_exceeded). Soft-hit-crossing
|
|
92
92
|
// notifies via the supplied notifier + flips lastSoftWarnedAt.
|
|
93
93
|
await enforceCapAndMaybeNotify(ctx, {
|
|
94
94
|
capName: cap.capName,
|
|
@@ -29,8 +29,8 @@ describe("deserializeValue", () => {
|
|
|
29
29
|
expect(deserializeValue("true", "number")).toBe(1);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
test("an uncoercible value
|
|
33
|
-
expect(deserializeValue('"abc"', "number")).
|
|
32
|
+
test("an uncoercible value throws instead of silently returning NaN", () => {
|
|
33
|
+
expect(() => deserializeValue('"abc"', "number")).toThrow(/does not deserialize to a number/);
|
|
34
34
|
});
|
|
35
35
|
});
|
|
36
36
|
|
package/src/config/resolver.ts
CHANGED
|
@@ -36,8 +36,13 @@ export function deserializeValue(
|
|
|
36
36
|
if (raw === null || raw === undefined) return undefined;
|
|
37
37
|
const parsed = parseJsonOrThrow<unknown>(raw, `config value (type=${type})`);
|
|
38
38
|
switch (type) {
|
|
39
|
-
case "number":
|
|
40
|
-
|
|
39
|
+
case "number": {
|
|
40
|
+
const n = typeof parsed === "number" ? parsed : Number(parsed);
|
|
41
|
+
if (!Number.isFinite(n)) {
|
|
42
|
+
throw new Error(`config value (type=number): "${raw}" does not deserialize to a number.`);
|
|
43
|
+
}
|
|
44
|
+
return n;
|
|
45
|
+
}
|
|
41
46
|
case "boolean":
|
|
42
47
|
return typeof parsed === "boolean" ? parsed : parsed === "true";
|
|
43
48
|
case "text":
|
|
@@ -324,4 +324,29 @@ describe("fail-closed on corrupt serialized_field", () => {
|
|
|
324
324
|
fieldKey: "corruptField",
|
|
325
325
|
});
|
|
326
326
|
});
|
|
327
|
+
|
|
328
|
+
test("set: legacy `sensitive`-key definition (#972) → unprocessable field_definition_corrupt, not a 500", async () => {
|
|
329
|
+
const propertyId = "99999999-9999-4000-8000-000000000009";
|
|
330
|
+
await defineField("legacyPii", { type: "text" });
|
|
331
|
+
await asRawClient(stack.db).unsafe(
|
|
332
|
+
`UPDATE read_custom_field_definitions SET serialized_field = '{"type":"text","sensitive":true}' WHERE field_key = 'legacyPii'`,
|
|
333
|
+
);
|
|
334
|
+
await stack.http.writeOk(
|
|
335
|
+
"property-t15b:write:property:create",
|
|
336
|
+
{ id: propertyId, name: "LegacySensitive" },
|
|
337
|
+
tenantAdmin,
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const err = await stack.http.writeErr(
|
|
341
|
+
"custom-fields:write:set-custom-field",
|
|
342
|
+
{ entityName: "property", entityId: propertyId, fieldKey: "legacyPii", value: "X-42" },
|
|
343
|
+
tenantAdmin,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
expect(err.code).toBe("unprocessable");
|
|
347
|
+
expect(err.details).toMatchObject({
|
|
348
|
+
reason: "field_definition_corrupt",
|
|
349
|
+
fieldKey: "legacyPii",
|
|
350
|
+
});
|
|
351
|
+
});
|
|
327
352
|
});
|
|
@@ -337,4 +337,44 @@ describe("T1.5d: per-field retention sweep", () => {
|
|
|
337
337
|
expect(cf).not.toHaveProperty("temp");
|
|
338
338
|
expect(cf["liveEdit"]).toBe("written-mid-sweep");
|
|
339
339
|
});
|
|
340
|
+
|
|
341
|
+
test("#975: a legacy field with the removed `sensitive` key is skipped, not fatal to the sweep", async () => {
|
|
342
|
+
const propertyId = "88888888-8888-4000-8000-000000000008";
|
|
343
|
+
await defineField("temp", {
|
|
344
|
+
type: "text",
|
|
345
|
+
retention: { keepFor: "30d", strategy: "delete" },
|
|
346
|
+
});
|
|
347
|
+
// Pre-#972 legacy definition — parseSerializedField throws on `sensitive`.
|
|
348
|
+
// Simulate a pre-#972 legacy definition: define normally (valid shape),
|
|
349
|
+
// then rewrite the stored column directly — write-time validation would
|
|
350
|
+
// reject `sensitive` today, but legacy rows written before #972 exist.
|
|
351
|
+
await defineField("legacyPii", { type: "text" });
|
|
352
|
+
await asRawClient(stack.db).unsafe(
|
|
353
|
+
`UPDATE read_custom_field_definitions SET serialized_field = '{"type":"text","sensitive":true}' WHERE field_key = 'legacyPii' AND entity_name = 'property'`,
|
|
354
|
+
);
|
|
355
|
+
await createProperty(propertyId, "LegacyMix");
|
|
356
|
+
await setField(propertyId, "temp", "expired-value");
|
|
357
|
+
// setField would now be denied (fail-closed on the corrupt/legacy
|
|
358
|
+
// definition) — write the value directly to simulate data that was
|
|
359
|
+
// already stored before #972 removed `sensitive` support.
|
|
360
|
+
await asRawClient(stack.db).unsafe(
|
|
361
|
+
`UPDATE read_t15d_properties SET custom_fields = custom_fields || '{"legacyPii":"untouched-value"}'::jsonb WHERE id = $1`,
|
|
362
|
+
[propertyId],
|
|
363
|
+
);
|
|
364
|
+
await stack.eventDispatcher?.runOnce();
|
|
365
|
+
await backdateRow(propertyId, "2026-04-22T10:00:00Z");
|
|
366
|
+
|
|
367
|
+
const report = await runCustomFieldsRetention({
|
|
368
|
+
db: stack.db,
|
|
369
|
+
tenantId: admin.tenantId,
|
|
370
|
+
entityName: "property",
|
|
371
|
+
entityTable: propertyTable,
|
|
372
|
+
now: NOW,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(report.rowsUpdated).toBe(1);
|
|
376
|
+
const cf = (await readRow(propertyId))?.["custom_fields"] as Record<string, unknown>;
|
|
377
|
+
expect(cf).not.toHaveProperty("temp");
|
|
378
|
+
expect(cf["legacyPii"]).toBe("untouched-value");
|
|
379
|
+
});
|
|
340
380
|
});
|
|
@@ -29,7 +29,14 @@ export async function loadFieldDefinition(
|
|
|
29
29
|
): Promise<LoadedFieldDefinition> {
|
|
30
30
|
const serialized = await selectSerializedFieldDefinition(db, tenantId, entityName, fieldKey);
|
|
31
31
|
if (serialized === null) return { found: false };
|
|
32
|
-
|
|
32
|
+
// parseSerializedField throws on a #972 legacy `sensitive` definition —
|
|
33
|
+
// that's a corrupt/unprocessable row from this write-gate's perspective,
|
|
34
|
+
// same fail-closed treatment as a shape that fails isShape().
|
|
35
|
+
try {
|
|
36
|
+
return { found: true, field: parseSerializedField(serialized) };
|
|
37
|
+
} catch {
|
|
38
|
+
return { found: true, field: null };
|
|
39
|
+
}
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
// Pure access-check on an already-loaded definition. Returns the required
|
|
@@ -158,7 +158,15 @@ async function loadRetentionPolicies(
|
|
|
158
158
|
for (const raw of rows) {
|
|
159
159
|
// skip: see asHostRow rationale.
|
|
160
160
|
if (!isFieldDefinitionRow(raw)) continue;
|
|
161
|
-
|
|
161
|
+
// parseSerializedField throws on a #972 legacy `sensitive` definition —
|
|
162
|
+
// one unmigrated field must not abort the whole tenant's retention sweep
|
|
163
|
+
// for every other field; skip it (no retention policy to apply anyway).
|
|
164
|
+
let parsed: ReturnType<typeof parseSerializedField>;
|
|
165
|
+
try {
|
|
166
|
+
parsed = parseSerializedField(raw.serialized_field);
|
|
167
|
+
} catch {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
162
170
|
if (parsed?.retention) {
|
|
163
171
|
out.set(raw.field_key, parsed.retention);
|
|
164
172
|
}
|
|
@@ -50,6 +50,7 @@ describe("parseRetentionOverrideOrNull", () => {
|
|
|
50
50
|
test("each dropped value surfaces exactly one operator warning", () => {
|
|
51
51
|
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
52
52
|
parse("{not json");
|
|
53
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
53
54
|
parse('{"strategy":"delete"}');
|
|
54
55
|
expect(warn).toHaveBeenCalledTimes(2);
|
|
55
56
|
});
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// - Crypto-Shredding: Subject-Key-Erase macht Payload unlesbar (#800)
|
|
13
13
|
|
|
14
14
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
15
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
16
|
import {
|
|
17
17
|
configuredPiiSubjectKms,
|
|
18
18
|
configurePiiSubjectKms,
|
|
@@ -23,7 +23,12 @@ import {
|
|
|
23
23
|
resetPiiSubjectKmsForTests,
|
|
24
24
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
25
25
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
append,
|
|
28
|
+
createEventsTable,
|
|
29
|
+
getStreamVersion,
|
|
30
|
+
loadAggregate,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
27
32
|
import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
28
33
|
import {
|
|
29
34
|
createTestUser,
|
|
@@ -43,6 +48,11 @@ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
|
43
48
|
import { inboundMessageAggregateId, mailThreadAggregateId } from "../aggregate-id";
|
|
44
49
|
import { InboundMailFoundationHandlers, InboundMailFoundationQueries } from "../constants";
|
|
45
50
|
import { seenMessageEntity, syncCursorEntity } from "../entities";
|
|
51
|
+
import {
|
|
52
|
+
MAIL_THREAD_AGGREGATE_TYPE,
|
|
53
|
+
MAIL_THREAD_UPDATED_EVENT_QN,
|
|
54
|
+
type MailThreadEventPayload,
|
|
55
|
+
} from "../events";
|
|
46
56
|
import { inboundMailFoundationFeature } from "../feature";
|
|
47
57
|
import {
|
|
48
58
|
inboundMessagesProjectionTable,
|
|
@@ -240,6 +250,75 @@ describe("scenario 2: ingest-message — Dedup + Thread-Rollup", () => {
|
|
|
240
250
|
});
|
|
241
251
|
});
|
|
242
252
|
|
|
253
|
+
describe("scenario 2b: ingest-message — Thread-Rollup selbstkorrigierend statt kumulativ", () => {
|
|
254
|
+
test("nach einem Race-verzerrten (zu niedrigen) messageCount-Event korrigiert der naechste ingest auf den echten Row-Count", async () => {
|
|
255
|
+
const admin = adminFor(4021);
|
|
256
|
+
const accountId = await connectSharedAccount(admin);
|
|
257
|
+
|
|
258
|
+
await stack.http.writeOk(
|
|
259
|
+
InboundMailFoundationHandlers.ingestMessage,
|
|
260
|
+
ingestPayload(accountId, {
|
|
261
|
+
providerMessageId: "self-heal-root",
|
|
262
|
+
messageIdHeader: "self-heal-root@example.com",
|
|
263
|
+
}),
|
|
264
|
+
admin,
|
|
265
|
+
);
|
|
266
|
+
await stack.http.writeOk(
|
|
267
|
+
InboundMailFoundationHandlers.ingestMessage,
|
|
268
|
+
ingestPayload(accountId, {
|
|
269
|
+
providerMessageId: "self-heal-reply-1",
|
|
270
|
+
messageIdHeader: "self-heal-reply-1@example.com",
|
|
271
|
+
references: ["self-heal-root@example.com"],
|
|
272
|
+
}),
|
|
273
|
+
admin,
|
|
274
|
+
);
|
|
275
|
+
const threadAggId = mailThreadAggregateId(admin.tenantId, "mid:self-heal-root@example.com");
|
|
276
|
+
|
|
277
|
+
// Simuliert exakt den Drift, den ein Race auf getStreamVersion (siehe
|
|
278
|
+
// Handler-Kommentar Schritt 5) verursachen kann: 2 Message-Rows liegen
|
|
279
|
+
// vor, aber der ZULETZT geschriebene Thread-Event traegt einen zu
|
|
280
|
+
// niedrigen Snapshot (previousCount+1 aus einer stale-Read). Das Event
|
|
281
|
+
// ist die Quelle, die previousCount+1 (alter Code) liest — der Live-
|
|
282
|
+
// COUNT (neuer Code) ignoriert es und liest read_inbound_messages neu.
|
|
283
|
+
const expectedVersion = await getStreamVersion(db, threadAggId, admin.tenantId);
|
|
284
|
+
const corruptedPayload: MailThreadEventPayload = {
|
|
285
|
+
threadKey: "mid:self-heal-root@example.com",
|
|
286
|
+
subject: "corrupted-by-race",
|
|
287
|
+
lastMessageAtIso: "2026-07-01T10:00:00Z",
|
|
288
|
+
messageCount: 1,
|
|
289
|
+
};
|
|
290
|
+
await append(db, {
|
|
291
|
+
aggregateId: threadAggId,
|
|
292
|
+
aggregateType: MAIL_THREAD_AGGREGATE_TYPE,
|
|
293
|
+
tenantId: admin.tenantId,
|
|
294
|
+
expectedVersion,
|
|
295
|
+
type: MAIL_THREAD_UPDATED_EVENT_QN,
|
|
296
|
+
payload: corruptedPayload,
|
|
297
|
+
metadata: { userId: admin.id },
|
|
298
|
+
});
|
|
299
|
+
// mailThreadsProjectionTable ist executor-only (WritableTable-Typecheck
|
|
300
|
+
// lehnt raw updateMany ab) — die Test-Korruption geht bewusst am
|
|
301
|
+
// Type-Guard vorbei, genau wie die Race-Situation die sie simuliert.
|
|
302
|
+
await asRawClient(db).unsafe(`UPDATE "read_mail_threads" SET message_count = 1 WHERE id = $1`, [
|
|
303
|
+
threadAggId,
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
await stack.http.writeOk(
|
|
307
|
+
InboundMailFoundationHandlers.ingestMessage,
|
|
308
|
+
ingestPayload(accountId, {
|
|
309
|
+
providerMessageId: "self-heal-reply-2",
|
|
310
|
+
messageIdHeader: "self-heal-reply-2@example.com",
|
|
311
|
+
references: ["self-heal-root@example.com"],
|
|
312
|
+
}),
|
|
313
|
+
admin,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const healed = await selectMany(db, mailThreadsProjectionTable, { id: threadAggId });
|
|
317
|
+
expect(healed).toHaveLength(1);
|
|
318
|
+
expect(healed[0]?.["messageCount"]).toBe(3);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
243
322
|
describe("scenario 3: Scope-Sichtbarkeit (persönliches Postfach)", () => {
|
|
244
323
|
test("Messages eines user-scoped Accounts sieht nur der Owner (+ TenantAdmin)", async () => {
|
|
245
324
|
const tenantNumber = 4003;
|
|
@@ -93,6 +93,7 @@ async function ingest(
|
|
|
93
93
|
admin: ReturnType<typeof adminFor>,
|
|
94
94
|
providerMessageId: string,
|
|
95
95
|
receivedAtIso: string,
|
|
96
|
+
bodyRef = "",
|
|
96
97
|
): Promise<void> {
|
|
97
98
|
await stack.http.writeOk(
|
|
98
99
|
InboundMailFoundationHandlers.ingestMessage,
|
|
@@ -110,7 +111,7 @@ async function ingest(
|
|
|
110
111
|
subject: "Hello",
|
|
111
112
|
snippet: "Hello world …",
|
|
112
113
|
receivedAtIso,
|
|
113
|
-
bodyRef
|
|
114
|
+
bodyRef,
|
|
114
115
|
scope: "inbox",
|
|
115
116
|
providerCursor: '{"offset":1}',
|
|
116
117
|
},
|
|
@@ -160,6 +161,43 @@ describe("retention: inbound messages", () => {
|
|
|
160
161
|
});
|
|
161
162
|
});
|
|
162
163
|
|
|
164
|
+
describe("retention: body-delete failure keeps the row (no orphaned storage object)", () => {
|
|
165
|
+
test("deleteBodyObject throws → row + stream survive this sweep, bodyObjectErrors=1", async () => {
|
|
166
|
+
const admin = adminFor(5002);
|
|
167
|
+
const accountId = await connectSharedAccount(admin);
|
|
168
|
+
await ingest(accountId, admin, "uid-body-fail", "2024-01-01T10:00:00Z", "body-ref-1");
|
|
169
|
+
|
|
170
|
+
const now = getTemporal().Instant.from("2026-07-13T00:00:00Z");
|
|
171
|
+
const report = await runInboundMailRetention({
|
|
172
|
+
db,
|
|
173
|
+
tenantId: admin.tenantId,
|
|
174
|
+
now,
|
|
175
|
+
seenRetentionDays: 100000,
|
|
176
|
+
deleteBodyObject: async () => {
|
|
177
|
+
throw new Error("storage unavailable");
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
expect(report.messagesPurged).toBe(0);
|
|
181
|
+
expect(report.bodyObjectErrors).toBe(1);
|
|
182
|
+
|
|
183
|
+
const failId = inboundMessageAggregateId(accountId, "uid-body-fail");
|
|
184
|
+
const rows = await selectMany(db, inboundMessagesProjectionTable, { accountId });
|
|
185
|
+
expect(rows.map((r) => r["id"])).toContain(failId);
|
|
186
|
+
|
|
187
|
+
// Naechster Lauf (deleteBodyObject funktioniert jetzt) holt die Row nach.
|
|
188
|
+
const retry = await runInboundMailRetention({
|
|
189
|
+
db,
|
|
190
|
+
tenantId: admin.tenantId,
|
|
191
|
+
now,
|
|
192
|
+
seenRetentionDays: 100000,
|
|
193
|
+
deleteBodyObject: async () => {},
|
|
194
|
+
});
|
|
195
|
+
expect(retry.messagesPurged).toBe(1);
|
|
196
|
+
expect(retry.bodyObjectsDeleted).toBe(1);
|
|
197
|
+
expect(await selectMany(db, inboundMessagesProjectionTable, { accountId })).toHaveLength(0);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
163
201
|
describe("retention: seen anchors", () => {
|
|
164
202
|
test("past-cutoff anchor purged, in-window anchor stays (dedup window intact)", async () => {
|
|
165
203
|
const admin = adminFor(5002);
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// SystemAdmin-only: Aufrufer ist der Watch/Sync-Supervisor bzw. der
|
|
19
19
|
// Poll-Cron mit programmatic SystemUser — nie ein Tenant-Request.
|
|
20
20
|
|
|
21
|
-
import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
21
|
+
import { countWhere, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
22
22
|
import {
|
|
23
23
|
configuredPiiSubjectKms,
|
|
24
24
|
encryptPiiFieldValues,
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
MAIL_THREAD_UPDATED_EVENT_QN,
|
|
44
44
|
type MailThreadEventPayload,
|
|
45
45
|
} from "../events";
|
|
46
|
+
import { inboundMessagesProjectionTable } from "../projection";
|
|
46
47
|
|
|
47
48
|
// =============================================================================
|
|
48
49
|
// Input-Schema — der normalisierte Provider-Output (RawInboundMessage,
|
|
@@ -208,24 +209,44 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
208
209
|
|
|
209
210
|
// ---------------------------------------------------------------
|
|
210
211
|
// 5. Thread-Rollup. Der Handler berechnet den NEUEN Snapshot
|
|
211
|
-
// (count
|
|
212
|
-
// UPSERT und bleibt replay-idempotent. lastMessageAtIso ist
|
|
212
|
+
// (count via Live-COUNT, max(lastMessageAt)) — die apply ist ein
|
|
213
|
+
// dummer UPSERT und bleibt replay-idempotent. lastMessageAtIso ist
|
|
213
214
|
// kein PII (Zeitpunkt), damit plaintext-vergleichbar; subject
|
|
214
215
|
// wird frisch encrypted (jede Thread-Version trägt das Subject
|
|
215
216
|
// der jüngsten Mail).
|
|
216
217
|
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
218
|
+
// messageCount kommt bewusst NICHT aus einem frueher gelesenen
|
|
219
|
+
// threadEvents-Snapshot (previousCount+1): getStreamVersion in
|
|
220
|
+
// unsafeAppendEvent liest die Stream-Version FRISCH zum
|
|
221
|
+
// Append-Zeitpunkt, unabhaengig vom Read hier — zwei parallele
|
|
222
|
+
// ingests auf denselben Thread (Watch-Push + Poll-Reconciliation)
|
|
223
|
+
// koennten beide denselben previousCount sehen und der Append der
|
|
224
|
+
// zweiten TX wuerde trotzdem gelingen (kein VersionConflictError,
|
|
225
|
+
// da der Version-Check nur die Stream-Version prueft), sodass
|
|
226
|
+
// previousCount+1 dauerhaft nach unten drieftet.
|
|
227
|
+
//
|
|
228
|
+
// Ein COUNT gegen read_inbound_messages SCHLIESST das Race-Fenster
|
|
229
|
+
// NICHT (COUNT und Append sind weiterhin zwei getrennte Statements —
|
|
230
|
+
// zwischen COUNT und Append kann die andere TX committen und derselbe
|
|
231
|
+
// Drift theoretisch weiterhin auftreten). Was der COUNT aendert: er
|
|
232
|
+
// macht den Fehler SELBSTKORRIGIEREND statt kumulativ — jeder
|
|
233
|
+
// nachfolgende ingest liest den echten Row-Count neu, statt einen
|
|
234
|
+
// frueher falsch geschriebenen previousCount fortzuschreiben. Eine
|
|
235
|
+
// echte Race-freie Loesung braucht ein atomares Read+Append (z.B.
|
|
236
|
+
// SELECT ... FOR UPDATE oder eine Advisory-Lock auf threadAggId) —
|
|
237
|
+
// das ist bewusst nicht Teil dieses Fixes (siehe PR-Beschreibung).
|
|
220
238
|
// ---------------------------------------------------------------
|
|
221
239
|
const threadEvents = await ctx.loadAggregate(threadAggId);
|
|
222
240
|
const lastThreadEvent = threadEvents[threadEvents.length - 1];
|
|
223
241
|
// @cast-boundary engine-payload — eigene events, shape via defineEvent
|
|
224
242
|
const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
|
|
225
|
-
const previousCount = previousThread?.messageCount ?? 0;
|
|
226
243
|
const previousLastAt = previousThread?.lastMessageAtIso ?? "";
|
|
227
244
|
const newLastAt =
|
|
228
245
|
payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
|
|
246
|
+
const messageCount = await countWhere(ctx.db.raw, inboundMessagesProjectionTable, {
|
|
247
|
+
tenantId,
|
|
248
|
+
threadKey,
|
|
249
|
+
});
|
|
229
250
|
|
|
230
251
|
const threadPlainPii = { tenantId, subject: payload.subject };
|
|
231
252
|
const encryptedThreadFields = piiKms
|
|
@@ -245,7 +266,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
245
266
|
threadKey,
|
|
246
267
|
subject: encryptedThreadFields["subject"] as string,
|
|
247
268
|
lastMessageAtIso: newLastAt,
|
|
248
|
-
messageCount
|
|
269
|
+
messageCount,
|
|
249
270
|
};
|
|
250
271
|
await ctx.unsafeAppendEvent({
|
|
251
272
|
aggregateId: threadAggId,
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
//
|
|
16
16
|
// read_mail_seen_messages (unmanaged, direct-write, nicht event-sourced):
|
|
17
17
|
// Dedup-Anker braucht nur das Backfill-/Replay-Fenster → seenAt-cutoff
|
|
18
|
-
// via plain deleteMany, kein Stream/Archiv. Gescoped
|
|
19
|
-
//
|
|
18
|
+
// via plain deleteMany, kein Stream/Archiv. Gescoped direkt über die
|
|
19
|
+
// tenant_id-Spalte der Tabelle (table-builder-Konvention), kein Account-Join.
|
|
20
20
|
//
|
|
21
21
|
// GRENZE (#957 Teil 2, Plan §3.4): die PII in den Message-Events bleibt
|
|
22
22
|
// unter dem TENANT-Subject-Key entschluesselbar — per-Row-Retention
|
|
@@ -96,13 +96,22 @@ async function purgeExpiredMessages(
|
|
|
96
96
|
// archivierten ids, nie per cutoff-WHERE — sonst koennte eine zwischen
|
|
97
97
|
// select und delete eingegangene Alt-Mail unarchiviert geloescht werden
|
|
98
98
|
// und beim Rebuild resurrecten.
|
|
99
|
+
//
|
|
100
|
+
// Ein Row deren deleteBodyObject fehlschlaegt wird NICHT geloescht — sonst
|
|
101
|
+
// ginge die einzige Referenz auf das (dann verwaiste) File-Objekt verloren
|
|
102
|
+
// (kein Retry, kein Dead-Letter). Die Row bleibt fuer den naechsten Sweep
|
|
103
|
+
// stehen (archiveStream ist idempotent). failedIds verhindert, dass so ein
|
|
104
|
+
// Row innerhalb DIESES Laufs endlos re-selektiert wird.
|
|
105
|
+
const failedIds = new Set<string>();
|
|
99
106
|
for (;;) {
|
|
100
|
-
const rows =
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
107
|
+
const rows = (
|
|
108
|
+
await selectMany<ExpiredMessageRow>(
|
|
109
|
+
args.db,
|
|
110
|
+
inboundMessagesProjectionTable,
|
|
111
|
+
{ receivedAt: { lt: cutoff }, tenantId: args.tenantId },
|
|
112
|
+
{ limit: BATCH_LIMIT },
|
|
113
|
+
)
|
|
114
|
+
).filter((row) => !failedIds.has(row.id));
|
|
106
115
|
if (rows.length === 0) break;
|
|
107
116
|
|
|
108
117
|
const ids: string[] = [];
|
|
@@ -120,14 +129,18 @@ async function purgeExpiredMessages(
|
|
|
120
129
|
bodyObjectsDeleted++;
|
|
121
130
|
} catch {
|
|
122
131
|
bodyObjectErrors++;
|
|
132
|
+
failedIds.add(row.id);
|
|
133
|
+
continue;
|
|
123
134
|
}
|
|
124
135
|
}
|
|
125
136
|
ids.push(row.id);
|
|
126
137
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
138
|
+
if (ids.length > 0) {
|
|
139
|
+
await deleteMany(args.db, inboundMessagesProjectionTable as EntityTableMeta, {
|
|
140
|
+
id: { in: ids },
|
|
141
|
+
});
|
|
142
|
+
messagesPurged += ids.length;
|
|
143
|
+
}
|
|
131
144
|
|
|
132
145
|
if (rows.length < BATCH_LIMIT) break;
|
|
133
146
|
}
|
|
@@ -89,14 +89,10 @@ async function fetchMessages(
|
|
|
89
89
|
): Promise<InboundFetchResult> {
|
|
90
90
|
const doc = await readCredentialDocument(ctx, account);
|
|
91
91
|
const client = createImapClient(doc);
|
|
92
|
+
let lock: Awaited<ReturnType<typeof client.getMailboxLock>> | undefined;
|
|
92
93
|
try {
|
|
93
94
|
await client.connect();
|
|
94
|
-
|
|
95
|
-
throw mapImapError(err, doc.host);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const lock = await client.getMailboxLock(IMAP_MAILBOX);
|
|
99
|
-
try {
|
|
95
|
+
lock = await client.getMailboxLock(IMAP_MAILBOX);
|
|
100
96
|
const mailbox = client.mailbox as MailboxObject;
|
|
101
97
|
const serverUidValidity = String(mailbox.uidValidity);
|
|
102
98
|
const parsedCursor = parseImapCursor(cursor);
|
|
@@ -149,7 +145,7 @@ async function fetchMessages(
|
|
|
149
145
|
} catch (err) {
|
|
150
146
|
throw err instanceof Error && "kind" in err ? err : mapImapError(err, doc.host);
|
|
151
147
|
} finally {
|
|
152
|
-
lock
|
|
148
|
+
lock?.release();
|
|
153
149
|
await client.logout().catch(() => {});
|
|
154
150
|
}
|
|
155
151
|
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
// Single job-run detail + logs. Route entityId = run uuid.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
formatWhen,
|
|
6
|
+
useDispatcher,
|
|
7
|
+
useNav,
|
|
8
|
+
usePrimitives,
|
|
9
|
+
useTranslation,
|
|
10
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
5
11
|
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
6
12
|
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
7
13
|
import { JOB_RUNS_SCREEN_ID, JobHandlers, JobQueries } from "../constants";
|
|
@@ -172,11 +178,3 @@ export function JobRunDetailScreen(): ReactNode {
|
|
|
172
178
|
</FormScreenShell>
|
|
173
179
|
);
|
|
174
180
|
}
|
|
175
|
-
|
|
176
|
-
function formatWhen(value: string): string {
|
|
177
|
-
try {
|
|
178
|
-
return new Date(value).toLocaleString();
|
|
179
|
-
} catch {
|
|
180
|
-
return value;
|
|
181
|
-
}
|
|
182
|
-
}
|