Note
This is a rewrite currently WIP that will take time. Drink a coffee and join us if you would like to contribute.
Currently we are building a backend which can be reached by other services/repos with a tRPC client. If you are using another language than TS... then you might fulfill a PR and implement gRPC (are you sure u want pain?).
Requirements:
- Bun installed
- A Postgres database
Note
Azure credentials are optional. When AZURE_TENANT_ID, AZURE_CLIENT_ID, and
AZURE_CLIENT_SECRET are all omitted, the Azure tRPC routes use a seeded in-memory directory.
Its changes last until the backend restarts. Set all three variables to connect to Microsoft Graph.
- Install packages
bun install
- Setup environment variables in
.env(use.env.exampleas template and see./src/env.tsas source of truth) - Run the DB migration
bun db:migrate
- Run the server
bun dev
- (Optional, for logging into the admin panel locally) Seed a test user with Telegram already linked and the
ownerrole:The script sends a sign-in OTP tobun run seed:user
test@example.comand prints a link to a temporary inbox where you can read the code — paste it when prompted. Without this step, logging into admin withtest@example.comlands on the "Link your Telegram account" onboarding page, since that account has notelegramIdassociated yet. Re-run with--forceto recreate the test user from scratch.
The backend is moving to the PoliNetwork IdP (RFC v3, Phase 3). Both paths run side by side until every caller sends a token:
- With a token (
Authorization: Bearer <IdP access token>): the token is verified with@polinetwork/auth-kit, and the call is fully enforced. Every procedure declares, per actor kind, the scope it needs and, for people, the permission they must hold in the IdP's access snapshot. A token that fails verification is rejected; it never falls back to the legacy path. The bot acts for a Telegram user withX-PN-Actor: telegram:<id>, which only a service token withbackend:tg:act-asmay send. - Without a token: the legacy behaviour, while
LEGACY_ANONYMOUS=allow. Each call is logged as[AUTH] legacy anonymous callwith its procedure, so callers still on this path can be found. Procedures added for the IdP refuse anonymous calls.
Procedures are built with policy(...) or legacyProcedure from src/trpc.ts; the bare tRPC procedure is not exported, and tests/idp-router.test.ts fails if any procedure lacks a policy. legacyProcedure marks procedures that the RFC removes, which token callers cannot use.
Actor fields such as createdBy, adderId or performerId are read only from legacy callers. For token callers the author comes from the token: content tables record it in *_by_sub columns, and grants made with a token go to tg_grants_v2. Until Phase 6 a grant counts as active if it is active in either tg_grants or tg_grants_v2. The bot records moderation actions with tg.auditLog.record, which takes an idempotency key and flags idp_permission actions the snapshot does not back.
The bot's socket.io client authenticates with its service token in auth.token (scope backend:tg:events) and is disconnected when the token expires. Set OAUTH_BOT_CLIENT_ID on the backend to the same registered OAuth client ID used by the bot. This is the actual client ID, not its display name. Token sockets are rejected if this setting is absent, the client ID differs, the token is not a service token or the scope is missing. Without a token the socket is identified by its type query, while LEGACY_ANONYMOUS=allow.
The IdP notifies POST /internal/events when access changes; the backend also polls the snapshot every 30 s. The last good signing keys and snapshot are stored in common_idp_state, so a restart during an IdP outage resumes from them. Permission checks deny once the snapshot is more than an hour old. Configuration is in .env.example.