Expand description
cratefield-module-telegram: Telegram in the harness — the
secret-token webhook, account linking, and consent buttons that never
move value without a passkey (issue #764).
use cratefield_module_telegram::{Telegram, TelegramEvents};
let module = Telegram::new(TelegramEvents::new().on_message(|message| {
Box::pin(async move { Ok(()) })
}));The module mounts at /v1/telegram with four routes:
POST /webhook— one verified Telegram delivery in. TheX-Telegram-Bot-Api-Secret-Tokenheader is checked againstWEBHOOK_SECRET_KEYover the raw body before any JSON is read; without the secret configured, every delivery is refused with a503. Deduplication runs through theInboxledger on the update id, so a redelivery is a200no-op.POST /link-codesandDELETE /link— the signed-in app issues a one-time code (stored only as a SHA-256 hash) and can unlink again.POST /actions/{action_id}/confirm— the web app’s passkey confirmation, the only thing that can finish a value-moving approval a Telegram tap armed.
The security model of the buttons is in one sentence: a tap in
Telegram can deny anything, approve anything harmless, and only arm
— never finish — anything that moves value. See
send_action_prompt and StepUp.
§cratefield-module-telegram
Telegram in a Cratefield venture: the secret-token webhook, account linking, and consent buttons with a rule this module does not bend — a tap in Telegram can deny anything, approve anything harmless, and only arm — never finish — anything that moves value.
The module mounts at /v1/telegram and owns four tables
(telegram_inbox, telegram_link_codes, telegram_links,
telegram_actions), shipped as one migration.
§Routes
| Route | Who | What |
|---|---|---|
POST /webhook | Telegram | One verified delivery in. Dispatched to the venture’s hooks and to the two flows below. |
POST /link-codes | signed-in caller | Issues a one-time code (43 base64url characters) and, when TELEGRAM_BOT_USERNAME is set, a ready-to-open deep link. |
DELETE /link | signed-in caller | Unlinks the caller’s Telegram account. Idempotent. |
POST /actions/{action_id}/confirm | signed-in owner | The web app’s passkey confirmation for a value-moving action. |
§Configuration
Every key is optional at boot — the module fails closed per request
instead — but set-and-blank fails validate_config:
| Key | Means |
|---|---|
TELEGRAM_WEBHOOK_SECRET | The X-Telegram-Bot-Api-Secret-Token shared secret. Absent: every delivery answers 503. Set: checked over the raw body before any JSON is read, 1–256 characters of A-Z a-z 0-9 _ -. |
TELEGRAM_BOT_TOKEN | Used to build a bot when none was injected with .bot(..). Never logged. |
TELEGRAM_BOT_USERNAME | Present: /link-codes answers a https://t.me/<username>?start=<code> deep link. Absent: the code is issued and the deep link is omitted. |
TELEGRAM_ACTION_SECRET | The HMAC key the action buttons’ tokens are signed with. Absent or blank: prompts cannot be issued and every tap is refused. |
TELEGRAM_APPROVAL_URL | The web app base URL a value-moving action’s confirm button points at, with ?action=<action_id> appended. |
§Linking
The signed-in app calls /link-codes and shows the deep link. The code
is stored only as a SHA-256 hash, works for ten minutes, and works once.
/start <code> in a private chat consumes it (a conditional update,
so a code pasted twice links once), refuses a Telegram user already
linked to a different account — out loud, in the chat — and answers
linked. on_linked fires with the subject, the Telegram user id and
the chat id.
§Action buttons
send_action_prompt sends a subject’s linked chat a text with Approve
and Deny buttons. Each button carries its own compact token in
callback_data (Telegram caps it at 64 bytes):
a1.<base64url(action_id[16] ‖ decision[1] ‖ exp_unix[4 BE] ‖ tag[12])>The tag is HMAC-SHA256 over the action id, the decision, the expiry and
the intended Telegram user, verified in constant time — so a button
forwarded to a group is dead paper, and so is a replayed one: the
decision is a conditional update out of pending, judged by rows
affected.
What a tap does:
- Deny — the action is
denied,on_actionfires, done. - Approve, not value-moving — the action is
approved,on_actionfires withpasskey_confirmed: false, done. - Approve, value-moving — the action only moves to
awaiting_passkey, no hook fires, and the bot replies with a URL button toTELEGRAM_APPROVAL_URL?action=<action_id>.
§The passkey rule
awaiting_passkey has exactly one exit: POST /actions/{action_id}/confirm, from the web app, by the subject who owns
the action, while the action is unexpired, with a configured
StepUp willing to swear the request carries a fresh
passkey ceremony. No StepUp injected — 403, always. Only then does
the confirmation commit (single-use) and on_action fire with
passkey_confirmed: true.
There is deliberately no stock StepUp implementation here. One that
reads the bearer token’s amr and iat claims would be unsound: the
auth service stores the login’s methods in the session and copies them
into every token it mints, stamping each mint with a fresh iat — the
refresh grant included. A token refreshed hours after a passkey login
carries passkey and a seconds-old iat while proving nothing about
the last minutes, and no access-token claim (there is no auth_time)
tells the two apart.
An implementation must therefore prove what the trait’s documentation says: a passkey assertion completed for this subject within this request, or within a freshness window it names — a WebAuthn ceremony it observed, not a passkey-born session. Being signed in is not being present; a consent button that moves value requires the second one.
If the hook behind a finished confirmation fails, the route rolls the
action back to awaiting_passkey — only while it still carries
approved — and answers 5xx, so the caller’s retry can run the
confirmation again; the same is true of a Telegram tap whose on_action
hook fails, and of a /start <code> whose on_linked hook fails (the
code is put back; the redelivery re-runs the linking).
§Composition
use std::sync::Arc;
use cratefield_module_telegram::{Telegram, TelegramEvents, StepUp};
let module = Telegram::new(
TelegramEvents::new()
.on_message(|message| Box::pin(async move { Ok(()) }))
.on_linked(|linked| Box::pin(async move {
println!("{} linked telegram user {}", linked.subject, linked.telegram_user_id);
Ok(())
}))
.on_action(|decision| Box::pin(async move { Ok(()) })),
)
// .bot(Arc::new(HttpTelegramBot::new(http, clock, token))) // or TELEGRAM_BOT_TOKEN
// .random(rng) // required: codes and ids
// .step_up(Arc::new(MyStepUp::new(...))); // see `StepUp`: a fresh passkey assertion, per request.random(..) should always be there — self_check names its absence;
without it no link code or action id can be drawn.
Structs§
- Action
Decision - A consent decision, reported to the venture’s
on_actionhook. - Action
Prompt - A prompt Telegram accepted.
- Action
Request - One consent request, as the venture files it.
- Link
Code Response - The response of
POST /link-codes. - Linked
- A Telegram account has been linked to a subject — the end of a
successful
/start <code>in a private chat. - Telegram
- The Telegram module: the webhook, account linking, and the consent buttons.
- Telegram
Events - The venture’s hooks:
TelegramEvents::new().on_message(|message| ...). - Verified
Token - What a token that opened says.
Enums§
- Action
Error - Why a prompt could not be issued.
- Decision
- Which button the token stands for.
Constants§
- ACTION_
SECRET_ KEY - The config key holding the HMAC key the action buttons’ tokens are
signed with. Absent or blank fails closed:
send_action_promptcannot issue and every tap is refused. - ACTION_
TOKEN_ PREFIX - The prefix every action token starts with: what tells a callback query
apart from any other
dataa venture’s buttons might carry. - APPROVAL_
URL_ KEY - The config key holding the web app’s base URL — where a value-moving
action’s passkey confirmation happens. Telegram is told it as a URL
button with the action id appended to its query string (
?action=…, joined with&when the URL already carries a query). - BOT_
TOKEN_ KEY - The config key holding the bot token. Read only when no bot was
injected with
Telegram::bot; the token is never logged, and a missing one means replies are skipped with a warning — never a crash. - BOT_
USERNAME_ KEY - The config key holding the bot’s
@username. When set,POST /link-codesanswers a ready-to-openhttps://t.me/<username>?start=<code>deep link alongside the code; when absent, the code is still issued and the deep link is simply omitted. - LINK_
CODE_ TTL - How long a link code works: ten minutes. Long enough to switch from the web app to Telegram and press send; short enough that a code pasted into a group chat hours ago is dead paper.
- MAX_
ACTION_ TTL - How long a prompt may live at most: an hour. A consent request that outlives the conversation it started in is one nobody remembers making.
- MODULE_
NAME - The module’s name: mounted at
/v1/telegram, config keys prefixedTELEGRAM_. - WEBHOOK_
SECRET_ KEY - The config key holding the webhook’s shared secret
(
X-Telegram-Bot-Api-Secret-Token). Absent fails closed — every delivery answers503rather than trusting unverified bytes; set but blank failsModule::validate_config.
Traits§
- StepUp
- Proves, or refuses to prove, that a request carries a fresh passkey
ceremony for
subject.
Functions§
- action_
key - The
TELEGRAM_ACTION_SECRETvalue as key bytes,Nonewhen absent or blank — the fail-closed read every issuer and every tap goes through. - action_
token - Mints the token for one button of an action prompt.
- open_
action_ token - Opens
tokenas if it were pressed bytelegram_user_id, at Unix timenow_unix. The tag check is constant-time (Mac::verify_truncated_left), and an expired token is refused here so a stale button never reaches the database. - send_
action_ prompt - Sends one action prompt to the subject’s linked chat: the request’s text with an Approve and a Deny button under it.
Type Aliases§
- Telegram
Hook - A venture hook: called inline as part of handling a delivery (or a
confirm request). Its error is the delivery’s
5xx— which is what makes Telegram retry, and what keeps the dedup key unclaimed.