import _defineProperty from "@babel/runtime/helpers/defineProperty"; /* Copyright 2026 Element Creations Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { MatrixEvent } from "../models/event.js"; import { RoomEvent } from "./room.js"; import { RoomStateEvent } from "./room-state.js"; import { logger as rootLogger } from "../logger.js"; import { EventType } from "../@types/event.js"; import { ROOM_RETENTION_TYPE } from "../@types/retention.js"; /** * Applies https://github.com/matrix-org/matrix-spec-proposals/pull/1763 by checking the current * state of the room and applying the *latest* retention policy to all preceeding events. * Currently does not: * - Apply global server policies (requires Synapse work) * - React at all to `min_retention` configs. * - Loop */ export class RoomRetentionPolicy { constructor(room, retentionService, store) { // minRetention is not implemented. _defineProperty(this, "maxRetention", null); _defineProperty(this, "retentionTimeout", void 0); _defineProperty(this, "logger", void 0); _defineProperty(this, "handleRetentionUpdate", async () => { // First store if we currently have a retention period. const hadRetentionPeriod = this.maxRetention !== null; try { await this.recalculateRetention(this.room.currentState); } catch (err) { this.logger.warn("Failed to recalculate retention policy", err); return; } // Bind/unbind the Timeline event handler based on whether we should be running retention. if (hadRetentionPeriod !== (this.maxRetention !== null)) { this.logger.info("retention recalculated to be", this.maxRetention); // We've changed if (this.maxRetention) { this.room.on(RoomEvent.Timeline, this.timelineUpdated); } else { this.room.off(RoomEvent.Timeline, this.timelineUpdated); } } }); _defineProperty(this, "recalculateRetention", async roomState => { const unstableEvent = roomState.getStateEvents(ROOM_RETENTION_TYPE.name).find(e => e.getStateKey() === ""); const stableEvent = roomState.getStateEvents(ROOM_RETENTION_TYPE.altName).find(e => e.getStateKey() === ""); const serverPolicy = this.retentionService.getCached(); const serverRoomPolicy = serverPolicy?.policies?.[this.room.roomId]; const roomStatePolicy = unstableEvent?.getContent() ?? stableEvent?.getContent(); const content = // * if the homeserver defines a specific retention policy for this room, then use this policy as the effective retention policy of the room. serverRoomPolicy ?? // * otherwise, if the state of the room includes a `m.room.retention` event with an empty state key: roomStatePolicy ?? // * otherwise, if the state of the room does not include a m.room.retention event with an empty state key: serverPolicy?.policies?.["*"]; if (!content) { // * otherwise, don't apply a retention policy in this room. this.maxRetention = null; return; } const validatePolicy = !serverRoomPolicy && roomStatePolicy; // Parse it let { max_lifetime: maxLifetime } = content; if (!maxLifetime && validatePolicy) { // if there is no value specified in the room's state, use the limit's min value for the effective retention policy of the room (which can be null or absent). maxLifetime = serverPolicy?.limits?.max_lifetime?.min; if (!maxLifetime) { this.maxRetention = null; return; } } if (typeof maxLifetime !== "number") { throw Error(`max_lifetime must be a number, got "${maxLifetime}"`); } if (maxLifetime < 0 || !Number.isInteger(maxLifetime)) { throw Error(`max_lifetime must be >= 0, got ${maxLifetime}`); } this.maxRetention = maxLifetime; if (validatePolicy && serverPolicy?.limits?.max_lifetime) { // We're using room state so we need to validate this policy matches the server. /** if the value specified in the room's state complies with the limit, use this value for the effective retention policy of the room. if the value specified in the room's state is lower than the limit's min value, use the min value for the effective retention policy of the room. if the value specified in the room's state is greater than the limit's max value, use the max value for the effective retention policy of the room. */ this.maxRetention = Math.max(this.maxRetention, serverPolicy.limits.max_lifetime.min ?? 0); this.maxRetention = Math.min(this.maxRetention, serverPolicy.limits.max_lifetime.max ?? Number.MAX_SAFE_INTEGER); } this.processTimeline(); }); _defineProperty(this, "timelineUpdated", () => { this.logger.info("timelineUpdated"); this.scheduleTimelineCheck(200); }); _defineProperty(this, "scheduleTimelineCheck", nextTs => { if (this.retentionTimeout) { clearTimeout(this.retentionTimeout); } this.retentionTimeout = setTimeout(() => { this.processTimeline(); this.retentionTimeout = undefined; }, nextTs); }); this.room = room; this.retentionService = retentionService; this.store = store; this.logger = rootLogger.getChild(`RetentionPolicy ${room.roomId}`); // Recalculate on room state update. room.on(RoomStateEvent.Events, event => { if (event.getStateKey() !== "" || event.getType() !== "org.matrix.msc1763.retention" && event.getType() !== "m.room.retention") { return; } void this.handleRetentionUpdate(); }); // Recalculate our retention whenever the global policy changes. this.retentionService.on("update", () => { this.logger.info("Got global retention policy update!"); void this.handleRetentionUpdate(); }); // Do an initial check on construction. void this.handleRetentionUpdate(); // Only bind RoomEvent.Timeline once we know we have a retention policy. } shouldEventBeRetained(ev) { if (!this.maxRetention) { return true; } const expireBefore = Date.now() - this.maxRetention; return ev.getTs() > expireBefore; } processTimeline() { if (!this.maxRetention) { return; // No policy, skip. } this.logger.info(`Running processTimeline`); const expireBefore = Date.now() - this.maxRetention; const events = this.room.getLiveTimeline().getEvents().filter(ev => ev.getStateKey() === undefined && !ev.isRedacted() && ev.getType() !== "m.room.redaction"); const expiredEvents = events.filter(ev => ev.getTs() < expireBefore); const [earliestExpiringEvent] = events.filter(ev => ev.getTs() >= expireBefore).sort((a, b) => a.getTs() - b.getTs()); if (earliestExpiringEvent) { const nextTs = earliestExpiringEvent.getTs() + this.maxRetention - Date.now(); this.logger.info(`Next expiry scheduled for ${nextTs}`); this.scheduleTimelineCheck(nextTs); } if (expiredEvents.length === 0) { this.logger.info("Found no expired events"); return; } this.logger.info(`Found ${expiredEvents.length} expired events`); for (const event of expiredEvents) { this.room.tryApplyRedaction(new MatrixEvent({ type: EventType.RoomRedaction, sender: event.getSender(), event_id: "$synthetic_redaction", room_id: this.room.roomId, redacts: event.getId(), content: { reason: "Retention policy" }, origin_server_ts: Date.now(), unsigned: {} })); } void this.store.removeEventsFromRoom(this.room.roomId, expiredEvents.map(e => e.getId())).catch(ex => { this.logger.warn(`Failed to remove events from store`, ex); }); } } //# sourceMappingURL=room-retention.js.map