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

@ -1,4 +1,5 @@
import { Pool, type PoolClient } from "pg";
import type { ArchiveIndexPort, IndexedFile } from "./archive-index.js";
export type OutboxStatus = "pending" | "done" | "failed";
@ -53,6 +54,11 @@ export interface ArchiveStore {
*/
markRedacted(workspaceId: string, roomId: string, eventId: string, claimKey: string): Promise<number>;
countPending(workspaceIds: readonly string[]): Promise<number>;
/**
* Phase-B export manifest source (ArchiveIndexPort): every archive row for
* one room, oldest first. Read-only; no payload bytes leave this interface.
*/
listRoom(workspaceId: string, roomId: string): Promise<IndexedFile[]>;
}
interface OutboxDbRow {
@ -68,9 +74,44 @@ function toOutbox(row: OutboxDbRow): OutboxRow {
}
const OUTBOX_COLUMNS = "id, status, attempts, last_error, updated_at";
interface RoomArchiveDbRow {
id: string;
event_id: string;
sender_mxid: string;
original_name: string;
safe_name: string;
mime: string;
mime_class: string;
byte_size: string | number;
sha256: string;
ocis_resource_id: string;
created_at: Date;
available: boolean;
/** Extracted from the stored event JSON; NULL for pre-archive rows without one. */
mxc_uri: string | null;
}
function toIndexedFile(row: RoomArchiveDbRow): IndexedFile {
return {
id: row.id,
eventId: row.event_id,
mxcUri: row.mxc_uri ?? "",
originalName: row.original_name,
safeName: row.safe_name,
mime: row.mime,
mimeClass: row.mime_class as IndexedFile["mimeClass"],
byteSize: typeof row.byte_size === "string" ? Number(row.byte_size) : row.byte_size,
sha256: row.sha256,
sender: row.sender_mxid,
createdAt: row.created_at.toISOString(),
ocisResourceId: row.ocis_resource_id,
available: row.available,
};
}
const LAST_ERROR_MAX = 1000;
export class PgArchiveStore implements ArchiveStore {
export class PgArchiveStore implements ArchiveStore, ArchiveIndexPort {
constructor(private readonly pool: Pool) {}
/** Single service connection; tenant context per transaction, like apps/api withTenant. */
@ -222,6 +263,21 @@ export class PgArchiveStore implements ArchiveStore {
return total;
}
async listRoom(workspaceId: string, roomId: string): Promise<IndexedFile[]> {
return this.withTenant(workspaceId, async (c) => {
const res = await c.query<RoomArchiveDbRow>(
`SELECT id, event_id, sender_mxid, original_name, safe_name, mime, mime_class,
byte_size, sha256, ocis_resource_id, created_at, available,
matrix_event_json->'content'->>'url' AS mxc_uri
FROM chat_media_archive
WHERE workspace_id = $1 AND room_id = $2
ORDER BY created_at, id`,
[workspaceId, roomId],
);
return res.rows.map(toIndexedFile);
});
}
private async withTenant<T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {