@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.
Files changed (56) hide show
  1. package/package.json +6 -6
  2. package/src/admin-shell/__tests__/overview-allowlist.test.ts +1 -0
  3. package/src/admin-shell/overview-allowlist.ts +1 -0
  4. package/src/admin-shell/web/platform-overview-screen.tsx +11 -13
  5. package/src/audit/__tests__/audit-screens.boot.test.ts +17 -8
  6. package/src/audit/handlers/details.query.ts +1 -1
  7. package/src/audit/web/audit-log-detail-screen.tsx +7 -9
  8. package/src/audit/web/audit-log-screen.tsx +16 -29
  9. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +4 -2
  10. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -2
  11. package/src/auth-email-password/password-hashing.ts +4 -55
  12. package/src/auth-email-password/web/user-menu.tsx +1 -1
  13. package/src/auth-mfa/__tests__/totp.test.ts +122 -0
  14. package/src/auth-mfa/base32.ts +45 -0
  15. package/src/auth-mfa/otpauth-uri.ts +21 -0
  16. package/src/auth-mfa/totp.ts +47 -0
  17. package/src/billing-foundation/events.ts +4 -4
  18. package/src/billing-foundation/tenant-destroy-hook.ts +5 -6
  19. package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +8 -5
  20. package/src/cap-counter/enforce-cap.ts +15 -18
  21. package/src/cap-counter/with-cap-enforcement.ts +2 -2
  22. package/src/config/__tests__/deserialize-value.test.ts +2 -2
  23. package/src/config/resolver.ts +7 -2
  24. package/src/custom-fields/__tests__/field-access.integration.test.ts +25 -0
  25. package/src/custom-fields/__tests__/retention.integration.test.ts +40 -0
  26. package/src/custom-fields/lib/field-access.ts +8 -1
  27. package/src/custom-fields/run-retention.ts +9 -1
  28. package/src/data-retention/__tests__/parse-override.test.ts +1 -0
  29. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +81 -2
  30. package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +39 -1
  31. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +29 -8
  32. package/src/inbound-mail-foundation/retention-sweep.ts +25 -12
  33. package/src/inbound-provider-imap/feature.ts +3 -7
  34. package/src/jobs/web/job-run-detail-screen.tsx +7 -9
  35. package/src/jobs/web/job-runs-screen.tsx +3 -21
  36. package/src/page-render/__tests__/layout.test.ts +10 -0
  37. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +1 -1
  38. package/src/renderer-simple/__tests__/resolve-variables.test.ts +37 -0
  39. package/src/renderer-simple/resolve-variables.ts +3 -1
  40. package/src/seo/__tests__/seo.integration.test.ts +12 -0
  41. package/src/seo/feature.ts +16 -4
  42. package/src/seo/sitemap.ts +3 -0
  43. package/src/{auth-email-password/__tests__ → shared}/identity-v3-hash.test.ts +2 -2
  44. package/src/shared/index.ts +2 -0
  45. package/src/shared/password-hashing.ts +55 -0
  46. package/src/tags/web/__tests__/tag-section.test.tsx +47 -1
  47. package/src/tags/web/tag-section.tsx +6 -3
  48. package/src/tenant-lifecycle/lifecycle-gate.ts +17 -10
  49. package/src/text-content/web/client-plugin.tsx +1 -1
  50. package/src/tier-engine/__tests__/trial-config-validation.test.ts +30 -0
  51. package/src/tier-engine/feature.ts +5 -0
  52. package/src/user-data-rights/__tests__/email-templates.test.ts +15 -0
  53. package/src/user-data-rights/email-templates.ts +6 -6
  54. package/src/user-data-rights/web/privacy-center-screen.tsx +2 -0
  55. /package/src/{auth-email-password → shared}/identity-v3-hash.ts +0 -0
  56. /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
- // No field-level encryption is wired up for `read_subscriptions` (see
10
- // entities.ts) a crypto-shredding retention strategy would need that built
11
- // first (envelope-encrypt on write, decrypt on every read call site, backfill
12
- // existing rows). Redaction achieves the same GDPR outcome without it: HGB
13
- // needs the accounting facts (status/tier/period), not the Stripe/Mollie
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, der
174
- // dispatcher wickelt's als internal_error mit causeName=CapExceededError.
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(JSON.stringify(error)).toMatch(/CapExceededError/);
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(JSON.stringify(error)).toMatch(/CapExceededError/);
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(JSON.stringify(blocked)).toMatch(/CapExceededError/);
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, the dispatcher maps the error to HTTP 429 with the
85
- * upgrade-hint shape (see CapExceededError below).
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. Includes enough
226
- * context for the HTTP layer to render an actionable 429 — `code`
227
- * matches the framework's error-contract pattern (kebab + scope).
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 Error {
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
- this.name = "CapExceededError";
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; the dispatcher
91
- // maps it to HTTP 429 + cap_exceeded code. Soft-hit-crossing
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 yields NaN rather than throwing", () => {
33
- expect(deserializeValue('"abc"', "number")).toBeNaN();
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
 
@@ -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
- return typeof parsed === "number" ? parsed : Number(parsed);
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
- return { found: true, field: parseSerializedField(serialized) };
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
- const parsed = parseSerializedField(raw.serialized_field);
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 { createEventsTable, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
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+1, max(lastMessageAt)) — die apply ist ein dummer
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
- // **Kein Lost-Update-Fenster:** loadAggregate + append laufen in
218
- // derselben TX; zwei parallele ingests auf denselben Thread
219
- // serialisiert der event-store über die Stream-Version.
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: previousCount + 1,
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 auf die accountIds
19
- // dieses Tenants (die Tabelle traegt keine tenantId-Spalte).
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 = await selectMany<ExpiredMessageRow>(
101
- args.db,
102
- inboundMessagesProjectionTable,
103
- { receivedAt: { lt: cutoff }, tenantId: args.tenantId },
104
- { limit: BATCH_LIMIT },
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
- await deleteMany(args.db, inboundMessagesProjectionTable as EntityTableMeta, {
128
- id: { in: ids },
129
- });
130
- messagesPurged += ids.length;
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
- } catch (err) {
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.release();
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 { useDispatcher, useNav, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
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
- }