fix(retention): a tiny or brand-new retention value can't wipe a room (#298)
CI / Build & Quality Checks (pull_request) Successful in 2m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m33s

m.room.retention can be set to anything by anyone with state power. A
fat-fingered `max_lifetime: 1000` blanked the timeline for every Lotus
user and, for members with "Enforce Message Retention" on, the sweeper
redacted all their messages within a minute. The RETENTION_MIN_MS floor
existed but was never applied.

- effectiveRetentionMs(): anything under 10 min counts as off; used by
  getRoomRetentionMs (sweeper) and the timeline's hiding.
- The sweeper never deletes under a policy less than 24 h old, so a
  mistake or a malicious change can be reverted before anything is lost.
  (Local hiding of expired messages still applies at once.)

Verified headless (the issue's repro, enforcement on): after
max_lifetime 1000, all 4 messages stay visible and none are redacted
after 45 s (before: timeline blank, all 4 redacted). Unit tests for the
floor and the grace window.

Fixes #298

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
Lotus CI
2026-10-16 21:37:16 -04:00
co-authored by Claude Opus 5.5
parent 400ee76b63
commit 8b2322f751
4 changed files with 68 additions and 6 deletions
+27 -1
View File
@@ -1,6 +1,13 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } from './retention';
import {
effectiveRetentionMs,
isExpired,
RETENTION_DELETE_GRACE_MS,
RETENTION_MIN_MS,
RETENTION_PRESETS,
retentionDeletesAllowed,
} from './retention';
// MSC1763 retention: `isExpired` decides whether a message is past the room's
// retention window. It must be strict (> window, not >=) and a disabled policy
@@ -40,3 +47,22 @@ test('presets: Off is 0 and the rest are strictly increasing, all >= the floor',
'all presets above the floor',
);
});
test('effectiveRetentionMs: tiny or invalid windows count as off (#298)', () => {
assert.equal(effectiveRetentionMs(1000), undefined, '1 s is a typo, not a policy');
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS - 1), undefined);
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS), RETENTION_MIN_MS);
assert.equal(effectiveRetentionMs(86_400_000), 86_400_000);
assert.equal(effectiveRetentionMs(0), undefined);
assert.equal(effectiveRetentionMs(-5), undefined);
assert.equal(effectiveRetentionMs('86400000'), undefined);
assert.equal(effectiveRetentionMs(Number.NaN), undefined);
});
test('retentionDeletesAllowed: no deletions in the first 24 h of a policy (#298)', () => {
const now = 1_000 * HOUR;
assert.equal(retentionDeletesAllowed(now - 2 * HOUR, now), false);
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS + 1, now), false);
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS, now), true);
assert.equal(retentionDeletesAllowed(now - 72 * HOUR, now), true);
});