Phase B: encrypted archive export bundles (age), power-level-gated export command, encryption-state linter

This commit is contained in:
glass beadian 2026-10-12 12:46:56 +00:00
parent a7ad1f059f
commit 699b9412c1
22 changed files with 2325 additions and 9 deletions

View file

@ -7,6 +7,10 @@ import { PermanentArchiveError } from "./inspector.js";
import { claimKey, replyTxnId } from "./keys.js";
import { MatrixHttpError, type MatrixPort } from "./matrix.js";
import type { ArchiveStore, RoomCursorTarget } from "./store.js";
import type { BundleExporter } from "./exporter.js";
import { exportNotice, isExportCommand } from "./exporter.js";
import { senderIsRoomAdmin, toEncryptionHealth, type EncryptionLintReport, type MatrixLintPort } from "./encryption-lint.js";
import type { ExportConfig } from "./config.js";
export interface BatchResult {
/** True when the sync cursor was durably advanced past this batch. */
@ -20,10 +24,24 @@ export interface HealthSnapshot {
cursor: string | null;
pending: number;
lastProcessedAt: string | null;
/** Phase-B: encrypted export bundle state, null when exports are not configured. */
export: {
enabled: boolean;
every: number;
lastExportAt: string | null;
lastObjectKey: string | null;
roomsExported: string[];
} | null;
/** Phase-B: encryption-state lint (unmapped unencrypted rooms = violations). */
encryption: ReturnType<typeof toEncryptionHealth>;
}
type Outcome = { blockedUntil: Date } | null;
function lintEmpty(now: Date): EncryptionLintReport {
return { checked: 0, mappedUnencrypted: [], issues: [], checkedAt: now.toISOString() };
}
export function replyContent(safeName: string, eventId: string): Record<string, unknown> {
return {
msgtype: "m.notice",
@ -63,6 +81,12 @@ export class ArchiveProcessor {
private readonly byRoom: Map<string, RoomMapping>;
private readonly cursorRooms: RoomCursorTarget[];
private readonly workspaceIds: string[];
private readonly exporter: BundleExporter | null;
private readonly exportConfig: ExportConfig | null;
private lastExportAt: Date | null = null;
private lastObjectKey: string | null = null;
private readonly roomsExported = new Set<string>();
private encryptionReport: EncryptionLintReport | null = null;
constructor(
private readonly matrix: MatrixPort,
@ -71,10 +95,14 @@ export class ArchiveProcessor {
mappings: readonly RoomMapping[],
private readonly logger: Logger,
private readonly now: () => Date = () => new Date(),
exporter: BundleExporter | null = null,
exportConfig: ExportConfig | null = null,
) {
this.byRoom = new Map(mappings.map((m) => [m.roomId, m]));
this.cursorRooms = mappings.map((m) => ({ roomId: m.roomId, workspaceId: m.workspaceId }));
this.workspaceIds = [...new Set(mappings.map((m) => m.workspaceId))];
this.exporter = exporter;
this.exportConfig = exportConfig;
}
async init(): Promise<void> {
@ -87,9 +115,29 @@ export class ArchiveProcessor {
cursor: this.cursor,
pending: await this.store.countPending(this.workspaceIds),
lastProcessedAt: this.lastProcessedAt?.toISOString() ?? null,
export: this.exportConfig === null ? null : {
enabled: this.exportConfig.enabled,
every: this.exportConfig.every,
lastExportAt: this.lastExportAt?.toISOString() ?? null,
lastObjectKey: this.lastObjectKey,
roomsExported: [...this.roomsExported],
},
encryption: toEncryptionHealth(this.encryptionReport),
};
}
/** One encryption-lint pass over every joined room (startup + periodic). */
async lintEncryption(signal: AbortSignal): Promise<EncryptionLintReport> {
const lintPort = this.matrix as MatrixLintPort;
if (typeof lintPort.joinedRooms !== "function") {
this.logger.warn("archive-bot: matrix port lacks joinedRooms; encryption lint skipped");
return this.encryptionReport ?? lintEmpty(new Date());
}
const { scanEncryption } = await import("./encryption-lint.js");
this.encryptionReport = await scanEncryption(lintPort, [...this.byRoom.keys()], this.logger, signal, this.now());
return this.encryptionReport;
}
async runBatch(signal: AbortSignal): Promise<BatchResult> {
const since = this.cursor;
const sync = await this.matrix.sync(since, signal);
@ -166,6 +214,11 @@ export class ArchiveProcessor {
encryption: RoomEncryption,
signal: AbortSignal,
): Promise<Outcome> {
// Admin export command: an m.text message, so it is never an attachment;
// check it before classification (which would skip it as not-an-attachment).
if (isExportCommand(event.content)) {
return this.handleExportCommand(mapping, event, signal);
}
const classified = classifyEvent(event, mapping, encryption, this.matrix.userId);
switch (classified.kind) {
case "skip":
@ -205,6 +258,7 @@ export class ArchiveProcessor {
this.logger.info({ ...log, fileId: archived.file.id, sha256: archived.file.checksum.value }, "archive-bot: archived");
this.lastProcessedAt = this.now();
this.unsentReplies.set(key, archived.safeName);
await this.maybeAutoExport(mapping, signal);
return this.sendReply(mapping, eventId, key, signal);
} catch (err) {
if (signal.aborted) throw err;
@ -220,6 +274,66 @@ export class ArchiveProcessor {
}
}
/**
* `!archive export` from a mapped room. Power level >= 50 required; the
* bundle is built, uploaded to R2 and the ciphertext object key is posted
* back as a notice (the key material lives only in the E2EE admin room).
*/
private async handleExportCommand(
mapping: RoomMapping,
event: MatrixEvent,
signal: AbortSignal,
): Promise<Outcome> {
if (this.exporter === null || this.exportConfig === null || !this.exportConfig.enabled) {
this.logger.info({ roomId: mapping.roomId }, "archive-export: not configured; command ignored");
return null;
}
try {
const powerLevels = await this.matrix.roomPowerLevels(mapping.roomId, signal);
if (!senderIsRoomAdmin(powerLevels, event.sender)) {
this.logger.warn({ roomId: mapping.roomId, sender: event.sender }, "archive-export: sender is not a room admin");
return null;
}
} catch (err) {
this.logger.warn({ roomId: mapping.roomId, err: errorText(err) }, "archive-export: power-level check failed");
return null;
}
const result = await this.exporter.exportRoom(mapping, signal, "manual");
this.recordExport(mapping.roomId, result.objectKey);
await this.matrix.sendNotice(
mapping.roomId,
replyTxnId(`export:${result.objectKey}`),
exportNotice(result.objectKey, this.exportConfig.r2.bucket, this.exportConfig.ageRecipients),
signal,
);
return null;
}
private recordExport(roomId: string, objectKey: string): void {
this.lastExportAt = this.now();
this.lastObjectKey = objectKey;
this.roomsExported.add(roomId);
}
/** Rolling auto-export after ARCHIVE_EXPORT_EVERY archived attachments in a room. */
private async maybeAutoExport(mapping: RoomMapping, signal: AbortSignal): Promise<void> {
if (this.exporter === null || this.exportConfig === null || !this.exportConfig.enabled) return;
this.exporter.countAttachment(mapping.roomId);
if (!this.exporter.shouldAutoExport(mapping.roomId, this.exportConfig.every)) return;
try {
const result = await this.exporter.exportRoom(mapping.roomId === mapping.roomId ? mapping : mapping, signal, "auto");
this.recordExport(mapping.roomId, result.objectKey);
await this.matrix.sendNotice(
mapping.roomId,
replyTxnId(`export:${result.objectKey}`),
exportNotice(result.objectKey, this.exportConfig.r2.bucket, this.exportConfig.ageRecipients),
signal,
);
} catch (err) {
this.logger.error({ roomId: mapping.roomId, err: errorText(err) }, "archive-export: automatic export failed");
}
}
/** Exactly one reply per committed claim; the stable txn ID makes resends idempotent server-side. */
private async sendReply(mapping: RoomMapping, eventId: string, key: string, signal: AbortSignal): Promise<Outcome> {
const safeName = this.unsentReplies.get(key);