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.
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.
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) — onlystorageandusersmay hold a database pool, only the four repositories may name an RLS-protected table, and nothing outsidestorage/cmd/botmay reachDB.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.
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.
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.
- Create the bot via @BotFather:
/newbot, retrieve the token (TELEGRAM_BOT_TOKEN). - 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. - 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_connectionupdate 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.
cp .env.example .env
# edit .env: TELEGRAM_BOT_TOKEN, Postgres passwords, etc.
docker compose up --build -d
docker compose logs -f botAt 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.
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.
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.shRead-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).
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-integrationThe 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-integrationThe 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.
.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 tidycheck (module files must be committed tidy),go test ./...on thebot/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 frombot/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-restoreandmake test-restore-mediaagainst 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:
- Require a pull request before merging.
- Require status checks to pass before merging, then select the checks
lint + unit tests,PostgreSQL 16 integration,combined coverageanddocker build(they only appear in the list after the workflow has run once). - Require branches to be up to date before merging.
- Forbid force-pushes on
main.
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.shcreates the application roleundelete_app(NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS) on the first start of the Postgres container.storage.RunMigrationsappliesinternal/storage/migrations/*.sqlwith the owner DSN, at boot, before opening the application pool.storage.DB.InTenantis the only legitimate entry point to themessages,notification_outbox,chats,media_filesanddata_erasure_requeststables: it setsapp.current_owner_user_idtoLOCAL(transaction scope) before any query.- Durable outbox:
deleted_atand the notification chunks are written in the same transaction. A unique constraint absorbs redeliveries. The worker picks uppendingjobs or expiredprocessingleases, respectsretry_afteron 429 and applies exponential backoff on network errors and 5xx. Thepending,processing,sentandfailedstates remain observable without logging the payload. - Readable alerts: each deletion notification carries the chat (its
label when known, its
chat_idalways), the sender, the type and the send date in UTC, before the restored content. The label comes from thechatstable, 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>".
- 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 inchunk_indexorder:Claimrefuses a chunk as long as a lower-index chunk is notsentorfailed. 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.
- Explicit
allowed_updatesingetUpdates:business_connection,business_message,edited_business_message,deleted_business_messages. Without it, Telegram sends nothing at all — no error, just silence. - Two Postgres roles, two DSNs.
config.Load()refuses to start ifDATABASE_URL == MIGRATION_DATABASE_URL: otherwise the bot would run with the owner role's privileges and RLS would be decorative. FORCE ROW LEVEL SECURITYonmessages.ENABLEalone does not apply to the table owner. No RLS onbusiness_connections(resolution table, queried before the owner is known).InTenantis the only path tomessages. The retention purge cannot be a globalDELETEthrough the bare pool: withFORCE RLSand no context set, the query would succeed and delete zero rows, without any error.PurgeExpiredloops tenant by tenant.- 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. message_idsis an array. A batch deletion arrives in a singledeleted_business_messagesupdate.- Alerts are sent FROM the bot, without
business_connection_id: that field would send the message as the account holder, in the monitored conversation. - Exhaustive and automatic saving. No table, command, environment
variable or condition lets you choose which chats to record.
is_enabledconcerns the Business connection as a whole, never an individual conversation. Thechatstable 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. - A Business connection never changes owner. The upsert in
business/service.goguards itsON CONFLICTclause onowner_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.
| 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.
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:
- disable the tenant's Business connections (database and the in-memory resolution cache) — nothing new is captured while the rest runs;
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;- the attachments — the blobs on disk first, then the
media_filesrows, then a sweep of the tenant's own storage subtree for whatever an earlier interrupted attempt left behind; messagesandchats, in one transaction;- 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.
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.
undeletekeeps 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/retentionand sets it with/retention <days>(typed in a covered chat like/privacy, answered privately); there is no per-chat setting. /delete_my_dataerases, 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.
- Phase 1: mono-tenant, plaintext text, RLS in place.
- Phase 2: media (
media_filestable, backup of./mediaseparately from SQL dumps), GDPR commands (/privacyand/delete_my_datashipped). - Phase 3 (this task): real multi-tenancy — several simultaneous account
holders, the
OWNER_TELEGRAM_USER_IDguard replaced byOWNER_ALLOWLIST_TELEGRAM_USER_IDS, connection lifecycle (onboarding, deactivation, reactivation, revocation), an executable audit of the RLS/InTenantsurface, per-chat sharding (#18) and per-tenant quotas (#19). - Phase 4: content encryption (
text_encrypted BYTEA, AES-256-GCM, per-tenant key) replacing plaintexttext_content.