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

@ -0,0 +1,199 @@
import type { Logger } from "pino";
import type { RoomMapping } from "./config.js";
import {
newBundleId,
payloadPath,
type BundleManifest,
type BundlePayload,
type IndexedFile,
type ManifestEntry,
} from "./archive-index.js";
import { BundleError, encryptBundleStream, parseAgeRecipients } from "./bundle.js";
import type { LibraryPort } from "./webdav.js";
import type { ArchiveIndexPort } from "./archive-index.js";
import { exportObjectKey, R2ExportStore } from "./r2-export.js";
/** Per-attachment budget for one bundle payload in RAM (bounded, like staging). */
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
/** Hard ceiling for one bundle's total payload bytes (100 × max attachment). */
export const MAX_BUNDLE_BYTES = 100 * MAX_ATTACHMENT_BYTES;
export class ExportError extends Error {}
export interface ExportResult {
objectKey: string;
fileCount: number;
totalBytes: number;
byteSize: number;
}
/** Admin gate: power level 50+ in the room (spec default moderator). */
export const ADMIN_POWER_LEVEL = 50;
export function exportNotice(objectKey: string, bucket: string, recipients: readonly string[]): Record<string, unknown> {
return {
msgtype: "m.notice",
body: `Encrypted archive bundle uploaded: ${objectKey} (${bucket}) — ${recipients.length} age recipient(s). `
+ "Ciphertext only; open it with the admin age identity from this room.",
};
}
/** `!archive export` from a room admin. */
export function isExportCommand(content: Record<string, unknown>): boolean {
const body = typeof content.body === "string" ? content.body.trim() : "";
return body === "!archive export" || body.startsWith("!archive export ");
}
/**
* Phase-B exporter: builds an encrypted bundle of a room's archived media and
* uploads the ciphertext to R2. Payloads stream oCIS → tar → zstd → age; only
* the ciphertext spill file (see R2ExportStore) ever touches disk.
*/
export class BundleExporter {
private readonly recipients: string[];
private readonly r2: R2ExportStore;
/** room → attachments archived since the last successful export */
private readonly counters = new Map<string, number>();
private readonly libraryDir: (mapping: RoomMapping) => string[];
constructor(
private readonly index: ArchiveIndexPort,
private readonly library: LibraryPort,
private readonly upload: R2ExportStore,
recipients: readonly string[],
private readonly bucket: string,
private readonly logger: Logger,
private readonly now: () => Date = () => new Date(),
) {
this.recipients = parseAgeRecipients(recipients.join(","));
this.r2 = upload;
this.bucket = bucket;
this.libraryDir = (mapping) => ["Library", mapping.workspaceId, mapping.groupId];
}
/** Count one archived attachment toward the room's rolling auto-export. */
countAttachment(roomId: string): void {
this.counters.set(roomId, (this.counters.get(roomId) ?? 0) + 1);
}
/** True (and resets) when the room crosses ARCHIVE_EXPORT_EVERY. */
shouldAutoExport(roomId: string, every: number): boolean {
if (every <= 0) return false;
const count = this.counters.get(roomId) ?? 0;
if (count < every) return false;
this.counters.set(roomId, 0);
return true;
}
/**
* Build and upload one bundle. Reads every archive row for the room (payload
* streams come from oCIS WebDAV), wraps the file key for all recipients and
* PUTs the ciphertext under `exports/<room-id>/<timestamp>.tar.zst.age`.
*/
async exportRoom(
mapping: RoomMapping,
signal: AbortSignal,
reason: "manual" | "auto",
): Promise<ExportResult> {
const started = this.now();
const rows = await this.index.listRoom(mapping.workspaceId, mapping.roomId);
const manifest = buildManifest(mapping, rows, started, this.recipients);
const payload = await this.collectPayloads(mapping, rows, manifest);
const objectKey = exportObjectKey(mapping.roomId, started);
const ciphertext = await encryptBundleStream(payload, this.recipients);
await this.r2.putBundle(objectKey, ciphertext, signal);
this.logger.info(
{ roomId: mapping.roomId, objectKey, files: manifest.fileCount, totalBytes: manifest.totalBytes, reason },
"archive-export: bundle uploaded",
);
return { objectKey, fileCount: manifest.fileCount, totalBytes: manifest.totalBytes, byteSize: manifest.totalBytes };
}
/** Stream every available payload out of oCIS; unavailable rows contribute metadata only. */
private async collectPayloads(mapping: RoomMapping, rows: IndexedFile[], manifest: BundleManifest): Promise<BundlePayload> {
const payloads = new Map<string, Uint8Array>();
let total = 0;
const dir = this.libraryDir(mapping);
for (const row of rows) {
if (!row.available) continue;
if (row.byteSize > MAX_ATTACHMENT_BYTES) {
throw new ExportError(`attachment ${row.eventId} exceeds the ${MAX_ATTACHMENT_BYTES} byte bundle budget`);
}
if (total + row.byteSize > MAX_BUNDLE_BYTES) {
throw new ExportError(`bundle for ${mapping.roomId} exceeds ${MAX_BUNDLE_BYTES} bytes; raise ARCHIVE_EXPORT_EVERY`);
}
const stream = await this.library.getResource(dir, `${row.sha256}-${row.safeName}`, AbortSignal.timeout(120_000));
const bytes = await readAll(stream, row.byteSize);
if (bytes.byteLength !== row.byteSize) {
throw new ExportError(`payload ${row.eventId} size mismatch (want ${row.byteSize}, got ${bytes.byteLength})`);
}
payloads.set(payloadPath(row), bytes);
total += row.byteSize;
}
return { manifest, payloads };
}
}
/** Manifest header; `files` follows from the index rows. */
export function buildManifest(
mapping: RoomMapping,
rows: IndexedFile[],
exportedAt: Date,
recipients: readonly string[],
): BundleManifest {
const files: ManifestEntry[] = rows.map((row) => ({
eventId: row.eventId,
mxcUri: row.mxcUri,
originalName: row.originalName,
safeName: row.safeName,
mime: row.mime,
mimeClass: row.mimeClass,
byteSize: row.byteSize,
sha256: row.sha256,
sender: row.sender,
createdAt: row.createdAt,
ocisResourceId: row.ocisResourceId,
available: row.available,
}));
const available = files.filter((f) => f.available);
return {
format: "chat-media-archive-bundle",
version: 1,
roomId: mapping.roomId,
workspaceId: mapping.workspaceId,
groupId: mapping.groupId,
exportedAt: exportedAt.toISOString(),
objectKey: null,
recipients: [...recipients],
fileCount: available.length,
totalBytes: available.reduce((n, f) => n + f.byteSize, 0),
files,
};
}
/** Read one payload stream fully, enforcing the declared size (bounded RAM). */
async function readAll(stream: ReadableStream<Uint8Array>, expected: number): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
let total = 0;
const reader = stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > expected) {
await reader.cancel().catch(() => undefined);
throw new ExportError(`payload longer than the indexed size (${expected})`);
}
chunks.push(value);
}
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.byteLength;
}
return out;
}
export { BundleError, newBundleId };