owpengram-server/docs/verification.md
Egor Egorov fff8de783a
feat: add NFT usernames and bot verification (#22)
Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
2026-07-28 01:18:00 +08:00

291 lines
17 KiB
Markdown

# Official platform verification
Official verification is the platform badge shown next to the name of a bot,
public channel or public supergroup whose identity a human reviewer has
confirmed. In this server it is exactly one boolean on one peer record:
`users.verified` or `channels.verified`. Nothing else about the peer changes.
Applications are filed through the built-in `@verifybot`, decided in the admin
panel, and the decision commits together with the flag write. The protocol edge
then makes the new flag observable: it drops the cached peer projections and
pushes the ordinary peer-refresh update to online clients.
## What this is not
The badge here is the **platform** flag: `user#b1b8cc83 verified:flags.17` and
`channel#d49f34c6 verified:flags.7`.
Telegram also has a second, unrelated mechanism — **third-party bot
verification** — where an ordinary bot that a platform operator has appointed as
a "verifier" attaches its own custom icon and description to arbitrary peers.
That is `botVerification#f93cd45c`, `botVerifierSettings#b0cd6617`,
`bots.setCustomVerification#8b89dfbd`, `user.bot_verification_icon:flags2.14?long`,
`channel.bot_verification_icon:flags2.13?long` and
`channelFull.bot_verification:flags2.17?BotVerification`.
The two are deliberately kept apart:
- an approval in this flow never writes `bot_verification*`, and never issues
`botVerifierSettings` to anybody;
- `bots.setCustomVerification` is routed and argument-checked at the RPC edge and
then refused with `403 BOT_VERIFIER_FORBIDDEN`
(`internal/rpc/bots_longtail.go`), because no bot on this deployment is a
verifier;
- a client that renders `bot_verification_icon` renders it from data this flow
never produces, so a third-party icon can neither stand in for the platform
badge nor be shadowed by it.
## TL constructors and flags (Layer 228)
Checked against the schema snapshot the server is built for,
`/tmp/td/_schema/layers/layer-228.tl`.
Platform verification:
| Constructor | Field |
| --- | --- |
| `user#b1b8cc83` | `verified:flags.17?true` |
| `channel#d49f34c6` | `verified:flags.7?true` |
| `chatInvite#5c9d3702` | `verified:flags.7?true`, `scam:flags.8?true`, `fake:flags.9?true` |
`chatInviteAlready#5a686d7c` carries a whole `Chat`, so a member's preview gets
the badge through `channel#d49f34c6` rather than through invite-level flags.
Third-party bot verification, for contrast — none of these are written by this
flow:
| Constructor / method | Field |
| --- | --- |
| `botVerification#f93cd45c` | `bot_id:long icon:long description:string` |
| `botVerifierSettings#b0cd6617` | `icon:long company:string custom_description:flags.0?string` |
| `bots.setCustomVerification#8b89dfbd` | `enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string` |
| `user#b1b8cc83` | `bot_verification_icon:flags2.14?long` |
| `channel#d49f34c6` | `bot_verification_icon:flags2.13?long` |
| `channelFull#a04e8d3a` | `bot_verification:flags2.17?BotVerification` |
`chatInvite#5c9d3702` also has `bot_verification:flags.13?BotVerification`; it is
never populated, for the same reason.
## End-to-end path
1. **`@verifybot`** (user id `1250000011`, seeded by migration `0152`) collects
the application in a step-by-step dialog: subject, category, description,
official website, optional social links, independent press links, optional
note. Commands are `/new`, `/status`, `/cancel`, `/help`.
2. **Eligibility, first gate.** The subject must be a bot, public channel or
public supergroup with a public `@username`, created or administered by the
applicant, not a built-in system entity, not already verified, and not
scam/fake/frozen/deleted. Per-applicant rate limits, an open-application cap
and a post-rejection cooldown also apply.
3. **Submission** writes `verification_applications` (status `submitted`) and an
immutable `verification_application_events` row.
4. **Admin panel** lists the queue and lets a reviewer claim, approve or reject.
Panel BFF routes are under `/api/verification/...`; the Admin API routes are
`GET /v1/verification/applications`, `.../{id}`, `.../counts`,
`POST .../{id}/claim|approve|reject` and `POST /v1/verification/revoke`.
Every decision also goes through the shared admin command journal, so it lands
in `admin_commands` / `admin_audit_logs`.
5. **Eligibility, second gate.** At approval time the target is re-loaded and
re-evaluated against a fresh snapshot, so a target that turned scam, lost its
username, was frozen or got verified by another route between filing and
review is refused. The review queue cannot launder a state the submission path
forbids.
6. **Decision transaction.** The status transition, the audit event, the
applicant-notification outbox row and the `verified` flag write on the peer
commit in one transaction (`verification.PeerVerifier` is invoked with the
store transaction taken from the context). "Approved" and "the peer carries
the badge" can never disagree.
7. **Protocol edge.** After the commit the service calls
`rpc.Router.NotifyPeerVerified(ctx, domain.Peer)`
(`internal/rpc/verification_notify.go`), which:
- drops the cached peer projections for the target
(`invalidateRPCProjectionForUser` / `invalidateRPCProjectionForChannel`);
- for a user or bot, reuses `NotifyUserModerationFlagsChanged` — the same
audience-wide, non-PTS `updateUser` fan-out the scam/fake flags use. The
audience is `ModerationFlagAudience` (accounts that already see the peer),
filtered to the ones currently online; each recipient gets the peer
re-projected for itself;
- for a channel, reuses `NotifyChannelChanged`
`channelStateMutationUpdates``pushChannelStateToMembersWithLinkedMonoforum`,
i.e. `updateChannel` plus the refreshed `channel#d49f34c6` object to the
channel's members (and the linked monoforum when there is one);
- reports a clear error instead of panicking when the peer cannot be resolved,
and is a no-op on a nil receiver. A push failure never invalidates the
committed decision; the caller logs it and moves on.
8. **Applicant notification.** `@verifybot` messages the applicant from a durable
outbox drained by a retrying worker, never from inside the decision
transaction. Kinds are `approved`, `rejected`, `revoked`.
9. **What the client sees.** An online client applies the flag from the pushed
`User`/`Channel` object immediately: the badge appears in the dialog list,
profile, search results and message headers without a restart. An offline
client converges on reconnect — see below.
## Offline convergence
`updateUser` and `updateChannel` carry no `pts`, so they are not stored as
message-box events and are not replayed by `updates.getDifference`. Offline
sessions converge because `verified` is part of the peer's **base read model**,
whose version is bumped by the triggers shipped in `0001_init`:
- `users.verified` is listed in `telesrv_notify_user_base_read_model` (trigger
`users_read_model_changed`), which bumps `user_base`, `contact_account` and the
private dialog-light models, and in
`telesrv_notify_user_channel_participants_read_model` (trigger
`users_channel_participants_read_model_changed`), which bumps
`channel_participants`;
- `channels` bumps `channel_base` on every row change (trigger
`channels_read_model_changed`) and additionally fires
`pg_notify('telesrv_channel_changed')`.
The `user_base` notification is consumed by the read-model listener
(`internal/store/postgres/read_model_listener.go`), which invalidates the RPC
projections **and** the shared Redis `user:base` row across instances. So any
later authoritative read — `users.getUsers`, `users.getFullUser`,
`channels.getChannels`, `channels.getFullChannel`, `messages.getDialogs`, or the
`users`/`chats` vectors attached to a `getDifference` answer — already carries the
new flag. No migration is needed for this, and none was added.
## Where the flag surfaces
`verified` is projected wherever a `User` or `Channel` object is projected, which
is every one of these:
| Surface | Method | Constructor |
| --- | --- | --- |
| Dialog list | `messages.getDialogs`, `messages.getPeerDialogs` | `user`, `channel` in `users`/`chats` |
| Search | `contacts.search`, `contacts.resolveUsername`, `messages.searchGlobal`, `channels.getAdminedPublicChannels` | `user`, `channel` |
| Profile | `users.getUsers`, `users.getFullUser` | `user` in `users.userFull.users` |
| Channel info | `channels.getChannels`, `channels.getFullChannel` | `channel` in `chats` |
| Message history | `messages.getHistory`, `messages.getMessages`, channel history | `user`, `channel` in `users`/`chats` |
| Invite preview | `messages.checkChatInvite` | `chatInvite` (`verified:flags.7`) or `chatInviteAlready.chat` |
| Live updates | pushed `updates` envelopes | `updateUser` / `updateChannel` plus the peer object |
| Difference | `updates.getDifference`, `updates.getChannelDifference` | `user`, `channel` in `users`/`chats` |
The invite preview is the one that used to be missing: before, a non-member saw
an unbadged preview and the badge only appeared after joining. It is now set from
the persistent channel record in `internal/rpc/channels_invites.go`, through the
generated `Set*` helpers so the `flags` word and the struct field stay in step,
and left entirely unset for an unflagged peer.
## Migrations
- **`0153_verify_service_bot`** seeds `@verifybot` (id `1250000011`, fixed
`access_hash` double-written with `domain.VerifyBotAccessHash`), its `bots` row
and command list, and its `peer_usernames` registry entry. The handle is
occupied from the moment the schema is current, so an ordinary user cannot claim
`@verifybot` in the window before first use.
- **`0154_verification_applications`** creates
`verification_applications` (the durable audit subject, never deleted, moved
through `draft → submitted → in_review → approved|rejected|cancelled` under an
optimistic-locking `version`), the append-only
`verification_application_events` history, and
`verification_notification_outbox` with
`UNIQUE (application_id, kind)` so a repeated approve delivers one message.
Partial unique indexes enforce one live application per target and one draft per
applicant.
Neither migration touches the `verified` columns or the read-model triggers:
`users.verified` and `channels.verified` already existed and were already covered.
## Configuration
Verification (`internal/config/config.go`, `.env.example`):
| Key | Default | Meaning |
| --- | --- | --- |
| `TELESRV_VERIFICATION_ENABLED` | `true` | Master switch. When off every use case refuses explicitly; peers already badged keep the badge. |
| `TELESRV_VERIFICATION_ALLOW_USER_TARGETS` | `false` | Whether plain user accounts may be subjects. |
| `TELESRV_VERIFICATION_REJECT_COOLDOWN` | `720h` | Wait before re-filing the same target after a rejection, measured from the decision. `0` disables; max `8760h`. |
| `TELESRV_VERIFICATION_APPLY_RATE_LIMIT` | `3` | Applications one applicant may create per window. `0` disables. |
| `TELESRV_VERIFICATION_APPLY_RATE_WINDOW` | `24h` | That window. |
| `TELESRV_VERIFICATION_BOT_RATE_LIMIT` | `30` | `@verifybot` dialog rate per applicant, independent of applications created. `0` disables. |
| `TELESRV_VERIFICATION_BOT_RATE_WINDOW` | `1m` | That window. |
| `TELESRV_VERIFICATION_NOTIFY_INTERVAL` | `15s` | Applicant-notification worker cadence. Must be positive. |
| `TELESRV_VERIFICATION_NOTIFY_BATCH` | `50` | Rows per cycle, `1..500`. |
| `TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER` | `3` | Open applications per applicant. `0` disables, max `50`. |
Reviewer access:
| Key | Default | Meaning |
| --- | --- | --- |
| `TELESRV_ADMIN_UI_PERMISSIONS` | `*` | Permissions of an Admin UI session. Reviewing needs `verification.review`; clearing an existing badge needs `verification.revoke` on top of it. |
| `TELESRV_ADMIN_SCOPED_TOKENS` | *(empty)* | `name:token:perm1,perm2` entries separated by `;`, for Admin API integrations that should get `verification.review` and nothing else. |
## Manual check with official Telegram Desktop
1. Log in to the deployment with official Telegram Desktop.
2. Open `@verifybot` — it resolves by username and its own profile already shows
the badge (the seed row is `verified`).
3. Send `/new`, pick the subject from the inline picker, and answer the steps:
category, description, official website, social links (or *Skip*), at least the
required number of independent press links, optional note. Press
*Submit application*.
4. Send `/status`; the application is listed as submitted.
5. In the admin panel open the verification queue, claim the application and
approve it. Confirm the audit entry appeared.
6. Within one notification-worker interval `@verifybot` messages the applicant
with the decision.
7. Without restarting the client, check the badge on the approved subject:
- **Profile** — open the peer's profile; the badge sits next to the title.
- **Search** — type the `@username` in global search; the result row is badged.
- **Dialog list** — the chat row in the main list is badged.
- **Message header** — open the chat; the header title is badged.
- **Invite preview** — from a *second* account that is **not** a member, open
an invite link to the approved channel. The join-confirmation box is badged
before joining. (This is the `chatInvite#5c9d3702 verified:flags.7` path.)
8. To check offline convergence, quit the client before approving, approve, then
start it again: the badge is present on the first read, delivered by
`getDifference` and the peer reads it triggers rather than by a live push.
9. Revoking from the panel takes the badge away by the same route.
## Limitations
- **Plain user accounts are off by default.** With
`TELESRV_VERIFICATION_ALLOW_USER_TARGETS=false` (the shipped default) an
application whose subject is an ordinary account is refused
(`ErrVerificationUserTargetsDisabled`). Turning it on does not add any extra
identity checks; it only stops refusing the target type.
- **Third-party bot verification is not part of this mechanism and is not
implemented.** `bots.setCustomVerification` never succeeds: it validates its
arguments and then refuses with `403 BOT_VERIFIER_FORBIDDEN`. No
`botVerifierSettings` are issued, and
`user.bot_verification_icon` / `channel.bot_verification_icon` /
`channelFull.bot_verification` / `chatInvite.bot_verification` are never
populated. A client asking for a custom verifier icon gets nothing.
- **The badge has no attributes.** It is a single boolean: no verifier company, no
custom description, no per-peer icon, no expiry, no verification tier. There is
nothing in the TL surface to carry them for the platform flag.
- **Built-in system entities cannot be applied for.** Service accounts are refused
with `ErrVerificationTargetSystem`; their badge is set by the seed migrations.
- **A subject with no public `@username` is refused**
(`ErrVerificationTargetNotPublic`), so private channels and usernameless bots
cannot be verified at all — not even by an operator using the panel's revoke
route in reverse.
- **Legacy basic groups (`chat#…`) have no `verified` field** in Layer 228, so a
non-migrated basic group can never show a badge regardless of what is stored.
- **The live push only reaches online sessions.** `NotifyPeerVerified` filters the
audience by the online index; everybody else converges on their next
authoritative read. The user fan-out is additionally bounded (the moderation
audience is capped, currently at 4096 accounts) and the channel fan-out is
bounded by the online-member index, so on a very large peer some sessions get
the flag from their next read rather than from a push.
- **`updateUser` / `updateChannel` carry no `pts`.** They are not persisted as
message-box events, so a session that was offline during the decision never
replays the push itself; it depends on the read-model bump. That is by design
(a badge change is not a message), but it means the badge is not guaranteed to
arrive as an *event* — only as *state*.
- **The live push can be one beat behind the shared base-user cache.** The
decision writes the user row inside the verification transaction, bypassing the
`users` service and therefore its Redis `user:base` refresh; that cache is
dropped cross-instance by the asynchronous `user_base` read-model
notification. `NotifyPeerVerified` runs synchronously right after commit, so in
the small window before the listener processes the event the pushed `user`
object can still carry the pre-decision flag. The persisted state is always
correct, the projections are always invalidated, and the client repairs itself
on the next read, so this shows up at worst as a badge that appears a moment
late rather than instantly. The channel path is not affected: the
transaction-scoped channel store is handed the channel row cache and drops it
on the flag write.
- **Nothing re-checks a verified peer over time.** Once badged, a peer keeps the
badge until an operator revokes it. There is no periodic re-validation, and
losing the username or picking up a scam flag later does not clear it.