feat: Telegram V3 — Mini App cockpit (initData auth + /tg surface) (#554)

* feat(telegram): Mini App auth — initData validation mints the cloud-auth session cookie

* feat(panel): /tg Mini App cockpit — approvals, inbox, read-only board, A2A chat

* fix(telegram,panel): unconditional webapp-auth rate limit, future-dated initData rejection, anchored /tg matcher

* docs(map,rag): Telegram Mini App auth route, initData validator, (tg) surface

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 02:47:59 +02:00
committed by GitHub
co-authored by Renn F
parent 3b88c706dd
commit c40a7a39c3
33 changed files with 1725 additions and 27 deletions
+2 -2
View File
@@ -398,7 +398,7 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
**Sandboxed dev DB/Redis/Mongo (default-off).** Per-project opt-in (`projects.sandbox_services`, migration 057); when armed (`ROBOCO_SANDBOX_DB_ENABLED`), provisioning is **on-demand (2026-07-08)**, not eager at spawn: a developer or QA agent calls the `request_sandbox` do-verb (role-scoped to `_DEV_DO`/`_QA_DO` in `role_config.py`; `services` omitted means the project's whole opted-in set) and `ContentActions.request_sandbox` (`roboco/services/gateway/content_actions.py`) walks a guard chain — flag off; no active project-bound task; project not opted into any service; a requested service outside the opted set (remediate names the allowed set); orchestrator handle unavailable (the one **retryable** guard) — before calling `AgentOrchestrator.ensure_sandbox`, which always provisions the project's whole opted-in set regardless of the requested subset (so a later subset/superset request within that set is a guaranteed cache hit and can never trigger a mid-session teardown of a live container the agent is using), verifies a cache hit is still live before trusting it (evicting + re-provisioning on a dead container), serializes concurrent calls for one agent behind a per-slug `asyncio.Lock`, and caches the result in-memory per agent slug (`_sandbox_info`) — the verb filters the returned creds back down to what this call actually asked for. Sibling containers get random per-sandbox creds, tmpfs data dir, memory/cpu caps, labeled `roboco.sandbox=1`; creds return in the verb's envelope `evidence` (`SandboxInfo.as_payload()`), one entry per service including a ready-to-`export` `env` sub-dict (`ROBOCO_TEST_DB_*` / `ROBOCO_TEST_REDIS_*` / `ROBOCO_TEST_MONGO_*`) — never injected as container env, so no spawn-time creds delivery exists at all. Spawn itself only injects a cheap marker env `ROBOCO_SANDBOX_SERVICES_AVAILABLE=<csv>` (never creds) for an opted-in project, plus a briefing line naming `request_sandbox()` explicitly, **in place of** the legacy prod-creds gate-env injection (`_append_gate_env`, which points agents at RoboCo's own production Postgres under `ROBOCO_TOOLCHAIN_MATCH_ENABLED`) — sandbox replaces, never coexists with, prod creds. A provisioning failure now surfaces as a retryable envelope on the verb, never a spawn refusal — sandbox trouble can no longer block dispatch. A sandbox is torn down at end-of-engagement, not just at container removal: `AgentOrchestrator.release_sandbox(agent_slug)` is called (best-effort, never failing the verb; a fast cache-check no-op when nothing was ever requested) by the Choreographer on the SUCCESSFUL exit of `i_am_done` / `unclaim` / `i_am_idle` / `pass_review` / `fail_review` / `i_documented`, so a sidecar doesn't outlive the work that requested it. Lifetime still tracks the agent container 1:1 as the backstop: teardown at every removal path plus an orphan janitor at startup + each reaper tick (grace-windowed so a sweep can't reap a sandbox whose request is still mid-flight; the pre-spawn stale-clear likewise spares a just-requested sandbox) also evicts the `_sandbox_info` cache entry. **Known ceiling:** the cache is in-memory only — an orchestrator restart forgets live sandboxes, so the next `request_sandbox` call re-provisions (the pre-clear tears down any still-running stale container) and returns fresh creds. Docker-in-agent stays structurally absent throughout. The service set is a **pluggable engine registry** (`roboco/models/sandbox.py`): each engine declares its image, run args, readiness probe, and `ROBOCO_TEST_*` env; `VALID_SANDBOX_SERVICES` is derived from the registry, and the provisioner + the verb's payload builder iterate it, so adding an engine (e.g. mongo) is one class + one registry line — no branch edited in the provisioner or the env emitter. **Extensions/modules on the fly (2026-07-13):** a project may declare `sandbox_extensions` (migration 072, jsonb null) — a per-service extension/module map (e.g. `{"postgres": ["vector", "postgis"], "redis": ["search"]}`) activated post-ready via `docker exec` (`CREATE EXTENSION IF NOT EXISTS` / `MODULE LOAD`) then verified; `request_sandbox(extensions=...)` unions a per-call override with the project's standing set, bounded to the opted set + a fixed allowlist (`SANDBOX_PG_EXTENSIONS` = vector/postgis/pg_trgm/citext/uuid-ossp, `SANDBOX_REDIS_MODULES` = search/json/bloom — `plpython3u` excluded by construction; mongo has none). No default set — opters set extensions explicitly, existing opters stay bare. A bare request uses the light upstream image; features pull a kitchen-sink image (`image_for(features)`), so the pgvector+postgis intersection just works. Cache-by-features: a cached entry satisfies a new call iff services are a subset AND per-service requested features are a subset of cached features; a superset re-provisions (rotates creds). The evidence entry carries `available_extensions`. Set the full set in project settings so agents request subsets.
**Cloud auth via FastAPI Users (default-off).** Lets the panel/API be safely exposed beyond localhost without touching the CEO's local no-login flow while off. Gated by `ROBOCO_CLOUD_AUTH_ENABLED` (+ `ROBOCO_CLOUD_AUTH_EMAIL` / `_PASSWORD` / `_SECRET` / `_COOKIE_MAX_AGE`; `Settings` fails loud at startup if the flag is on with no secret). Off: `get_agent_context` (`roboco/api/deps.py`) and the WS `_require_panel_token` gate (`roboco/api/websocket.py`) are byte-for-byte unchanged (header-trust). On: header-trust is dead for humans — any agent-role claim (`ceo` OR a privileged `main_pm`/`cell_pm`/board role) with no valid HMAC token or session cookie is 401, closing the header-spoof hole on the host-published `:8000` port for every role, not just `ceo` (real agents always carry a signed token, so they're unaffected); the agent-fleet HMAC path (and the orchestrator's `system` self-PATCH) keeps working unmodified in both modes; a valid session cookie authenticates as the single seeded CEO user. New `users` table (migration 058, `UserTable` in `roboco/db/tables.py`) backs FastAPI Users' `SQLAlchemyUserDatabase`; no registration router — `roboco/api/auth/seed.py` idempotently upserts exactly one row from env at startup (by primary key, so an email change renames the row instead of duplicating it). `roboco/api/auth/backend.py` wires a **cookie** transport (httponly, secure, samesite=lax) + a `JWTStrategy` subclass that binds each token to a fingerprint of the current `hashed_password`, so rotating the seeded password invalidates every prior session. Session lifetime is **sliding**: every authenticated request through `get_agent_context` re-mints + re-sets the cookie (`_slide_session_cookie`), so an active session never expires — only genuine inactivity past `cloud_auth_cookie_max_age` (default 30 days) logs out. `GET /api/auth/status` is always mounted (public); `/api/auth/login` + `/api/auth/logout` mount only when armed (`roboco/api/auth/routes.py`, mirroring `apply_guard`'s conditional mount). Panel: `(auth)/login/page.tsx` + `proxy.ts` (the Next 16 rename of `middleware.ts`; probes `/auth/status` over the docker-internal orchestrator URL, not through nginx, and fails open to "off" on any probe error/timeout) gate the `(dashboard)` group; `client.ts` adds `withCredentials` + a 401→`/login` redirect. nginx needs no changes (`/api/auth/*` rides the existing `/api/` proxy location) — but its own static `X-Agent-Token` injection (`ROBOCO_PANEL_AGENT_TOKEN`) is itself a valid HMAC credential that bypasses login when present, so a deployment arming cloud auth for real public exposure should leave that token unset (the two are alternative human-auth tiers, not layered).
**Cloud auth via FastAPI Users (default-off).** Lets the panel/API be safely exposed beyond localhost without touching the CEO's local no-login flow while off. Gated by `ROBOCO_CLOUD_AUTH_ENABLED` (+ `ROBOCO_CLOUD_AUTH_EMAIL` / `_PASSWORD` / `_SECRET` / `_COOKIE_MAX_AGE`; `Settings` fails loud at startup if the flag is on with no secret). Off: `get_agent_context` (`roboco/api/deps.py`) and the WS `_require_panel_token` gate (`roboco/api/websocket.py`) are byte-for-byte unchanged (header-trust). On: header-trust is dead for humans — any agent-role claim (`ceo` OR a privileged `main_pm`/`cell_pm`/board role) with no valid HMAC token or session cookie is 401, closing the header-spoof hole on the host-published `:8000` port for every role, not just `ceo` (real agents always carry a signed token, so they're unaffected); the agent-fleet HMAC path (and the orchestrator's `system` self-PATCH) keeps working unmodified in both modes; a valid session cookie authenticates as the single seeded CEO user. New `users` table (migration 058, `UserTable` in `roboco/db/tables.py`) backs FastAPI Users' `SQLAlchemyUserDatabase`; no registration router — `roboco/api/auth/seed.py` idempotently upserts exactly one row from env at startup (by primary key, so an email change renames the row instead of duplicating it). `roboco/api/auth/backend.py` wires a **cookie** transport (httponly, secure, samesite=lax) + a `JWTStrategy` subclass that binds each token to a fingerprint of the current `hashed_password`, so rotating the seeded password invalidates every prior session. Session lifetime is **sliding**: every authenticated request through `get_agent_context` re-mints + re-sets the cookie (`_slide_session_cookie`), so an active session never expires — only genuine inactivity past `cloud_auth_cookie_max_age` (default 30 days) logs out. `GET /api/auth/status` is always mounted (public); `/api/auth/login` + `/api/auth/logout` mount only when armed (`roboco/api/auth/routes.py`, mirroring `apply_guard`'s conditional mount). A second route mints the identical cookie without a password: `POST /api/telegram/webapp-auth` (`roboco/api/routes/telegram.py`), mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` are both armed — see the Telegram bridge entry below. Panel: `(auth)/login/page.tsx` + `proxy.ts` (the Next 16 rename of `middleware.ts`; probes `/auth/status` over the docker-internal orchestrator URL, not through nginx, and fails open to "off" on any probe error/timeout) gate the `(dashboard)` group; `client.ts` adds `withCredentials` + a 401→`/login` redirect. nginx needs no changes (`/api/auth/*` rides the existing `/api/` proxy location) — but its own static `X-Agent-Token` injection (`ROBOCO_PANEL_AGENT_TOKEN`) is itself a valid HMAC credential that bypasses login when present, so a deployment arming cloud auth for real public exposure should leave that token unset (the two are alternative human-auth tiers, not layered).
**RoboCo X account (default-off).** The Head-of-Marketing voice on X (Twitter): drafts a post when a release publishes, drafts replies to meaningful mentions, and — a third, independent capability — periodically investigates RoboCo's own shipped features and drafts a spotlight for an under-publicized one. NOTHING auto-posts across any of the three; every tweet is held in a panel queue for the CEO to edit/approve. Gated by `ROBOCO_X_ENGINE_ENABLED` (+ `_MENTIONS_INTERVAL_SECONDS` / `_MENTIONS_MAX_PER_CYCLE` / `_MENTIONS_MIN_ENGAGEMENT` / `_MAX_OPEN_POSTS` / `X_ACCOUNT_USER_ID`); inert without credentials regardless. Mirrors the `ReleaseManagerEngine` held-artifact shape: `XEngine` (`roboco/services/x_engine.py`) originates a held task (`source` `x_post` / `x_reply` / `x_feature`, `confirmed_by_human=False`, Secretary-owned, skipped by every dispatcher) whose marker payload carries a body clamped to 280 chars. Release posts hook `ReleaseProposalService.approve`'s publish-success branch via a small `draft_release_post` seam; mentions ride a dedicated `_x_mentions_poll_loop` (no webhook infra exists) deduped by a `x_seen_mentions` ledger + per-cycle/open caps — both are **local-model-drafted** (never a cloud LLM in the hot path). The spotlight half is the one exception to "no agent spawn": gated by its own sub-switch `ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED` (+ `_INTERVAL_SECONDS`, default 3 days) on top of `x_engine_enabled`, `_x_feature_spotlight_loop` opens a held PENDING exploration task (`source=x_feature_exploration`, team=Board, assigned to Head of Marketing, carrying a `x_seen_features` dedup-ledger snapshot marker) that `_dispatch_pm_work` routes (mirroring `ROADMAP_SOURCE`) to a one-shot real cloud-LLM spawn of the Head of Marketing — full read tools, investigates CHANGELOG.md/feature-flags/docs/map/charter/KB, calls the Head-of-Marketing-only `propose_feature_spotlight` do-tool exactly once, which marks the feature slug seen (`x_seen_features` table, migration 061) and materializes a brand-new `source=x_feature` held draft (completing the exploration task as a side effect — a deliberate asymmetry from `propose_roadmap`, which instead leaves its own task open). The four OAuth 1.0a secrets live Fernet-encrypted in a singleton `x_credentials` row (migration 059, all-or-nothing set/clear, mirroring the git-token pattern; the API only ever returns `has_credentials`) — decryption is server-side only, agents never hold creds or egress. `XPostService.approve` (CEO-only route) is the ONLY caller of `x_client.post_tweet`: it posts under a Redis single-flight lock, **re-reads the committed task state inside the lock and commits COMPLETED before releasing** so a concurrent approve can't double-post, and is idempotent (an already-posted draft is a no-op). The hand-rolled OAuth 1.0a HMAC-SHA1 signer (`roboco/services/x_client.py`) adds no dependency; a `NullXClient` makes the unconfigured path a graceful no-op (research-engine posture). All three draft kinds share one voice: `XEngine._voice_guide` reads the CEO-editable `company_goals.brand_voice` charter field (migration 061, panel-editable in Business → Goals) and appends it to a generic baseline (`_HOM_VOICE`) — the baseline alone until the CEO supplies a real sample. Panel: `x-post-queue.tsx` (editable draft + 280 counter, approve/reject, a `sourceMeta`-driven label/icon per source including "Feature spotlight") + `x-credentials-card.tsx` (4 write-only secret inputs).
@@ -414,7 +414,7 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
**Env-branches ladder + EnvSyncEngine (default-off `ROBOCO_ENV_SYNC_ENABLED`).** Replaces a project's single `default_branch` with an ordered environment ladder: nullable `projects.environments` JSONB (migration 073), an ordered `list[{name, branch}]` where index 0 is the **head** rung (where dev/cell/leaf PRs land) and index -1 is the **prod** rung (where the gated release executor commits + tags); middle rungs are intermediates (qa/stag). A null ladder degenerates to a single-branch ladder synthesized from `default_branch` at read time (`roboco/models/env_branches.py`: `head_branch` / `prod_branch` / `ladder_pairs` / `promotion_chain`) — no backfill, byte-for-byte legacy behavior until the CEO declares a real split. Every former `default_branch` consumer now routes through the shim: the PR target and per-agent clone (`WorkspaceService.ensure_workspace` / `ensure_read_clone`), the CI branch, the release executor's clone/commit/tag target (`_ReleaseContext.prod_branch`) plus its full-chain head→…→prod promotion before bumping (`promote_env_chain`, fail-closed `promotion_failed` on a merge conflict), and `release_readiness`'s diff baseline (`prod..head` instead of `last_tag..HEAD`) with a tag-drift cross-check (`_tag_drift_gaps` — the last tag's commit vs. prod tip disagreeing flags a hotfix that landed on prod after the tag). `EnvSyncEngine` (`roboco/services/env_sync_engine.py`) cascades the ladder prod→…→head via GitHub's merges API: a clean merge auto-pushes straight to the lower rung, a conflict opens ONE idempotent sync PR + a Main-PM coordination task and stops that project's cascade for the cycle — the cascade's target is never the prod rung by construction, so "only the CEO merges master" still holds. Bounded + deduped per repo (one open env_sync task at a time). Panel: an environment-ladder editor on the project edit dialog.
**Telegram notifications bridge V1+V2 (default-off `ROBOCO_TELEGRAM_ENABLED`).** V1: best-effort, outbound-only Telegram DMs to the CEO on escalation and completion. Mirrors the `x_credentials` pattern: a singleton Fernet-encrypted `telegram_credentials` row (migration 074, bot token + chat id; the API returns `has_credentials` only) behind CEO-only `/telegram/credentials` routes and a panel credentials card. `_notify_telegram` (`roboco/services/notification_delivery.py`) fans out from `notify_ceo_of_escalation` / `notify_ceo_of_completion`, sending only the notification's subject plus an optional panel deep-link (`panel_base_url`) — never the body — via a deferred, best-effort send that never raises into the producer (`NullTelegramClient` when unconfigured or the flag is off, `LiveTelegramClient` posting to the Bot API otherwise). V2 (`ROBOCO_TELEGRAM_INBOUND_ENABLED`, sub-switch on top of V1's flag — both plus stored credentials are required, otherwise the bot only sends and never listens) makes the bridge two-way: `TelegramInboundEngine` (`roboco/services/telegram_inbound.py`) long-polls `getUpdates` from a dedicated orchestrator loop (`_telegram_poll_loop`), authorizing every update by BOTH chat id and sender id, and routes `/status` / `/queue` / `/task` commands plus `Approve`/`Reject`/`Open` inline-keyboard taps (a compact `apv|rej:<kind>:<id8>` callback codec; a reject reason or a task-approve note is collected via a force_reply prompt held in a TTL'd in-memory pending-action map) through the SAME CEO-gated service calls the HTTP routes make (task/release/xpost/video/roadmap), stamping a `via=telegram` audit row on each. Escalation DMs (not completion DMs) carry the actionable keyboard when V2 is armed. Closing the loop exposed a real hole: a stale Approve/Reject button targets its item by id regardless of current status, so `ReleaseProposalService.approve`/`.reject`, `XPostService.approve`, and `VideoPostService.approve` now all refuse an already-CANCELLED (rejected) or already-COMPLETED (published/posted) target instead of silently re-executing — a fix that also closes the identical hole via a replayed HTTP call, not just Telegram.
**Telegram notifications bridge V1+V2+V3 (default-off `ROBOCO_TELEGRAM_ENABLED`).** V1: best-effort, outbound-only Telegram DMs to the CEO on escalation and completion. Mirrors the `x_credentials` pattern: a singleton Fernet-encrypted `telegram_credentials` row (migration 074, bot token + chat id; the API returns `has_credentials` only) behind CEO-only `/telegram/credentials` routes and a panel credentials card. `_notify_telegram` (`roboco/services/notification_delivery.py`) fans out from `notify_ceo_of_escalation` / `notify_ceo_of_completion`, sending only the notification's subject plus an optional panel deep-link (`panel_base_url`) — never the body — via a deferred, best-effort send that never raises into the producer (`NullTelegramClient` when unconfigured or the flag is off, `LiveTelegramClient` posting to the Bot API otherwise). V2 (`ROBOCO_TELEGRAM_INBOUND_ENABLED`, sub-switch on top of V1's flag — both plus stored credentials are required, otherwise the bot only sends and never listens) makes the bridge two-way: `TelegramInboundEngine` (`roboco/services/telegram_inbound.py`) long-polls `getUpdates` from a dedicated orchestrator loop (`_telegram_poll_loop`), authorizing every update by BOTH chat id and sender id, and routes `/status` / `/queue` / `/task` commands plus `Approve`/`Reject`/`Open` inline-keyboard taps (a compact `apv|rej:<kind>:<id8>` callback codec; a reject reason or a task-approve note is collected via a force_reply prompt held in a TTL'd in-memory pending-action map) through the SAME CEO-gated service calls the HTTP routes make (task/release/xpost/video/roadmap), stamping a `via=telegram` audit row on each. Escalation DMs (not completion DMs) carry the actionable keyboard when V2 is armed. Closing the loop exposed a real hole: a stale Approve/Reject button targets its item by id regardless of current status, so `ReleaseProposalService.approve`/`.reject`, `XPostService.approve`, and `VideoPostService.approve` now all refuse an already-CANCELLED (rejected) or already-COMPLETED (published/posted) target instead of silently re-executing — a fix that also closes the identical hole via a replayed HTTP call, not just Telegram. V3 adds a Telegram **Mini App** sign-in: `POST /api/telegram/webapp-auth` (`roboco/api/routes/telegram.py`, mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` are both armed — `telegram_miniapp_enabled` is env-only like `cloud_auth_enabled`, deliberately off the panel feature-flags card, and fails loud at startup if armed without cloud auth on) validates Telegram's signed `initData` (`roboco/utils/telegram_initdata.py` — pure HMAC-SHA256 `WebAppData`-keyed validation, constant-time compare, a `telegram_initdata_max_age_seconds` freshness window with 60s clock-skew tolerance) against the stored bot token and the CEO's own `chat_id`, then mints the same cloud-auth session cookie `/api/auth/login` issues — turning the CEO's phone into a real panel client at the new `(tg)` route group (`/tg`: Approvals/Inbox/Board/Chat tabs, outside the normal dashboard shell; `proxy.ts`'s matcher excludes `tg(?:/|$)` so a phone session is never bounced to the password `/login` page it can't reach). Requires a public HTTPS origin (the cookie is secure-only) and BotFather's `/setmenubutton` pointed at `https://<host>/tg`.
**Possibilities matrix (default-off `ROBOCO_POSSIBILITIES_MATRIX_ENABLED`).** A work-already-done fast path on `i_am_done`: when a claimed/in_progress task already has commits, an open PR, every acceptance criterion addressed, and no open findings (`_work_appears_done`), the dev submits straight to QA in one call instead of the standard multi-turn plan/journal/local-gate derivation. `_i_am_done_fast_path` still runs the non-negotiable guards — ownership, branch-pushed, not-behind-base, conventions, `FINDINGS_ADDRESSED` — and trusts the PR's own CI-green signal as the quality-gate proxy (`_fast_path_quality_verdict`, the same signal `pr_pass` trusts); a repo with no CI signal falls back to the local `make quality` gate (plus the toolchain-match guard when `ROBOCO_TOOLCHAIN_MATCH_ENABLED` is armed), and a known-red CI refuses the fast path outright rather than shipping it to QA. The orchestrator's dev spawn prompt steers a matching task to a `WORK_ALREADY_DONE` state that tells the dev to call `i_am_done` directly instead of re-deriving what's already done.
+8
View File
@@ -538,6 +538,14 @@ services:
# above); the whole bridge stays inert until that flag AND credentials
# are both set, so arming this alone does nothing yet.
ROBOCO_TELEGRAM_INBOUND_ENABLED: ${ROBOCO_TELEGRAM_INBOUND_ENABLED:-true}
# Telegram Mini App sign-in: validates Telegram's signed WebApp initData
# and mints the same cloud-auth session cookie /api/auth/login issues,
# so the CEO's phone becomes an authenticated panel client. Requires
# ROBOCO_CLOUD_AUTH_ENABLED=true (startup fails loud otherwise) AND a
# public HTTPS origin (the cookie is secure-only, and Telegram itself
# only opens Mini Apps over https). Default OFF; not armed here — the
# operator flips it on once TLS + cloud-auth creds are both live.
ROBOCO_TELEGRAM_MINIAPP_ENABLED: ${ROBOCO_TELEGRAM_MINIAPP_ENABLED:-false}
ROBOCO_OBSIDIAN_VAULT_ENABLED: ${ROBOCO_OBSIDIAN_VAULT_ENABLED:-true}
ROBOCO_VAULT_PATH: ${ROBOCO_VAULT_PATH:-/app/vault}
ROBOCO_VAULT_INTAKE_ENABLED: ${ROBOCO_VAULT_INTAKE_ENABLED:-true}
+9 -2
View File
@@ -38,7 +38,8 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| roboco/api/routes/docs.py | Project docs write/read/list/delete. |
| roboco/api/routes/x.py | X (Twitter) engine — CEO-only: list/approve/reject held draft posts + set/status OAuth 1.0a credentials. |
| roboco/api/routes/roadmap.py | Board roadmap engine — CEO-only: list open cycles + per-item approve/reject. |
| roboco/api/auth/ | Cloud auth (FastAPI Users, default off): `backend.py` (cookie transport + password-fingerprint-bound JWT strategy), `manager.py` (`UserManager` + DI chain), `session.py` (`resolve_session_user`, shared by the HTTP dual-path and the WS panel-token gate), `seed.py` (idempotent single seeded CEO login upsert), `routes.py` (always-public `/auth/status` + conditional login/logout mount). |
| roboco/api/routes/telegram.py | Telegram credentials CRUD (CEO-only, write-only) + `webapp_auth_router` — a separate public, pre-auth `POST /webapp-auth` mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` are both armed (`mount_telegram_miniapp_auth`); validates a Mini App's `initData` and mints the cloud-auth session cookie; adds its own unconditional `LoginRateLimiter`. |
| roboco/api/auth/ | Cloud auth (FastAPI Users, default off): `backend.py` (cookie transport + password-fingerprint-bound JWT strategy), `manager.py` (`UserManager` + DI chain), `session.py` (`resolve_session_user`, shared by the HTTP dual-path and the WS panel-token gate), `seed.py` (idempotent single seeded CEO login upsert), `routes.py` (always-public `/auth/status` + conditional login/logout mount), `login_limit.py` (`LoginRateLimiter` — per-IP POST rate limit, path-keyed via a `paths: tuple[str, ...]` set so `/login` and `telegram.py`'s `/webapp-auth` get independent buckets). |
| roboco/api/routes/v1/_role_dep.py | Per-role HMAC guards + `envelope_to_response` helper. |
| roboco/api/routes/v1/do.py | Content verbs `/api/v1/do/*` (commit/note/say/dm/evidence/playbook...). |
| roboco/api/routes/v1/flow_dev.py | Developer flow verbs. |
@@ -73,6 +74,8 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| GET/POST | /api/playbooks, /{id}/{approve,reject,archive} | playbooks.py | agent context (Auditor/CEO) |
| GET/POST | /api/x/posts, /posts/{id}/{approve,reject}, /credentials | x.py | `require_ceo_role` (agent context) |
| GET/POST | /api/roadmap/cycles, /cycles/{id}/items/{id}/{approve,reject} | roadmap.py | `require_ceo_role` (agent context) |
| GET/POST | /api/telegram/credentials | telegram.py | `require_ceo_role` (agent context) |
| POST | /api/telegram/webapp-auth | telegram.py | public, pre-auth — Telegram `initData` HMAC validation; mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` |
| GET/POST | /api/auth/status (always), /auth/login, /auth/logout (mounted only when `cloud_auth_enabled`) | auth/routes.py | none (status) / FastAPI Users cookie login |
| GET/POST/PUT/DELETE | /api/projects, /{id}/conventions, /workspace, /sync | project.py | agent context |
| POST | /api/git/branches/cleanup | git.py | agent context, PM/CEO role-gated like `/rebase`; rate-limit 5/60 — cursor-resumable stale-branch sweep, `GitBranchCleanupRequest`/`Response` (wave 2, open PR #548) |
@@ -167,6 +170,7 @@ roboco/api/
│ │ ├── pitch.py pitch approve/reject
│ │ ├── x.py X engine post queue approve/reject + credentials
│ │ ├── roadmap.py board roadmap cycle item approve/reject
│ │ ├── telegram.py credentials CRUD + webapp-auth (Mini App initData → session cookie)
│ │ ├── a2a.py agent-to-agent + SSE
│ │ ├── prompter_live.py live Intake chat
│ │ ├── secretary.py company state + directives
@@ -188,7 +192,8 @@ roboco/api/
│ ├── manager.py UserManager + get_user_db/get_user_manager DI chain
│ ├── session.py resolve_session_user (shared HTTP + WS cookie validation)
│ ├── seed.py ensure_seed_user / ensure_seed_user_startup (single CEO row)
── routes.py always-public /status + conditional login/logout mount
── routes.py always-public /status + conditional login/logout mount
│ └── login_limit.py LoginRateLimiter (per-IP POST limit; path-keyed, shared with telegram.py webapp-auth)
└── schemas/
├── *.py per-domain Pydantic models
└── v1/
@@ -213,6 +218,7 @@ roboco/api/
## Config Flags
- Auth-gate mode: `_auth_required()` (env-driven; HMAC mandatory in prod-ish, optional in dev) — `api/deps.py`.
- Feature-flag routes are inert when their backing engine is off: `release.py` (ROBOCO_RELEASE_MANAGER_ENABLED), `prompter_live.py` MegaTask batch, `optimal.py` learnings (ROBOCO_ORG_MEMORY_ENABLED), `research.py` (ROBOCO_RESEARCH_ENABLED), `provider.py` grok/self-hosted (ROBOCO_GROK / self-hosted), CI-watch/dep-update originate elsewhere but surface via orchestrator/tasks.
- `telegram.py`'s `webapp_auth_router` doesn't merely no-op off — the route doesn't exist at all unless `telegram_miniapp_enabled` AND `cloud_auth_enabled` are both true (`mount_telegram_miniapp_auth`, called from `app.py`, mirrors `mount_cloud_auth`'s conditional mount).
## Gotchas
- `do` + `a2a` routers are token-only (any authenticated role), not role-asserted — any signed agent can call any content verb; service-layer scope is the only gate.
@@ -241,6 +247,7 @@ roboco/api/
> - `876e19b3` Wave 2c (#298) — adds `/api/a2a/chat/admin/pairs` (the switchboard, same `_require_ceo` gate); tightens `/api/tasks` PATCH so cell/main PM roles get a content-only field allowlist instead of the unrestricted CEO/Board/Auditor admin bypass (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, `roboco/api/routes/tasks.py:256,278`) — closes an over-permission hole where PM identities could edit any-team tasks via the ASSIGN-holding bypass.
> - `637c75dc` (2026-07-17, PR #546, "wave-1 quick wins") fix(api): normalize agent UUID to slug at the orchestrator route boundary — `_validated_agent_id` now also calls `_resolve_to_slug` after its path-injection checks, so a caller-supplied DB UUID (e.g. from the panel) resolves to the canonical slug before spawn/stop/status/resolve-wait/mark-waiting address the runtime, fixing UUID-named containers and registry misses.
> - (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) adds `POST /api/git/branches/cleanup` (PM/CEO role-gated like `/rebase`, rate-limit 5/60) + `GitBranchCleanupRequest`/`GitBranchCleanupResponse` schemas — cursor-resumable sweep of terminal tasks' remote+local branches, backing a confirm-dialog button on the panel Git page.
> - `82642bea`+`e16fb634`+`8d727785` (2026-07-18, PR #554, Telegram V3 Mini App) adds `POST /api/telegram/webapp-auth` (`webapp_auth_router`, mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` via `mount_telegram_miniapp_auth`) + `TelegramWebAppAuthRequest` schema, exchanging a validated Telegram `initData` payload for the same cloud-auth session cookie `/api/auth/login` mints (binds to the CEO's stored `chat_id`, audits `telegram.webapp.login`); generalizes `LoginRateLimiter` from a single `prefix` to a `paths: tuple[str, ...]` set (`roboco/api/auth/login_limit.py`) so `/webapp-auth` gets its own unconditional per-IP bucket, independent of the guard middleware's `rate_limit` decorator; the fix commit also rejects a far-future `initData.auth_date` (only ±60s clock-skew tolerated) and anchors the panel's `/tg` matcher exclusion.
## Regression Risks
+1 -1
View File
@@ -1,5 +1,5 @@
## Purpose
This slice implements RoboCo's formal-notification backbone: NotificationService is the typed notification factory (blocker, QA-ready, A2A, board-review, ack), NotificationDeliveryService handles delivery (transactional-outbox bus publish), ACK tracking, expiry sweeps, PM/CEO task-handoff notifications, and the best-effort Telegram DM bridge (`_notify_telegram`), and notification_dedup is a bounded Redis SET-NX re-fire guard for loop-prone notification types. Together they turn lifecycle events into both a durable DB record and a real-time push, with multiple dedup layers (Redis re-fire window + DB purpose-dedup) to keep agent inboxes from flooding under coordinator loops. The Telegram side has grown into its own two-way bridge: V1 (outbound-only DMs on escalation/completion) plus V2's `TelegramInboundEngine` (`telegram_inbound.py`) — a poll loop that turns the CEO's Telegram replies/button-taps into the same CEO-gated service calls the HTTP routes make.
This slice implements RoboCo's formal-notification backbone: NotificationService is the typed notification factory (blocker, QA-ready, A2A, board-review, ack), NotificationDeliveryService handles delivery (transactional-outbox bus publish), ACK tracking, expiry sweeps, PM/CEO task-handoff notifications, and the best-effort Telegram DM bridge (`_notify_telegram`), and notification_dedup is a bounded Redis SET-NX re-fire guard for loop-prone notification types. Together they turn lifecycle events into both a durable DB record and a real-time push, with multiple dedup layers (Redis re-fire window + DB purpose-dedup) to keep agent inboxes from flooding under coordinator loops. The Telegram side has grown into its own two-way bridge: V1 (outbound-only DMs on escalation/completion) plus V2's `TelegramInboundEngine` (`telegram_inbound.py`) — a poll loop that turns the CEO's Telegram replies/button-taps into the same CEO-gated service calls the HTTP routes make. V3 (the Telegram Mini App sign-in) touches none of this slice's files — it's a pure HTTP route + validator, mapped in `api-routes-schemas.md` / `support-services.md` / `panel.md`.
## Files
+18 -6
View File
@@ -20,13 +20,16 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `panel/src/app/(dashboard)/workstation/page.tsx` | Workstation: Products + Projects merged as URL-param tabs (`?tab=products\|projects`, Products first); `products/page.tsx` and `projects/page.tsx` are now server-component redirects to it |
| `panel/src/app/(dashboard)/{agents,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages |
| `panel/src/app/(auth)/login/page.tsx` | Cloud-auth login form (email/password → `useLogin``/auth/login`); only reachable/relevant once `proxy.ts` starts gating the `(dashboard)` group |
| `panel/src/proxy.ts` | Next 16's rename of `middleware.ts`: probes `/auth/status` (docker-internal orchestrator URL, fails open to "off" on any error/timeout) and redirects to `/login` when cloud auth is on and no session cookie is present |
| `panel/src/app/(tg)/layout.tsx` + `(tg)/tg/page.tsx` | Telegram Mini App cockpit at `/tg`: slim shell (no sidebar/header, `next/script` loads the Telegram WebApp bridge `afterInteractive`) + bootstrap page that resolves `window.Telegram.WebApp`, POSTs its `initData` to `/telegram/webapp-auth` unconditionally, then renders the tabbed cockpit (or an "Open from Telegram" / error state) |
| `panel/src/proxy.ts` | Next 16's rename of `middleware.ts`: probes `/auth/status` (docker-internal orchestrator URL, fails open to "off" on any error/timeout) and redirects to `/login` when cloud auth is on and no session cookie is present; matcher excludes `tg(?:/|$)` — the Mini App authenticates via Telegram `initData`, not the password cookie, so it must never be redirected to `/login` |
| `panel/src/components/dashboard/` | Overview cards: command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity, `x-post-queue.tsx`, `roadmap-review-queue.tsx` |
| `panel/src/components/metrics/` | delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table |
| `panel/src/components/kanban/{core,shared,views}/` | core: kanban-board/column/card + bypass-preconditions; views: dev/qa/pm/pr-review kanban |
| `panel/src/components/prompter/` | intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card |
| `panel/src/components/a2a/` | a2a-switchboard (org-chart pair cards, 45s pulse fade) + a2a-switchboard-utils (pairKey/grouping/pulse), a2a-pair-card, a2a-conversation-list (classic fallback), a2a-transcript, a2a-reply-composer (CEO chime-in on a watched conversation), a2a-new-dm-dialog (CEO opens a fresh 1:1), a2a-direct-composer (CEO's own thread, no task link required), a2a-utils |
| `panel/src/components/tasks/` + `tasks/task-detail/` | task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector, task-detail tabs (overview/plan/progress/commits/sessions/notes/dependencies/**findings**) |
| `panel/src/components/a2a/` | a2a-switchboard (org-chart pair cards, 45s pulse fade) + a2a-switchboard-utils (pairKey/grouping/pulse), a2a-pair-card, a2a-conversation-list (classic fallback), a2a-transcript, a2a-reply-composer (CEO chime-in on a watched conversation), a2a-new-dm-dialog (CEO opens a fresh 1:1; exports `EXCLUDE_NON_DM_ROLES` so `tg-chat-tab.tsx`'s compose picker shares the same non-DM-capable-role exclusion instead of drifting out of sync), a2a-direct-composer (CEO's own thread, no task link required), a2a-utils |
| `panel/src/components/tasks/` + `tasks/task-detail/` | task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector, task-detail tabs (overview/plan/progress/commits/sessions/notes/dependencies/**findings**), `mobile-task-board.tsx` (read-only, grouped-by-status phone board for the `/tg` cockpit) |
| `panel/src/components/tg/` | The `/tg` cockpit's own tabs: `tg-tab-bar.tsx` (4-tab bottom nav, page-state-controlled), `tg-approvals-tab.tsx` (stacks the existing held-artifact queue cards), `tg-inbox-tab.tsx` (notifications + ack), `tg-board-tab.tsx` (wraps `mobile-task-board.tsx`), `tg-chat-tab.tsx` (A2A conversation list / compose / polled thread, phone-scoped rebuild of the desktop A2A page) |
| `panel/src/lib/telegram/webapp.ts` | Thin typed wrapper over the global `window.Telegram.WebApp` (`ready`/`expand`/`initData`); `waitForTelegramWebApp` polls (100ms, 1.5s timeout) for the CDN script since it loads `afterInteractive` |
| `panel/src/components/settings/` | feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, `x-credentials-card.tsx` (write-only OAuth 1.0a secrets, mounted in `settings/page.tsx`) |
| `panel/src/components/conventions/conventions-tab.tsx` | Per-project architecture map + health (in edit-project dialog) |
| `panel/src/components/projects/`, `products/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups (`projects/` and `products/` each export a `*-view.tsx` consumed by `workstation/page.tsx`); `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) |
@@ -64,6 +67,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| Kanban | `components/kanban/{core,views}/*` | dnd-kit drag board; dev/qa/pm/pr-review views; drag routes through admin status-override with bypass-precondition prompt |
| Task Detail | `components/tasks/task-detail/*` | Tabbed: overview, plan, progress, commits, sessions, notes, dependencies, **findings**, AC, action dialogs |
| AI Providers | `app/(dashboard)/settings/ai-providers/page.tsx` + `components/settings/ai-routing-card.tsx` | Per-slug/role/global model routing |
| Telegram Mini App | `app/(tg)/tg/page.tsx` + `components/tg/*` | The CEO's phone cockpit, outside the `(dashboard)` shell: 4 tabs (Approvals — the held-artifact queues restacked; Inbox — notifications + ack; Board — `mobile-task-board.tsx` read-only grouped-by-status; Chat — A2A conversation list/compose/thread, polled not WS). Bootstraps via Telegram `initData``/telegram/webapp-auth`, requires both `telegram_miniapp_enabled` and `cloud_auth_enabled` armed server-side |
## Key Symbols
@@ -81,7 +85,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `usePrompter` | hook | `hooks/use-prompter.ts` | Intake state machine: SSE refs, draft/batch extraction, turn lifecycle |
| `useRateLimitWebsocket` | hook | `hooks/use-rate-limit-websocket.ts` | Single `/ws/system` subscriber; dispatches RATE_LIMIT_* + USAGE_SNAPSHOT; clears usage on disconnect |
| `useA2ALiveStream` | hook | `hooks/use-websocket.ts` | Second `/ws/system` consumer (same shared connection): filters `a2a.message` frames, exposes `lastMessage`/`a2aMessages`/`isConnected` for the A2A page's invalidate-on-frame + switchboard pulses |
| `useA2AAdminPairs` / `useA2AConversations` / `useA2AMessages` | hooks | `hooks/use-a2a-live.ts` | TanStack Query wrappers over `a2aApi.listAdminPairs/listAdminConversations/listAdminMessages`; 30s `staleTime`, invalidated by `a2a.message` frames |
| `useA2AAdminPairs` / `useA2AConversations` / `useA2AMessages` | hooks | `hooks/use-a2a-live.ts` | TanStack Query wrappers over `a2aApi.listAdminPairs/listAdminConversations/listAdminMessages`; 30s `staleTime`, invalidated by `a2a.message` frames; `useA2AMessages` takes an optional `{ refetchInterval }` (default off — the desktop A2A page relies on WS invalidation) that `tg-chat-tab.tsx` sets to ~10s since the `/tg` cockpit has no WS wiring |
| `useReplyAsCeo` | hook | `hooks/use-a2a-live.ts` | Mutation wrapping `a2aApi.replyAsCeo`; invalidates the conversation list + the watched transcript's messages on success |
| `A2ASwitchboard` / `A2APairCard` | comp | `components/a2a/a2a-switchboard.tsx` + `a2a-pair-card.tsx` | Org-chart pair cards grouped into sections (cell/PM-chain/board/cross-team) via `groupPairsBySection`; each card pulses for `PAIR_PULSE_FADE_MS` (45s) after a matching live frame |
| `A2AReplyComposer` | comp | `components/a2a/a2a-reply-composer.tsx` | CEO chime-in box on a selected WATCHED conversation; disabled when it has no linked task (A2A sends require one) |
@@ -145,6 +149,9 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
├── src/app/
│ ├── layout.tsx (root layout: providers, theme, fonts)
│ ├── (auth)/login/page.tsx (cloud-auth login form; gated by proxy.ts)
│ ├── (tg)/
│ │ ├── layout.tsx (slim shell, no sidebar/header; loads Telegram WebApp bridge script)
│ │ └── tg/page.tsx (bootstrap: initData → /telegram/webapp-auth, then 4-tab cockpit)
│ └── (dashboard)/
│ ├── layout.tsx (dashboard shell: sidebar + header + connection status)
│ ├── overview/page.tsx (→ <CommandCenter/>)
@@ -165,7 +172,8 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
│ │ └── views/ (dev/qa/pm/pr-review kanban)
│ ├── prompter/ (intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card)
│ ├── a2a/ (a2a-switchboard + a2a-switchboard-utils, a2a-pair-card, a2a-conversation-list, a2a-transcript, a2a-reply-composer, a2a-new-dm-dialog, a2a-direct-composer, a2a-utils)
│ ├── tasks/ + tasks/task-detail/ (task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector; detail tabs: overview/plan/progress/commits/sessions/notes/dependencies/findings)
│ ├── tasks/ + tasks/task-detail/ (task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector; detail tabs: overview/plan/progress/commits/sessions/notes/dependencies/findings; mobile-task-board.tsx for /tg)
│ ├── tg/ (tg-tab-bar, tg-approvals-tab, tg-inbox-tab, tg-board-tab, tg-chat-tab — the /tg cockpit's own tabs)
│ ├── settings/ (feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, x-credentials-card)
│ ├── conventions/conventions-tab.tsx (per-project architecture map + health)
│ ├── projects/projects-view.tsx, products/products-view.tsx (Workstation tab panes)
@@ -177,6 +185,7 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
├── src/lib/
│ ├── api/*.ts (per-domain axios clients; client.ts shared instance; release, playbooks, prompter-live, tasks, settings, usage, cockpit, a2a, auth, x, roadmap, …)
│ ├── websocket/connection.ts (WebSocketConnection + getWebSocketUrl)
│ ├── telegram/webapp.ts (window.Telegram.WebApp wrapper + waitForTelegramWebApp poll)
│ ├── stores/ (scroll-restoration-store only; ui-store is sole-canonical in src/store/)
│ └── {constants,utils,agent-definitions,agent-utils,repo-url,mock-data}.ts
├── src/proxy.ts (Next 16 rename of middleware.ts: gates (dashboard) behind cloud auth)
@@ -223,7 +232,7 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
- `telegram_enabled` — Telegram CEO-DM bridge (V1, outbound-only; pre-existing, previously missing from this list)
- `telegram_inbound_enabled` — Telegram V2 sub-switch (on top of `telegram_enabled`): poll for commands/button-taps and make escalation DMs actionable
Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime toggle): `ROBOCO_CLOUD_AUTH_ENABLED` and `ROBOCO_DB_NETWORK_ISOLATED`.
Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime toggle): `ROBOCO_CLOUD_AUTH_ENABLED`, `ROBOCO_DB_NETWORK_ISOLATED`, and `ROBOCO_TELEGRAM_MINIAPP_ENABLED` (Mini App sign-in — same TLS coupling as cloud auth, which it also requires).
## Gotchas
- **Relative URLs only** (`/api`, `/ws`); overriding `NEXT_PUBLIC_API_URL`/`NEXT_PUBLIC_WS_URL` to an absolute URL reintroduces CORS — leave defaults.
@@ -236,6 +245,8 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
- ~~`ui-store` exists under both `store/ui-store.ts` and `lib/stores/ui-store.ts`~~**FIXED** (536bbb64): `lib/stores/ui-store.ts` was removed and replaced with `scroll-restoration-store.ts`; `store/ui-store.ts` is now the sole canonical location.
- **`proxy.ts` is Next.js 16's renamed `middleware.ts`** — same file-convention contract (default export + `config.matcher`), just relocated/renamed terminology (it never ran in true Edge middleware). A reader searching the repo for `middleware.ts` will find nothing; the gate lives at `src/proxy.ts`.
- **`proxy.ts` fails OPEN, not closed** — a slow/unreachable orchestrator on the `/auth/status` probe (1500ms timeout) is treated as "cloud auth off," so the dashboard stays reachable rather than the CEO getting locked out by a transient backend hiccup. This is the deliberately safe default (off is what every deploy starts on) but means a genuinely-armed deployment with a flaky orchestrator could intermittently skip the login gate.
- **`proxy.ts`'s `/tg` exclusion must be anchored** (`tg(?:/|$)`, not a bare `tg`) — an unanchored `tg` in the negative-lookahead matcher would also skip gating on any unrelated route that merely starts with "tg", not just the Mini App; fixed same-PR (8d727785) alongside the initData far-future rejection.
- **`/tg`'s bootstrap POSTs `initData` to `/telegram/webapp-auth` on every mount**, not just cold loads — there's no client-readable signal (the session cookie is httponly) to know a warm reload already has a valid cookie, so `tg/page.tsx` always re-validates; the route is idempotent (re-mints the same cookie) so this is cheap by design, not an oversight.
- **X Post Queue / Roadmap Review Queue hide when empty**, mirroring the release-proposal + playbook queues — a CEO who doesn't see the card has no signal that the underlying engine is even armed; both need `refetchInterval: 30000` to surface a newly-originated draft/cycle without a manual refresh.
- **A2A page activity is A2A-only by design**: `latestPulseTimestamps` (switchboard-utils) derives pulses purely from `a2a.message` frames on `/ws/system`, never from the verb/flow traffic sharing that same stream — a CEO ruling, not an oversight, so don't "fix" the switchboard to also light up on ordinary gateway verbs.
- **A2A reply composer is read-only on a task-less conversation**: the backend's `reply_as_ceo` route 400s exactly when the watched conversation has no `task_id` (A2A sends always ride the gateway `send` path, which requires one) — the panel pre-empts that bounce with an explanatory message instead of letting the POST fail. Conversation `status` does NOT gate the composer; the CEO's reply lands in its own direct thread with the participant, not into the watched conversation.
@@ -275,6 +286,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
> - **Wave 3** (2026-07-17, branch `feature/wave-3-a2a-ceo`, PR #547) — CEO New-DM composer: `a2a-new-dm-dialog.tsx` (opens a fresh CEO-owned 1:1, `AgentSelector`'s new `excludeRoles` prop) + `a2a-direct-composer.tsx` (posts in a CEO-owned thread, no task link needed) wired into `page.tsx`'s composer-selection branch (CEO-owned thread → direct composer; task-linked watched thread → reply composer; else read-only). `use-a2a-live.ts` adds `useCreateCeoConversation`/`useSendCeoMessage`; `lib/api/a2a.ts` adds `createConversation`/`sendCeoMessage` (both force `X-Agent-ID: "ceo"` per-call). `client.ts`'s header injection changed from an unconditional overwrite to a `has()`/`set()` default so a per-call override survives. Backend: `A2AService._maybe_wake_ceo_recipient` wakes an offline `read_a2a`-capable recipient of a CEO DM via the `a2a_request` dispatch path — see `docs/map/a2a-audit-journal-permissions.md`. Same branch also scrubbed "message the CEO" recipes from `docs/rag`/`agents/prompts` (agents are never taught to DM the CEO — reply-only).
> - (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) Wave 2 hygiene + charts: `/work-sessions` route now redirects to `/git?tab=sessions` (moved under Git as a "Work Sessions" tab, `git-page.tsx` gains a `Tabs`); `GitActionsPanel` gains a confirm-gated "Clean Up Stale Branches" button (`useCleanupBranches`, cursor-resumable); `WorkSessionsView`'s filters moved from URL params to local state (ScrollRestoration bounce fix); new `SessionTrendChart` / `CostTrendChart` / `SpendTrendChart`.
> - `dd4cb7f1` Wave 4: Workstation page (PR #549, 2026-07-17) — Products + Projects merged into one `/workstation?tab=` page (Products first); content extracted byte-faithfully into `components/products/products-view.tsx` + `components/projects/projects-view.tsx`; `products/page.tsx` + `projects/page.tsx` become redirect shims; the two sidebar entries collapse into one "Workstation" entry (`Briefcase` icon); `task-metadata.tsx`'s project-card link retargets to `/workstation?tab=projects`; `ProjectsView`'s q/cell/inactive filters move from URL params to local `useState` (scroll-bounce prevention, trades away shareable filtered-view links).
> - `e16fb634`+`8d727785` (2026-07-18, PR #554, Telegram V3 Mini App) — new `(tg)` route group (`layout.tsx` + `tg/page.tsx`) and `components/tg/{tg-tab-bar,tg-approvals-tab,tg-inbox-tab,tg-board-tab,tg-chat-tab}.tsx`; new `components/tasks/mobile-task-board.tsx` (read-only, grouped-by-status) and `lib/telegram/webapp.ts` (WebApp bridge + `waitForTelegramWebApp` poll); `a2a-new-dm-dialog.tsx` exports `EXCLUDE_NON_DM_ROLES` for `tg-chat-tab.tsx` to reuse; `use-a2a-live.ts`'s `useA2AMessages` gains an optional `refetchInterval` (the cockpit polls instead of using WS); `proxy.ts` matcher excludes `tg(?:/|$)` (anchored in the fix commit — see Gotchas). Backend companion: `POST /api/telegram/webapp-auth` — see `docs/map/api-routes-schemas.md`.
## Regression Risks
+3 -1
View File
@@ -29,6 +29,7 @@ Cross-cutting support layer beneath the delivery services: the service-base/erro
| `roboco/utils/__init__.py` | Re-exports crypto + converter helpers | 23 |
| `roboco/utils/converters.py` | `InvalidIdentifierError` + `require_uuid` / `to_python_uuid` / `to_python_uuid_list` + `repo_key` | 99 |
| `roboco/utils/crypto.py` | Fernet `encrypt_token` / `decrypt_token` / `is_encryption_configured` + `EncryptionError` | 111 |
| `roboco/utils/telegram_initdata.py` | Pure Telegram Mini App `initData` validation (no I/O): `WebAppData`-keyed HMAC-SHA256 derivation, `hmac.compare_digest` check, freshness window (`auth_date` within `max_age_seconds`, ±60s clock-skew tolerance, no far-future) | 76 |
## Key Symbols
@@ -271,7 +272,7 @@ Panel-tunable flags defined in `services/settings.py:46` `FEATURE_FLAGS` (stored
| `x_engine_enabled` | X (Twitter) engine | `ROBOCO_X_ENGINE_ENABLED` |
| `roadmap_engine_enabled` | Board roadmap engine | `ROBOCO_ROADMAP_ENGINE_ENABLED` |
Cloud auth (`ROBOCO_CLOUD_AUTH_ENABLED`) and DB network isolation (`ROBOCO_DB_NETWORK_ISOLATED`) are deliberately **not** in `FEATURE_FLAGS` — both are compose/env-coupled (cookie/TLS posture and the `networks:` topology respectively) and unsafe for a runtime toggle to flip mid-session; they stay pure env vars, not panel-tunable settings.
Cloud auth (`ROBOCO_CLOUD_AUTH_ENABLED`) and DB network isolation (`ROBOCO_DB_NETWORK_ISOLATED`) are deliberately **not** in `FEATURE_FLAGS` — both are compose/env-coupled (cookie/TLS posture and the `networks:` topology respectively) and unsafe for a runtime toggle to flip mid-session; they stay pure env vars, not panel-tunable settings. `ROBOCO_TELEGRAM_MINIAPP_ENABLED` (Telegram Mini App sign-in) joins them for the same reason — security/TLS-coupled, and `Settings` fails loud at startup if it's armed without `cloud_auth_enabled`; its sibling `telegram_initdata_max_age_seconds` (default 600) is likewise env-only.
Other settings read here: `transcript_retention_days` (int, ≥1; read by orchestrator at `runtime/orchestrator.py:5910`). Non-flag config consumed: `settings.redis_url` (`health`, `stream_bus`), `settings.encryption_key` (`crypto`).
@@ -314,6 +315,7 @@ No logic-touching commits to list — IMPACT: none.
> - `321e68d7` [sweep] proactive: `_find_code_patterns` method, its call, summary line, and count removed; `ContextPackage.code_patterns` field retained (always-empty, back-compat).
> - `536bbb64` Chore/all/logical-gaps-sweep (#286) — merge commit pulling the above into the branch.
> - `d83104e9` (2026-07-17, PR #546, "wave-1 quick wins") fix(llm): provider mode switches preserve per-agent model pins — `_apply_anthropic`/`_apply_grok`/`_apply_ollama`/`_apply_self_hosted` now delete only ROLE/GLOBAL `model_assignments` rows (`scope != AGENT_SLUG`) instead of wiping the whole table, so an AGENT_SLUG pin survives a mode switch; `OLLAMA_ROLE_DEFAULTS` removed from `llm_catalog.py` as dead code (it was never consulted by routing — see `models.md`).
> - `82642bea` (2026-07-18, PR #554, Telegram V3 Mini App) adds `roboco/utils/telegram_initdata.py` (new file, pure `validate_init_data`) — no other file in this slice's scope touched by the PR.
## Regression Risks
+2
View File
@@ -37,6 +37,8 @@ Agent HMAC auth and the orchestrator's `system` self-PATCH are untouched in both
`panel/src/proxy.ts` (the Next.js middleware entry) probes `GET /api/auth/status` on every non-API, non-static request; if `cloud_auth_enabled` is true and the `roboco_session` cookie is absent, it redirects to `/login`. The probe fails open (treats a slow/unreachable backend as "cloud auth off") within a 1.5s timeout — a stuck backend must never turn into a stuck redirect loop.
A second route mints this same session cookie without a password: `POST /api/telegram/webapp-auth` validates a Telegram Mini App's signed `initData` instead, gated by its own `ROBOCO_TELEGRAM_MINIAPP_ENABLED` (which itself requires `cloud_auth_enabled`) — see `docs/map/api-routes-schemas.md`.
## Related
- `docs/rag/architecture/config-reference.md` — full env var table
+31
View File
@@ -0,0 +1,31 @@
import Script from "next/script";
/**
* Slim shell for the Telegram Mini App surface (`/tg`) no Sidebar/Header/
* BottomTabBar, just a full-height scroll region. QueryClient/Theme/Toaster
* already come from the root layout's <Providers>, so nothing new is
* provided here.
*
* `beforeInteractive` is root-layout-only (Next.js throws outside
* app/layout.tsx), so this loads the Telegram bridge script with the default
* `afterInteractive` strategy instead `waitForTelegramWebApp` (in
* lib/telegram/webapp.ts) briefly polls for `window.Telegram.WebApp` to
* absorb the resulting load race rather than assuming it's present on mount.
*/
export default function TelegramLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
<Script
src="https://telegram.org/js/telegram-web-app.js"
strategy="afterInteractive"
/>
<main className="flex-1 overflow-auto pt-[env(safe-area-inset-top)]">
{children}
</main>
</div>
);
}
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
const { waitForTelegramWebApp } = vi.hoisted(() => ({
waitForTelegramWebApp: vi.fn(),
}));
vi.mock("@/lib/telegram/webapp", () => ({ waitForTelegramWebApp }));
const { post } = vi.hoisted(() => ({ post: vi.fn() }));
vi.mock("@/lib/api/client", () => ({
default: { post },
getErrorMessage: (err: unknown) =>
(err as { message?: string } | undefined)?.message ?? "Unknown error",
}));
// The cockpit tabs each fetch their own data (queue cards, tasks,
// notifications, A2A) — stubbed out here since this test only exercises the
// bootstrap state machine, not tab content (each tab gets its own coverage).
vi.mock("@/components/tg/tg-tab-bar", () => ({
TgTabBar: () => <div data-testid="tg-tab-bar" />,
}));
vi.mock("@/components/tg/tg-approvals-tab", () => ({
TgApprovalsTab: () => <div data-testid="tg-approvals-tab" />,
}));
vi.mock("@/components/tg/tg-inbox-tab", () => ({
TgInboxTab: () => <div data-testid="tg-inbox-tab" />,
}));
vi.mock("@/components/tg/tg-board-tab", () => ({
TgBoardTab: () => <div data-testid="tg-board-tab" />,
}));
vi.mock("@/components/tg/tg-chat-tab", () => ({
TgChatTab: () => <div data-testid="tg-chat-tab" />,
}));
import TelegramMiniAppPage from "../page";
function mockWebApp(initData = "abc123") {
return { ready: vi.fn(), expand: vi.fn(), initData };
}
describe("TelegramMiniAppPage — auth bootstrap", () => {
beforeEach(() => {
waitForTelegramWebApp.mockReset();
post.mockReset();
});
it("shows a spinner while validating", () => {
waitForTelegramWebApp.mockReturnValue(new Promise(() => {}));
render(<TelegramMiniAppPage />);
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
});
it("renders the not-inside-Telegram screen when no WebApp object exists", async () => {
waitForTelegramWebApp.mockResolvedValue(null);
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(screen.getByText(/open from telegram/i)).toBeInTheDocument(),
);
expect(post).not.toHaveBeenCalled();
});
it("calls ready/expand, posts initData, and renders the cockpit on success", async () => {
const webApp = mockWebApp("real-init-data");
waitForTelegramWebApp.mockResolvedValue(webApp);
post.mockResolvedValue({ data: { ok: true } });
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(screen.getByTestId("tg-tab-bar")).toBeInTheDocument(),
);
expect(webApp.ready).toHaveBeenCalledTimes(1);
expect(webApp.expand).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith("/telegram/webapp-auth", {
init_data: "real-init-data",
});
// Default tab is Approvals.
expect(screen.getByTestId("tg-approvals-tab")).toBeInTheDocument();
});
it("renders an error screen with the server's message when auth is refused", async () => {
waitForTelegramWebApp.mockResolvedValue(mockWebApp());
post.mockRejectedValue({ message: "Mini App disabled" });
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(screen.getByText(/couldn.t sign in/i)).toBeInTheDocument(),
);
expect(screen.getByText("Mini App disabled")).toBeInTheDocument();
expect(screen.queryByTestId("tg-tab-bar")).not.toBeInTheDocument();
});
});
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import api, { getErrorMessage } from "@/lib/api/client";
import { waitForTelegramWebApp } from "@/lib/telegram/webapp";
import { TgTabBar, type TgTab } from "@/components/tg/tg-tab-bar";
import { TgApprovalsTab } from "@/components/tg/tg-approvals-tab";
import { TgInboxTab } from "@/components/tg/tg-inbox-tab";
import { TgBoardTab } from "@/components/tg/tg-board-tab";
import { TgChatTab } from "@/components/tg/tg-chat-tab";
import { Loader2, AlertTriangle, ExternalLink } from "lucide-react";
type BootstrapState =
| { kind: "validating" }
| { kind: "ready" }
| { kind: "not_in_telegram" }
| { kind: "error"; message: string };
function CenteredMessage({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full min-h-[70dvh] flex-col items-center justify-center gap-3 p-6 text-center">
{children}
</div>
);
}
/**
* `/tg` the CEO's phone cockpit. On mount: resolve the Telegram WebApp
* bridge, then POST its initData to the auth route unconditionally (the
* route is idempotent it just re-mints the session cookie on every call)
* before rendering the tabbed cockpit. There's no way to read the resulting
* httponly session cookie client-side to skip this on a warm reload, so it
* always runs; it's cheap and the backend contract says so explicitly.
*/
export default function TelegramMiniAppPage() {
const [state, setState] = useState<BootstrapState>({ kind: "validating" });
const [tab, setTab] = useState<TgTab>("approvals");
useEffect(() => {
let cancelled = false;
void (async () => {
const webApp = await waitForTelegramWebApp();
if (cancelled) return;
if (!webApp) {
setState({ kind: "not_in_telegram" });
return;
}
webApp.ready();
webApp.expand();
try {
await api.post("/telegram/webapp-auth", {
init_data: webApp.initData ?? "",
});
if (!cancelled) setState({ kind: "ready" });
} catch (err) {
if (!cancelled) {
setState({ kind: "error", message: getErrorMessage(err) });
}
}
})();
return () => {
cancelled = true;
};
}, []);
if (state.kind === "validating") {
return (
<CenteredMessage>
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Connecting</p>
</CenteredMessage>
);
}
if (state.kind === "not_in_telegram") {
return (
<CenteredMessage>
<ExternalLink className="h-10 w-10 text-muted-foreground" />
<h1 className="text-lg font-semibold">Open from Telegram</h1>
<p className="text-sm text-muted-foreground">
This cockpit only runs inside Telegram. Open it from the bot&apos;s
menu button.
</p>
</CenteredMessage>
);
}
if (state.kind === "error") {
return (
<CenteredMessage>
<AlertTriangle className="h-10 w-10 text-destructive" />
<h1 className="text-lg font-semibold">Couldn&apos;t sign in</h1>
<p className="text-sm text-muted-foreground">{state.message}</p>
</CenteredMessage>
);
}
return (
<div className="p-3 pb-20">
{tab === "approvals" && <TgApprovalsTab />}
{tab === "inbox" && <TgInboxTab />}
{tab === "board" && <TgBoardTab />}
{tab === "chat" && <TgChatTab />}
<TgTabBar active={tab} onChange={setTab} />
</div>
);
}
@@ -25,7 +25,9 @@ import { useCreateCeoConversation } from "@/hooks/use-a2a-live";
// Self, plus every role that can't actually read/answer a DM: auditor and
// pr_reviewer carry no read_a2a on their manifests, prompter and secretary
// are human-only note/evidence roles — a DM to any of them is a black hole.
const EXCLUDE_NON_DM_ROLES = [
// Exported so other "start a fresh 1:1" surfaces (the /tg Mini App chat tab)
// share the exact same exclusion list instead of drifting out of sync.
export const EXCLUDE_NON_DM_ROLES = [
AgentRole.CEO,
AgentRole.AUDITOR,
AgentRole.PR_REVIEWER,
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
const { useTasks } = vi.hoisted(() => ({ useTasks: vi.fn() }));
vi.mock("@/hooks/use-tasks", () => ({ useTasks }));
import { MobileTaskBoard } from "../mobile-task-board";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "t1",
title: "Fix the thing",
description: "",
status: TaskStatus.IN_PROGRESS,
team: Team.BACKEND,
task_type: TaskType.CODE,
acceptance_criteria: [],
parent_task_id: null,
assigned_to: "be-dev-1",
...overrides,
} as unknown as Task;
}
describe("MobileTaskBoard", () => {
beforeEach(() => {
useTasks.mockReset();
});
it("renders skeletons while loading", () => {
useTasks.mockReturnValue({ data: undefined, isLoading: true });
const { container } = render(<MobileTaskBoard />);
expect(container.querySelectorAll('[data-slot="skeleton"]').length).toBeGreaterThan(0);
});
it("shows an empty state when there are no tasks", () => {
useTasks.mockReturnValue({ data: [], isLoading: false });
render(<MobileTaskBoard />);
expect(screen.getByText("No tasks")).toBeInTheDocument();
});
it("groups tasks into per-status collapsible sections with title/assignee rows", () => {
useTasks.mockReturnValue({
data: [
buildTask({ id: "a", title: "In progress task", status: TaskStatus.IN_PROGRESS, assigned_to: "be-dev-1" }),
buildTask({ id: "b", title: "Another in-progress task", status: TaskStatus.IN_PROGRESS, assigned_to: "be-dev-2" }),
buildTask({ id: "c", title: "Done task", status: TaskStatus.COMPLETED, assigned_to: null }),
],
isLoading: false,
});
render(<MobileTaskBoard />);
// in_progress is open-by-default: its 2 rows are immediately visible.
expect(screen.getByText("In progress task")).toBeInTheDocument();
expect(screen.getByText("Another in-progress task")).toBeInTheDocument();
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
expect(screen.getByText("Backend Dev 2")).toBeInTheDocument();
// Section header text is split across nested spans ("in progress" + a
// separately-styled "(2)"), so query by the trigger button's accessible
// name (which aggregates descendant text) rather than getByText, which
// doesn't match text broken up across multiple elements.
expect(
screen.getByRole("button", { name: /in progress \(2\)/i }),
).toBeInTheDocument();
// completed is collapsed by default: the section header shows, the row doesn't.
expect(
screen.getByRole("button", { name: /completed \(1\)/i }),
).toBeInTheDocument();
expect(screen.queryByText("Done task")).not.toBeInTheDocument();
// expanding it reveals the row and its "Unassigned" fallback.
fireEvent.click(screen.getByRole("button", { name: /completed \(1\)/i }));
expect(screen.getByText("Done task")).toBeInTheDocument();
expect(screen.getByText("Unassigned")).toBeInTheDocument();
});
});
@@ -0,0 +1,150 @@
"use client";
import { useMemo, useState } from "react";
import { useTasks } from "@/hooks/use-tasks";
import { TaskStatus, type Task } from "@/types";
import { TaskStatusBadge } from "@/components/tasks/task-status-badge";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { Skeleton } from "@/components/ui/skeleton";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ChevronDown, ListTodo } from "lucide-react";
import { cn } from "@/lib/utils";
// Active-work-first display order (mirrors the lifecycle doc's left-to-right
// flow) rather than the enum's declaration order.
const STATUS_ORDER: TaskStatus[] = [
TaskStatus.IN_PROGRESS,
TaskStatus.BLOCKED,
TaskStatus.NEEDS_REVISION,
TaskStatus.VERIFYING,
TaskStatus.AWAITING_QA,
TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.AWAITING_PR_REVIEW,
TaskStatus.AWAITING_PM_REVIEW,
TaskStatus.AWAITING_CEO_APPROVAL,
TaskStatus.PAUSED,
TaskStatus.CLAIMED,
TaskStatus.PENDING,
TaskStatus.BACKLOG,
TaskStatus.COMPLETED,
TaskStatus.CANCELLED,
];
// Open by default: the actionable half of the lifecycle. Terminal and
// not-yet-started sections start collapsed to keep the first scroll short.
const DEFAULT_OPEN = new Set<TaskStatus>([
TaskStatus.IN_PROGRESS,
TaskStatus.BLOCKED,
TaskStatus.NEEDS_REVISION,
TaskStatus.AWAITING_CEO_APPROVAL,
]);
function TaskRow({ task }: { task: Task }) {
return (
<div className="flex items-center justify-between gap-2 border-t px-3 py-2 first:border-t-0">
<div className="min-w-0">
<p className="truncate text-sm">{task.title}</p>
<p className="truncate text-xs text-muted-foreground">
{getAgentDisplayName(task.assigned_to)}
</p>
</div>
<TaskStatusBadge status={task.status} />
</div>
);
}
function StatusSection({
status,
tasks,
defaultOpen,
}: {
status: TaskStatus;
tasks: Task[];
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="rounded-lg border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left">
<span className="text-sm font-medium">
{status.replace(/_/g, " ")}{" "}
<span className="text-muted-foreground">({tasks.length})</span>
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</CollapsibleTrigger>
<CollapsibleContent>
{tasks.map((t) => (
<TaskRow key={t.id} task={t} />
))}
</CollapsibleContent>
</Collapsible>
);
}
/**
* Read-only phone-cockpit task board: every task grouped by status into
* collapsible sections, compact rows (title, assignee, status pill). No
* drag-and-drop that's the desktop kanban columns' job; this is a
* glance-and-tap surface for the /tg Mini App.
*/
export function MobileTaskBoard() {
const { data, isLoading } = useTasks({ limit: 200 });
const grouped = useMemo(() => {
const byStatus = new Map<TaskStatus, Task[]>();
for (const task of data ?? []) {
const list = byStatus.get(task.status);
if (list) list.push(task);
else byStatus.set(task.status, [task]);
}
return STATUS_ORDER.filter((s) => byStatus.has(s)).map((s) => ({
status: s,
tasks: byStatus.get(s)!,
}));
}, [data]);
if (isLoading) {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full" />
))}
</div>
);
}
if (grouped.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground">
<ListTodo className="h-8 w-8 opacity-50" />
<p className="text-sm">No tasks</p>
</div>
);
}
return (
<div className="space-y-2">
{grouped.map(({ status, tasks }) => (
<StatusSection
key={status}
status={status}
tasks={tasks}
defaultOpen={DEFAULT_OPEN.has(status)}
/>
))}
</div>
);
}
@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { TgTabBar } from "../tg-tab-bar";
describe("TgTabBar", () => {
it("renders all 4 tabs and marks the active one with aria-current", () => {
render(<TgTabBar active="inbox" onChange={vi.fn()} />);
expect(screen.getByRole("button", { name: /approvals/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /board/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /chat/i })).toBeInTheDocument();
const inbox = screen.getByRole("button", { name: /inbox/i });
expect(inbox).toHaveAttribute("aria-current", "page");
expect(
screen.getByRole("button", { name: /approvals/i }),
).not.toHaveAttribute("aria-current");
});
it("calls onChange with the tapped tab's id", () => {
const onChange = vi.fn();
render(<TgTabBar active="approvals" onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: /chat/i }));
expect(onChange).toHaveBeenCalledWith("chat");
fireEvent.click(screen.getByRole("button", { name: /board/i }));
expect(onChange).toHaveBeenCalledWith("board");
});
});
@@ -0,0 +1,24 @@
"use client";
import { ReleaseProposalCard } from "@/components/dashboard/release-proposal-card";
import { XPostQueue } from "@/components/dashboard/x-post-queue";
import { VideoPostQueue } from "@/components/dashboard/video-post-queue";
import { RoadmapReviewQueue } from "@/components/dashboard/roadmap-review-queue";
/**
* The CEO's held-artifact stack, vertically stacked for a single thumb
* scroll column. Every card is the exact same self-contained
* dashboard-layout-independent component the desktop dashboard renders
* each already fetches its own data and no-ops (renders nothing useful) when
* empty, so there's nothing to compose here beyond stacking them.
*/
export function TgApprovalsTab() {
return (
<div className="space-y-4">
<ReleaseProposalCard />
<XPostQueue />
<VideoPostQueue />
<RoadmapReviewQueue />
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { MobileTaskBoard } from "@/components/tasks/mobile-task-board";
/** Cockpit Board tab thin wrapper so every tab has its own file under
* components/tg/ (per the per-tab-file convention); the board itself lives
* in components/tasks since it's a general read-only task view, not
* Mini-App-specific. */
export function TgBoardTab() {
return <MobileTaskBoard />;
}
+271
View File
@@ -0,0 +1,271 @@
"use client";
import { useState } from "react";
import {
useA2AConversations,
useA2AMessages,
useCreateCeoConversation,
useSendCeoMessage,
} from "@/hooks/use-a2a-live";
import { CEO_SLUG } from "@/components/a2a/a2a-utils";
import { AgentSelector } from "@/components/agents/agent-selector";
import { EXCLUDE_NON_DM_ROLES } from "@/components/a2a/a2a-new-dm-dialog";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { getErrorMessage } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import { ArrowLeft, MessageSquarePlus, Send } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
/** Thread polling cadence the /tg cockpit has no WS wiring (unlike the
* desktop A2A page), so the actively-viewed thread polls instead. */
const THREAD_POLL_MS = 10_000;
function ConversationList({
onSelect,
onCompose,
}: {
onSelect: (id: string, peerLabel: string) => void;
onCompose: () => void;
}) {
const { data, isLoading } = useA2AConversations(50);
return (
<div className="space-y-2">
<Button
type="button"
variant="outline"
className="w-full justify-center gap-2"
onClick={onCompose}
>
<MessageSquarePlus className="h-4 w-4" />
New chat
</Button>
{isLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))
) : !data?.items.length ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No conversations yet
</p>
) : (
data.items.map((c) => {
const peer = c.agent_a === CEO_SLUG ? c.agent_b : c.agent_a;
const peerLabel = getAgentDisplayName(peer);
return (
<button
key={c.id}
type="button"
onClick={() => onSelect(c.id, peerLabel)}
className="flex w-full flex-col gap-0.5 rounded-lg border p-3 text-left"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{peerLabel}</span>
{c.last_message_at && (
<span className="shrink-0 text-[11px] text-muted-foreground">
{formatDistanceToNow(new Date(c.last_message_at))} ago
</span>
)}
</div>
{c.last_message_preview && (
<p className="truncate text-xs text-muted-foreground">
{c.last_message_preview}
</p>
)}
</button>
);
})
)}
</div>
);
}
function ComposeNewChat({
onCreated,
onCancel,
}: {
onCreated: (id: string, peerSlug: string) => void;
onCancel: () => void;
}) {
const [target, setTarget] = useState<string | null>(null);
const [message, setMessage] = useState("");
const create = useCreateCeoConversation();
const submit = () => {
const trimmed = message.trim();
if (!target || !trimmed || create.isPending) return;
const targetAgent = target;
create.mutate(
{ target_agent: targetAgent, initial_message: trimmed },
{
onSuccess: (conversation) => onCreated(conversation.id, targetAgent),
onError: (err) => toast.error(getErrorMessage(err)),
},
);
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="icon" onClick={onCancel}>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium">New chat</span>
</div>
<AgentSelector
value={target}
onChange={setTarget}
excludeRoles={EXCLUDE_NON_DM_ROLES}
placeholder="Who do you want to message?"
allowClear={false}
/>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type a message…"
className="min-h-[90px] resize-none"
disabled={create.isPending}
/>
<Button
type="button"
className="w-full"
disabled={!target || !message.trim() || create.isPending}
onClick={submit}
>
<Send className="mr-2 h-4 w-4" />
Send
</Button>
</div>
);
}
function ThreadView({
conversationId,
peerLabel,
onBack,
}: {
conversationId: string;
peerLabel: string;
onBack: () => void;
}) {
const { data, isLoading } = useA2AMessages(conversationId, {
refetchInterval: THREAD_POLL_MS,
});
const [draft, setDraft] = useState("");
const send = useSendCeoMessage();
const submit = () => {
const trimmed = draft.trim();
if (!trimmed || send.isPending) return;
send.mutate(
{ conversationId, content: trimmed },
{
onSuccess: () => setDraft(""),
onError: (err) => toast.error(getErrorMessage(err)),
},
);
};
// max-h (not flex-1/h-full) deliberately: the page root has no fixed
// height (other tabs need the outer layout scroll, not a clipped one), so
// a flex height chain here would have nothing definite to inherit. A
// capped, independently-scrolling message region is the simplest thing
// that actually scrolls regardless of ancestor height.
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 border-b pb-2">
<Button type="button" variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="font-medium">{peerLabel}</span>
</div>
<div className="max-h-[60dvh] space-y-2 overflow-y-auto py-1">
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : !data?.items.length ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No messages yet
</p>
) : (
data.items.map((m) => (
<div
key={m.id}
className={cn(
"max-w-[85%] rounded-lg px-3 py-2 text-sm",
m.from_agent === CEO_SLUG
? "ml-auto bg-primary text-primary-foreground"
: "bg-muted",
)}
>
{m.content}
</div>
))
)}
</div>
<div className="flex items-end gap-2 border-t pt-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Message…"
className="min-h-[44px] resize-none"
disabled={send.isPending}
/>
<Button
type="button"
size="icon"
disabled={!draft.trim() || send.isPending}
onClick={submit}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
);
}
type ChatView =
| { mode: "list" }
| { mode: "compose" }
| { mode: "thread"; id: string; peer: string };
/**
* A2A chat for the CEO's phone: a conversation list, a compose-new-DM
* picker, and a polled thread view the mobile-scoped equivalent of the
* desktop A2A admin page, built fresh rather than reusing its WS-wired,
* switchboard-heavy components (not a fit for a single thumb column).
*/
export function TgChatTab() {
const [view, setView] = useState<ChatView>({ mode: "list" });
if (view.mode === "compose") {
return (
<ComposeNewChat
onCreated={(id, peerSlug) =>
setView({ mode: "thread", id, peer: getAgentDisplayName(peerSlug) })
}
onCancel={() => setView({ mode: "list" })}
/>
);
}
if (view.mode === "thread") {
return (
<ThreadView
conversationId={view.id}
peerLabel={view.peer}
onBack={() => setView({ mode: "list" })}
/>
);
}
return (
<ConversationList
onSelect={(id, peerLabel) => setView({ mode: "thread", id, peer: peerLabel })}
onCompose={() => setView({ mode: "compose" })}
/>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import {
useNotifications,
useAcknowledgeNotification,
} from "@/hooks/use-notifications";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { getErrorMessage } from "@/lib/api/client";
import type { Notification } from "@/types";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Bell, Check } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
function TgNotificationRow({ notification }: { notification: Notification }) {
const acknowledge = useAcknowledgeNotification();
const needsAck = notification.requires_ack && !notification.is_acknowledged;
return (
<div
className={cn(
"rounded-lg border p-3",
notification.is_read ? "opacity-70" : "border-l-4 border-l-primary",
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug">
{notification.subject}
</p>
{needsAck && (
<Button
size="sm"
className="h-7 shrink-0 px-2 text-xs"
disabled={acknowledge.isPending}
onClick={() =>
acknowledge.mutate(notification.id, {
onError: (err) => toast.error(getErrorMessage(err)),
})
}
>
<Check className="mr-1 h-3.5 w-3.5" />
Ack
</Button>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">
{notification.body}
</p>
<p className="mt-1.5 text-[11px] text-muted-foreground">
{getAgentDisplayName(notification.from_agent)} ·{" "}
{formatDistanceToNow(new Date(notification.timestamp))} ago
</p>
</div>
);
}
/**
* Notification inbox for the /tg cockpit every notification, newest
* first, with an Ack button on the ones that require it. Polling rides
* useNotifications' own 30s refetchInterval; no extra wiring needed here.
*/
export function TgInboxTab() {
const { data, isLoading } = useNotifications();
if (isLoading) {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
);
}
if (!data?.items.length) {
return (
<div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground">
<Bell className="h-8 w-8 opacity-50" />
<p className="text-sm">No notifications</p>
</div>
);
}
return (
<div className="space-y-2">
{data.items.map((n) => (
<TgNotificationRow key={n.id} notification={n} />
))}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { CheckSquare, Bell, Kanban, MessageSquare } from "lucide-react";
import { cn } from "@/lib/utils";
export type TgTab = "approvals" | "inbox" | "board" | "chat";
const TABS: ReadonlyArray<{
id: TgTab;
label: string;
icon: typeof CheckSquare;
}> = [
{ id: "approvals", label: "Approvals", icon: CheckSquare },
{ id: "inbox", label: "Inbox", icon: Bell },
{ id: "board", label: "Board", icon: Kanban },
{ id: "chat", label: "Chat", icon: MessageSquare },
];
interface TgTabBarProps {
active: TgTab;
onChange: (tab: TgTab) => void;
}
/**
* The cockpit's own bottom nav 4 thumb-sized tabs, controlled by page
* state (not routes, unlike the dashboard's BottomTabBar) since the whole
* Mini App lives on the single `/tg` route.
*/
export function TgTabBar({ active, onChange }: TgTabBarProps) {
return (
<nav
aria-label="Cockpit"
className="fixed inset-x-0 bottom-0 z-40 flex border-t bg-background pb-[env(safe-area-inset-bottom)]"
>
{TABS.map((tab) => {
const isActive = active === tab.id;
return (
<button
key={tab.id}
type="button"
aria-current={isActive ? "page" : undefined}
onClick={() => onChange(tab.id)}
className={cn(
"flex flex-1 flex-col items-center gap-1 py-2.5 text-xs font-medium transition-colors",
isActive ? "text-primary" : "text-muted-foreground",
)}
>
<tab.icon className="h-6 w-6" />
{tab.label}
</button>
);
})}
</nav>
);
}
+8 -1
View File
@@ -38,12 +38,19 @@ export function useA2AAdminPairs() {
// Transcript for one conversation. WS frames for the selected conversation
// invalidate this key; full bodies always come from REST (excerpts are capped).
export function useA2AMessages(conversationId: string | null) {
// `refetchInterval` defaults to off (the desktop A2A page relies on WS
// invalidation instead) — the /tg Mini App chat tab has no WS wiring, so it
// passes a ~10s interval to poll the thread it's actively viewing.
export function useA2AMessages(
conversationId: string | null,
options?: { refetchInterval?: number | false },
) {
return useQuery({
queryKey: a2aLiveKeys.messages(conversationId || ""),
queryFn: () => a2aApi.listAdminMessages(conversationId!),
enabled: !!conversationId,
staleTime: 30_000,
refetchInterval: options?.refetchInterval ?? false,
});
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Telegram Mini App WebApp bridge.
*
* Thin wrapper over the global `window.Telegram.WebApp` object injected by
* https://telegram.org/js/telegram-web-app.js (loaded by the `(tg)` layout).
* Only the handful of fields/methods the cockpit actually needs are typed
* the real object carries far more (haptics, theme params, main button,
* etc.) that nothing here uses yet.
*/
export interface TelegramWebApp {
/** Signals the Mini App is ready to be displayed hides Telegram's own
* loading placeholder. Safe to call more than once. */
ready: () => void;
/** Expands the Mini App to full height (past the default half-screen). */
expand: () => void;
/** Opaque, HMAC-signed payload proving this session came from Telegram
* forwarded verbatim to `POST /api/telegram/webapp-auth`. Empty string
* when the WebApp object exists but wasn't launched with real init data
* (e.g. a bare browser tab pointed at the URL). */
initData: string;
}
declare global {
interface Window {
Telegram?: {
WebApp?: TelegramWebApp;
};
}
}
/** The live WebApp object, or null outside Telegram (or during SSR). */
export function getTelegramWebApp(): TelegramWebApp | null {
if (typeof window === "undefined") return null;
return window.Telegram?.WebApp ?? null;
}
/** Convenience accessor "" when there's no WebApp (never null, so callers
* don't need a separate not-in-Telegram branch just to read this). */
export function getInitData(): string {
return getTelegramWebApp()?.initData ?? "";
}
const POLL_INTERVAL_MS = 100;
/**
* Resolves the WebApp object, waiting briefly for the CDN script to finish
* loading (it's fetched with `next/script`'s `afterInteractive` strategy, so
* it can still be in flight when this runs on mount). Resolves null once
* `timeoutMs` elapses with no `window.Telegram.WebApp` the caller then
* knows for certain this isn't a Telegram launch, not just a slow network.
*
* ponytail: a plain poll loop, not a script `onLoad` event the script tag
* lives in a layout the caller doesn't render, so there's no ref to hang a
* listener off; polling a global is the shortest correct thing here.
*/
export function waitForTelegramWebApp(
timeoutMs = 1500,
): Promise<TelegramWebApp | null> {
const existing = getTelegramWebApp();
if (existing) return Promise.resolve(existing);
if (typeof window === "undefined") return Promise.resolve(null);
return new Promise((resolve) => {
const deadline = Date.now() + timeoutMs;
const timer = setInterval(() => {
const webApp = getTelegramWebApp();
if (webApp) {
clearInterval(timer);
resolve(webApp);
} else if (Date.now() >= deadline) {
clearInterval(timer);
resolve(null);
}
}, POLL_INTERVAL_MS);
});
}
+5 -2
View File
@@ -61,8 +61,11 @@ export const config = {
// Everything except the login page itself (avoids a redirect loop), API
// routes (nginx routes /api/* straight to the orchestrator in prod — this
// never sees them there; excluded defensively for a bare `next start`),
// Next's internal asset paths, and the static icon files at the app root.
// the Telegram Mini App surface (/tg authenticates via Telegram initData,
// not the password-login cookie — redirecting it to /login would strand a
// phone session that can never reach that page), Next's internal asset
// paths, and the static icon files at the app root.
matcher: [
"/((?!login|api|_next/static|_next/image|favicon.ico|apple-icon.png|icon.png).*)",
"/((?!login|api|tg(?:/|$)|_next/static|_next/image|favicon.ico|apple-icon.png|icon.png).*)",
],
};
+4
View File
@@ -45,6 +45,7 @@ from roboco.api.routes.settings import router as settings_router
from roboco.api.routes.stream import router as stream_router
from roboco.api.routes.system import router as system_router
from roboco.api.routes.tasks import router as tasks_router
from roboco.api.routes.telegram import mount_telegram_miniapp_auth
from roboco.api.routes.telegram import router as telegram_router
from roboco.api.routes.usage import router as usage_router
from roboco.api.routes.v1 import do as do_module
@@ -486,6 +487,9 @@ def create_app() -> FastAPI:
prefix=f"{api_prefix}/telegram",
tags=["Telegram"],
)
# Telegram Mini App sign-in — public, pre-auth; mounted only when both
# telegram_miniapp_enabled and cloud_auth_enabled are armed.
mount_telegram_miniapp_auth(app, f"{api_prefix}/telegram")
# Pitches — Board proposals + CEO approve -> auto-provision origination path.
app.include_router(
+7 -5
View File
@@ -21,12 +21,12 @@ class LoginRateLimiter(BaseHTTPMiddleware):
def __init__(
self,
app: ASGIApp,
prefix: str,
paths: tuple[str, ...],
max_attempts: int = 10,
window: int = 60,
) -> None:
super().__init__(app)
self.prefix = prefix.rstrip("/")
self.paths = frozenset(paths)
self.max_attempts = max_attempts
self.window = window
@@ -48,10 +48,12 @@ class LoginRateLimiter(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
# Only the login endpoint is limited; everything else passes through.
if request.method != "POST" or request.url.path != f"{self.prefix}/login":
# Only the configured auth endpoints are limited; everything else
# passes through. Path-keyed buckets so login and webapp-auth attempts
# don't share a counter.
if request.method != "POST" or request.url.path not in self.paths:
return await call_next(request)
key = f"auth:login:rl:{self._client_ip(request)}"
key = f"auth:login:rl:{request.url.path}:{self._client_ip(request)}"
try:
# Test seam: a fake injected via app.state.login_redis. Production
# leaves it unset and opens its own per-request connection.
+1 -1
View File
@@ -90,7 +90,7 @@ def mount_cloud_auth(app: FastAPI, prefix: str) -> None:
app.include_router(_logout_router, prefix=prefix, tags=["Auth"])
app.add_middleware(
LoginRateLimiter,
prefix=prefix,
paths=(f"{prefix}/login",),
max_attempts=settings.login_max_attempts,
window=60,
)
+127 -4
View File
@@ -1,22 +1,45 @@
"""Telegram notifications bridge API — CEO-managed credentials (write-only).
"""Telegram notifications bridge API — CEO-managed credentials (write-only)
plus the Mini App sign-in exchange.
The bridge itself is a server-side fan-out from the CEO-notify producers; the
only surface here is the credentials card. CEO-only; credentials are
write-only (the API never returns plaintext, mirroring ``/x/credentials``).
credentials card is CEO-only, write-only (the API never returns plaintext,
mirroring ``/x/credentials``). ``webapp_auth_router`` is a separate PUBLIC,
pre-auth router mounted only when both ``telegram_miniapp_enabled`` and
``cloud_auth_enabled`` are armed (see ``mount_telegram_miniapp_auth``,
mirroring ``roboco.api.auth.routes.mount_cloud_auth``'s conditional mount) —
its own signed ``initData`` is the authentication, not an agent/session header.
"""
from fastapi import APIRouter, HTTPException, status
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import APIRouter, HTTPException, Response, status
from sqlalchemy import select
from roboco.api.auth.backend import cookie_transport, get_jwt_strategy
from roboco.api.auth.login_limit import LoginRateLimiter
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.telegram import (
TelegramCredentialsSetRequest,
TelegramCredentialsStatus,
TelegramWebAppAuthRequest,
)
from roboco.config import settings
from roboco.db.tables import UserTable
from roboco.logging import get_logger
from roboco.security import guard_deco
from roboco.services.audit import get_audit_service
from roboco.services.telegram_credentials import (
TelegramCredentialsValidationError,
get_telegram_credentials_service,
)
from roboco.utils.telegram_initdata import validate_init_data
if TYPE_CHECKING:
from fastapi import FastAPI
_logger = get_logger(__name__)
router = APIRouter()
@@ -59,3 +82,103 @@ async def set_telegram_credentials(
) from e
await db.commit()
return TelegramCredentialsStatus(has_credentials=has_creds)
# ==========================================================================
# Mini App sign-in — public, pre-auth. Conditionally mounted; see
# ``mount_telegram_miniapp_auth`` below.
# ==========================================================================
webapp_auth_router = APIRouter()
@webapp_auth_router.post("/webapp-auth")
@guard_deco.rate_limit(requests=10, window=60)
@guard_deco.max_request_size(size_bytes=8192)
@guard_deco.block_clouds()
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
@guard_deco.usage_monitor(max_calls=30, window=3600)
async def webapp_auth(
data: TelegramWebAppAuthRequest, db: DbSession, response: Response
) -> dict[str, bool]:
"""Exchange a validated Telegram Mini App ``initData`` for the same
cloud-auth session cookie ``/api/auth/login`` issues.
Refuses (no detail leak beyond "not configured"/"not authorized") unless:
credentials are stored, the HMAC signature verifies, ``auth_date`` is
fresh, and the initData's ``user.id`` matches the CEO's own stored
``chat_id`` the same single-CEO trust anchor
``telegram_inbound._authorized_chat`` uses for the bot's inbound commands.
"""
creds = await get_telegram_credentials_service(db).get_decrypted()
if creds is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Telegram Mini App sign-in is not configured",
)
parsed = validate_init_data(
data.init_data,
creds.bot_token,
settings.telegram_initdata_max_age_seconds,
)
if parsed is None:
_logger.warning("Telegram Mini App auth: invalid or expired initData")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Telegram sign-in data",
)
user_field = parsed.get("user")
telegram_user_id = user_field.get("id") if isinstance(user_field, dict) else None
if telegram_user_id is None or str(telegram_user_id) != str(creds.chat_id):
_logger.warning(
"Telegram Mini App auth: user id does not match the configured chat id"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
# The single seeded CEO login user — same lookup convention as
# roboco.api.auth.seed.ensure_seed_user (ordered by the primary key's
# text label, not UserTable.id, so mypy's TYPE_CHECKING split still sees
# a column expression).
user = (
await db.execute(select(UserTable).order_by("id").limit(1))
).scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="No cloud-auth user is seeded",
)
token = await get_jwt_strategy().write_token(user)
cookie_transport._set_login_cookie(response, token)
await get_audit_service().log_event(
event_type="telegram.webapp.login",
details={"via": "telegram_miniapp"},
severity="info",
)
return {"ok": True}
def mount_telegram_miniapp_auth(app: FastAPI, prefix: str) -> None:
"""Mount ``POST {prefix}/webapp-auth`` only when the Mini App switch AND
cloud auth are both armed mirrors ``mount_cloud_auth``'s conditional
mount. Off (either flag): the route doesn't exist at all."""
if not (settings.telegram_miniapp_enabled and settings.cloud_auth_enabled):
return
app.include_router(webapp_auth_router, prefix=prefix, tags=["Telegram"])
# Unconditional per-IP limiter, same backstop /auth/login gets: the guard
# middleware's rate_limit decorator only bites when ROBOCO_GUARD_ENABLED
# is on, which is toggled independently — a public session-minting route
# must not depend on that coupling.
app.add_middleware(
LoginRateLimiter,
paths=(f"{prefix}/webapp-auth",),
max_attempts=settings.login_max_attempts,
window=60,
)
+11
View File
@@ -14,3 +14,14 @@ class TelegramCredentialsSetRequest(BaseModel):
bot_token: str = Field(default="")
chat_id: str = Field(default="")
class TelegramWebAppAuthRequest(BaseModel):
"""A Telegram Mini App's raw ``initData`` handoff, pre-validation.
Size-capped well above a real Telegram WebApp initData payload (query_id
+ user + auth_date + hash rarely exceeds a few hundred bytes) so an
oversized body is rejected by the schema before it reaches HMAC work.
"""
init_data: str = Field(min_length=1, max_length=4096)
+35
View File
@@ -459,6 +459,32 @@ class Settings(BaseSettings):
"the cloud-auth login endpoint returns 429."
),
)
# Telegram Mini App sign-in: validates Telegram's signed WebApp initData
# and mints the SAME cloud-auth session cookie /api/auth/login issues —
# zero changes to deps.py/websocket.py, whose cookie gate already accepts
# it. Security/TLS-coupled like cloud_auth_enabled, so deliberately NOT
# on the panel's runtime feature-flags card (see FEATURE_FLAGS in
# roboco/services/settings.py).
telegram_miniapp_enabled: bool = Field(
default=False,
description=(
"Master switch for Telegram Mini App sign-in "
"(POST /api/telegram/webapp-auth). OFF by default. Requires "
"cloud_auth_enabled (startup fails loud if on without it) since "
"the Mini App mints a cloud-auth session cookie and there is "
"nothing to mint without it. Env-only — excluded from the panel "
"feature-flags card, same reasoning as cloud_auth_enabled."
),
)
telegram_initdata_max_age_seconds: int = Field(
default=600,
ge=1,
description=(
"Max age (seconds) of a Telegram WebApp initData payload's "
"auth_date before POST /api/telegram/webapp-auth refuses it as "
"stale."
),
)
agent_token_ttl_seconds: int = Field(
default=604800,
ge=60,
@@ -492,6 +518,15 @@ class Settings(BaseSettings):
"the login cookie). Unset ROBOCO_PANEL_AGENT_TOKEN for a "
"publicly-exposed cloud-auth deploy."
)
# The Mini App auth route mints a cloud-auth session cookie — with
# cloud auth off there is no session mechanism to hand it to.
if self.telegram_miniapp_enabled and not self.cloud_auth_enabled:
raise ValueError(
"ROBOCO_TELEGRAM_MINIAPP_ENABLED=true requires "
"ROBOCO_CLOUD_AUTH_ENABLED=true (the Mini App mints a "
"cloud-auth session cookie; there is nothing to mint "
"without it)."
)
return self
# ==========================================================================
+76
View File
@@ -0,0 +1,76 @@
"""Telegram Mini App ``initData`` validation.
Implements Telegram's documented WebApp signing algorithm — pure, no I/O:
https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
data_check_string = "\\n".join(sorted("key=value" pairs, hash excluded))
secret_key = HMAC_SHA256(key=b"WebAppData", msg=bot_token)
expected_hash = HMAC_SHA256(key=secret_key, msg=data_check_string).hexdigest()
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from urllib.parse import parse_qsl
_WEBAPP_DATA_KEY = b"WebAppData"
def _hash_matches(fields: dict[str, str], received_hash: str, bot_token: str) -> bool:
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(fields.items()))
secret_key = hmac.new(_WEBAPP_DATA_KEY, bot_token.encode(), hashlib.sha256).digest()
expected_hash = hmac.new(
secret_key, data_check_string.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_hash, received_hash)
# Telegram stamps auth_date server-side; allow a small negative delta for
# local clock skew, but a far-future auth_date is nonsense — reject it
# rather than treating it as eternally fresh.
_CLOCK_SKEW_TOLERANCE_SECONDS = 60
def _is_fresh(auth_date_raw: str | None, max_age_seconds: int) -> bool:
if auth_date_raw is None:
return False
try:
auth_date = int(auth_date_raw)
except ValueError:
return False
delta = time.time() - auth_date
return -_CLOCK_SKEW_TOLERANCE_SECONDS <= delta <= max_age_seconds
def validate_init_data(
init_data: str, bot_token: str, max_age_seconds: int
) -> dict[str, object] | None:
"""Validate a Telegram Mini App ``initData`` query string.
Returns the parsed fields (``user`` JSON-decoded) on a valid, correctly
signed, still-fresh payload. Returns ``None`` on any failure: missing/bad
hash, an unparsable ``user`` field, or ``auth_date`` older than
``max_age_seconds`` the caller can't distinguish the reason, which is
the point (no oracle for an attacker to iterate against).
"""
if not init_data or not bot_token:
return None
fields = dict(parse_qsl(init_data, keep_blank_values=True, strict_parsing=False))
received_hash = fields.pop("hash", None)
if not received_hash or not _hash_matches(fields, received_hash, bot_token):
return None
if not _is_fresh(fields.get("auth_date"), max_age_seconds):
return None
result: dict[str, object] = dict(fields)
user_raw = result.get("user")
if user_raw is not None:
try:
result["user"] = json.loads(str(user_raw))
except json.JSONDecodeError:
return None
return result
@@ -0,0 +1,225 @@
"""Telegram Mini App sign-in route coverage.
Covers the conditional mount (both `telegram_miniapp_enabled` AND
`cloud_auth_enabled` required) and the exchange flow: valid signed initData
+ matching CEO chat id -> the same cloud-auth session cookie `/api/auth/login`
issues; every refusal path (unconfigured creds, bad HMAC, wrong user id,
oversized body) never mints a cookie.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from http import HTTPStatus
from typing import TYPE_CHECKING
from urllib.parse import urlencode
import pytest
import pytest_asyncio
from cryptography.fernet import Fernet
from fastapi import FastAPI
from fastapi_users.password import PasswordHelper
from httpx import ASGITransport, AsyncClient
from roboco.api.auth.backend import SESSION_COOKIE_NAME
from roboco.api.deps import get_db
from roboco.api.routes.telegram import mount_telegram_miniapp_auth, webapp_auth_router
from roboco.config import settings
from roboco.db.tables import UserTable
from roboco.services.telegram_credentials import get_telegram_credentials_service
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
_BOT_TOKEN = "123456:TEST-bot-token"
_CHAT_ID = "987654321"
_SECRET = "test-secret-for-miniapp-padded-32bytes"
_password_helper = PasswordHelper()
def _sign(fields: dict[str, str], bot_token: str = _BOT_TOKEN) -> str:
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(fields.items()))
secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
return hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
def _init_data(user_id: str = _CHAT_ID, bot_token: str = _BOT_TOKEN) -> str:
fields = {
"auth_date": str(int(time.time())),
"user": json.dumps({"id": int(user_id), "first_name": "Renzo"}),
"query_id": "AAH_test",
}
signed = dict(fields)
signed["hash"] = _sign(fields, bot_token)
return urlencode(signed)
@pytest.fixture(autouse=True)
def _armed_settings(monkeypatch: pytest.MonkeyPatch) -> None:
"""Every test gets a signing secret + encryption key + both flags on."""
monkeypatch.setattr(settings, "cloud_auth_enabled", True)
monkeypatch.setattr(settings, "cloud_auth_secret", _SECRET)
monkeypatch.setattr(settings, "telegram_miniapp_enabled", True)
monkeypatch.setattr(settings, "telegram_initdata_max_age_seconds", 600)
monkeypatch.setattr(settings, "encryption_key", Fernet.generate_key().decode())
async def _seed_creds(db: AsyncSession) -> None:
await get_telegram_credentials_service(db).set_credentials(
bot_token=_BOT_TOKEN, chat_id=_CHAT_ID
)
await db.flush()
async def _seed_user(db: AsyncSession) -> UserTable:
user = UserTable(
email="ceo@example.com",
hashed_password=_password_helper.hash("hunter2"),
is_active=True,
is_superuser=True,
is_verified=True,
)
db.add(user)
await db.flush()
return user
def _build_app(db_session: AsyncSession) -> FastAPI:
app = FastAPI()
app.include_router(webapp_auth_router, prefix="/api/telegram")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
app.dependency_overrides[get_db] = _override_db
return app
@pytest_asyncio.fixture
async def client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
app = _build_app(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Conditional mount — both flags required. Driven through a real request
# rather than introspecting `app.routes`: FastAPI wraps an included router in
# a lazily-resolved `_IncludedRouter` that doesn't expose child paths
# directly, so a live 404-vs-not check is the accurate signal.
# ---------------------------------------------------------------------------
async def _post_unmounted_probe(app: FastAPI) -> int:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
resp = await c.post("/api/telegram/webapp-auth", json={"init_data": "x"})
return resp.status_code
@pytest.mark.asyncio
async def test_mount_skipped_when_miniapp_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "telegram_miniapp_enabled", False)
app = FastAPI()
mount_telegram_miniapp_auth(app, "/api/telegram")
assert await _post_unmounted_probe(app) == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_mount_skipped_when_cloud_auth_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "cloud_auth_enabled", False)
app = FastAPI()
mount_telegram_miniapp_auth(app, "/api/telegram")
assert await _post_unmounted_probe(app) == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_mount_included_when_both_armed(db_session: AsyncSession) -> None:
app = FastAPI()
mount_telegram_miniapp_auth(app, "/api/telegram")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
app.dependency_overrides[get_db] = _override_db
# Mounted -> never 404 (the unmounted signal); the exchange flow itself
# is covered by the `client` fixture tests below.
assert await _post_unmounted_probe(app) != HTTPStatus.NOT_FOUND
# ---------------------------------------------------------------------------
# Exchange flow.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_happy_path_sets_session_cookie(
db_session: AsyncSession, client: AsyncClient
) -> None:
await _seed_creds(db_session)
await _seed_user(db_session)
resp = await client.post(
"/api/telegram/webapp-auth", json={"init_data": _init_data()}
)
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"ok": True}
assert resp.cookies.get(SESSION_COOKIE_NAME) is not None
@pytest.mark.asyncio
async def test_wrong_user_id_refused(
db_session: AsyncSession, client: AsyncClient
) -> None:
await _seed_creds(db_session)
await _seed_user(db_session)
resp = await client.post(
"/api/telegram/webapp-auth",
json={"init_data": _init_data(user_id="111111111")},
)
assert resp.status_code == HTTPStatus.FORBIDDEN
assert resp.cookies.get(SESSION_COOKIE_NAME) is None
@pytest.mark.asyncio
async def test_bad_hmac_refused(db_session: AsyncSession, client: AsyncClient) -> None:
await _seed_creds(db_session)
await _seed_user(db_session)
resp = await client.post(
"/api/telegram/webapp-auth",
json={"init_data": _init_data(bot_token="a-different-bot-token")},
)
assert resp.status_code == HTTPStatus.UNAUTHORIZED
assert resp.cookies.get(SESSION_COOKIE_NAME) is None
@pytest.mark.asyncio
async def test_unconfigured_credentials_refused(client: AsyncClient) -> None:
# No _seed_creds() call — credentials never set.
resp = await client.post(
"/api/telegram/webapp-auth", json={"init_data": _init_data()}
)
assert resp.status_code == HTTPStatus.SERVICE_UNAVAILABLE
assert resp.cookies.get(SESSION_COOKIE_NAME) is None
@pytest.mark.asyncio
async def test_oversized_body_refused(
db_session: AsyncSession, client: AsyncClient
) -> None:
await _seed_creds(db_session)
await _seed_user(db_session)
resp = await client.post(
"/api/telegram/webapp-auth", json={"init_data": "x" * 5000}
)
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert resp.cookies.get(SESSION_COOKIE_NAME) is None
+3 -1
View File
@@ -34,7 +34,9 @@ class _BrokenRedis:
def _app(redis: Any) -> FastAPI:
app = FastAPI()
app.state.login_redis = redis
app.add_middleware(LoginRateLimiter, prefix="/auth", max_attempts=3, window=60)
app.add_middleware(
LoginRateLimiter, paths=("/auth/login",), max_attempts=3, window=60
)
@app.post("/auth/login")
async def login() -> dict[str, bool]:
+33
View File
@@ -90,6 +90,39 @@ def test_cloud_auth_ok_without_panel_agent_token(
assert s.cloud_auth_enabled is True
# ---------------------------------------------------------------------------
# Telegram Mini App sign-in — requires cloud_auth_enabled
# ---------------------------------------------------------------------------
def test_telegram_miniapp_off_does_not_require_cloud_auth() -> None:
"""Default (off) construction never raises regardless of cloud auth."""
s = Settings(telegram_miniapp_enabled=False, cloud_auth_enabled=False)
assert s.telegram_miniapp_enabled is False
def test_telegram_miniapp_enabled_without_cloud_auth_fails_loud() -> None:
"""The Mini App route mints a cloud-auth session cookie — with cloud
auth off there's nothing to mint, so this must fail at startup."""
with pytest.raises(ValueError, match="ROBOCO_TELEGRAM_MINIAPP_ENABLED"):
Settings(telegram_miniapp_enabled=True, cloud_auth_enabled=False)
def test_telegram_miniapp_enabled_with_cloud_auth_succeeds() -> None:
s = Settings(
telegram_miniapp_enabled=True,
cloud_auth_enabled=True,
cloud_auth_secret="s" * 32,
)
assert s.telegram_miniapp_enabled is True
def test_telegram_initdata_max_age_defaults_to_600() -> None:
ten_minutes = 10 * 60
s = Settings()
assert s.telegram_initdata_max_age_seconds == ten_minutes
# ---------------------------------------------------------------------------
# local_llm_base_url — internal-host guard (H13)
# ---------------------------------------------------------------------------
+124
View File
@@ -0,0 +1,124 @@
"""``validate_init_data`` coverage — hand-computed HMAC vectors pin the exact
algorithm shape (HMAC key=b"WebAppData"/msg=bot_token for the secret, then
HMAC key=secret/msg=data_check_string for the hash), plus tamper/expiry/
missing-field cases. Pure function, no I/O no DB/network fixtures needed.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from urllib.parse import urlencode
from roboco.utils.telegram_initdata import validate_init_data
_BOT_TOKEN = "123456:TEST-bot-token-for-unit-tests"
def _sign(fields: dict[str, str], bot_token: str = _BOT_TOKEN) -> str:
"""Reference HMAC computation, independent of the module under test."""
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(fields.items()))
secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
return hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
def _init_data(fields: dict[str, str], bot_token: str = _BOT_TOKEN) -> str:
signed = dict(fields)
signed["hash"] = _sign(fields, bot_token)
return urlencode(signed)
def test_valid_init_data_returns_parsed_fields_with_user_decoded() -> None:
user = {"id": 987654321, "first_name": "Renzo"}
fields = {
"auth_date": str(int(time.time())),
"user": json.dumps(user),
"query_id": "AAH_abc123",
}
result = validate_init_data(_init_data(fields), _BOT_TOKEN, max_age_seconds=600)
assert result is not None
assert result["user"] == user
assert result["query_id"] == "AAH_abc123"
def test_missing_hash_rejected() -> None:
fields = {"auth_date": str(int(time.time()))}
init_data = urlencode(fields) # no hash field at all
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is None
def test_tampered_field_after_signing_rejected() -> None:
fields = {"auth_date": str(int(time.time())), "user": json.dumps({"id": 1})}
signed_hash = _sign(fields)
tampered = dict(fields)
tampered["auth_date"] = str(int(time.time()) + 999) # changed post-signing
tampered["hash"] = signed_hash
init_data = urlencode(tampered)
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is None
def test_wrong_bot_token_rejected() -> None:
fields = {"auth_date": str(int(time.time()))}
init_data = _init_data(fields, bot_token=_BOT_TOKEN)
assert validate_init_data(init_data, "wrong-token", max_age_seconds=600) is None
def test_expired_auth_date_rejected() -> None:
stale = int(time.time()) - 3600
fields = {"auth_date": str(stale)}
init_data = _init_data(fields)
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is None
def test_fresh_auth_date_within_window_accepted() -> None:
fields = {"auth_date": str(int(time.time()) - 10)}
init_data = _init_data(fields)
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is not None
def test_far_future_auth_date_rejected() -> None:
# A far-future auth_date is nonsense from a server-stamped field; without
# an upper bound it would count as eternally fresh.
fields = {"auth_date": str(int(time.time()) + 100_000)}
init_data = _init_data(fields)
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is None
def test_slightly_future_auth_date_within_skew_tolerance_accepted() -> None:
# Local clock lagging Telegram's by a few seconds must not break login.
fields = {"auth_date": str(int(time.time()) + 30)}
init_data = _init_data(fields)
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is not None
def test_missing_auth_date_rejected() -> None:
fields = {"query_id": "abc"}
init_data = _init_data(fields)
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is None
def test_malformed_user_json_rejected() -> None:
fields = {"auth_date": str(int(time.time())), "user": "not-json"}
init_data = _init_data(fields)
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is None
def test_empty_init_data_rejected() -> None:
assert validate_init_data("", _BOT_TOKEN, max_age_seconds=600) is None
def test_empty_bot_token_rejected() -> None:
fields = {"auth_date": str(int(time.time()))}
init_data = _init_data(fields)
assert validate_init_data(init_data, "", max_age_seconds=600) is None
if __name__ == "__main__":
# ponytail: smallest runnable self-check; pytest is the real suite above.
user = {"id": 42}
fields = {"auth_date": str(int(time.time())), "user": json.dumps(user)}
assert validate_init_data(_init_data(fields), _BOT_TOKEN, 600)["user"] == user
assert validate_init_data(_init_data(fields), "wrong", 600) is None
print("telegram_initdata self-check OK")