T-HUB/apps/archive-bot
2026-10-12 12:46:56 +00:00
..
src Phase B: encrypted archive export bundles (age), power-level-gated export command, encryption-state linter 2026-10-12 12:46:56 +00:00
Dockerfile Add group cloud: files index api, web UI, archive sync bot 2026-10-11 08:21:11 +00:00
package.json Phase B: encrypted archive export bundles (age), power-level-gated export command, encryption-state linter 2026-10-12 12:46:56 +00:00
README.md Phase B: encrypted archive export bundles (age), power-level-gated export command, encryption-state linter 2026-10-12 12:46:56 +00:00
tsconfig.build.json Add group cloud: files index api, web UI, archive sync bot 2026-10-11 08:21:11 +00:00
tsconfig.json Add group cloud: files index api, web UI, archive sync bot 2026-10-11 08:21:11 +00:00
vitest.config.ts Add group cloud: files index api, web UI, archive sync bot 2026-10-11 08:21:11 +00:00

archive-bot

Matrix → oCIS attachment archiver with encrypted export bundles (phase B).

The bot mirrors attachments from deliberately-unencrypted archive rooms into oCIS group libraries (Library/<workspace>/<group>/), indexes every file (sha256, mime class, sender, Matrix event) in chat_media_archive, and — when phase B is configured — periodically packs a room's archive into an encrypted export bundle uploaded to R2.

Runtime contract

Variable Required Meaning
DATABASE_URL yes Postgres with chat_media_archive
MATRIX_HOMESERVER_URL yes tuwunel homeserver base URL
MATRIX_USER / MATRIX_PASSWORD yes archive bot account
ROOM_MAPPINGS yes JSON [{roomId, workspaceId, groupId}]
FILES_OCID_WEBDAV_URL / FILES_OCID_USER / FILES_OCID_PASSWORD yes oCIS WebDAV (basic auth)
ARCHIVE_BOT_HEALTH_PORT yes /healthz listener
MATRIX_MEDIA_REDIRECT_HOSTS no Allowed media redirect hosts

All phase-B variables are optional: with none set the bot behaves exactly as before (no exporter is constructed, /healthz reports export: null).

Phase B: encrypted export bundles

Variable Default Meaning
ARCHIVE_EXPORT_AGE_RECIPIENTS — Comma-separated bech32 age1… public keys (one per admin)
ARCHIVE_EXPORT_R2_ACCESS_KEY / ARCHIVE_EXPORT_R2_SECRET_KEY — R2 S3 credentials
ARCHIVE_EXPORT_R2_ENDPOINT — https://<account>.r2.cloudflarestorage.com (https enforced)
ARCHIVE_EXPORT_R2_REGION auto R2 region
ARCHIVE_EXPORT_R2_BUCKET hub-matrix-media Target bucket
ARCHIVE_EXPORT_EVERY 500 Auto-export after N newly archived attachments per room (0 = manual only)
ARCHIVE_EXPORT_TMP_DIR $TMPDIR/archive-bot-export Ciphertext spill dir (see below)
ARCHIVE_EXPORT_ENABLED — Set false to force-disable despite other variables

What gets encrypted

A bundle is tar(zstd(age(...))) containing:

  • manifest.json — room identity, export time, recipients, one metadata row per archived file (event id, names, mime, size, sha256, sender, timestamp, availability). Payload bytes are never in the manifest.
  • files/<eventId>/<safeName> — original payload bytes, one entry per available archive row (redacted rows keep their manifest row but no bytes).

The age file key is wrapped for every configured recipient, so any admin identity opens a bundle. Private keys never exist on the server: generate keypairs on admin machines with age-keygen, publish only the age1… lines in ARCHIVE_EXPORT_AGE_RECIPIENTS.

Triggers

  • Manual: !archive export in a mapped room, from a sender with power level ≥ 50 (m.room.power_levels). The bot posts the ciphertext object key back as a notice.
  • Rolling: every ARCHIVE_EXPORT_EVERY newly archived attachments in a room.

The spill file (single documented disk touch)

The final object length is only known once zstd+age complete, so R2ExportStore streams the ciphertext to one temp file (mode 0600, O_EXCL, random name) and PUTs it with an explicit Content-Length, then unlinks it in finally. It holds ciphertext only — never payload bytes — and a failed PUT leaves nothing behind. Mount ARCHIVE_EXPORT_TMP_DIR on tmpfs if any disk write is unacceptable.

Encryption-state linter

On startup the bot walks every joined room and WARNs on any unmapped, unencrypted room — an encrypted-by-default policy violation. Mapped rooms are expected to be unencrypted (that is the archive policy); an encrypted mapped room is logged INFO (the processor already refuses to mirror it). The last report is surfaced in /healthz under encryption.

Restoring a bundle

node dist/restore.js --bundle <file.tar.zst.age> --identity <age-identity-file> --out <dir>

The identity file holds AGE-SECRET-KEY-1… lines (# comments allowed). Every restored file is sha256-verified against manifest.json; exit code is non-zero unless all files verify.

Operations

  • /healthz — rooms, cursor, pending outbox, last export object key, encryption lint state.
  • Egress: WebDAV (443) + homeserver + R2 endpoint only; the R2 credential is PUT-scoped by policy — this bot never lists, reads or deletes objects.