mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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).*)",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
# ==========================================================================
|
||||
|
||||
@@ -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
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user