Skip to content
LouisMorettiPublic

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

undelete

Anti-delete Telegram bot connected via Telegram Business / Business Automation (connected business bot — not a classic group bot, and certainly not an MTProto userbot).

As soon as the account holder connects the bot to their Telegram Business account, the bot automatically saves messages from all private conversations that this Business connection gives it access to. There is no conversation selector on the undelete side: no list of chats to check, no per-conversation allowlist, no per-conversation preference. (The one allowlist this bot does have, OWNER_ALLOWLIST_TELEGRAM_USER_IDS, decides which ACCOUNT HOLDERS may connect at all -- never which of their chats are captured.) When Telegram reports a deletion, the bot retrieves the original content from the database (saved at the time of reception, because the deletion event does not carry the content) and notifies the account holder.

Scope

Multi-tenant: several Telegram Business account holders at once, each an isolated tenant. Onboarding is controlled by OWNER_ALLOWLIST_TELEGRAM_USER_IDS (empty = open onboarding, a list of Telegram user ids = only those holders). Text AND media capture: messages are saved on receipt with their attachments downloaded to ./media, and a deletion is notified to the holder with the original content -- text always, media when the download completed in time. Content rests in plaintext in the database (encryption is an explicit non-goal of this stage, see docs/runbook.md and the privacy policy). Owner commands: /privacy, /retention (read and set, 1-365 days) and /delete_my_data (single-use expiring challenge, full tenant erasure). The schema is multi-tenant and under Row Level Security (RLS) throughout.

Onboarding modes and tenant isolation

OWNER_ALLOWLIST_TELEGRAM_USER_IDS is the only knob that decides whose data this instance holds. It is comma- or space-separated, and each entry is a Telegram user id in canonical decimal form.

Value Mode Effect
empty / unset open onboarding any Telegram Business account holder can connect the bot and becomes a tenant of their own
123,456 restricted only those holders are admitted; every other business_connection is refused, persisting nothing and answering nothing

A refusal is memoised like a revocation, for the cache's TTL: an unadmitted holder who connects the bot to their own Business account and then types costs one business_connections read (and, for an id no row matches, one getBusinessConnection) in total rather than one per message — those calls run on a shard worker of the poller and on the Telegram rate budget the admitted tenants share. The memo carries the refusal and the holder it names, never a tenant key, and it is re-checked against the allowlist rather than served on trust.

The allowlist is admission control, never isolation. Whatever it contains, each tenant's rows stay behind the same FORCE ROW LEVEL SECURITY policies, keyed on owner_user_id and reachable only through storage.DB.InTenant. Two audits keep that true as the code changes:

  • bot/internal/storage/tenantsurface_test.go (unit) — only storage and users may hold a database pool, only the four repositories may name an RLS-protected table, and nothing outside storage/cmd/bot may reach DB.Pool. Each allowlist in it is exact: a stale entry fails too.
  • bot/integration/multi_tenant_test.go (integration) — three tenants on a real PostgreSQL 16: every cross-tenant read returns zero, every cross-tenant write is refused, and the connection lifecycle is walked end to end.

OWNER_TELEGRAM_USER_ID (the Phase 1 mono-tenant guard) no longer exists. The bot refuses to start while it is still set to a value, rather than silently switching that deployment to open onboarding — see "Mono-tenant to multi-tenant" in docs/runbook.md for the migration.

Connection lifecycle

Telegram expresses the whole lifecycle through one update type, business_connection:

Transition Wire signal Effect
onboarding first update for an id, is_enabled: true users + business_connections rows created, holder welcomed
deactivation is_enabled: false capture stops at the next message, in the database and in the in-memory cache
reactivation is_enabled: true again capture resumes, holder welcomed again
revocation the id stops being recognised (getBusinessConnection answers 400) the connection is refused, the refusal is memoised so the updates Telegram still has buffered cost one API call in total

Removing the bot from a Business account is reported by Telegram as a deactivation, so it is handled by that row. One holder may keep several connections at once; all of them resolve to the same tenant, which is what makes /delete_my_data cover the whole account rather than one connection.

Per-tenant quotas

Open onboarding means strangers can store their conversations on this disk, so each tenant is bounded: stored messages (QUOTA_MAX_MESSAGES_PER_TENANT, default 100000), catalogued media files (QUOTA_MAX_MEDIA_FILES_PER_TENANT, default 10000), stored media bytes (QUOTA_MAX_MEDIA_BYTES_PER_TENANT, default 5 GiB) and captures per sliding minute (QUOTA_CAPTURES_PER_MINUTE_PER_TENANT, default 300). Every quota is keyed by the internal owner_user_id the connection resolved to -- never by anything read from the update, so no identifier can be spoofed into another tenant's budget -- and applies to the tenant as a whole, never to a selection of chats.

Past a quota the capture is dropped explicitly: the update costs no write, the poller advances past it like past a refused connection, and the drop is logged with ids only plus counted in undelete_quota_drops_total. Deletions already stored are still alerted and /delete_my_data still works -- an erasure must work precisely when the tenant is over quota. At QUOTA_WARN_PERCENT (default 80) of a volume limit (stored messages, catalogued media files, stored media bytes -- never the capture rate) the pre-saturation alert fires once per crossing (log plus undelete_quota_warnings_total), and a fresh volume refusal alerts once more, so the operator hears "approaching" and then "now dropping" without one log line per update under sustained saturation. A rate refusal only increments undelete_quota_drops_total and logs at Debug: under a rate flood a warning per refusal would be the flood.

Quota and commands: the erasure lifecycle bypasses the quota -- a /delete_my_data confirmation (which carries a code and is never saved) and a bare /delete_my_data on a disabled connection are served without consulting the tracker, so an erasure resumes past quota. Every other owner command on an enabled connection (/privacy, /retention, a bare /delete_my_data with nothing to keep secret) flows through the capture first: past quota the message is dropped before command parsing, so the command goes silently unanswered. The silence is deliberate -- answering past quota would spend the Telegram budget the quota protects -- and the command is retried by typing it again once captures flow.

Ledger accounting is approximate by design. The message admission is taken before the tenant-exclusion recheck and before the write: an update skipped as disabled-after-admit, or a write that fails after admission (message or media row), leaves its ledger unit counted with no database row behind it -- and so does the first attempt of an update retried after a transient failure. Moving the admission after the guard would hold the Shared side across the resync database reads -- a saturated tenant's slow source would then delay its own (and only its own, the guard is per-tenant) erasure path -- so the drift is accepted instead: tiny in production (only in-flight updates racing an erasure or disable) and self-healing on the next over-quota resync. Edits and Telegram redeliveries consume message units without adding rows: an edit is admitted exactly like a new capture (rate and volume), while the message store is an idempotent upsert, so an edit-heavy tenant or a redelivered burst can reach the ledger limit while the database sits far below it. The next over-quota admission then pays the re-verification (three COUNT/SUM queries) and at most one minute of memoised drops before healing. The byte quota is enforced at batch granularity for the same reason: a download that starts under quota may push the tenant over it, and the next download is refused.

The enforcement is in-memory with the database as the source of truth: the first touch of a tenant seeds its counters (which is also what makes a restart safe), and a tenant the ledger calls over quota is re-verified before being refused, then memoised for a minute -- retention purges and erasures shrink the truth behind the tracker's back, and the next admission resyncs instead of refusing forever. A failing source fails open: while the database is away every admission still pays its three seeding/re-verification queries (which fail) and the unseeded ledger counts up in memory, then the resync overwrites the counters on recovery -- no data is lost by refusing, and the write that follows fails loudly on its own. A saturated tenant never blocks the others: admissions take no lock across database reads, and the outbox (100 jobs per tenant per tick) and media fetch (one batch per tenant per pass) loops visit tenants in order.

Telegram setup (3 steps)

  1. Create the bot via @BotFather: /newbot, retrieve the token (TELEGRAM_BOT_TOKEN).
  2. Enable Business Mode in BotFather: /mybots → select the bot → Business Mode → Turn on. Without this step, Telegram refuses any Business connection attempt — the bot simply does not appear in the list of available chatbots.
  3. Connect the bot from the account holder's Telegram account: Settings → Telegram Business → Chatbots → select the bot. This is the step that triggers the business_connection update received by the bot.

Honest note: the Telegram Business entry in settings was long reserved for Telegram Premium accounts. The current MTProto documentation indicates that connecting a bot to Business would no longer require Premium on the side of the user connecting the bot — verify this yourself on a real non-Premium account before relying on it in production; the documentation and the app's actual behavior sometimes diverge.

Getting started

cp .env.example .env
# edit .env: TELEGRAM_BOT_TOKEN, Postgres passwords, etc.

docker compose up --build -d
docker compose logs -f bot

At boot, the binary applies migrations with the owner DSN (MIGRATION_DATABASE_URL) then opens the application pool with the restricted DSN (DATABASE_URL, role undelete_app), before starting long polling.

Supervision (probes and metrics)

The bot opens a dedicated HTTP server on HEALTH_ADDR (default :9090, empty value = server disabled). This port stays internal to the Docker network: it is not published on the host, just like Postgres.

Route Response
GET /livez 200 {"status":"ok"} as soon as the process serves HTTP, no external dependency.
GET /readyz 200 if the database responds (ping, 2 s) and if the last successful getUpdates is less than 90 s old; otherwise 503 {"status":"degraded","checks":{...}}.
GET /metrics Prometheus text exposition.

The Compose healthcheck of the bot service queries /livez and not /readyz: liveness must depend on neither Postgres nor Telegram, otherwise an external incident would restart a perfectly healthy bot in a loop.

Exposed metrics (counters, except undelete_outbox_backlog which is a gauge): undelete_updates_total, undelete_update_errors_total, undelete_outbox_retries_total, undelete_outbox_failed_total, undelete_deletions_total, undelete_outbox_backlog, undelete_quota_drops_total, undelete_quota_warnings_total, undelete_update_retries_total, undelete_updates_dropped_transient_total.

An update whose handling fails on a transient cause (database connection lost or refused, serialization failure, Telegram 429/5xx) is retried in place up to 3 times with a jittered backoff (undelete_update_retries_total) before the poller advances past it. One that still fails is counted in undelete_updates_dropped_transient_total: a capture lost to an outage. After such a loss the handler gives every update a single attempt for 30s, so a real outage stalls the poller for one retry budget, not one per update.

undelete_outbox_failed_total counts alerts that exhausted the fast lane (10 attempts) and entered the slow lane: they are deferred with a fresh budget after 6h, never abandoned for failing -- only retention removes them, like any other outbox row (retention_days, counted from creation): an alert still undeliverable after that many days is purged with the content it carries. undelete_outbox_backlog counts pending/processing/failed: failed rows are undelivered work, even while parked until their resweep deadline — excluding them would drop the gauge to zero precisely when every alert is stuck.

No series has a label, and the list of names is hard-coded: cardinality is bounded by construction and no identifier, name, message text or token can end up in a scrape. /readyz responses follow the same rule: degraded checks are described by short, fixed reasons (unreachable, stale, no_successful_poll_yet), never by the PostgreSQL error message, which would contain the DSN.

Operations

The homelab deployment is fully manual: docs/runbook.md is the reference procedure (preflight, backup → migration → rollout → verification order, rollback, secret rotation, staging recipe), and the closed list of destructive actions forbidden without explicit confirmation.

Before any deployment:

sh scripts/preflight.sh

Read-only check of required variables, .env permissions, disk space, the distinction between the two DSNs, PostgreSQL roles (undelete_app without RLS bypass privilege) and the validity of the Telegram token via getMe — the token is never displayed. Exits with code 1 if any check fails.

The dump restoration procedure is documented separately in docs/backup-restore.md (delivered by PR #40, the bottom layer in the stack — exists as soon as the stack is merged).

PostgreSQL 16 integration tests

The real suite (no mocks) verifies migrations and their re-run, the runtime role, refusals of dangerous roles and DDL, fail-closed RLS, CRUD isolation of two tenants and PurgeExpired tenant by tenant.

make test-integration

The command starts a throwaway PostgreSQL 16 container, without a Docker volume, then removes only that container at the end. It does not prune or modify any existing Docker resource. If Docker is not available, it fails explicitly and accepts instead two DSNs pointing to a local PostgreSQL 16 instance prepared with db/init/01-app-role.sh. This external mode refuses any operation as long as the database reported by current_database() is not exactly named undelete_integration and the literal destructive opt-in is not provided:

POSTGRES_INTEGRATION_ADMIN_DSN='postgres://...' \
POSTGRES_INTEGRATION_RUNTIME_DSN='postgres://undelete_app:...' \
POSTGRES_INTEGRATION_ALLOW_DESTRUCTIVE=I_UNDERSTAND_THIS_WILL_DELETE_DATA \
make test-integration

The Docker recipe sets this opt-in itself, only for its ephemeral container and its dedicated database.

The script also wires OUTBOX_TEST_DATABASE_URL (runtime DSN) and OUTBOX_TEST_MIGRATION_DATABASE_URL (admin DSN) to the same ephemeral database, so that the outbox tests in bot/internal/outbox — gated by these variables and otherwise silently skipped — run in the same execution, after the migrations laid down by the bot/integration suite. Values provided by the caller are respected as-is.

CI

.github/workflows/ci.yml runs on every pull_request and every push to main, plus a weekly schedule (drift check) and manual dispatch, with five jobs:

  • lint + unit tests: gofmt -l (fails on output), go vet ./..., go mod tidy check (module files must be committed tidy), go test ./... on the bot/ module with a 70% statement-coverage floor.
  • PostgreSQL 16 integration: make test-integration, which starts its own throwaway PostgreSQL 16 container on the runner and runs the integration suite then the outbox tests.
  • combined coverage (unit + integration): make test-coverage, with a 75% floor gate.
  • docker build: builds the production image from bot/Dockerfile, so a broken Dockerfile fails here and not on the deployment machine.
  • backup restore recipes (schedule and manual runs only -- too slow for every PR): make test-restore and make test-restore-media against throwaway containers; the scripts refuse foreign DSNs and never touch existing volumes.

No secret is used, no real database is exposed: only ephemeral containers with throwaway credentials. The GITHUB_TOKEN token has contents: read.

Branch protection to enable on the GitHub side (Settings → Branches → Add branch ruleset / Add rule on main) — not automatable from this repository:

  1. Require a pull request before merging.
  2. Require status checks to pass before merging, then select the checks lint + unit tests, PostgreSQL 16 integration, combined coverage and docker build (they only appear in the list after the workflow has run once).
  3. Require branches to be up to date before merging.
  4. Forbid force-pushes on main.

Architecture

                    getUpdates (long polling, explicit allowed_updates)
                              │
                              ▼
                    ┌───────────────────┐
                    │  telegram.Poller  │  single fetch, backoff, offset;
                    └─────────┬─────────┘  sharded execution per (connection,
                              │ Update     chat), order kept per partition;
                              │            offset advances even if the handler
                              │            fails
                              │ Update
                              ▼
                    ┌───────────────────┐
                    │   app.Handler     │  routes by update type
                    └─────────┬─────────┘
                              │
          ┌───────────────────┼───────────────────┐
          ▼                   ▼                   ▼
  business.Service    messages.Repository   outbox.Worker
  (resolution:         (InTenant + RLS)      (lease + backoff,
  cache→DB→API)         deleted_at + outbox   sendMessage without
                         atomically)           business_connection_id)
                              │
                              ▼
                        PostgreSQL 16
  users / business_connections / chats / messages / notification_outbox
        / media_files / data_erasure_requests
     (FORCE RLS on messages, notification_outbox, chats, media_files
      and data_erasure_requests; users and business_connections are
      resolution tables without RLS)
  • db/init/01-app-role.sh creates the application role undelete_app (NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS) on the first start of the Postgres container.
  • storage.RunMigrations applies internal/storage/migrations/*.sql with the owner DSN, at boot, before opening the application pool.
  • storage.DB.InTenant is the only legitimate entry point to the messages, notification_outbox, chats, media_files and data_erasure_requests tables: it sets app.current_owner_user_id to LOCAL (transaction scope) before any query.
  • Durable outbox: deleted_at and the notification chunks are written in the same transaction. A unique constraint absorbs redeliveries. The worker picks up pending jobs or expired processing leases, respects retry_after on 429 and applies exponential backoff on network errors and 5xx. The pending, processing, sent and failed states remain observable without logging the payload.
  • Readable alerts: each deletion notification carries the chat (its label when known, its chat_id always), the sender, the type and the send date in UTC, before the restored content. The label comes from the chats table, written on every message received — it is a DISPLAY label, never a filter (cf. pitfall n°8). A chat without a known label displays "chat <id>".

Alert delivery guarantees

  • At-least-once, not exactly-once. The worker sends the alert to Telegram then acknowledges the outbox row. A crash in between leaves the row in processing; its lease expires and a worker picks it up again. An alert already received can therefore be delivered a second time. No deduplication is done on the Telegram side: the reverse (acknowledging before sending) would trade this duplicate for a lost alert, which is unacceptable for this product.
  • Order guaranteed within a message, not between messages. The chunks of a single deleted message (same business_connection_id, chat_id, message_id, event_type) leave in chunk_index order: Claim refuses a chunk as long as a lower-index chunk is not sent or failed. No order is guaranteed BETWEEN two different messages — a message rescheduled by a backoff can arrive after a message deleted later. Alerts carry the chat identifier, never a sequence number: do not rely on their order of arrival.
  • Single clock. Retry deadlines and lease expirations are entirely evaluated and written by PostgreSQL (clock_timestamp()). A drift between the bot's clock and the database's can neither hide a job nor make it claimable too early.

The pitfalls (non-negotiable constraints)

  1. Explicit allowed_updates in getUpdates: business_connection, business_message, edited_business_message, deleted_business_messages. Without it, Telegram sends nothing at all — no error, just silence.
  2. Two Postgres roles, two DSNs. config.Load() refuses to start if DATABASE_URL == MIGRATION_DATABASE_URL: otherwise the bot would run with the owner role's privileges and RLS would be decorative.
  3. FORCE ROW LEVEL SECURITY on messages. ENABLE alone does not apply to the table owner. No RLS on business_connections (resolution table, queried before the owner is known).
  4. InTenant is the only path to messages. The retention purge cannot be a global DELETE through the bare pool: with FORCE RLS and no context set, the query would succeed and delete zero rows, without any error. PurgeExpired loops tenant by tenant.
  5. Sharded update processing. Updates execute sharded by (business_connection_id, chat_id) with a strict FIFO order per partition -- a deletion can never overtake its message -- never an unordered pool. Submission is fair across partitions (a full slow shard never head-of-line-blocks idle ones), but the next fetch waits for the slowest partition of the current batch, bounded by the handler ceilings (command 10s, welcome 30s, erasure 60s): one slow tenant stalls global freshness, it never deadlocks the loop.
  6. message_ids is an array. A batch deletion arrives in a single deleted_business_messages update.
  7. Alerts are sent FROM the bot, without business_connection_id: that field would send the message as the account holder, in the monitored conversation.
  8. Exhaustive and automatic saving. No table, command, environment variable or condition lets you choose which chats to record. is_enabled concerns the Business connection as a whole, never an individual conversation. The chats table is no exception to anything: it stores a label to make alerts readable, with no activation flag, and is never consulted to decide what to save or notify.
  9. A Business connection never changes owner. The upsert in business/service.go guards its ON CONFLICT clause on owner_user_id: an update claiming an existing connection for a different account holder writes nothing and is refused (ErrConnectionOwnerConflict). Telegram never reuses a connection id across holders, so this cannot happen in normal operation — and if it ever does, it is one tenant's connection starting to feed another tenant's rows, which is exactly the isolation multi-tenancy exists to hold.

Commands

Command Who Answer
/privacy the account holder only the privacy policy, as a direct message from the bot
/retention the account holder only the current retention period, as a direct message from the bot
/retention <days> the account holder only the new retention period (1 to 365 days), as a direct message from the bot
/delete_my_data the account holder only a single-use confirmation code, as a direct message from the bot
/delete_my_data <code> the account holder only the erasure of this account's live data (three tombstones kept, see Privacy), then a confirmation

Where to type them. allowed_updates requests the four business_* types and nothing else (the explicit allowed_updates constraint), so a plain message sent to the bot is never delivered to it. Commands are therefore typed inside a chat covered by the Business connection, where they arrive as business_message like any other message — the holder's own outgoing messages included. That the holder's own outgoing messages arrive this way is read from the Bot API contract and has not been exercised against a real Business account in this repository — verify it manually on a real account before relying on it.

Who gets answered. Only the holder of the connection the command arrived through: the sender's telegram_user_id is compared against the owner resolved from business_connections. A contact writing /privacy in a monitored chat receives nothing at all — no answer in the chat, no answer to themselves. The reply goes out as a direct message from the bot, without business_connection_id (the alerts-without-business_connection_id constraint), and never into the chat where the command was typed. The command itself is saved like any other message (the exhaustive-and-automatic-saving constraint), and it stays visible in the conversation it was typed in: the answer is private, the command is not.

The answer is labelled Privacy policy (i/n): the document does not fit in one Telegram message, and a delivery that stops short must be readable as incomplete rather than pass for the whole policy.

/delete_my_data

Two steps, because one would put the most destructive action of the product one typo away in a chat the holder types in all day. Typed alone, the command issues a random confirmation code (crypto/rand) that is valid for ten minutes and usable once; typed with that code, it runs the erasure. Only the SHA-256 of the code is stored, in data_erasure_requests (migration 0006, FORCE ROW LEVEL SECURITY like every other tenant table): the challenge survives a restart, and a dump hands its reader no spendable token. The code is spent by a single UPDATE ... WHERE status = 'pending', so of two submissions exactly one is granted.

The erasure runs in this order, and the order is what makes an interrupted one safe to rerun rather than a state nobody can name:

  1. disable the tenant's Business connections (database and the in-memory resolution cache) — nothing new is captured while the rest runs;
  2. notification_outbox, every status included, leased rows as well: an alert is content on its way out, and a worker must not deliver one from a tenant that asked to disappear;
  3. the attachments — the blobs on disk first, then the media_files rows, then a sweep of the tenant's own storage subtree for whatever an earlier interrupted attempt left behind;
  4. messages and chats, in one transaction;
  5. the tenant's other erasure requests, and the spent one is marked completed.

Every step is idempotent (a DELETE that matches nothing succeeds, unlinking an absent file succeeds), so a crash leaves a strict prefix applied and the same code resubmitted replays it as no-ops before continuing. Submitting a code whose erasure already completed deletes nothing and says so. Every deletion is tenant-scoped and goes through storage.DB.InTenant; the disk sweep is rooted at ./media/<owner_user_id>, so one tenant's erasure never even visits another's files. MEDIA_PURGE_DRY_RUN deliberately does not apply here: it holds back the deletions the bot decides on its own, not one the owner confirmed in writing.

The final message states the residual survival in the backups as a conditional target — BACKUP_RETENTION_DAYS (14 by default) for the dumps, which holds only while the daily backup job runs, and the fact that media archives are not purged automatically. It never promises an erasure of the backups, which no deletion can deliver.

Privacy

The policy served by /privacy is bot/internal/privacy/policy.md, versioned here and embedded in the binary: the command sends that document verbatim, and its version and effective date are read back from it, so the text a user receives and the text reviewed here cannot describe two different policies.

  • Every private conversation exposed by the active Business connection is saved in full (text), with no per-conversation opt-out.
  • Important technical limitation: the Bot API gives the bot neither retroactive access to the account history nor visibility into a conversation that Telegram does not explicitly expose to it via the Business connection. undelete keeps exhaustively what Telegram delivers to it through this connection from its activation onward, and nothing more — neither before, nor outside the scope that Telegram decides to transmit.
  • Logs (log/slog, JSON) never contain message content: identifiers, types and counters only.
  • Retention configurable per user (retention_days, 1 to 365 days), purged daily. The owner reads it with /retention and sets it with /retention <days> (typed in a covered chat like /privacy, answered privately); there is no per-chat setting.
  • /delete_my_data erases, on demand, this account's live data: messages, chat labels, attachments (rows and files), queued alerts and the tenant's other erasure requests, after disabling the Business connections. Three things are deliberately kept, because the erasure needs them to stay erased and answerable: the disabled connection records, the account row, and one scrubbed receipt of the erasure (code hash and timestamps, no Telegram or connection identifier) — see section 9 of the policy for the exact list.
  • Database backups (scripts/backup.sh) do not cover ./media (Phase 2). The backup retention duration (BACKUP_RETENTION_DAYS) is, in effect, the residual survival time of data after a /delete_my_data, as a conditional target: rows deleted in the database remain present in already-written archives until the daily job's own purge reaches them, and each day that job misses moves every deletion by a day. Section 8 of the policy states this explicitly, in wording that stays true once content encryption (Phase 4) lands: an erasure never rewrites an archive already written, and the confirmation message says so too.

Roadmap by phases

  • Phase 1: mono-tenant, plaintext text, RLS in place.
  • Phase 2: media (media_files table, backup of ./media separately from SQL dumps), GDPR commands (/privacy and /delete_my_data shipped).
  • Phase 3 (this task): real multi-tenancy — several simultaneous account holders, the OWNER_TELEGRAM_USER_ID guard replaced by OWNER_ALLOWLIST_TELEGRAM_USER_IDS, connection lifecycle (onboarding, deactivation, reactivation, revocation), an executable audit of the RLS/InTenant surface, per-chat sharding (#18) and per-tenant quotas (#19).
  • Phase 4: content encryption (text_encrypted BYTEA, AES-256-GCM, per-tenant key) replacing plaintext text_content.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages