Phase B: encrypted archive export bundles (age), power-level-gated export command, encryption-state linter
This commit is contained in:
parent
a7ad1f059f
commit
699b9412c1
22 changed files with 2325 additions and 9 deletions
75
apps/archive-bot/src/archive-index.ts
Normal file
75
apps/archive-bot/src/archive-index.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import type { MimeClass } from "@tactical-media-broadcast-hub/core";
|
||||
|
||||
/**
|
||||
* One manifest entry = one `chat_media_archive` row for the room. Metadata
|
||||
* only; payloads stay in oCIS until the exporter pulls them over WebDAV.
|
||||
* Field names follow the index columns; nothing here can identify file bytes.
|
||||
*/
|
||||
export interface ManifestEntry {
|
||||
eventId: string;
|
||||
mxcUri: string;
|
||||
originalName: string;
|
||||
safeName: string;
|
||||
mime: string;
|
||||
mimeClass: MimeClass;
|
||||
byteSize: number;
|
||||
/** Lowercase hex SHA-256 of verified payload bytes; the restore script re-verifies it. */
|
||||
sha256: string;
|
||||
sender: string;
|
||||
/** UTC ISO-8601, from the Matrix event timestamp. */
|
||||
createdAt: string;
|
||||
ocisResourceId: string;
|
||||
/** False once redacted/retention marks the row unavailable. */
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
/** Static bundle metadata; `files` carries the per-row entries. */
|
||||
export interface BundleManifest {
|
||||
format: "chat-media-archive-bundle";
|
||||
version: 1;
|
||||
roomId: string;
|
||||
workspaceId: string;
|
||||
groupId: string;
|
||||
/** Bundle build time (UTC ISO-8601). */
|
||||
exportedAt: string;
|
||||
/** Object key the ciphertext was uploaded to, filled in after the PUT. */
|
||||
objectKey: string | null;
|
||||
/** bech32 `age1...` recipients the file key was wrapped for. */
|
||||
recipients: string[];
|
||||
fileCount: number;
|
||||
/** Sum of ManifestEntry.byteSize. */
|
||||
totalBytes: number;
|
||||
files: ManifestEntry[];
|
||||
}
|
||||
|
||||
/** Manifest + payload bytes in memory; the exporter packs and encrypts this. */
|
||||
export interface BundlePayload {
|
||||
manifest: BundleManifest;
|
||||
/** Entry path inside the tar → payload bytes. Keyed by `files/<eventId>` style paths. */
|
||||
payloads: Map<string, Uint8Array>;
|
||||
}
|
||||
|
||||
export type IndexedFile = ManifestEntry & { id: string };
|
||||
|
||||
/** Read side of `chat_media_archive` for exports; faked in tests. */
|
||||
export interface ArchiveIndexPort {
|
||||
/**
|
||||
* Every archive row for the room, oldest first, including rows already
|
||||
* marked unavailable (the manifest records their state; unavailable rows
|
||||
* contribute no payload — see `buildBundle`).
|
||||
*/
|
||||
listRoom(workspaceId: string, roomId: string): Promise<IndexedFile[]>;
|
||||
}
|
||||
|
||||
export const MANIFEST_PATH = "manifest.json";
|
||||
export const PAYLOAD_DIR = "files";
|
||||
|
||||
/** Deterministic, collision-free tar path for one archive row's payload. */
|
||||
export function payloadPath(entry: ManifestEntry): string {
|
||||
return `${PAYLOAD_DIR}/${entry.eventId}/${entry.safeName}`;
|
||||
}
|
||||
|
||||
export function newBundleId(): string {
|
||||
// Timetap + randomness: readable object keys, no crypto requirement.
|
||||
return `${Date.now().toString(36)}${Math.floor(Math.random() * 0x1_000_000).toString(36).padStart(4, "0")}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue